Skip to content

Commit 297be85

Browse files
committed
ENG-1487 Fix query to retrieve a PrivacyNotice's cookies (#6636)
1 parent 0da25ea commit 297be85

File tree

4 files changed

+416
-6
lines changed

4 files changed

+416
-6
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
1919
- https://github.com/ethyca/fides/labels/high-risk: to indicate that a change is a "high-risk" change that could potentially lead to unanticipated regressions or degradations
2020
- https://github.com/ethyca/fides/labels/db-migration: to indicate that a given change includes a DB migration
2121

22-
## [Unreleased](https://github.com/ethyca/fides/compare/2.70.3...main)
22+
## [Unreleased](https://github.com/ethyca/fides/compare/2.70.4...main)
2323

2424
### Added
2525
- Added SRV and SSL support for MongoDB [#6590](https://github.com/ethyca/fides/pull/6590)
@@ -42,6 +42,11 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
4242
- Fixed an issue that allowed new taxonomy items to be submitted multiple times [#6609](https://github.com/ethyca/fides/pull/6609)
4343
- Fixed an error on trying to submit optional integer fields in the integration form [#6626](https://github.com/ethyca/fides/pull/6626)
4444

45+
## [2.70.4](https://github.com/ethyca/fides/compare/2.70.3...2.70.4)
46+
47+
### Fixed
48+
- Fixed query to retrieve a `PrivacyNotice`'s cookies [#6636](https://github.com/ethyca/fides/pull/6636)
49+
4550
## [2.70.3](https://github.com/ethyca/fides/compare/2.70.2...2.70.3)
4651

4752
### Fixed

src/fides/api/models/privacy_notice.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,16 +191,36 @@ def default_preference(self) -> UserConsentPreference:
191191

192192
@property
193193
def cookies(self) -> List[Asset]:
194-
"""Return relevant assets of type 'cookie' (via the data use)"""
194+
"""
195+
Return the privacy notice's assets of type 'cookie'.
196+
Cookies are matched to the privacy notice if they have at least one data use
197+
that is either an exact match or a hierarchical descendant of a one of the
198+
data uses in the privacy notice.
199+
"""
195200
db = Session.object_session(self)
196-
or_queries = [
197-
f"array_to_string(data_uses, ',') ILIKE '{data_use}%'"
198-
for data_use in self.data_uses
201+
202+
if not self.data_uses:
203+
return []
204+
205+
# Use array overlap operator (&&) for exact matches - GIN index friendly
206+
exact_matches_condition = Asset.data_uses.op("&&")(self.data_uses)
207+
208+
# For hierarchical children, we still need to check individual elements with LIKE
209+
# They have to match the data_use and the period separator, so we know it's a hierarchical descendant
210+
hierarchical_conditions = [
211+
text(
212+
f"EXISTS(SELECT 1 FROM unnest(data_uses) AS data_use WHERE data_use LIKE :pattern_{i})"
213+
).bindparams(**{f"pattern_{i}": f"{data_use}.%"})
214+
for i, data_use in enumerate(self.data_uses)
199215
]
200216

217+
asset_matching_condition = or_(
218+
exact_matches_condition, *hierarchical_conditions
219+
)
220+
201221
query = db.query(Asset).filter(
202222
Asset.asset_type == "Cookie",
203-
or_(*[text(query) for query in or_queries]),
223+
asset_matching_condition,
204224
)
205225

206226
return query.all()

tests/fixtures/application_fixtures.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2106,6 +2106,218 @@ def failed_privacy_request(db: Session, policy: Policy) -> PrivacyRequest:
21062106
pr.delete(db)
21072107

21082108

2109+
@pytest.fixture(scope="function")
2110+
def privacy_notice_targeted_advertising(db: Session) -> Generator:
2111+
"""
2112+
Privacy notice fixture for targeted advertising use cases.
2113+
2114+
Creates a privacy notice with targeted advertising data uses that can be used
2115+
to test matching with various cookie assets.
2116+
"""
2117+
template = PrivacyNoticeTemplate.create(
2118+
db,
2119+
check_name=False,
2120+
data={
2121+
"name": "targeted advertising notice",
2122+
"notice_key": "targeted_advertising_notice_1",
2123+
"consent_mechanism": ConsentMechanism.opt_out,
2124+
"data_uses": [
2125+
"marketing.advertising.first_party.targeted",
2126+
"marketing.advertising.third_party.targeted",
2127+
],
2128+
"enforcement_level": EnforcementLevel.system_wide,
2129+
"translations": [
2130+
{
2131+
"language": "en",
2132+
"title": "Targeted Advertising Notice",
2133+
"description": "Notice for targeted advertising",
2134+
}
2135+
],
2136+
},
2137+
)
2138+
privacy_notice = PrivacyNotice.create(
2139+
db=db,
2140+
data={
2141+
"name": "targeted advertising notice",
2142+
"notice_key": "targeted_advertising_notice_1",
2143+
"consent_mechanism": ConsentMechanism.opt_out,
2144+
"data_uses": [
2145+
"marketing.advertising.first_party.targeted",
2146+
"marketing.advertising.third_party.targeted",
2147+
],
2148+
"enforcement_level": EnforcementLevel.system_wide,
2149+
"origin": template.id,
2150+
"translations": [
2151+
{
2152+
"language": "en",
2153+
"title": "Targeted Advertising Notice",
2154+
"description": "Notice for targeted advertising",
2155+
}
2156+
],
2157+
},
2158+
)
2159+
2160+
yield privacy_notice
2161+
2162+
# Clean up translations and histories
2163+
for translation in privacy_notice.translations:
2164+
for history in translation.histories:
2165+
history.delete(db)
2166+
translation.delete(db)
2167+
privacy_notice.delete(db)
2168+
template.delete(db)
2169+
2170+
2171+
@pytest.fixture(scope="function")
2172+
def multi_data_use_cookie_asset(db: Session, system) -> Generator:
2173+
"""
2174+
Asset that mimics a real-world cookie with multiple data uses.
2175+
"""
2176+
asset = Asset(
2177+
name="_gcl_au",
2178+
asset_type="Cookie",
2179+
domain="*",
2180+
system_id=system.id,
2181+
data_uses=[
2182+
"marketing.advertising.first_party.contextual",
2183+
"marketing.advertising.negative_targeting",
2184+
"analytics.reporting.campaign_insights",
2185+
"marketing.advertising.first_party.targeted", # Should match targeted advertising notices
2186+
"analytics.reporting.ad_performance",
2187+
"functional.service.improve",
2188+
"marketing.advertising.third_party.targeted", # Should match targeted advertising notices
2189+
"marketing.advertising.profiling",
2190+
"marketing.advertising.frequency_capping",
2191+
"functional.storage",
2192+
],
2193+
locations=[],
2194+
parent=[],
2195+
consent_status="unknown",
2196+
page=[],
2197+
)
2198+
asset.save(db)
2199+
2200+
yield asset
2201+
2202+
# Clean up
2203+
try:
2204+
asset.delete(db)
2205+
except ObjectDeletedError:
2206+
# Skip if already deleted
2207+
pass
2208+
2209+
2210+
@pytest.fixture(scope="function")
2211+
def privacy_notice_fun_data_use(db: Session) -> Generator:
2212+
"""
2213+
Privacy notice fixture for testing substring matching edge cases.
2214+
2215+
Creates a privacy notice with 'fun' data use to test against 'funding' assets.
2216+
"""
2217+
template = PrivacyNoticeTemplate.create(
2218+
db,
2219+
check_name=False,
2220+
data={
2221+
"name": "fun activities notice",
2222+
"notice_key": "fun_activities_notice_1",
2223+
"consent_mechanism": ConsentMechanism.opt_in,
2224+
"data_uses": ["fun"],
2225+
"enforcement_level": EnforcementLevel.system_wide,
2226+
"translations": [
2227+
{
2228+
"language": "en",
2229+
"title": "Fun Activities Notice",
2230+
"description": "Notice for fun activities",
2231+
}
2232+
],
2233+
},
2234+
)
2235+
privacy_notice = PrivacyNotice.create(
2236+
db=db,
2237+
data={
2238+
"name": "fun activities notice",
2239+
"notice_key": "fun_activities_notice_1",
2240+
"consent_mechanism": ConsentMechanism.opt_in,
2241+
"data_uses": ["fun"],
2242+
"enforcement_level": EnforcementLevel.system_wide,
2243+
"origin": template.id,
2244+
"translations": [
2245+
{
2246+
"language": "en",
2247+
"title": "Fun Activities Notice",
2248+
"description": "Notice for fun activities",
2249+
}
2250+
],
2251+
},
2252+
)
2253+
2254+
yield privacy_notice
2255+
2256+
# Clean up
2257+
for translation in privacy_notice.translations:
2258+
for history in translation.histories:
2259+
history.delete(db)
2260+
translation.delete(db)
2261+
privacy_notice.delete(db)
2262+
template.delete(db)
2263+
2264+
2265+
@pytest.fixture(scope="function")
2266+
def privacy_notice_empty_data_uses(db: Session) -> Generator:
2267+
"""
2268+
Privacy notice fixture with no data uses for testing edge cases.
2269+
2270+
Creates a privacy notice with empty data uses list to test that it returns
2271+
no cookies regardless of what cookie assets exist.
2272+
"""
2273+
template = PrivacyNoticeTemplate.create(
2274+
db,
2275+
check_name=False,
2276+
data={
2277+
"name": "Empty Notice",
2278+
"notice_key": "empty_notice_1",
2279+
"consent_mechanism": ConsentMechanism.opt_in,
2280+
"data_uses": [], # Empty data uses
2281+
"enforcement_level": EnforcementLevel.system_wide,
2282+
"translations": [
2283+
{
2284+
"language": "en",
2285+
"title": "Empty Notice",
2286+
"description": "This notice has no data uses.",
2287+
}
2288+
],
2289+
},
2290+
)
2291+
privacy_notice = PrivacyNotice.create(
2292+
db=db,
2293+
data={
2294+
"name": "Empty Notice",
2295+
"notice_key": "empty_notice_1",
2296+
"consent_mechanism": ConsentMechanism.opt_in,
2297+
"data_uses": [], # Empty data uses
2298+
"enforcement_level": EnforcementLevel.system_wide,
2299+
"origin": template.id,
2300+
"translations": [
2301+
{
2302+
"language": "en",
2303+
"title": "Empty Notice",
2304+
"description": "This notice has no data uses.",
2305+
}
2306+
],
2307+
},
2308+
)
2309+
2310+
yield privacy_notice
2311+
2312+
# Clean up
2313+
for translation in privacy_notice.translations:
2314+
for history in translation.histories:
2315+
history.delete(db)
2316+
translation.delete(db)
2317+
privacy_notice.delete(db)
2318+
template.delete(db)
2319+
2320+
21092321
@pytest.fixture(scope="function")
21102322
def privacy_notice_2(db: Session) -> Generator:
21112323
template = PrivacyNoticeTemplate.create(

0 commit comments

Comments
 (0)