diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e3a91369..e888e92e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ The types of changes are: - Added a `FidesPreferenceToggled` event to Fides.js to track when user preferences change without being saved [#4253](https://github.com/ethyca/fides/pull/4253) - Add AC Systems to the TCF Overlay under Vendor Consents section [#4266](https://github.com/ethyca/fides/pull/4266/) +### Changed +- Derive cookie storage info, privacy policy and legitimate interest disclosure URLs, and data retention data from the data map instead of directly from gvl.json [#4286](https://github.com/ethyca/fides/pull/4286) + ### Fixed - Stacks that do not have any purposes will no longer render an empty purpose block [#4278](https://github.com/ethyca/fides/pull/4278) - Forcing hidden sections to use display none [#4299](https://github.com/ethyca/fides/pull/4299) diff --git a/clients/admin-ui/src/features/system/SystemInformationForm.tsx b/clients/admin-ui/src/features/system/SystemInformationForm.tsx index 1e31710967..006707b4e2 100644 --- a/clients/admin-ui/src/features/system/SystemInformationForm.tsx +++ b/clients/admin-ui/src/features/system/SystemInformationForm.tsx @@ -432,7 +432,7 @@ const SystemInformationForm = ({ /> diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index 8b1f622fc9..b21c1d10a2 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -122,6 +122,7 @@ export type { DynamoDBDocsSchema } from "./models/DynamoDBDocsSchema"; export { EdgeDirection } from "./models/EdgeDirection"; export type { EmailDocsSchema } from "./models/EmailDocsSchema"; export type { EmbeddedLineItem } from "./models/EmbeddedLineItem"; +export type { EmbeddedPurpose } from "./models/EmbeddedPurpose"; export type { EmbeddedVendor } from "./models/EmbeddedVendor"; export type { Endpoint } from "./models/Endpoint"; export { EnforcementLevel } from "./models/EnforcementLevel"; diff --git a/clients/admin-ui/src/types/api/models/EmbeddedPurpose.ts b/clients/admin-ui/src/types/api/models/EmbeddedPurpose.ts new file mode 100644 index 0000000000..faf672f2f3 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/EmbeddedPurpose.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Sparse details for an embedded purpose beneath a system or vendor section. Read-only. + */ +export type EmbeddedPurpose = { + id: number; + name: string; + retention_period?: string; +}; diff --git a/clients/admin-ui/src/types/api/models/TCFVendorConsentRecord.ts b/clients/admin-ui/src/types/api/models/TCFVendorConsentRecord.ts index 3b6b1e7555..e50e2c5fca 100644 --- a/clients/admin-ui/src/types/api/models/TCFVendorConsentRecord.ts +++ b/clients/admin-ui/src/types/api/models/TCFVendorConsentRecord.ts @@ -2,7 +2,7 @@ /* tslint:disable */ /* eslint-disable */ -import type { EmbeddedLineItem } from "./EmbeddedLineItem"; +import type { EmbeddedPurpose } from "./EmbeddedPurpose"; import type { UserConsentPreference } from "./UserConsentPreference"; /** @@ -18,5 +18,5 @@ export type TCFVendorConsentRecord = { outdated_preference?: UserConsentPreference; current_served?: boolean; outdated_served?: boolean; - purpose_consents?: Array; + purpose_consents?: Array; }; diff --git a/clients/admin-ui/src/types/api/models/TCFVendorLegitimateInterestsRecord.ts b/clients/admin-ui/src/types/api/models/TCFVendorLegitimateInterestsRecord.ts index 012b4354b8..0d93d13bd3 100644 --- a/clients/admin-ui/src/types/api/models/TCFVendorLegitimateInterestsRecord.ts +++ b/clients/admin-ui/src/types/api/models/TCFVendorLegitimateInterestsRecord.ts @@ -2,7 +2,7 @@ /* tslint:disable */ /* eslint-disable */ -import type { EmbeddedLineItem } from "./EmbeddedLineItem"; +import type { EmbeddedPurpose } from "./EmbeddedPurpose"; import type { UserConsentPreference } from "./UserConsentPreference"; /** @@ -18,5 +18,5 @@ export type TCFVendorLegitimateInterestsRecord = { outdated_preference?: UserConsentPreference; current_served?: boolean; outdated_served?: boolean; - purpose_legitimate_interests?: Array; + purpose_legitimate_interests?: Array; }; diff --git a/clients/admin-ui/src/types/api/models/TCFVendorRelationships.ts b/clients/admin-ui/src/types/api/models/TCFVendorRelationships.ts index a5eb8d1dd4..07c27fbc7f 100644 --- a/clients/admin-ui/src/types/api/models/TCFVendorRelationships.ts +++ b/clients/admin-ui/src/types/api/models/TCFVendorRelationships.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { EmbeddedLineItem } from "./EmbeddedLineItem"; +import type { EmbeddedPurpose } from "./EmbeddedPurpose"; /** * Collects the other relationships for a given vendor - no preferences are saved here @@ -12,7 +13,7 @@ export type TCFVendorRelationships = { has_vendor_id?: boolean; name?: string; description?: string; - special_purposes?: Array; + special_purposes?: Array; features?: Array; special_features?: Array; cookie_max_age_seconds?: number; @@ -20,4 +21,5 @@ export type TCFVendorRelationships = { cookie_refresh?: boolean; uses_non_cookie_access?: boolean; legitimate_interest_disclosure_url?: string; + privacy_policy_url?: string; }; diff --git a/clients/fides-js/src/components/tcf/TcfVendors.tsx b/clients/fides-js/src/components/tcf/TcfVendors.tsx index 2357d1a891..95a942ce84 100644 --- a/clients/fides-js/src/components/tcf/TcfVendors.tsx +++ b/clients/fides-js/src/components/tcf/TcfVendors.tsx @@ -2,12 +2,10 @@ import { VNode, h } from "preact"; import { useMemo, useState } from "preact/hooks"; import { Vendor } from "@iabtechlabtcf/core"; import { - GvlDataRetention, - EmbeddedLineItem, GvlDataCategories, - GvlVendorUrl, GvlDataDeclarations, VendorRecord, + EmbeddedPurpose, } from "../../lib/tcf/types"; import { PrivacyExperience } from "../../lib/consent-types"; import { UpdateEnabledIds } from "./TcfOverlay"; @@ -21,30 +19,25 @@ import DoubleToggleTable from "./DoubleToggleTable"; const FILTERS = [{ name: "All vendors" }, { name: "IAB TCF vendors" }]; -interface Retention { - mapping: Record; - default: number; -} - const VendorDetails = ({ label, lineItems, - dataRetention, }: { label: string; - lineItems: EmbeddedLineItem[] | undefined; - dataRetention?: Retention; + lineItems: EmbeddedPurpose[] | undefined; }) => { if (!lineItems || lineItems.length === 0) { return null; } + const hasRetentionInfo = lineItems.some((li) => li.retention_period != null); + return ( - {dataRetention ? ( + {hasRetentionInfo ? ( @@ -52,22 +45,18 @@ const VendorDetails = ({ - {lineItems.map((item) => { - let retention: string | number = "N/A"; - if (dataRetention) { - retention = dataRetention.mapping[item.id] ?? dataRetention.default; - } - return ( - - - {dataRetention ? ( - - ) : null} - - ); - })} + {lineItems.map((item) => ( + + + {hasRetentionInfo ? ( + + ) : null} + + ))}
{label} Retention
{item.name} - {retention == null ? "N/A" : `${retention} day(s)`} -
{item.name} + {item.retention_period + ? `${item.retention_period} day(s)` + : "N/A"} +
); @@ -76,11 +65,9 @@ const VendorDetails = ({ const PurposeVendorDetails = ({ purposes, specialPurposes, - gvlVendor, }: { - purposes: EmbeddedLineItem[] | undefined; - specialPurposes: EmbeddedLineItem[] | undefined; - gvlVendor: Vendor | undefined; + purposes: EmbeddedPurpose[] | undefined; + specialPurposes: EmbeddedPurpose[] | undefined; }) => { const emptyPurposes = purposes ? purposes.length === 0 : true; const emptySpecialPurposes = specialPurposes @@ -90,35 +77,11 @@ const PurposeVendorDetails = ({ if (emptyPurposes && emptySpecialPurposes) { return null; } - // @ts-ignore our TCF lib does not have GVL v3 types yet - const dataRetention: GvlDataRetention | undefined = gvlVendor?.dataRetention; return (
- - + +
); }; @@ -158,13 +121,13 @@ const DataCategories = ({ ); }; -const StorageDisclosure = ({ vendor }: { vendor: Vendor }) => { +const StorageDisclosure = ({ vendor }: { vendor: VendorRecord }) => { const { name, - usesCookies, - usesNonCookieAccess, - cookieMaxAgeSeconds, - cookieRefresh, + uses_cookies: usesCookies, + uses_non_cookie_access: usesNonCookieAccess, + cookie_max_age_seconds: cookieMaxAgeSeconds, + cookie_refresh: cookieRefresh, } = vendor; let disclosure = ""; if (usesCookies) { @@ -172,12 +135,18 @@ const StorageDisclosure = ({ vendor }: { vendor: Vendor }) => { ? Math.ceil(cookieMaxAgeSeconds / 60 / 60 / 24) : 0; disclosure = `${name} stores cookies with a maximum duration of about ${days} Day(s).`; + if (cookieRefresh) { + disclosure = `${disclosure} These cookies may be refreshed.`; + } + if (usesNonCookieAccess) { + disclosure = `${disclosure} This vendor also uses other methods like "local storage" to store and access information on your device.`; + } + } else if (usesNonCookieAccess) { + disclosure = `${name} uses methods like "local storage" to store and access information on your device.`; } - if (cookieRefresh) { - disclosure = `${disclosure} These cookies may be refreshed.`; - } - if (usesNonCookieAccess) { - disclosure = `${disclosure} This vendor also uses other methods like "local storage" to store and access information on your device.`; + + if (disclosure === "") { + return null; } return

{disclosure}

; @@ -238,33 +207,37 @@ const TcfVendors = ({ } renderToggleChild={(vendor) => { const gvlVendor = vendorGvlEntry(vendor.id, experience.gvl); - // @ts-ignore the IAB-TCF lib doesn't support GVL v3 types yet - const url: GvlVendorUrl | undefined = gvlVendor?.urls.find( - (u: GvlVendorUrl) => u.langId === "en" - ); const dataCategories: GvlDataCategories | undefined = // @ts-ignore the IAB-TCF lib doesn't support GVL v3 types yet experience.gvl?.dataCategories; + const hasUrls = + vendor.privacy_policy_url || + vendor.legitimate_interest_disclosure_url; return (
- {gvlVendor ? : null} -
- {url?.privacy ? ( - Privacy policy - ) : null} - {url?.legIntClaim ? ( - - Legitimate interest disclosure - - ) : null} -
+ + {hasUrls ? ( +
+ {vendor.privacy_policy_url ? ( + + Privacy policy + + ) : null} + {vendor.legitimate_interest_disclosure_url ? ( + + Legitimate interest disclosure + + ) : null} +
+ ) : null} ; purpose_consent_preferences?: Array; purpose_legitimate_interests_preferences?: Array; diff --git a/clients/fides-js/src/lib/tcf/types.ts b/clients/fides-js/src/lib/tcf/types.ts index 6992d0051c..0d21c658d4 100644 --- a/clients/fides-js/src/lib/tcf/types.ts +++ b/clients/fides-js/src/lib/tcf/types.ts @@ -31,6 +31,12 @@ export type EmbeddedVendor = { name: string; }; +export type EmbeddedPurpose = { + id: number; + name: string; + retention_period?: string; +}; + // Purposes export type TCFPurposeConsentRecord = { id: number; @@ -141,7 +147,7 @@ export type TCFVendorConsentRecord = { outdated_preference?: UserConsentPreference; current_served?: boolean; outdated_served?: boolean; - purpose_consents?: Array; + purpose_consents?: Array; }; export type TCFVendorLegitimateInterestsRecord = { @@ -154,7 +160,7 @@ export type TCFVendorLegitimateInterestsRecord = { outdated_preference?: UserConsentPreference; current_served?: boolean; outdated_served?: boolean; - purpose_legitimate_interests?: Array; + purpose_legitimate_interests?: Array; }; export type TCFVendorRelationships = { @@ -162,9 +168,15 @@ export type TCFVendorRelationships = { has_vendor_id?: boolean; name?: string; description?: string; - special_purposes?: Array; + special_purposes?: Array; features?: Array; special_features?: Array; + cookie_max_age_seconds?: number; + uses_cookies?: boolean; + cookie_refresh?: boolean; + uses_non_cookie_access?: boolean; + legitimate_interest_disclosure_url?: string; + privacy_policy_url?: string; }; export type TCFVendorSave = { @@ -272,16 +284,6 @@ export type GVLJson = Pick< // GVL types—we should be able to get these from the library at some point, // but since they are on GVL 2.2, the types aren't quite right for GVL 3. -export interface GvlVendorUrl { - langId: string; - privacy?: string; - legIntClaim?: string; -} -export interface GvlDataRetention { - stdRetention: number; - purposes: Record; - specialPurposes: Record; -} interface GvlDataCategory { id: number; name: string; diff --git a/clients/fides-js/src/lib/tcf/vendors.ts b/clients/fides-js/src/lib/tcf/vendors.ts index b7aab96567..dec60dabd8 100644 --- a/clients/fides-js/src/lib/tcf/vendors.ts +++ b/clients/fides-js/src/lib/tcf/vendors.ts @@ -51,22 +51,12 @@ export const vendorIsAc = (vendorId: TCFVendorRelationships["id"]) => decodeVendorId(vendorId).source === VendorSources.AC; export const uniqueGvlVendorIds = (experience: PrivacyExperience): number[] => { - const { - tcf_vendor_consents: vendorConsents = [], - tcf_vendor_legitimate_interests: vendorLegints = [], - } = experience; + const { tcf_vendor_relationships: vendors = [] } = experience; - // List of i.e. [gvl.2, gacp.3, gvl.4] - const universalIds = Array.from( - new Set([ - ...vendorConsents.map((v) => v.id), - ...vendorLegints.map((v) => v.id), - ]) - ); // Filter to just i.e. [gvl.2, gvl.4] - const gvlIds = universalIds.filter((uid) => - vendorGvlEntry(uid, experience.gvl) - ); + const gvlIds = vendors + .map((v) => v.id) + .filter((uid) => vendorGvlEntry(uid, experience.gvl)); // Return [2,4] as numbers return gvlIds.map((uid) => +decodeVendorId(uid).id); }; @@ -83,15 +73,10 @@ const transformVendorDataToVendorRecords = ({ isFidesSystem: boolean; }) => { const records: VendorRecord[] = []; - const uniqueVendorIds = Array.from( - new Set([...consents.map((c) => c.id), ...legints.map((l) => l.id)]) - ); - uniqueVendorIds.forEach((id) => { - const vendorConsent = consents.find((v) => v.id === id); - const vendorLegint = legints.find((v) => v.id === id); - const relationship = relationships.find((r) => r.id === id); + relationships.forEach((relationship) => { + const vendorConsent = consents.find((v) => v.id === relationship.id); + const vendorLegint = legints.find((v) => v.id === relationship.id); const record: VendorRecord = { - id, ...relationship, ...vendorConsent, ...vendorLegint, diff --git a/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts b/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts index 1f2b3c570f..8597b00d1e 100644 --- a/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts +++ b/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts @@ -276,6 +276,7 @@ describe("Fides-js TCF", () => { cy.fixture("consent/experience_tcf.json").then((payload) => { const experience = payload.items[0]; experience.tcf_vendor_consents.push(newVendor); + experience.tcf_vendor_relationships.push(newVendor); stubConfig({ options: { isOverlayEnabled: true, @@ -316,14 +317,61 @@ describe("Fides-js TCF", () => { }); cy.window().then((win) => { win.__tcfapi("getTCData", 2, cy.stub().as("getTCData")); + cy.get("@getTCData") + .should("have.been.calledOnce") + .its("lastCall.args") + .then(([tcData, success]) => { + expect(success).to.eql(true); + expect(tcData.vendor.consents).to.eql({ 1: true, 2: true }); + }); }); - cy.get("@getTCData") - .should("have.been.calledOnce") - .its("lastCall.args") - .then(([tcData, success]) => { - expect(success).to.eql(true); - expect(tcData.vendor.consents).to.eql({ 1: true, 2: true }); + }); + + it("can render extra vendor info such as cookie and retention data", () => { + cy.get("#fides-tab-Vendors").click(); + cy.get(".fides-notice-toggle-title").contains(VENDOR_1.name).click(); + cy.get(".fides-disclosure-visible").within(() => { + // Check urls + cy.get("a") + .contains("Privacy policy") + .should("have.attr", "href") + .and("contain", "https://www.example.com/privacy"); + cy.get("a") + .contains("Legitimate interest disclosure") + .should("have.attr", "href") + .and( + "contain", + "https://www.example.com/legitimate_interest_disclosure" + ); + + // Check retention periods + [PURPOSE_4, PURPOSE_6, PURPOSE_7, PURPOSE_9].forEach((purpose) => { + // In the fixture, all retention periods are their id's + cy.get("tr") + .contains(purpose.name) + .parent() + .contains(`${purpose.id} day(s)`); }); + cy.get("tr") + .contains(SPECIAL_PURPOSE_1.name) + .parent() + .contains(`${SPECIAL_PURPOSE_1.id} day(s)`); + + // Check cookie disclosure + cy.get("p").contains( + 'Captify stores cookies with a maximum duration of about 5 Day(s). These cookies may be refreshed. This vendor also uses other methods like "local storage" to store and access information on your device.' + ); + }); + // Check the cookie disclosure on the system + // First close the vendor + cy.get(".fides-notice-toggle-title").contains(VENDOR_1.name).click(); + // Then open the system + cy.get(".fides-notice-toggle-title").contains(SYSTEM_1.name).click(); + cy.get(".fides-disclosure-visible").within(() => { + cy.get("p").contains( + "Fides System stores cookies with a maximum duration of about 5 Day(s)" + ); + }); }); it("can group toggle and fire FidesPreferenceToggled events", () => { @@ -944,6 +992,7 @@ describe("Fides-js TCF", () => { id: "test", purpose_legitimate_interests: [{ id: 4, name: purpose4.name }], }); + experience.tcf_vendor_relationships?.push({ ...vendor, id: "test" }); stubConfig({ options: { @@ -1533,13 +1582,13 @@ describe("Fides-js TCF", () => { ], }; AC_IDS.forEach((id, idx) => { + const vendor = { ...baseVendor, id: `gacp.${id}`, name: `AC ${id}` }; experience.tcf_vendor_consents.push({ - ...baseVendor, - id: `gacp.${id}`, - name: `AC ${id}`, + ...vendor, // Set some of these vendors without purpose_consents purpose_consents: idx % 2 === 0 ? [] : baseVendor.purpose_consents, }); + experience.tcf_vendor_relationships.push(vendor); }); stubConfig({ diff --git a/clients/privacy-center/cypress/fixtures/consent/experience_tcf.json b/clients/privacy-center/cypress/fixtures/consent/experience_tcf.json index c65a801f5e..fd9c290ce0 100644 --- a/clients/privacy-center/cypress/fixtures/consent/experience_tcf.json +++ b/clients/privacy-center/cypress/fixtures/consent/experience_tcf.json @@ -281,19 +281,23 @@ "purpose_consents": [ { "id": 4, - "name": "Use profiles to select personalised advertising" + "name": "Use profiles to select personalised advertising", + "retention_period": "4" }, { "id": 6, - "name": "Use profiles to select personalised content" + "name": "Use profiles to select personalised content", + "retention_period": "6" }, { "id": 7, - "name": "Measure advertising performance" + "name": "Measure advertising performance", + "retention_period": "7" }, { "id": 9, - "name": "Understand audiences through statistics or combinations of data from different sources" + "name": "Understand audiences through statistics or combinations of data from different sources", + "retention_period": "9" } ] } @@ -301,6 +305,8 @@ "tcf_vendor_legitimate_interests": [], "tcf_vendor_relationships": [ { + "cookie_max_age_seconds": 360000, + "cookie_refresh": true, "id": "2", "has_vendor_id": true, "name": "Captify", @@ -308,11 +314,16 @@ "special_purposes": [ { "id": 1, - "name": "Ensure security, prevent and detect fraud, and fix errors" + "name": "Ensure security, prevent and detect fraud, and fix errors", + "retention_period": "1" } ], + "uses_cookies": true, + "uses_non_cookie_access": true, "features": [], - "special_features": [] + "special_features": [], + "privacy_policy_url": "https://www.example.com/privacy", + "legitimate_interest_disclosure_url": "https://www.example.com/legitimate_interest_disclosure" } ], "tcf_consent_systems": [], @@ -330,13 +341,16 @@ "purpose_legitimate_interests": [ { "id": 2, - "name": "Use limited data to select advertising" + "name": "Use limited data to select advertising", + "retention_period": "2" } ] } ], "tcf_system_relationships": [ { + "cookie_max_age_seconds": 400000, + "cookie_refresh": false, "id": "ctl_b3dde2d5-e535-4d9a-bf6e-a3b6beb01761", "has_vendor_id": false, "name": "Fides System", @@ -344,7 +358,8 @@ "special_purposes": [ { "id": 1, - "name": "Ensure security, prevent and detect fraud, and fix errors" + "name": "Ensure security, prevent and detect fraud, and fix errors", + "retention_period": "1" } ], "features": [ @@ -353,7 +368,9 @@ "name": "Match and combine data from other data sources" } ], - "special_features": [] + "special_features": [], + "uses_cookies": true, + "uses_non_cookie_access": false } ], "created_at": "2023-09-26T18:59:40.416181+00:00", diff --git a/clients/privacy-center/types/api/models/SavePrivacyPreferencesResponse.ts b/clients/privacy-center/types/api/models/SavePrivacyPreferencesResponse.ts index 086759e8ee..dea544de10 100644 --- a/clients/privacy-center/types/api/models/SavePrivacyPreferencesResponse.ts +++ b/clients/privacy-center/types/api/models/SavePrivacyPreferencesResponse.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { CurrentPrivacyPreferenceSchema } from "./CurrentPrivacyPreferenceSchema"; +import type { TCMobileData } from "./TCMobileData"; /** * Response schema when saving privacy preferences @@ -18,4 +19,5 @@ export type SavePrivacyPreferencesResponse = { special_feature_preferences?: Array; system_consent_preferences?: Array; system_legitimate_interests_preferences?: Array; + fides_mobile_data?: TCMobileData; }; diff --git a/src/fides/api/schemas/tcf.py b/src/fides/api/schemas/tcf.py index e16ee3932b..509edb1722 100644 --- a/src/fides/api/schemas/tcf.py +++ b/src/fides/api/schemas/tcf.py @@ -67,6 +67,12 @@ class EmbeddedLineItem(FidesSchema): name: str +class EmbeddedPurpose(EmbeddedLineItem): + """Sparse details for an embedded purpose beneath a system or vendor section. Read-only.""" + + retention_period: Optional[str] + + class CommonVendorFields(FidesSchema): """Fields shared between the three vendor sections of the TCF Experience""" @@ -79,7 +85,7 @@ class CommonVendorFields(FidesSchema): class TCFVendorConsentRecord(UserSpecificConsentDetails, CommonVendorFields): """Schema for a TCF Vendor with Consent legal basis""" - purpose_consents: List[EmbeddedLineItem] = [] + purpose_consents: List[EmbeddedPurpose] = [] @root_validator def add_default_preference(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -94,7 +100,7 @@ class TCFVendorLegitimateInterestsRecord( ): """Schema for a TCF Vendor with Legitimate interests legal basis""" - purpose_legitimate_interests: List[EmbeddedLineItem] = [] + purpose_legitimate_interests: List[EmbeddedPurpose] = [] @root_validator def add_default_preference(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -107,7 +113,7 @@ def add_default_preference(cls, values: Dict[str, Any]) -> Dict[str, Any]: class TCFVendorRelationships(CommonVendorFields): """Collects the other relationships for a given vendor - no preferences are saved here""" - special_purposes: List[EmbeddedLineItem] = [] + special_purposes: List[EmbeddedPurpose] = [] features: List[EmbeddedLineItem] = [] special_features: List[EmbeddedLineItem] = [] cookie_max_age_seconds: Optional[int] @@ -115,6 +121,7 @@ class TCFVendorRelationships(CommonVendorFields): cookie_refresh: Optional[bool] uses_non_cookie_access: Optional[bool] legitimate_interest_disclosure_url: Optional[AnyUrl] + privacy_policy_url: Optional[AnyUrl] class TCFFeatureRecord(NonVendorSection, Feature): diff --git a/src/fides/api/util/tcf/tcf_experience_contents.py b/src/fides/api/util/tcf/tcf_experience_contents.py index 0135e09427..90e78aca89 100644 --- a/src/fides/api/util/tcf/tcf_experience_contents.py +++ b/src/fides/api/util/tcf/tcf_experience_contents.py @@ -28,6 +28,7 @@ ) from fides.api.schemas.base_class import FidesSchema from fides.api.schemas.tcf import ( + EmbeddedPurpose, EmbeddedVendor, TCFFeatureRecord, TCFPurposeConsentRecord, @@ -205,10 +206,12 @@ def get_matching_privacy_declarations(db: Session) -> Query: System.legitimate_interest_disclosure_url.label( "system_legitimate_interest_disclosure_url" ), + System.privacy_policy.label("system_privacy_policy"), System.vendor_id, PrivacyDeclaration.data_use, PrivacyDeclaration.legal_basis_for_processing, PrivacyDeclaration.features, + PrivacyDeclaration.retention_period, ) .outerjoin(PrivacyDeclaration, System.id == PrivacyDeclaration.system_id) .filter( @@ -305,6 +308,8 @@ def _add_top_level_record_to_purpose_or_feature_section( def _embed_purpose_or_feature_under_system( embedded_tcf_record: NonVendorRecord, system_section: SystemSubSections, + retention_period: Optional[str], + is_purpose_section: bool, ) -> None: """ Embed a second-level TCF purpose/feature under the systems section. @@ -323,8 +328,18 @@ def _embed_purpose_or_feature_under_system( if embedded_non_vendor_record: return - # Nest new cloned TCF purpose or feature record beneath system otherwise - system_section.append(embedded_tcf_record) # type: ignore[arg-type] + if is_purpose_section: + # Build the EmbeddedPurpose record with the retention period + system_section.append( + EmbeddedPurpose( # type: ignore[arg-type] + id=embedded_tcf_record.id, + name=embedded_tcf_record.name, + retention_period=retention_period, + ) + ) + else: + # Nest new cloned feature record beneath system otherwise + system_section.append(embedded_tcf_record) def _embed_system_under_purpose_or_feature( @@ -432,6 +447,8 @@ def build_purpose_or_feature_section_and_update_vendor_map( system_section=getattr( vendor_map[system_identifier], vendor_subsection_name ), + retention_period=privacy_declaration_row.retention_period, + is_purpose_section=is_purpose_section, ) # Finally, nest the system beneath this top level non-vendor tcf record @@ -520,6 +537,9 @@ def populate_vendor_relationships_basic_attributes( vendor_relationship_record.legitimate_interest_disclosure_url = ( privacy_declaration_row.system_legitimate_interest_disclosure_url ) + vendor_relationship_record.privacy_policy_url = ( + privacy_declaration_row.system_privacy_policy + ) return vendor_map diff --git a/tests/fixtures/application_fixtures.py b/tests/fixtures/application_fixtures.py index b659255d5c..235ca66f02 100644 --- a/tests/fixtures/application_fixtures.py +++ b/tests/fixtures/application_fixtures.py @@ -2807,6 +2807,7 @@ def tcf_system(db: Session) -> System: "legal_basis_for_processing": "Consent", "egress": None, "ingress": None, + "retention_period": "3", }, ) @@ -2823,6 +2824,7 @@ def tcf_system(db: Session) -> System: "legal_basis_for_processing": "Legitimate interests", "egress": None, "ingress": None, + "retention_period": "1", }, ) diff --git a/tests/ops/util/test_tcf_experience_contents.py b/tests/ops/util/test_tcf_experience_contents.py index a13b6ba12e..7daf0891d1 100644 --- a/tests/ops/util/test_tcf_experience_contents.py +++ b/tests/ops/util/test_tcf_experience_contents.py @@ -1,8 +1,10 @@ +from uuid import uuid4 + import pytest from fideslang import MAPPED_PURPOSES from fideslang.models import LegalBasisForProcessingEnum -from fides.api.models.sql_models import PrivacyDeclaration +from fides.api.models.sql_models import PrivacyDeclaration, System from fides.api.schemas.tcf import EmbeddedVendor from fides.api.util.tcf.tcf_experience_contents import get_tcf_contents @@ -231,6 +233,7 @@ def test_system_has_declaration_no_features_special_features_special_purposes( assert vendor_relationship.uses_non_cookie_access is False assert vendor_relationship.cookie_refresh is False assert vendor_relationship.legitimate_interest_disclosure_url is None + assert vendor_relationship.privacy_policy_url is None @pytest.mark.usefixtures("tcf_system") def test_system_exists_with_tcf_purpose_and_vendor(self, db): @@ -274,6 +277,7 @@ def test_system_exists_with_tcf_purpose_and_vendor(self, db): assert not hasattr( tcf_contents.tcf_vendor_consents[0], "legitimate_interest_disclosure_url" ) + assert not hasattr(tcf_contents.tcf_vendor_consents[0], "privacy_policy_url") assert len(tcf_contents.tcf_vendor_consents[0].purpose_consents) == 1 assert tcf_contents.tcf_vendor_consents[0].purpose_consents[0].id == 8 @@ -294,8 +298,15 @@ def test_system_exists_with_tcf_purpose_and_vendor(self, db): tcf_contents.tcf_vendor_relationships[0].legitimate_interest_disclosure_url is None ) + assert tcf_contents.tcf_vendor_relationships[0].privacy_policy_url is None assert len(tcf_contents.tcf_vendor_relationships[0].special_purposes) == 1 assert tcf_contents.tcf_vendor_relationships[0].special_purposes[0].id == 1 + assert ( + tcf_contents.tcf_vendor_relationships[0] + .special_purposes[0] + .retention_period + == "1" + ) def test_system_exists_with_tcf_purpose_and_vendor_including_tcf_fields_set( self, db, tcf_system @@ -309,6 +320,7 @@ def test_system_exists_with_tcf_purpose_and_vendor_including_tcf_fields_set( tcf_system.cookie_refresh = True tcf_system.uses_non_cookie_access = True tcf_system.legitimate_interest_disclosure_url = "http://test.com/disclosure_url" + tcf_system.privacy_policy = "http://test.com/privacy_url" tcf_system.save(db) tcf_contents = get_tcf_contents(db) @@ -350,9 +362,14 @@ def test_system_exists_with_tcf_purpose_and_vendor_including_tcf_fields_set( assert not hasattr( tcf_contents.tcf_vendor_consents[0], "legitimate_interest_disclosure_url" ) + assert not hasattr(tcf_contents.tcf_vendor_consents[0], "privacy_policy_url") assert len(tcf_contents.tcf_vendor_consents[0].purpose_consents) == 1 assert tcf_contents.tcf_vendor_consents[0].purpose_consents[0].id == 8 + assert ( + tcf_contents.tcf_vendor_consents[0].purpose_consents[0].retention_period + == "3" + ) assert tcf_contents.tcf_vendor_relationships[0].id == "gvl.42" assert tcf_contents.tcf_vendor_relationships[0].name == "TCF System Test" @@ -372,6 +389,10 @@ def test_system_exists_with_tcf_purpose_and_vendor_including_tcf_fields_set( tcf_contents.tcf_vendor_relationships[0].legitimate_interest_disclosure_url == "http://test.com/disclosure_url" ) + assert ( + tcf_contents.tcf_vendor_relationships[0].privacy_policy_url + == "http://test.com/privacy_url" + ) assert len(tcf_contents.tcf_vendor_relationships[0].special_purposes) == 1 assert tcf_contents.tcf_vendor_relationships[0].special_purposes[0].id == 1 @@ -542,6 +563,75 @@ def test_system_matches_subset_of_purpose_data_uses(self, db, tcf_system): assert len(tcf_contents.tcf_vendor_consents[0].purpose_consents) == 1 assert tcf_contents.tcf_vendor_consents[0].purpose_consents[0].id == 2 + @pytest.mark.usefixtures("tcf_system") + def test_two_vendors_same_purpose_different_retention_period(self, db, tcf_system): + """Test making sure that two vendors that share the same purpose show up with + different retention periods in their EmbeddedPurposes""" + + # Create a second system with the same privacy declaration as tcf_system + # but with a different retention period + second_system = System.create( + db=db, + data={ + "fides_key": f"tcf-system_key-f{uuid4()}", + "vendor_id": "gvl.100", + "name": f"TCF System Second Test", + "description": "My Second TCF System Description", + "organization_fides_key": "default_organization", + "system_type": "Service", + "data_responsibility_title": "Processor", + "data_protection_impact_assessment": { + "is_required": False, + "progress": None, + "link": None, + }, + }, + ) + + PrivacyDeclaration.create( + db=db, + data={ + "name": "Collect data for content performance", + "system_id": second_system.id, + "data_categories": ["user.device.cookie_id"], + "data_use": "analytics.reporting.content_performance", + "data_qualifier": "aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified", + "data_subjects": ["customer"], + "dataset_references": None, + "legal_basis_for_processing": "Consent", + "egress": None, + "ingress": None, + "retention_period": "5", # the fixture already has a retention_period of 3 + }, + ) + + tcf_contents = get_tcf_contents(db) + + assert_length_of_tcf_sections( + tcf_contents, + p_c_len=1, + p_li_len=0, + f_len=0, + sp_len=1, + sf_len=0, + v_c_len=2, + v_li_len=0, + v_r_len=2, + s_c_len=0, + s_li_len=0, + s_r_len=0, + ) + + assert tcf_contents.tcf_vendor_consents[0].id == "gvl.100" + assert tcf_contents.tcf_vendor_consents[0].purpose_consents == [ + {"id": 8, "name": "Measure content performance", "retention_period": "5"} + ] + + assert tcf_contents.tcf_vendor_consents[1].id == "gvl.42" + assert tcf_contents.tcf_vendor_consents[1].purpose_consents == [ + {"id": 8, "name": "Measure content performance", "retention_period": "3"} + ] + @pytest.mark.usefixtures("tcf_system") def test_special_purposes(self, db): tcf_contents = get_tcf_contents(db) @@ -773,6 +863,12 @@ def test_duplicate_data_uses_on_system(self, tcf_system, db): .id == 3 ) + assert ( + tcf_contents.tcf_vendor_legitimate_interests[0] + .purpose_legitimate_interests[0] + .retention_period + == "1" + ) def test_add_different_data_uses_that_correspond_to_same_purpose( self, tcf_system, db