From f9c8200bb761c38a81fbeeff698407675988cc08 Mon Sep 17 00:00:00 2001 From: Dawn Pattison Date: Wed, 21 Jun 2023 18:12:46 -0500 Subject: [PATCH] Add Cookies and Surface with Privacy Notices (#3572) - Backend: Add a Cookies table with FK's to PrivacyDeclaration and System. Surface cookies on privacy notices, calculated at runtime - Frontend: Add cookie input field on system data use tab - Frontend: Have `fides-js` and privacy center delete cookies associated with notices that were opted out of Co-authored-by: Thomas Co-authored-by: Thomas Co-authored-by: Allison King --- .fides/db_dataset.yml | 37 +++ CHANGELOG.md | 5 +- clients/admin-ui/cypress/e2e/systems.cy.ts | 2 + .../cypress/fixtures/systems/system.json | 4 +- .../cypress/fixtures/systems/systems.json | 12 +- .../systems/systems_with_data_uses.json | 8 +- .../src/features/common/form/inputs.tsx | 19 +- .../src/features/system/SystemFormTabs.tsx | 2 +- .../PrivacyDeclarationAccordion.tsx | 21 +- .../PrivacyDeclarationForm.tsx | 74 +++--- .../PrivacyDeclarationManager.tsx | 77 +++---- .../PrivacyDeclarationStep.tsx | 5 +- .../system/privacy-declarations/types.ts | 11 - .../src/features/system/system.slice.ts | 7 +- clients/admin-ui/src/types/api/index.ts | 5 +- .../src/types/api/models/ConnectionType.ts | 34 +-- .../src/types/api/models/ConnectorParam.ts | 1 + .../src/types/api/models/ConsentReport.ts | 4 +- .../admin-ui/src/types/api/models/Cookies.ts | 12 + ...reateConnectionConfigurationWithSecrets.ts | 4 +- .../admin-ui/src/types/api/models/Dataset.ts | 4 +- .../types/api/models/DynamoDBDocsSchema.ts | 2 +- .../src/types/api/models/EdgeDirection.ts | 11 + .../src/types/api/models/EmailDocsSchema.ts | 2 +- .../types/api/models/FidesDatasetReference.ts | 11 +- .../src/types/api/models/IdentityBase.ts | 11 - .../types/api/models/PostgreSQLDocsSchema.ts | 1 + .../types/api/models/PrivacyDeclaration.ts | 6 + .../api/models/PrivacyDeclarationResponse.ts | 3 + .../types/api/models/PrivacyNoticeResponse.ts | 2 + ...rivacyNoticeResponseWithUserPreferences.ts | 2 + .../types/api/models/RedshiftDocsSchema.ts | 1 + .../src/types/api/models/ResponseFormat.ts | 1 + .../src/types/api/models/SaaSRequest.ts | 2 +- .../src/types/api/models/SovrnDocsSchema.ts | 2 +- .../admin-ui/src/types/api/models/System.ts | 4 +- .../src/types/api/models/SystemResponse.ts | 6 +- .../types/api/models/TimescaleDocsSchema.ts | 1 + clients/fides-js/__tests__/lib/cookie.test.ts | 49 +++- clients/fides-js/src/components/Overlay.tsx | 6 +- clients/fides-js/src/fides.ts | 3 +- clients/fides-js/src/lib/consent-types.ts | 20 +- clients/fides-js/src/lib/cookie.ts | 20 +- clients/fides-js/src/lib/preferences.ts | 43 ++-- clients/fides-js/src/services/fides/api.ts | 1 - .../consent/NoticeDrivenConsent.tsx | 63 ++++-- .../cypress/e2e/consent-banner.cy.ts | 56 +++++ .../cypress/e2e/consent-notices.cy.ts | 105 +++++++++ .../cypress/fixtures/consent/experience.json | 12 +- .../fixtures/consent/overlay_experience.json | 3 +- .../fixtures/consent/test_banner_options.json | 6 +- .../public/fides-js-components-demo.html | 2 + .../types/api/models/Cookies.ts | 12 + .../types/api/models/PrivacyNoticeResponse.ts | 2 + ...rivacyNoticeResponseWithUserPreferences.ts | 2 + .../postman/Fides.postman_collection.json | 139 ++++++++++++ requirements.txt | 4 +- .../versions/2be84e68df32_add_cookie_table.py | 70 ++++++ src/fides/api/db/crud.py | 2 +- src/fides/api/db/system.py | 82 ++++++- src/fides/api/models/privacy_notice.py | 28 ++- src/fides/api/models/sql_models.py | 49 ++++ src/fides/api/schemas/dataset.py | 4 +- src/fides/api/schemas/privacy_notice.py | 2 + src/fides/api/schemas/privacy_request.py | 4 +- src/fides/api/schemas/system.py | 5 +- tests/conftest.py | 17 +- tests/ctl/core/test_api.py | 85 +++++++ tests/ctl/core/test_system.py | 210 ++++++++++++++++++ ...est_privacy_experience_config_endpoints.py | 21 +- .../test_privacy_notice_endpoints.py | 29 +++ tests/ops/models/test_privacy_experience.py | 9 +- tests/ops/models/test_privacy_notice.py | 65 ++++++ 73 files changed, 1391 insertions(+), 255 deletions(-) delete mode 100644 clients/admin-ui/src/features/system/privacy-declarations/types.ts create mode 100644 clients/admin-ui/src/types/api/models/Cookies.ts create mode 100644 clients/admin-ui/src/types/api/models/EdgeDirection.ts delete mode 100644 clients/admin-ui/src/types/api/models/IdentityBase.ts create mode 100644 clients/privacy-center/types/api/models/Cookies.ts create mode 100644 src/fides/api/alembic/migrations/versions/2be84e68df32_add_cookie_table.py diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index 001fabf84a..a73b4c3261 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -2240,4 +2240,41 @@ dataset: description: 'The name of the organization this Fides deployment belongs to' data_categories: - user.workplace + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: cookies + description: 'Fides Generated Description for Table: cookies' + data_categories: [] + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + fields: + - name: created_at + data_categories: + - system.operations + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: domain + data_categories: + - system.operations + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: id + data_categories: + - system.operations + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: name + data_categories: + - system.operations + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: path + data_categories: + - system.operations + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: privacy_declaration_id + data_categories: + - system.operations + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: system_id + data_categories: + - system.operations + data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified + - name: updated_at + data_categories: + - system.operations data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d51be76e..921da9ec50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ The types of changes are: - HTML format for privacy request storage destinations [#3427](https://github.com/ethyca/fides/pull/3427) - Persistent message showing result and timestamp of last integration test to "Integrations" tab in system view [#3628](https://github.com/ethyca/fides/pull/3628) - Access and erasure support for SurveyMonkey [#3590](https://github.com/ethyca/fides/pull/3590) +- New Cookies Table for storing cookies associated with systems and privacy declarations [#3572](https://github.com/ethyca/fides/pull/3572) +- `fides-js` and privacy center now delete cookies associated with notices that were opted out of [#3569](https://github.com/ethyca/fides/pull/3569) +- Cookie input field on system data use tab [#3571](https://github.com/ethyca/fides/pull/3571) ### Fixed @@ -198,7 +201,7 @@ The types of changes are: ### Developer Experience -- Use prettier to format *all* source files in client packages [#3240](https://github.com/ethyca/fides/pull/3240) +- Use prettier to format _all_ source files in client packages [#3240](https://github.com/ethyca/fides/pull/3240) ### Deprecated diff --git a/clients/admin-ui/cypress/e2e/systems.cy.ts b/clients/admin-ui/cypress/e2e/systems.cy.ts index 7974b0ceab..2361b15a39 100644 --- a/clients/admin-ui/cypress/e2e/systems.cy.ts +++ b/clients/admin-ui/cypress/e2e/systems.cy.ts @@ -172,6 +172,8 @@ describe("System management page", () => { data_categories: declaration.data_categories, data_subjects: declaration.data_subjects, dataset_references: ["demo_users_dataset_2"], + cookies: [], + id: "", }); }); }); diff --git a/clients/admin-ui/cypress/fixtures/systems/system.json b/clients/admin-ui/cypress/fixtures/systems/system.json index 5929eb18dd..09e930e287 100644 --- a/clients/admin-ui/cypress/fixtures/systems/system.json +++ b/clients/admin-ui/cypress/fixtures/systems/system.json @@ -16,7 +16,9 @@ "data_use": "improve.system", "data_qualifier": "aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified", "data_subjects": ["customer"], - "dataset_references": ["demo_users_dataset"] + "dataset_references": ["demo_users_dataset"], + "cookies": [], + "id": "pri_ac9d4dfb-d033-4b06-bc7f-968df8d125ff" } ], "joint_controller": { "name": "Sally Controller" }, diff --git a/clients/admin-ui/cypress/fixtures/systems/systems.json b/clients/admin-ui/cypress/fixtures/systems/systems.json index a73c0d419f..6e9e6aa264 100644 --- a/clients/admin-ui/cypress/fixtures/systems/systems.json +++ b/clients/admin-ui/cypress/fixtures/systems/systems.json @@ -17,7 +17,9 @@ "data_use": "improve.system", "data_qualifier": "aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified", "data_subjects": ["anonymous_user"], - "dataset_references": ["public"] + "dataset_references": ["public"], + "cookies": [], + "id": "pri_ac9d4dfb-d033-4b06-bc7f-968df8d125ff" } ], "joint_controller": null, @@ -59,7 +61,9 @@ "data_subjects": ["customer"], "dataset_references": ["demo_users_dataset"], "egress": null, - "ingress": null + "ingress": null, + "cookies": [], + "id": "pri_ac9d4dfb-d033-4b06-bc7f-968df8d125ff" } ], "joint_controller": null, @@ -99,7 +103,9 @@ "data_subjects": ["customer"], "dataset_references": null, "egress": null, - "ingress": null + "ingress": null, + "cookies": [], + "id": "pri_06430a1c-1365-422e-90a7-d444ddb32181" } ], "joint_controller": null, diff --git a/clients/admin-ui/cypress/fixtures/systems/systems_with_data_uses.json b/clients/admin-ui/cypress/fixtures/systems/systems_with_data_uses.json index 533a20f7d9..37c89489c3 100644 --- a/clients/admin-ui/cypress/fixtures/systems/systems_with_data_uses.json +++ b/clients/admin-ui/cypress/fixtures/systems/systems_with_data_uses.json @@ -17,7 +17,9 @@ "data_use": "improve.system", "data_qualifier": "aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified", "data_subjects": ["anonymous_user"], - "dataset_references": ["public"] + "dataset_references": ["public"], + "cookies": [], + "id": "pri_ac9d4dfb-d033-4b06-bc7f-968df8d125ff" }, { "name": "Collect data for marketing", @@ -27,7 +29,9 @@ "data_subjects": ["customer"], "dataset_references": null, "egress": null, - "ingress": null + "ingress": null, + "cookies": [], + "id": "pri_bc6e6efe-f122-3e33-ac9a-732ae8b437bb" } ], "joint_controller": null, diff --git a/clients/admin-ui/src/features/common/form/inputs.tsx b/clients/admin-ui/src/features/common/form/inputs.tsx index d242550648..7f6de443f1 100644 --- a/clients/admin-ui/src/features/common/form/inputs.tsx +++ b/clients/admin-ui/src/features/common/form/inputs.tsx @@ -316,21 +316,26 @@ const CreatableSelectInput = ({ size={size} classNamePrefix="custom-creatable-select" chakraStyles={{ - container: (provided) => ({ ...provided, flexGrow: 1 }), + container: (provided) => ({ + ...provided, + flexGrow: 1, + backgroundColor: "white", + }), dropdownIndicator: (provided) => ({ ...provided, - background: "white", + bg: "transparent", + px: 2, + cursor: "inherit", + }), + indicatorSeparator: (provided) => ({ + ...provided, + display: "none", }), multiValue: (provided) => ({ ...provided, background: "primary.400", color: "white", }), - multiValueRemove: (provided) => ({ - ...provided, - display: "none", - visibility: "hidden", - }), }} components={components} isSearchable={isSearchable} diff --git a/clients/admin-ui/src/features/system/SystemFormTabs.tsx b/clients/admin-ui/src/features/system/SystemFormTabs.tsx index 35450b30fa..64171f0a84 100644 --- a/clients/admin-ui/src/features/system/SystemFormTabs.tsx +++ b/clients/admin-ui/src/features/system/SystemFormTabs.tsx @@ -185,7 +185,7 @@ const SystemFormTabs = ({ label: "Data uses", content: activeSystem ? ( - + ) : null, isDisabled: !activeSystem, diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx index a08df39a7d..0df4261190 100644 --- a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx @@ -9,6 +9,7 @@ import { import { Form, Formik } from "formik"; import { FormGuard } from "~/features/common/hooks/useIsAnyFormDirty"; +import { PrivacyDeclarationResponse } from "~/types/api"; import { DataProps, @@ -16,18 +17,18 @@ import { usePrivacyDeclarationForm, ValidationSchema, } from "./PrivacyDeclarationForm"; -import { PrivacyDeclarationWithId } from "./types"; interface AccordionProps extends DataProps { - privacyDeclarations: PrivacyDeclarationWithId[]; + privacyDeclarations: PrivacyDeclarationResponse[]; onEdit: ( - oldDeclaration: PrivacyDeclarationWithId, - newDeclaration: PrivacyDeclarationWithId - ) => Promise; + oldDeclaration: PrivacyDeclarationResponse, + newDeclaration: PrivacyDeclarationResponse + ) => Promise; onDelete: ( - declaration: PrivacyDeclarationWithId - ) => Promise; + declaration: PrivacyDeclarationResponse + ) => Promise; includeCustomFields?: boolean; + includeCookies?: boolean; } const PrivacyDeclarationAccordionItem = ({ @@ -35,12 +36,13 @@ const PrivacyDeclarationAccordionItem = ({ onEdit, onDelete, includeCustomFields, + includeCookies, ...dataProps -}: { privacyDeclaration: PrivacyDeclarationWithId } & Omit< +}: { privacyDeclaration: PrivacyDeclarationResponse } & Omit< AccordionProps, "privacyDeclarations" >) => { - const handleEdit = (values: PrivacyDeclarationWithId) => + const handleEdit = (values: PrivacyDeclarationResponse) => onEdit(privacyDeclaration, values); const { initialValues, renderHeader, handleSubmit } = @@ -85,6 +87,7 @@ const PrivacyDeclarationAccordionItem = ({ privacyDeclarationId={privacyDeclaration.id} onDelete={onDelete} includeCustomFields={includeCustomFields} + includeCookies={includeCookies} {...dataProps} /> diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx index e19c757382..124b004685 100644 --- a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx @@ -23,19 +23,21 @@ import { useMemo, useState } from "react"; import * as Yup from "yup"; import ConfirmationModal from "~/features/common/ConfirmationModal"; -import { CustomSelect, CustomTextInput } from "~/features/common/form/inputs"; +import { + CustomCreatableSelect, + CustomSelect, + CustomTextInput, +} from "~/features/common/form/inputs"; import { FormGuard } from "~/features/common/hooks/useIsAnyFormDirty"; import { DataCategory, Dataset, DataSubject, DataUse, - PrivacyDeclaration, + PrivacyDeclarationResponse, ResourceTypes, } from "~/types/api"; -import { PrivacyDeclarationWithId } from "./types"; - export const ValidationSchema = Yup.object().shape({ data_categories: Yup.array(Yup.string()) .min(1, "Must assign at least one data category") @@ -46,8 +48,9 @@ export const ValidationSchema = Yup.object().shape({ .label("Data subjects"), }); -export type FormValues = PrivacyDeclarationWithId & { +export type FormValues = Omit & { customFieldValues: CustomFieldValues; + cookies: string[]; }; const defaultInitialValues: FormValues = { @@ -57,30 +60,21 @@ const defaultInitialValues: FormValues = { dataset_references: [], customFieldValues: {}, id: "", + cookies: [], }; -const transformPrivacyDeclarationToHaveId = ( - privacyDeclaration: PrivacyDeclaration -) => { - // TODO: there's a typing problem here: the backend types still show PrivacyDeclaration - // instead of PrivacyDeclarationResponse (which has an id) - // @ts-ignore - const { id, name, data_use: dataUse } = privacyDeclaration; - let declarationId: string | undefined = id; - if (!declarationId) { - declarationId = name ? `${dataUse} - ${name}` : dataUse; - } +const transformFormValueToDeclaration = (values: FormValues) => { + const { customFieldValues, ...declaration } = values; + return { - ...privacyDeclaration, - id: declarationId, + ...declaration, + // Fill in an empty string for name because of https://github.com/ethyca/fideslang/issues/98 + name: values.name ?? "", + // Transform cookies from string back to an object with default values + cookies: declaration.cookies.map((name) => ({ name, path: "/" })), }; }; -export const transformPrivacyDeclarationsToHaveId = ( - privacyDeclarations: PrivacyDeclaration[] -): PrivacyDeclarationWithId[] => - privacyDeclarations.map(transformPrivacyDeclarationToHaveId); - export interface DataProps { allDataCategories: DataCategory[]; allDataUses: DataUse[]; @@ -95,10 +89,12 @@ export const PrivacyDeclarationFormComponents = ({ allDatasets, onDelete, privacyDeclarationId, + includeCookies, includeCustomFields, }: DataProps & Pick & { privacyDeclarationId?: string; + includeCookies?: boolean; includeCustomFields?: boolean; }) => { const { dirty, isSubmitting, isValid, initialValues } = @@ -113,7 +109,7 @@ export const PrivacyDeclarationFormComponents = ({ : []; const handleDelete = async () => { - await onDelete(transformPrivacyDeclarationToHaveId(initialValues)); + await onDelete(transformFormValueToDeclaration(initialValues)); deleteModal.onClose(); }; @@ -164,6 +160,16 @@ export const PrivacyDeclarationFormComponents = ({ isMulti variant="stacked" /> + {includeCookies ? ( + + ) : null} {allDatasets ? ( privacyDeclaration ? { ...privacyDeclaration, customFieldValues: customFieldValues || {}, + cookies: privacyDeclaration.cookies?.map((cookie) => cookie.name) ?? [], } : defaultInitialValues; @@ -264,8 +271,10 @@ export const usePrivacyDeclarationForm = ({ values: FormValues, formikHelpers: FormikHelpers ) => { - const { customFieldValues: formCustomFieldValues, ...declaration } = values; - const success = await onSubmit(declaration, formikHelpers); + const { customFieldValues: formCustomFieldValues } = values; + const declarationToSubmit = transformFormValueToDeclaration(values); + + const success = await onSubmit(declarationToSubmit, formikHelpers); if (success) { // find the matching resource based on data use and name const customFieldResource = success.filter( @@ -321,15 +330,16 @@ export const usePrivacyDeclarationForm = ({ interface Props { onSubmit: ( - values: PrivacyDeclarationWithId, + values: PrivacyDeclarationResponse, formikHelpers: FormikHelpers - ) => Promise; + ) => Promise; onDelete: ( - declaration: PrivacyDeclarationWithId - ) => Promise; - initialValues?: PrivacyDeclarationWithId; + declaration: PrivacyDeclarationResponse + ) => Promise; + initialValues?: PrivacyDeclarationResponse; privacyDeclarationId?: string; includeCustomFields?: boolean; + includeCookies?: boolean; } export const PrivacyDeclarationForm = ({ diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationManager.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationManager.tsx index bf18ade8c6..3a8a1318db 100644 --- a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationManager.tsx +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationManager.tsx @@ -13,33 +13,21 @@ import { useEffect, useMemo, useState } from "react"; import { getErrorMessage } from "~/features/common/helpers"; import { errorToastParams, successToastParams } from "~/features/common/toast"; import { useUpdateSystemMutation } from "~/features/system/system.slice"; -import { PrivacyDeclaration, System } from "~/types/api"; +import { + PrivacyDeclarationResponse, + System, + SystemResponse, +} from "~/types/api"; import { isErrorResult } from "~/types/errors"; import PrivacyDeclarationAccordion from "./PrivacyDeclarationAccordion"; -import { - DataProps, - PrivacyDeclarationForm, - transformPrivacyDeclarationsToHaveId, -} from "./PrivacyDeclarationForm"; -import { PrivacyDeclarationWithId } from "./types"; - -const transformDeclarationForSubmission = ( - formValues: PrivacyDeclarationWithId -): PrivacyDeclaration => { - // Remove the id which is only a frontend artifact - const { id, ...values } = formValues; - return { - ...values, - // Fill in an empty string for name because of https://github.com/ethyca/fideslang/issues/98 - name: values.name ?? "", - }; -}; +import { DataProps, PrivacyDeclarationForm } from "./PrivacyDeclarationForm"; interface Props { - system: System; + system: SystemResponse; addButtonProps?: ButtonProps; includeCustomFields?: boolean; + includeCookies?: boolean; onSave?: (system: System) => void; } @@ -47,6 +35,7 @@ const PrivacyDeclarationManager = ({ system, addButtonProps, includeCustomFields, + includeCookies, onSave, ...dataProps }: Props & DataProps) => { @@ -55,24 +44,21 @@ const PrivacyDeclarationManager = ({ const [updateSystemMutationTrigger] = useUpdateSystemMutation(); const [showNewForm, setShowNewForm] = useState(false); const [newDeclaration, setNewDeclaration] = useState< - PrivacyDeclarationWithId | undefined + PrivacyDeclarationResponse | undefined >(undefined); - const allDeclarations = useMemo( - () => transformPrivacyDeclarationsToHaveId(system.privacy_declarations), - [system.privacy_declarations] - ); - // Accordion declarations include all declarations but the newly created one (if it exists) const accordionDeclarations = useMemo(() => { if (!newDeclaration) { - return allDeclarations; + return system.privacy_declarations; } - return allDeclarations.filter((pd) => pd.id !== newDeclaration.id); - }, [newDeclaration, allDeclarations]); + return system.privacy_declarations.filter( + (pd) => pd.id !== newDeclaration.id + ); + }, [newDeclaration, system]); - const checkAlreadyExists = (values: PrivacyDeclaration) => { + const checkAlreadyExists = (values: PrivacyDeclarationResponse) => { if ( accordionDeclarations.filter( (d) => d.data_use === values.data_use && d.name === values.name @@ -89,19 +75,16 @@ const PrivacyDeclarationManager = ({ }; const handleSave = async ( - updatedDeclarations: PrivacyDeclarationWithId[], + updatedDeclarations: PrivacyDeclarationResponse[], isDelete?: boolean ) => { - const transformedDeclarations = updatedDeclarations.map((d) => - transformDeclarationForSubmission(d) - ); const systemBodyWithDeclaration = { ...system, - privacy_declarations: transformedDeclarations, + privacy_declarations: updatedDeclarations, }; const handleResult = ( result: - | { data: System } + | { data: SystemResponse } | { error: FetchBaseQueryError | SerializedError } ) => { if (isErrorResult(result)) { @@ -120,7 +103,7 @@ const PrivacyDeclarationManager = ({ if (onSave) { onSave(result.data); } - return result.data.privacy_declarations as PrivacyDeclarationWithId[]; + return result.data.privacy_declarations; }; const updateSystemResult = await updateSystemMutationTrigger( @@ -131,8 +114,8 @@ const PrivacyDeclarationManager = ({ }; const handleEditDeclaration = async ( - oldDeclaration: PrivacyDeclarationWithId, - updatedDeclaration: PrivacyDeclarationWithId + oldDeclaration: PrivacyDeclarationResponse, + updatedDeclaration: PrivacyDeclarationResponse ) => { // Do not allow editing a privacy declaration to have the same data use as one that already exists if ( @@ -143,13 +126,13 @@ const PrivacyDeclarationManager = ({ } // Because the data use can change, we also need a reference to the old declaration in order to // make sure we are replacing the proper one - const updatedDeclarations = allDeclarations.map((dec) => + const updatedDeclarations = system.privacy_declarations.map((dec) => dec.id === oldDeclaration.id ? updatedDeclaration : dec ); return handleSave(updatedDeclarations); }; - const saveNewDeclaration = async (values: PrivacyDeclarationWithId) => { + const saveNewDeclaration = async (values: PrivacyDeclarationResponse) => { if (checkAlreadyExists(values)) { return undefined; } @@ -174,16 +157,16 @@ const PrivacyDeclarationManager = ({ }; const handleDelete = async ( - declarationToDelete: PrivacyDeclarationWithId + declarationToDelete: PrivacyDeclarationResponse ) => { - const updatedDeclarations = transformPrivacyDeclarationsToHaveId( - system.privacy_declarations - ).filter((dec) => dec.id !== declarationToDelete.id); + const updatedDeclarations = system.privacy_declarations.filter( + (dec) => dec.id !== declarationToDelete.id + ); return handleSave(updatedDeclarations, true); }; const handleDeleteNew = async ( - declarationToDelete: PrivacyDeclarationWithId + declarationToDelete: PrivacyDeclarationResponse ) => { const success = await handleDelete(declarationToDelete); if (success) { @@ -209,6 +192,7 @@ const PrivacyDeclarationManager = ({ onEdit={handleEditDeclaration} onDelete={handleDelete} includeCustomFields={includeCustomFields} + includeCookies={includeCookies} {...dataProps} /> {showNewForm ? ( @@ -218,6 +202,7 @@ const PrivacyDeclarationManager = ({ onSubmit={saveNewDeclaration} onDelete={handleDeleteNew} includeCustomFields={includeCustomFields} + includeCookies={includeCookies} {...dataProps} /> diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationStep.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationStep.tsx index dd7d947f01..7634e8aaf1 100644 --- a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationStep.tsx +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationStep.tsx @@ -3,13 +3,13 @@ import NextLink from "next/link"; import { useAppDispatch } from "~/app/hooks"; import { setActiveSystem } from "~/features/system"; -import { System } from "~/types/api"; +import { System, SystemResponse } from "~/types/api"; import { usePrivacyDeclarationData } from "./hooks"; import PrivacyDeclarationManager from "./PrivacyDeclarationManager"; interface Props { - system: System; + system: SystemResponse; } const PrivacyDeclarationStep = ({ system }: Props) => { @@ -48,6 +48,7 @@ const PrivacyDeclarationStep = ({ system }: Props) => { system={system} onSave={onSave} includeCustomFields + includeCookies {...dataProps} /> )} diff --git a/clients/admin-ui/src/features/system/privacy-declarations/types.ts b/clients/admin-ui/src/features/system/privacy-declarations/types.ts deleted file mode 100644 index b16500a316..0000000000 --- a/clients/admin-ui/src/features/system/privacy-declarations/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PrivacyDeclaration } from "~/types/api"; - -/** - * This is because privacy declarations do not have an ID on the backend. - * It is very useful for React rendering to have a stable ID. We currently - * make this the composite of data_use - name, but even better may be to - * give it a UUID (or to have the backend actually enforce this!) - */ -export interface PrivacyDeclarationWithId extends PrivacyDeclaration { - id: string; -} diff --git a/clients/admin-ui/src/features/system/system.slice.ts b/clients/admin-ui/src/features/system/system.slice.ts index 1de03f616b..6a53b2a3fc 100644 --- a/clients/admin-ui/src/features/system/system.slice.ts +++ b/clients/admin-ui/src/features/system/system.slice.ts @@ -6,6 +6,7 @@ import { BulkPutConnectionConfiguration, ConnectionConfigurationResponse, System, + SystemResponse, } from "~/types/api"; interface SystemDeleteResponse { @@ -21,11 +22,11 @@ interface UpsertResponse { const systemApi = baseApi.injectEndpoints({ endpoints: (build) => ({ - getAllSystems: build.query({ + getAllSystems: build.query({ query: () => ({ url: `system/` }), providesTags: () => ["System"], }), - getSystemByFidesKey: build.query({ + getSystemByFidesKey: build.query({ query: (fides_key) => ({ url: `system/${fides_key}/` }), providesTags: ["System"], }), @@ -56,7 +57,7 @@ const systemApi = baseApi.injectEndpoints({ invalidatesTags: ["Datamap", "System", "Datastore Connection"], }), updateSystem: build.mutation< - System, + SystemResponse, Partial & Pick >({ query: ({ ...patch }) => ({ diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index 404068de1a..d0fc22bab5 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -72,6 +72,7 @@ export type { ConsentRequestMap } from "./models/ConsentRequestMap"; export type { ConsentRequestResponse } from "./models/ConsentRequestResponse"; export type { ConsentWithExecutableStatus } from "./models/ConsentWithExecutableStatus"; export type { ContactDetails } from "./models/ContactDetails"; +export type { Cookies } from "./models/Cookies"; export { CoreHealthCheck } from "./models/CoreHealthCheck"; export type { CreateConnectionConfigurationWithSecrets } from "./models/CreateConnectionConfigurationWithSecrets"; export type { CurrentPrivacyPreferenceReportingSchema } from "./models/CurrentPrivacyPreferenceReportingSchema"; @@ -111,6 +112,7 @@ export { DrpRegime } from "./models/DrpRegime"; export type { DrpRevokeRequest } from "./models/DrpRevokeRequest"; export type { DryRunDatasetResponse } from "./models/DryRunDatasetResponse"; export type { DynamoDBDocsSchema } from "./models/DynamoDBDocsSchema"; +export { EdgeDirection } from "./models/EdgeDirection"; export type { EmailDocsSchema } from "./models/EmailDocsSchema"; export type { Endpoint } from "./models/Endpoint"; export { EnforcementLevel } from "./models/EnforcementLevel"; @@ -128,7 +130,7 @@ export type { ExternalDatasetReference } from "./models/ExternalDatasetReference export type { fides__api__schemas__connection_configuration__connection_secrets_bigquery__KeyfileCreds } from "./models/fides__api__schemas__connection_configuration__connection_secrets_bigquery__KeyfileCreds"; export type { fides__api__schemas__policy__Policy } from "./models/fides__api__schemas__policy__Policy"; export type { fides__connectors__models__KeyfileCreds } from "./models/fides__connectors__models__KeyfileCreds"; -export { FidesDatasetReference } from "./models/FidesDatasetReference"; +export type { FidesDatasetReference } from "./models/FidesDatasetReference"; export type { FidesDocsSchema } from "./models/FidesDocsSchema"; export type { fideslang__models__Policy } from "./models/fideslang__models__Policy"; export type { FidesMeta } from "./models/FidesMeta"; @@ -143,7 +145,6 @@ export type { HealthCheck } from "./models/HealthCheck"; export { HTTPMethod } from "./models/HTTPMethod"; export type { HTTPValidationError } from "./models/HTTPValidationError"; export type { Identity } from "./models/Identity"; -export type { IdentityBase } from "./models/IdentityBase"; export type { IdentityTypes } from "./models/IdentityTypes"; export type { IdentityVerificationConfigResponse } from "./models/IdentityVerificationConfigResponse"; export { IncludeExcludeEnum } from "./models/IncludeExcludeEnum"; diff --git a/clients/admin-ui/src/types/api/models/ConnectionType.ts b/clients/admin-ui/src/types/api/models/ConnectionType.ts index 493ef4ef43..f85c008449 100644 --- a/clients/admin-ui/src/types/api/models/ConnectionType.ts +++ b/clients/admin-ui/src/types/api/models/ConnectionType.ts @@ -3,26 +3,26 @@ /* eslint-disable */ /** - * Supported types to which we can connect fidesops. + * Supported types to which we can connect Fides. */ export enum ConnectionType { - POSTGRES = "postgres", //DB - MONGODB = "mongodb", //DB - MYSQL = "mysql", // DB + POSTGRES = "postgres", + MONGODB = "mongodb", + MYSQL = "mysql", HTTPS = "https", SAAS = "saas", - REDSHIFT = "redshift", //DB - SNOWFLAKE = "snowflake", //DB - MSSQL = "mssql", //DB - MARIADB = "mariadb", //DB - BIGQUERY = "bigquery", //DB - MANUAL = "manual", // manual - SOVRN = "sovrn", // email - ATTENTIVE = "attentive", // email - DYNAMODB = "dynamodb", //DB - MANUAL_WEBHOOK = "manual_webhook", //manual - TIMESCALE = "timescale", //DB + REDSHIFT = "redshift", + SNOWFLAKE = "snowflake", + MSSQL = "mssql", + MARIADB = "mariadb", + BIGQUERY = "bigquery", + MANUAL = "manual", + SOVRN = "sovrn", + ATTENTIVE = "attentive", + DYNAMODB = "dynamodb", + MANUAL_WEBHOOK = "manual_webhook", + TIMESCALE = "timescale", FIDES = "fides", - GENERIC_ERASURE_EMAIL = "erasure_email", - GENERIC_CONSENT_EMAIL = "consent_email", + GENERIC_ERASURE_EMAIL = "generic_erasure_email", + GENERIC_CONSENT_EMAIL = "generic_consent_email", } diff --git a/clients/admin-ui/src/types/api/models/ConnectorParam.ts b/clients/admin-ui/src/types/api/models/ConnectorParam.ts index 1828a5c043..00e0eb43a4 100644 --- a/clients/admin-ui/src/types/api/models/ConnectorParam.ts +++ b/clients/admin-ui/src/types/api/models/ConnectorParam.ts @@ -12,4 +12,5 @@ export type ConnectorParam = { default_value?: string | Array; multiselect?: boolean; description?: string; + sensitive?: boolean; }; diff --git a/clients/admin-ui/src/types/api/models/ConsentReport.ts b/clients/admin-ui/src/types/api/models/ConsentReport.ts index a120d82ba1..f03914ff64 100644 --- a/clients/admin-ui/src/types/api/models/ConsentReport.ts +++ b/clients/admin-ui/src/types/api/models/ConsentReport.ts @@ -2,7 +2,7 @@ /* tslint:disable */ /* eslint-disable */ -import type { IdentityBase } from "./IdentityBase"; +import type { Identity } from "./Identity"; /** * Schema for reporting Consent requests. @@ -14,7 +14,7 @@ export type ConsentReport = { has_gpc_flag?: boolean; conflicts_with_gpc?: boolean; id: string; - identity: IdentityBase; + identity: Identity; created_at: string; updated_at: string; }; diff --git a/clients/admin-ui/src/types/api/models/Cookies.ts b/clients/admin-ui/src/types/api/models/Cookies.ts new file mode 100644 index 0000000000..923ab591c3 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/Cookies.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * The Cookies resource model + */ +export type Cookies = { + name: string; + path?: string; + domain?: string; +}; diff --git a/clients/admin-ui/src/types/api/models/CreateConnectionConfigurationWithSecrets.ts b/clients/admin-ui/src/types/api/models/CreateConnectionConfigurationWithSecrets.ts index 69301c0f42..8096369510 100644 --- a/clients/admin-ui/src/types/api/models/CreateConnectionConfigurationWithSecrets.ts +++ b/clients/admin-ui/src/types/api/models/CreateConnectionConfigurationWithSecrets.ts @@ -2,8 +2,8 @@ /* tslint:disable */ /* eslint-disable */ -import type { ActionType } from "~/features/privacy-requests/types"; import type { AccessLevel } from "./AccessLevel"; +import type { ActionType } from "./ActionType"; import type { BigQueryDocsSchema } from "./BigQueryDocsSchema"; import type { ConnectionType } from "./ConnectionType"; import type { DynamoDBDocsSchema } from "./DynamoDBDocsSchema"; @@ -31,6 +31,7 @@ export type CreateConnectionConfigurationWithSecrets = { access: AccessLevel; disabled?: boolean; description?: string; + enabled_actions?: Array; secrets?: | MongoDBDocsSchema | PostgreSQLDocsSchema @@ -48,5 +49,4 @@ export type CreateConnectionConfigurationWithSecrets = { | SovrnDocsSchema | DynamoDBDocsSchema; saas_connector_type?: string; - enabled_actions?: ActionType[]; }; diff --git a/clients/admin-ui/src/types/api/models/Dataset.ts b/clients/admin-ui/src/types/api/models/Dataset.ts index 0e37fe052b..9d009b8d61 100644 --- a/clients/admin-ui/src/types/api/models/Dataset.ts +++ b/clients/admin-ui/src/types/api/models/Dataset.ts @@ -28,9 +28,9 @@ export type Dataset = { */ description?: string; /** - * An optional object that provides additional information about the Dataset. You can structure the object however you like. It can be a simple set of `key: value` properties or a deeply nested hierarchy of objects. How you use the object is up to you: Fides ignores it. + * An optional property to store any extra information for a resource. Data can be structured in any way: simple set of `key: value` pairs or deeply nested objects. */ - meta?: Record; + meta?: any; /** * Array of Data Category resources identified by `fides_key`, that apply to all collections in the Dataset. */ diff --git a/clients/admin-ui/src/types/api/models/DynamoDBDocsSchema.ts b/clients/admin-ui/src/types/api/models/DynamoDBDocsSchema.ts index 3c5b33b8fa..f3bb707c35 100644 --- a/clients/admin-ui/src/types/api/models/DynamoDBDocsSchema.ts +++ b/clients/admin-ui/src/types/api/models/DynamoDBDocsSchema.ts @@ -8,6 +8,6 @@ export type DynamoDBDocsSchema = { url?: string; region_name: string; - aws_secret_access_key: string; aws_access_key_id: string; + aws_secret_access_key: string; }; diff --git a/clients/admin-ui/src/types/api/models/EdgeDirection.ts b/clients/admin-ui/src/types/api/models/EdgeDirection.ts new file mode 100644 index 0000000000..dcce420fd7 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/EdgeDirection.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Direction of a FidesDataSetReference + */ +export enum EdgeDirection { + FROM = "from", + TO = "to", +} diff --git a/clients/admin-ui/src/types/api/models/EmailDocsSchema.ts b/clients/admin-ui/src/types/api/models/EmailDocsSchema.ts index 51831625d4..dbd507a4ec 100644 --- a/clients/admin-ui/src/types/api/models/EmailDocsSchema.ts +++ b/clients/admin-ui/src/types/api/models/EmailDocsSchema.ts @@ -11,5 +11,5 @@ export type EmailDocsSchema = { third_party_vendor_name: string; recipient_email_address: string; test_email_address?: string; - advanced_settings: AdvancedSettings; + advanced_settings?: AdvancedSettings; }; diff --git a/clients/admin-ui/src/types/api/models/FidesDatasetReference.ts b/clients/admin-ui/src/types/api/models/FidesDatasetReference.ts index 8c6f702e24..39e2fd943e 100644 --- a/clients/admin-ui/src/types/api/models/FidesDatasetReference.ts +++ b/clients/admin-ui/src/types/api/models/FidesDatasetReference.ts @@ -2,18 +2,13 @@ /* tslint:disable */ /* eslint-disable */ +import type { EdgeDirection } from "./EdgeDirection"; + /** * Reference to a field from another Collection */ export type FidesDatasetReference = { dataset: string; field: string; - direction?: FidesDatasetReference.direction; + direction?: EdgeDirection; }; - -export namespace FidesDatasetReference { - export enum direction { - FROM = "from", - TO = "to", - } -} diff --git a/clients/admin-ui/src/types/api/models/IdentityBase.ts b/clients/admin-ui/src/types/api/models/IdentityBase.ts deleted file mode 100644 index 93927af410..0000000000 --- a/clients/admin-ui/src/types/api/models/IdentityBase.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ - -/** - * The minimum fields required to represent an identity. - */ -export type IdentityBase = { - phone_number?: string; - email?: string; -}; diff --git a/clients/admin-ui/src/types/api/models/PostgreSQLDocsSchema.ts b/clients/admin-ui/src/types/api/models/PostgreSQLDocsSchema.ts index f7ea1c5b24..1562d96221 100644 --- a/clients/admin-ui/src/types/api/models/PostgreSQLDocsSchema.ts +++ b/clients/admin-ui/src/types/api/models/PostgreSQLDocsSchema.ts @@ -13,4 +13,5 @@ export type PostgreSQLDocsSchema = { db_schema?: string; host?: string; port?: number; + ssh_required?: boolean; }; diff --git a/clients/admin-ui/src/types/api/models/PrivacyDeclaration.ts b/clients/admin-ui/src/types/api/models/PrivacyDeclaration.ts index 1edd7683e7..a826af7206 100644 --- a/clients/admin-ui/src/types/api/models/PrivacyDeclaration.ts +++ b/clients/admin-ui/src/types/api/models/PrivacyDeclaration.ts @@ -2,6 +2,8 @@ /* tslint:disable */ /* eslint-disable */ +import type { Cookies } from "./Cookies"; + /** * The PrivacyDeclaration resource model. * @@ -41,4 +43,8 @@ export type PrivacyDeclaration = { * The resources from which data is received. Any `fides_key`s included in this list reference `DataFlow` entries in the `ingress` array of any `System` resources to which this `PrivacyDeclaration` is applied. */ ingress?: Array; + /** + * Cookies associated with this data use to deliver services and functionality + */ + cookies?: Array; }; diff --git a/clients/admin-ui/src/types/api/models/PrivacyDeclarationResponse.ts b/clients/admin-ui/src/types/api/models/PrivacyDeclarationResponse.ts index dbf5327824..53de457c72 100644 --- a/clients/admin-ui/src/types/api/models/PrivacyDeclarationResponse.ts +++ b/clients/admin-ui/src/types/api/models/PrivacyDeclarationResponse.ts @@ -2,6 +2,8 @@ /* tslint:disable */ /* eslint-disable */ +import type { Cookies } from "./Cookies"; + /** * Extension of base pydantic model to include DB `id` field in the response */ @@ -38,6 +40,7 @@ export type PrivacyDeclarationResponse = { * The resources from which data is received. Any `fides_key`s included in this list reference `DataFlow` entries in the `ingress` array of any `System` resources to which this `PrivacyDeclaration` is applied. */ ingress?: Array; + cookies?: Array; /** * The database-assigned ID of the privacy declaration on the system. This is meant to be a read-only field, returned only in API responses */ diff --git a/clients/admin-ui/src/types/api/models/PrivacyNoticeResponse.ts b/clients/admin-ui/src/types/api/models/PrivacyNoticeResponse.ts index ab81aacaa6..623b92d76c 100644 --- a/clients/admin-ui/src/types/api/models/PrivacyNoticeResponse.ts +++ b/clients/admin-ui/src/types/api/models/PrivacyNoticeResponse.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { ConsentMechanism } from "./ConsentMechanism"; +import type { Cookies } from "./Cookies"; import type { EnforcementLevel } from "./EnforcementLevel"; import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion"; @@ -29,4 +30,5 @@ export type PrivacyNoticeResponse = { updated_at: string; version: number; privacy_notice_history_id: string; + cookies: Array; }; diff --git a/clients/admin-ui/src/types/api/models/PrivacyNoticeResponseWithUserPreferences.ts b/clients/admin-ui/src/types/api/models/PrivacyNoticeResponseWithUserPreferences.ts index 82f78dc9d0..6919ee30d5 100644 --- a/clients/admin-ui/src/types/api/models/PrivacyNoticeResponseWithUserPreferences.ts +++ b/clients/admin-ui/src/types/api/models/PrivacyNoticeResponseWithUserPreferences.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { ConsentMechanism } from "./ConsentMechanism"; +import type { Cookies } from "./Cookies"; import type { EnforcementLevel } from "./EnforcementLevel"; import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion"; import type { UserConsentPreference } from "./UserConsentPreference"; @@ -31,6 +32,7 @@ export type PrivacyNoticeResponseWithUserPreferences = { updated_at: string; version: number; privacy_notice_history_id: string; + cookies: Array; default_preference: UserConsentPreference; current_preference?: UserConsentPreference; outdated_preference?: UserConsentPreference; diff --git a/clients/admin-ui/src/types/api/models/RedshiftDocsSchema.ts b/clients/admin-ui/src/types/api/models/RedshiftDocsSchema.ts index 6e0eadf300..0b3ce296f6 100644 --- a/clients/admin-ui/src/types/api/models/RedshiftDocsSchema.ts +++ b/clients/admin-ui/src/types/api/models/RedshiftDocsSchema.ts @@ -13,4 +13,5 @@ export type RedshiftDocsSchema = { user?: string; password?: string; db_schema?: string; + ssh_required?: boolean; }; diff --git a/clients/admin-ui/src/types/api/models/ResponseFormat.ts b/clients/admin-ui/src/types/api/models/ResponseFormat.ts index c874d392e6..bf2c4df56a 100644 --- a/clients/admin-ui/src/types/api/models/ResponseFormat.ts +++ b/clients/admin-ui/src/types/api/models/ResponseFormat.ts @@ -8,4 +8,5 @@ export enum ResponseFormat { JSON = "json", CSV = "csv", + HTML = "html", } diff --git a/clients/admin-ui/src/types/api/models/SaaSRequest.ts b/clients/admin-ui/src/types/api/models/SaaSRequest.ts index 5628c8ae22..c522917335 100644 --- a/clients/admin-ui/src/types/api/models/SaaSRequest.ts +++ b/clients/admin-ui/src/types/api/models/SaaSRequest.ts @@ -29,7 +29,7 @@ export type SaaSRequest = { postprocessors?: Array; pagination?: Strategy; grouped_inputs?: Array; - ignore_errors?: boolean; + ignore_errors?: boolean | Array; rate_limit_config?: RateLimitConfig; skip_missing_param_values?: boolean; }; diff --git a/clients/admin-ui/src/types/api/models/SovrnDocsSchema.ts b/clients/admin-ui/src/types/api/models/SovrnDocsSchema.ts index 5825772c40..3e7dfb60c7 100644 --- a/clients/admin-ui/src/types/api/models/SovrnDocsSchema.ts +++ b/clients/admin-ui/src/types/api/models/SovrnDocsSchema.ts @@ -11,5 +11,5 @@ export type SovrnDocsSchema = { third_party_vendor_name?: string; recipient_email_address?: string; test_email_address?: string; - advanced_settings: AdvancedSettingsWithExtendedIdentityTypes; + advanced_settings?: AdvancedSettingsWithExtendedIdentityTypes; }; diff --git a/clients/admin-ui/src/types/api/models/System.ts b/clients/admin-ui/src/types/api/models/System.ts index ebc3a86179..e2ac6a4cb4 100644 --- a/clients/admin-ui/src/types/api/models/System.ts +++ b/clients/admin-ui/src/types/api/models/System.ts @@ -37,9 +37,9 @@ export type System = { */ registry_id?: number; /** - * An optional property to store any extra information for a system. Not used by fidesctl. + * An optional property to store any extra information for a resource. Data can be structured in any way: simple set of `key: value` pairs or deeply nested objects. */ - meta?: Record; + meta?: any; /** * * The SystemMetadata resource model. diff --git a/clients/admin-ui/src/types/api/models/SystemResponse.ts b/clients/admin-ui/src/types/api/models/SystemResponse.ts index 35f9d0ad6d..8b1847f8df 100644 --- a/clients/admin-ui/src/types/api/models/SystemResponse.ts +++ b/clients/admin-ui/src/types/api/models/SystemResponse.ts @@ -4,6 +4,7 @@ import type { ConnectionConfigurationResponse } from "./ConnectionConfigurationResponse"; import type { ContactDetails } from "./ContactDetails"; +import type { Cookies } from "./Cookies"; import type { DataFlow } from "./DataFlow"; import type { DataProtectionImpactAssessment } from "./DataProtectionImpactAssessment"; import type { DataResponsibilityTitle } from "./DataResponsibilityTitle"; @@ -36,9 +37,9 @@ export type SystemResponse = { */ registry_id?: number; /** - * An optional property to store any extra information for a system. Not used by fidesctl. + * An optional property to store any extra information for a resource. Data can be structured in any way: simple set of `key: value` pairs or deeply nested objects. */ - meta?: Record; + meta?: any; /** * * The SystemMetadata resource model. @@ -114,4 +115,5 @@ export type SystemResponse = { * */ connection_configs?: ConnectionConfigurationResponse; + cookies?: Array; }; diff --git a/clients/admin-ui/src/types/api/models/TimescaleDocsSchema.ts b/clients/admin-ui/src/types/api/models/TimescaleDocsSchema.ts index b087fffee4..b9f413993e 100644 --- a/clients/admin-ui/src/types/api/models/TimescaleDocsSchema.ts +++ b/clients/admin-ui/src/types/api/models/TimescaleDocsSchema.ts @@ -13,4 +13,5 @@ export type TimescaleDocsSchema = { db_schema?: string; host?: string; port?: number; + ssh_required?: boolean; }; diff --git a/clients/fides-js/__tests__/lib/cookie.test.ts b/clients/fides-js/__tests__/lib/cookie.test.ts index 9835335638..407aec55e3 100644 --- a/clients/fides-js/__tests__/lib/cookie.test.ts +++ b/clients/fides-js/__tests__/lib/cookie.test.ts @@ -1,4 +1,6 @@ import * as uuid from "uuid"; + +import { CookieAttributes } from "typescript-cookie/dist/types"; import { CookieKeyConsent, FidesCookie, @@ -6,10 +8,11 @@ import { isNewFidesCookie, makeConsentDefaultsLegacy, makeFidesCookie, + removeCookiesFromBrowser, saveFidesCookie, } from "../../src/lib/cookie"; import type { ConsentContext } from "../../src/lib/consent-context"; -import { LegacyConsentConfig } from "~/lib/consent-types"; +import { Cookies, LegacyConsentConfig } from "../../src/lib/consent-types"; // Setup mock date const MOCK_DATE = "2023-01-01T12:00:00.000Z"; @@ -31,6 +34,10 @@ const mockSetCookie = jest.fn( (name: string, value: string, attributes: object, encoding: object) => `mock setCookie return (value=${value})` ); +const mockRemoveCookie = jest.fn( + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + (name: string, attributes?: CookieAttributes) => undefined +); jest.mock("typescript-cookie", () => ({ getCookie: () => mockGetCookie(), setCookie: ( @@ -39,6 +46,8 @@ jest.mock("typescript-cookie", () => ({ attributes: object, encoding: object ) => mockSetCookie(name, value, attributes, encoding), + removeCookie: (name: string, attributes?: CookieAttributes) => + mockRemoveCookie(name, attributes), })); describe("makeFidesCookie", () => { @@ -276,3 +285,41 @@ describe("isNewFidesCookie", () => { }); }); }); + +describe("removeCookiesFromBrowser", () => { + afterEach(() => mockRemoveCookie.mockClear()); + + it.each([ + { cookies: [], expectedAttributes: [] }, + { cookies: [{ name: "_ga123" }], expectedAttributes: [{ path: "/" }] }, + { + cookies: [{ name: "_ga123", path: "" }], + expectedAttributes: [{ path: "" }], + }, + { + cookies: [{ name: "_ga123", path: "/subpage" }], + expectedAttributes: [{ path: "/subpage" }], + }, + { + cookies: [{ name: "_ga123" }, { name: "shopify" }], + expectedAttributes: [{ path: "/" }, { path: "/" }], + }, + ])( + "should remove a list of cookies", + ({ + cookies, + expectedAttributes, + }: { + cookies: Cookies[]; + expectedAttributes: CookieAttributes[]; + }) => { + removeCookiesFromBrowser(cookies); + expect(mockRemoveCookie.mock.calls).toHaveLength(cookies.length); + cookies.forEach((cookie, idx) => { + const [name, attributes] = mockRemoveCookie.mock.calls[idx]; + expect(name).toEqual(cookie.name); + expect(attributes).toEqual(expectedAttributes[idx]); + }); + } + ); +}); diff --git a/clients/fides-js/src/components/Overlay.tsx b/clients/fides-js/src/components/Overlay.tsx index fc79e6a768..94c9dfbb95 100644 --- a/clients/fides-js/src/components/Overlay.tsx +++ b/clients/fides-js/src/components/Overlay.tsx @@ -117,11 +117,7 @@ const Overlay: FunctionComponent = ({ enabledPrivacyNoticeKeys.includes(notice.notice_key), notice.consent_mechanism ); - return new SaveConsentPreference( - notice.notice_key, - notice.privacy_notice_history_id, - userPreference - ); + return new SaveConsentPreference(notice, userPreference); }); updateConsentPreferences({ consentPreferencesToSave, diff --git a/clients/fides-js/src/fides.ts b/clients/fides-js/src/fides.ts index eb3153b391..c4fc1fa93e 100644 --- a/clients/fides-js/src/fides.ts +++ b/clients/fides-js/src/fides.ts @@ -144,8 +144,7 @@ const automaticallyApplyGPCPreferences = ( if (notice.has_gpc_flag && !notice.current_preference) { consentPreferencesToSave.push( new SaveConsentPreference( - notice.notice_key, - notice.privacy_notice_history_id, + notice, transformConsentToFidesUserPreference(false, notice.consent_mechanism) ) ); diff --git a/clients/fides-js/src/lib/consent-types.ts b/clients/fides-js/src/lib/consent-types.ts index b56b089f65..be126828f7 100644 --- a/clients/fides-js/src/lib/consent-types.ts +++ b/clients/fides-js/src/lib/consent-types.ts @@ -40,17 +40,10 @@ export type FidesOptions = { export class SaveConsentPreference { consentPreference: UserConsentPreference; - noticeHistoryId: string; + notice: PrivacyNotice; - noticeKey: string; - - constructor( - noticeKey: string, - noticeHistoryId: string, - consentPreference: UserConsentPreference - ) { - this.noticeKey = noticeKey; - this.noticeHistoryId = noticeHistoryId; + constructor(notice: PrivacyNotice, consentPreference: UserConsentPreference) { + this.notice = notice; this.consentPreference = consentPreference; } } @@ -88,6 +81,12 @@ export type ExperienceConfig = { regions: Array; }; +export type Cookies = { + name: string; + path?: string; + domain?: string; +}; + export type PrivacyNotice = { name?: string; notice_key: string; @@ -108,6 +107,7 @@ export type PrivacyNotice = { updated_at: string; version: number; privacy_notice_history_id: string; + cookies: Array; default_preference: UserConsentPreference; current_preference?: UserConsentPreference; outdated_preference?: UserConsentPreference; diff --git a/clients/fides-js/src/lib/cookie.ts b/clients/fides-js/src/lib/cookie.ts index dee6a4b6cf..4cf8830469 100644 --- a/clients/fides-js/src/lib/cookie.ts +++ b/clients/fides-js/src/lib/cookie.ts @@ -1,12 +1,16 @@ import { v4 as uuidv4 } from "uuid"; -import { getCookie, setCookie, Types } from "typescript-cookie"; +import { getCookie, removeCookie, setCookie, Types } from "typescript-cookie"; import { ConsentContext } from "./consent-context"; import { resolveConsentValue, resolveLegacyConsentValue, } from "./consent-value"; -import { LegacyConsentConfig, PrivacyExperience } from "./consent-types"; +import { + Cookies, + LegacyConsentConfig, + PrivacyExperience, +} from "./consent-types"; import { debugLog } from "./consent-utils"; /** @@ -273,3 +277,15 @@ export const makeConsentDefaultsLegacy = ( debugLog(debug, `Returning defaults for legacy config.`, defaults); return defaults; }; + +/** + * Given a list of cookies, deletes them from the browser + */ +export const removeCookiesFromBrowser = (cookies: Cookies[]) => { + cookies.forEach((cookie) => { + removeCookie(cookie.name, { + path: cookie.path ?? "/", + domain: cookie.domain, + }); + }); +}; diff --git a/clients/fides-js/src/lib/preferences.ts b/clients/fides-js/src/lib/preferences.ts index ce6bbb9932..71646dd17b 100644 --- a/clients/fides-js/src/lib/preferences.ts +++ b/clients/fides-js/src/lib/preferences.ts @@ -3,9 +3,15 @@ import { ConsentOptionCreate, PrivacyPreferencesRequest, SaveConsentPreference, + UserConsentPreference, } from "./consent-types"; import { debugLog, transformUserPreferenceToBoolean } from "./consent-utils"; -import { CookieKeyConsent, FidesCookie, saveFidesCookie } from "./cookie"; +import { + CookieKeyConsent, + FidesCookie, + removeCookiesFromBrowser, + saveFidesCookie, +} from "./cookie"; import { dispatchFidesEvent } from "./events"; import { patchUserPreferenceToFidesServer } from "../services/fides/api"; @@ -14,6 +20,8 @@ import { patchUserPreferenceToFidesServer } from "../services/fides/api"; * 1. Save preferences to Fides API * 2. Update the window.Fides.consent object * 3. Save preferences to the `fides_consent` cookie in the browser + * 4. Remove any cookies from notices that were opted-out from the browser + * 5. Dispatch a "FidesUpdated" event */ export const updateConsentPreferences = ({ consentPreferencesToSave, @@ -34,21 +42,19 @@ export const updateConsentPreferences = ({ }) => { // Derive the CookieKeyConsent object from privacy notices const noticeMap = new Map( - consentPreferencesToSave.map(({ noticeKey, consentPreference }) => [ - noticeKey, + consentPreferencesToSave.map(({ notice, consentPreference }) => [ + notice.notice_key, transformUserPreferenceToBoolean(consentPreference), ]) ); const consentCookieKey: CookieKeyConsent = Object.fromEntries(noticeMap); // Derive the Fides user preferences array from privacy notices - const fidesUserPreferences: Array = []; - consentPreferencesToSave.forEach(({ noticeHistoryId, consentPreference }) => { - fidesUserPreferences.push({ - privacy_notice_history_id: noticeHistoryId, + const fidesUserPreferences: Array = + consentPreferencesToSave.map(({ notice, consentPreference }) => ({ + privacy_notice_history_id: notice.privacy_notice_history_id, preference: consentPreference, - }); - }); + })); // Update the cookie object // eslint-disable-next-line no-param-reassign @@ -63,12 +69,7 @@ export const updateConsentPreferences = ({ user_geography: userLocationString, method: consentMethod, }; - patchUserPreferenceToFidesServer( - privacyPreferenceCreate, - fidesApiUrl, - cookie.identity.fides_user_device_id, - debug - ); + patchUserPreferenceToFidesServer(privacyPreferenceCreate, fidesApiUrl, debug); // 2. Update the window.Fides.consent object debugLog(debug, "Updating window.Fides"); @@ -78,6 +79,16 @@ export const updateConsentPreferences = ({ debugLog(debug, "Saving preferences to cookie"); saveFidesCookie(cookie); - // 4. Dispatch a "FidesUpdated" event + // 4. Remove cookies associated with notices that were opted-out from the browser + consentPreferencesToSave + .filter( + (preference) => + preference.consentPreference === UserConsentPreference.OPT_OUT + ) + .forEach((preference) => { + removeCookiesFromBrowser(preference.notice.cookies); + }); + + // 5. Dispatch a "FidesUpdated" event dispatchFidesEvent("FidesUpdated", cookie); }; diff --git a/clients/fides-js/src/services/fides/api.ts b/clients/fides-js/src/services/fides/api.ts index b219a01db9..1e53c6840b 100644 --- a/clients/fides-js/src/services/fides/api.ts +++ b/clients/fides-js/src/services/fides/api.ts @@ -81,7 +81,6 @@ export const fetchExperience = async ( export const patchUserPreferenceToFidesServer = async ( preferences: PrivacyPreferencesRequest, fidesApiUrl: string, - fidesUserDeviceId: string, debug: boolean ): Promise => { debugLog(debug, "Saving user consent preference...", preferences); diff --git a/clients/privacy-center/components/consent/NoticeDrivenConsent.tsx b/clients/privacy-center/components/consent/NoticeDrivenConsent.tsx index bf16a75a27..73d137d9eb 100644 --- a/clients/privacy-center/components/consent/NoticeDrivenConsent.tsx +++ b/clients/privacy-center/components/consent/NoticeDrivenConsent.tsx @@ -5,6 +5,7 @@ import { CookieKeyConsent, getConsentContext, getOrMakeFidesCookie, + removeCookiesFromBrowser, saveFidesCookie, transformUserPreferenceToBoolean, } from "fides-js"; @@ -121,30 +122,44 @@ const NoticeDrivenConsent = () => { router.push("/"); }; + /** + * When saving, we need to: + * 1. Send PATCH to Fides backend + * 2. Save to cookie and window object + * 3. Delete any cookies that have been opted out of + */ const handleSave = async () => { const browserIdentities = inspectForBrowserIdentities(); const deviceIdentity = { fides_user_device_id: fidesUserDeviceId }; const identities = browserIdentities ? { ...deviceIdentity, ...browserIdentities } : deviceIdentity; + const notices = experience?.privacy_notices ?? []; - const preferences: ConsentOptionCreate[] = Object.entries( - draftPreferences - ).map(([key, value]) => { - const notice = experience?.privacy_notices?.find( - (n) => n.privacy_notice_history_id === key - ); - if (notice?.consent_mechanism === ConsentMechanism.NOTICE_ONLY) { + // Reconnect preferences to notices + const noticePreferences = Object.entries(draftPreferences).map( + ([historyKey, preference]) => { + const notice = notices.find( + (n) => n.privacy_notice_history_id === historyKey + ); + return { historyKey, preference, notice }; + } + ); + + const preferences: ConsentOptionCreate[] = noticePreferences.map( + ({ historyKey, preference, notice }) => { + if (notice?.consent_mechanism === ConsentMechanism.NOTICE_ONLY) { + return { + privacy_notice_history_id: historyKey, + preference: UserConsentPreference.ACKNOWLEDGE, + }; + } return { - privacy_notice_history_id: key, - preference: UserConsentPreference.ACKNOWLEDGE, + privacy_notice_history_id: historyKey, + preference: preference ?? UserConsentPreference.OPT_OUT, }; } - return { - privacy_notice_history_id: key, - preference: value ?? UserConsentPreference.OPT_OUT, - }; - }); + ); const payload: PrivacyPreferencesRequest = { browser_identity: identities, @@ -155,6 +170,7 @@ const NoticeDrivenConsent = () => { code: verificationCode, }; + // 1. Send PATCH to Fides backend const result = await updatePrivacyPreferencesMutationTrigger({ id: consentRequestId, body: payload, @@ -167,6 +183,8 @@ const NoticeDrivenConsent = () => { }); return; } + + // 2. Save the cookie and window obj on success const noticeKeyMap = new Map( result.data.map((preference) => [ preference.privacy_notice_history.notice_key || "", @@ -174,14 +192,23 @@ const NoticeDrivenConsent = () => { ]) ); const consentCookieKey: CookieKeyConsent = Object.fromEntries(noticeKeyMap); + window.Fides.consent = consentCookieKey; + const updatedCookie = { ...cookie, consent: consentCookieKey }; + saveFidesCookie(updatedCookie); toast({ title: "Your consent preferences have been saved", ...SuccessToastOptions, }); - // Save the cookie and window obj on success - window.Fides.consent = consentCookieKey; - const updatedCookie = { ...cookie, consent: consentCookieKey }; - saveFidesCookie(updatedCookie); + + // 3. Delete any cookies that have been opted out of + noticePreferences.forEach((noticePreference) => { + if ( + noticePreference.preference === UserConsentPreference.OPT_OUT && + noticePreference.notice + ) { + removeCookiesFromBrowser(noticePreference.notice.cookies); + } + }); router.push("/"); }; diff --git a/clients/privacy-center/cypress/e2e/consent-banner.cy.ts b/clients/privacy-center/cypress/e2e/consent-banner.cy.ts index d54664fe90..63f0453fbf 100644 --- a/clients/privacy-center/cypress/e2e/consent-banner.cy.ts +++ b/clients/privacy-center/cypress/e2e/consent-banner.cy.ts @@ -126,6 +126,7 @@ const mockPrivacyNotice = (params: Partial) => { version: 1.0, privacy_notice_history_id: "pri_b09058a7-9f54-4360-8da5-4521e8975d4f", notice_key: "advertising", + cookies: [], }; return { ...notice, ...params }; }; @@ -478,6 +479,61 @@ describe("Consent banner", () => { // TODO: add tests for CSS expect(false).is.eql(true); }); + + describe("cookie enforcement", () => { + beforeEach(() => { + const cookies = [ + { name: "cookie1", path: "/" }, + { name: "cookie2", path: "/" }, + ]; + cookies.forEach((cookie) => { + cy.setCookie(cookie.name, "value", { path: cookie.path }); + }); + stubConfig({ + experience: { + privacy_notices: [ + mockPrivacyNotice({ + name: "one", + privacy_notice_history_id: "one", + notice_key: "one", + consent_mechanism: ConsentMechanism.OPT_OUT, + cookies: [cookies[0]], + }), + mockPrivacyNotice({ + name: "two", + privacy_notice_history_id: "two", + notice_key: "second", + consent_mechanism: ConsentMechanism.OPT_OUT, + cookies: [cookies[1]], + }), + ], + }, + options: { + isOverlayEnabled: true, + }, + }); + }); + + it("can remove all cookies when rejecting all", () => { + cy.contains("button", "Reject Test").click(); + cy.getAllCookies().then((allCookies) => { + expect(allCookies.map((c) => c.name)).to.eql([CONSENT_COOKIE_NAME]); + }); + }); + + it("can remove just the cookies associated with notices that were opted out", () => { + cy.contains("button", "Manage preferences").click(); + // opt out of the first notice + cy.getByTestId("toggle-one").click(); + cy.getByTestId("Save test-btn").click(); + cy.getAllCookies().then((allCookies) => { + expect(allCookies.map((c) => c.name)).to.eql([ + CONSENT_COOKIE_NAME, + "cookie2", + ]); + }); + }); + }); }); describe("when there are only notice-only notices", () => { diff --git a/clients/privacy-center/cypress/e2e/consent-notices.cy.ts b/clients/privacy-center/cypress/e2e/consent-notices.cy.ts index d6c62ef382..cc5fac8f6e 100644 --- a/clients/privacy-center/cypress/e2e/consent-notices.cy.ts +++ b/clients/privacy-center/cypress/e2e/consent-notices.cy.ts @@ -87,6 +87,7 @@ describe("Privacy notice driven consent", () => { cy.getByTestId(`consent-item-${PRIVACY_NOTICE_ID_2}`).within(() => { cy.getRadio().should("be.checked"); }); + // Notice only, so should be checked and disabled cy.getByTestId(`consent-item-${PRIVACY_NOTICE_ID_3}`).within(() => { cy.getRadio().should("be.checked").should("be.disabled"); }); @@ -107,6 +108,7 @@ describe("Privacy notice driven consent", () => { expect( preferences.map((p: ConsentOptionCreate) => p.preference) ).to.eql(["opt_in", "opt_in", "acknowledge"]); + // Should update the cookie cy.waitUntilCookieExists(CONSENT_COOKIE_NAME).then(() => { cy.getCookie(CONSENT_COOKIE_NAME).then((cookieJson) => { const cookie = JSON.parse( @@ -118,6 +120,7 @@ describe("Privacy notice driven consent", () => { const expectedConsent = { data_sales: true, advertising: true }; const { consent } = cookie; expect(consent).to.eql(expectedConsent); + // Should update the window object cy.window().then((win) => { expect(win.Fides.consent).to.eql(expectedConsent); }); @@ -154,6 +157,108 @@ describe("Privacy notice driven consent", () => { }); }); }); + + describe("cookie enforcement", () => { + beforeEach(() => { + // First seed the browser with the cookies that are listed in the notices + cy.fixture("consent/experience.json").then((data) => { + const notices: PrivacyNoticeResponseWithUserPreferences[] = + data.items[0].privacy_notices; + + const allCookies = notices.map((notice) => notice.cookies).flat(); + allCookies.forEach((cookie) => { + cy.setCookie(cookie.name, "value", { + path: cookie.path ?? "/", + domain: cookie.domain ?? undefined, + }); + }); + cy.getAllCookies().then((cookies) => { + expect( + cookies.filter((c) => c.name !== CONSENT_COOKIE_NAME).length + ).to.eql(allCookies.length); + }); + cy.wrap(notices).as("notices"); + }); + }); + + it("can delete all cookies for when opting out of all notices", () => { + // Opt out of the opt-out notice + cy.getByTestId(`consent-item-${PRIVACY_NOTICE_ID_2}`).within(() => { + cy.getRadio().should("be.checked"); + cy.get("span").contains("No").click(); + }); + cy.getByTestId("save-btn").click(); + + cy.wait("@patchPrivacyPreference").then(() => { + // Use waitUntil to help with CI + cy.waitUntil(() => + cy.getAllCookies().then((cookies) => cookies.length === 1) + ).then(() => { + // There should be no cookies related to the privacy notices around + cy.getAllCookies().then((cookies) => { + const filteredCookies = cookies.filter( + (c) => c.name !== CONSENT_COOKIE_NAME + ); + expect(filteredCookies.length).to.eql(0); + }); + }); + }); + }); + + it("can delete only the cookies associated with opt-out notices", () => { + // Opt into first notice + cy.getByTestId(`consent-item-${PRIVACY_NOTICE_ID_1}`).within(() => { + cy.get("span").contains("Yes").click(); + }); + // Opt out of second notice + cy.getByTestId(`consent-item-${PRIVACY_NOTICE_ID_2}`).within(() => { + cy.getRadio().should("be.checked"); + cy.get("span").contains("No").click(); + }); + cy.getByTestId("save-btn").click(); + + cy.wait("@patchPrivacyPreference").then(() => { + // Use waitUntil to help with CI + cy.waitUntil(() => + cy.getAllCookies().then((cookies) => cookies.length === 2) + ).then(() => { + // The first notice's cookies should still be around + // But there should be none of the second cookie's + cy.getAllCookies().then((cookies) => { + const filteredCookies = cookies.filter( + (c) => c.name !== CONSENT_COOKIE_NAME + ); + expect(filteredCookies.length).to.eql(1); + cy.get("@notices").then((notices: any) => { + expect(filteredCookies[0]).to.have.property( + "name", + notices[0].cookies[0].name + ); + }); + }); + }); + }); + }); + + it("can successfully delete even if cookie does not exist", () => { + cy.clearAllCookies(); + // Opt out of second notice + cy.getByTestId(`consent-item-${PRIVACY_NOTICE_ID_2}`).within(() => { + cy.getRadio().should("be.checked"); + cy.get("span").contains("No").click(); + }); + cy.getByTestId("save-btn").click(); + + cy.wait("@patchPrivacyPreference").then(() => { + cy.getAllCookies().then((cookies) => { + const filteredCookies = cookies.filter( + (c) => c.name !== CONSENT_COOKIE_NAME + ); + expect(filteredCookies.length).to.eql(0); + }); + }); + }); + }); }); describe("when user has consented before", () => { diff --git a/clients/privacy-center/cypress/fixtures/consent/experience.json b/clients/privacy-center/cypress/fixtures/consent/experience.json index c52570afb2..3282ec2d51 100644 --- a/clients/privacy-center/cypress/fixtures/consent/experience.json +++ b/clients/privacy-center/cypress/fixtures/consent/experience.json @@ -51,7 +51,8 @@ "privacy_notice_history_id": "pri_df14051b-1eaf-4f07-ae63-232bffd2dc3e", "default_preference": "opt_out", "current_preference": null, - "outdated_preference": null + "outdated_preference": null, + "cookies": [{ "name": "sales", "path": null, "domain": null }] }, { "name": "Advertising", @@ -75,7 +76,11 @@ "privacy_notice_history_id": "pri_b2a0a2fa-ef59-4f7d-8e3d-d2e9bd076707", "default_preference": "opt_in", "current_preference": null, - "outdated_preference": null + "outdated_preference": null, + "cookies": [ + { "name": "_ga", "path": null, "domain": null }, + { "name": "advertisingCookie", "path": null, "domain": null } + ] }, { "name": "Essential", @@ -99,7 +104,8 @@ "privacy_notice_history_id": "pri_b09058a7-9f54-4360-8da5-4521e8975d4e", "default_preference": "opt_in", "current_preference": null, - "outdated_preference": null + "outdated_preference": null, + "cookies": [] } ] } diff --git a/clients/privacy-center/cypress/fixtures/consent/overlay_experience.json b/clients/privacy-center/cypress/fixtures/consent/overlay_experience.json index 1e68cd4194..79a78495bd 100644 --- a/clients/privacy-center/cypress/fixtures/consent/overlay_experience.json +++ b/clients/privacy-center/cypress/fixtures/consent/overlay_experience.json @@ -51,7 +51,8 @@ "privacy_notice_history_id": "pri_b2a0a2fa-ef59-4f7d-8e3d-d2e9bd076707", "default_preference": "opt_out", "current_preference": null, - "outdated_preference": null + "outdated_preference": null, + "cookies": [] } ] } diff --git a/clients/privacy-center/cypress/fixtures/consent/test_banner_options.json b/clients/privacy-center/cypress/fixtures/consent/test_banner_options.json index dc830e1fef..6c30154e0e 100644 --- a/clients/privacy-center/cypress/fixtures/consent/test_banner_options.json +++ b/clients/privacy-center/cypress/fixtures/consent/test_banner_options.json @@ -64,7 +64,8 @@ "updated_at": "2023-04-24T21:29:08.870351+00:00", "version": 1.0, "privacy_notice_history_id": "pri_b09058a7-9f54-4360-8da5-4521e8975d4f", - "notice_key": "advertising" + "notice_key": "advertising", + "cookies": [] }, { "name": "Essential", @@ -85,7 +86,8 @@ "updated_at": "2023-04-24T21:29:08.870351+00:00", "version": 1.0, "privacy_notice_history_id": "pri_b09058a7-9f54-4360-8da5-4521e8975d4e", - "notice_key": "essential" + "notice_key": "essential", + "cookies": [] } ] }, diff --git a/clients/privacy-center/public/fides-js-components-demo.html b/clients/privacy-center/public/fides-js-components-demo.html index 4f07dc08f8..3614d6c117 100644 --- a/clients/privacy-center/public/fides-js-components-demo.html +++ b/clients/privacy-center/public/fides-js-components-demo.html @@ -77,6 +77,7 @@ privacy_notice_history_id: "pri_b09058a7-9f54-4360-8da5-4521e8975d4f", notice_key: "advertising", + cookies: [{ name: "testCookie", path: "/", domain: null }], }, { name: "Essential", @@ -100,6 +101,7 @@ privacy_notice_history_id: "pri_b09058a7-9f54-4360-8da5-4521e8975d4e", notice_key: "essential", + cookies: [], }, ], }, diff --git a/clients/privacy-center/types/api/models/Cookies.ts b/clients/privacy-center/types/api/models/Cookies.ts new file mode 100644 index 0000000000..923ab591c3 --- /dev/null +++ b/clients/privacy-center/types/api/models/Cookies.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * The Cookies resource model + */ +export type Cookies = { + name: string; + path?: string; + domain?: string; +}; diff --git a/clients/privacy-center/types/api/models/PrivacyNoticeResponse.ts b/clients/privacy-center/types/api/models/PrivacyNoticeResponse.ts index 8f33d8b38b..de11c2abfe 100644 --- a/clients/privacy-center/types/api/models/PrivacyNoticeResponse.ts +++ b/clients/privacy-center/types/api/models/PrivacyNoticeResponse.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { ConsentMechanism } from "./ConsentMechanism"; +import type { Cookies } from "./Cookies"; import type { EnforcementLevel } from "./EnforcementLevel"; import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion"; @@ -28,4 +29,5 @@ export type PrivacyNoticeResponse = { updated_at: string; version: number; privacy_notice_history_id: string; + cookies: Array; }; diff --git a/clients/privacy-center/types/api/models/PrivacyNoticeResponseWithUserPreferences.ts b/clients/privacy-center/types/api/models/PrivacyNoticeResponseWithUserPreferences.ts index 82f78dc9d0..6919ee30d5 100644 --- a/clients/privacy-center/types/api/models/PrivacyNoticeResponseWithUserPreferences.ts +++ b/clients/privacy-center/types/api/models/PrivacyNoticeResponseWithUserPreferences.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { ConsentMechanism } from "./ConsentMechanism"; +import type { Cookies } from "./Cookies"; import type { EnforcementLevel } from "./EnforcementLevel"; import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion"; import type { UserConsentPreference } from "./UserConsentPreference"; @@ -31,6 +32,7 @@ export type PrivacyNoticeResponseWithUserPreferences = { updated_at: string; version: number; privacy_notice_history_id: string; + cookies: Array; default_preference: UserConsentPreference; current_preference?: UserConsentPreference; outdated_preference?: UserConsentPreference; diff --git a/docs/fides/docs/development/postman/Fides.postman_collection.json b/docs/fides/docs/development/postman/Fides.postman_collection.json index 27173fd320..f4a7c94c1e 100644 --- a/docs/fides/docs/development/postman/Fides.postman_collection.json +++ b/docs/fides/docs/development/postman/Fides.postman_collection.json @@ -4922,6 +4922,145 @@ } ] }, + { + "name": "Systems", + "item": [ + { + "name": "Get System", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{client_token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{host}}/system/", + "host": [ + "{{host}}" + ], + "path": [ + "system", + "" + ] + } + }, + "response": [] + }, + { + "name": "Create System", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{client_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"data_responsibility_title\":\"Processor\",\n \"description\":\"Collect data about our users for marketing.\",\n \"egress\":[\n {\n \"fides_key\":\"demo_analytics_system\",\n \"type\":\"system\",\n \"data_categories\":null\n }\n ],\n \"fides_key\":\"test_system\",\n \"ingress\":null,\n \"name\":\"Test system\",\n \"organization_fides_key\":\"default_organization\",\n \"privacy_declarations\":[\n {\n \"name\":\"Collect data for marketing\",\n \"data_categories\":[\n \"user.device.cookie_id\"\n ],\n \"data_use\":\"personalize\",\n \"data_qualifier\":\"aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified\",\n \"data_subjects\":[\n \"customer\"\n ],\n \"dataset_references\":null,\n \"egress\":null,\n \"ingress\":null,\n \"cookies\":[\n {\n \"name\":\"test_cookie\",\n \"path\":\"/\"\n }\n ]\n }\n ],\n \"system_dependencies\":[\n \"demo_analytics_system\"\n ],\n \"system_type\":\"Service\",\n \"tags\":null,\n \"third_country_transfers\":null,\n \"administrating_department\":\"Marketing\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{host}}/system", + "host": [ + "{{host}}" + ], + "path": [ + "system" + ] + } + }, + "response": [] + }, + { + "name": "Update System", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{client_token}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"data_responsibility_title\":\"Processor\",\n \"description\":\"Collect data about our users for marketing.\",\n \"egress\":[\n {\n \"fides_key\":\"demo_analytics_system\",\n \"type\":\"system\",\n \"data_categories\":null\n }\n ],\n \"fides_key\":\"test_system\",\n \"ingress\":null,\n \"name\":\"Test system\",\n \"organization_fides_key\":\"default_organization\",\n \"privacy_declarations\":[\n {\n \"name\":\"Collect data for marketing\",\n \"data_categories\":[\n \"user.device.cookie_id\"\n ],\n \"data_use\":\"marketing.advertising\",\n \"data_qualifier\":\"aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified\",\n \"data_subjects\":[\n \"customer\"\n ],\n \"dataset_references\":null,\n \"egress\":null,\n \"ingress\":null,\n \"cookies\":[\n {\n \"name\":\"another_cookie\",\n \"path\":\"/\"\n }\n ]\n }\n ],\n \"system_dependencies\":[\n \"demo_analytics_system\"\n ],\n \"system_type\":\"Service\",\n \"tags\":null,\n \"third_country_transfers\":null,\n \"administrating_department\":\"Marketing\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{host}}/system?", + "host": [ + "{{host}}" + ], + "path": [ + "system" + ], + "query": [ + { + "key": "", + "value": null + } + ] + } + }, + "response": [] + }, + { + "name": "Delete System", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{client_token}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "{{host}}/system/test_system", + "host": [ + "{{host}}" + ], + "path": [ + "system", + "test_system" + ] + } + }, + "response": [] + } + ] + }, { "name": "Roles", "item": [ diff --git a/requirements.txt b/requirements.txt index 191f9f08a2..90ee39b71b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ expandvars==0.9.0 fastapi[all]==0.89.1 fastapi-caching[redis]==0.3.0 fastapi-pagination[sqlalchemy]~= 0.10.0 -fideslang==1.4.1 +fideslang==1.4.2 fideslog==1.2.10 firebase-admin==5.3.0 GitPython==3.1.31 @@ -31,7 +31,7 @@ passlib[bcrypt]==1.7.4 plotly==5.13.1 pyarrow==6.0.0 psycopg2-binary==2.9.6 -pydantic<1.10.2 +pydantic==1.10.9 pydash==6.0.2 PyJWT==2.4.0 pymongo==3.13.0 diff --git a/src/fides/api/alembic/migrations/versions/2be84e68df32_add_cookie_table.py b/src/fides/api/alembic/migrations/versions/2be84e68df32_add_cookie_table.py new file mode 100644 index 0000000000..889e466304 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/2be84e68df32_add_cookie_table.py @@ -0,0 +1,70 @@ +"""add cookie table + +Revision ID: 2be84e68df32 +Revises: c1885270b3cc +Create Date: 2023-06-13 23:08:35.011377 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2be84e68df32" +down_revision = "c1885270b3cc" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "cookies", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("name", sa.String(), nullable=False), + sa.Column("domain", sa.String(), nullable=True), + sa.Column("path", sa.String(), nullable=True), + sa.Column("system_id", sa.String(), nullable=True), + sa.Column("privacy_declaration_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["privacy_declaration_id"], ["privacydeclaration.id"], ondelete="SET NULL" + ), + sa.ForeignKeyConstraint(["system_id"], ["ctl_systems.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "name", "privacy_declaration_id", name="_cookie_name_privacy_declaration_uc" + ), + ) + op.create_index(op.f("ix_cookies_id"), "cookies", ["id"], unique=False) + op.create_index(op.f("ix_cookies_name"), "cookies", ["name"], unique=False) + op.create_index( + op.f("ix_cookies_privacy_declaration_id"), + "cookies", + ["privacy_declaration_id"], + unique=False, + ) + op.create_index( + op.f("ix_cookies_system_id"), "cookies", ["system_id"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_cookies_system_id"), table_name="cookies") + op.drop_index(op.f("ix_cookies_privacy_declaration_id"), table_name="cookies") + op.drop_index(op.f("ix_cookies_name"), table_name="cookies") + op.drop_index(op.f("ix_cookies_id"), table_name="cookies") + op.drop_table("cookies") + # ### end Alembic commands ### diff --git a/src/fides/api/db/crud.py b/src/fides/api/db/crud.py index 10412dd257..eb13fa38de 100644 --- a/src/fides/api/db/crud.py +++ b/src/fides/api/db/crud.py @@ -113,7 +113,7 @@ async def get_resource( raise_not_found: bool = True, ) -> Base: """ - Get a resource from the databse by its FidesKey. + Get a resource from the database by its FidesKey. Returns a SQLAlchemy model of that resource. """ diff --git a/src/fides/api/db/system.py b/src/fides/api/db/system.py index 4d8d7f179e..924f8fb6d6 100644 --- a/src/fides/api/db/system.py +++ b/src/fides/api/db/system.py @@ -1,17 +1,20 @@ """ Functions for interacting with System objects in the database. """ -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple from fastapi import HTTPException +from fideslang.models import Cookies as CookieSchema from fideslang.models import System as SystemSchema from loguru import logger as log +from sqlalchemy import and_, delete, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from starlette.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND from fides.api.db.crud import create_resource, get_resource, update_resource from fides.api.models.sql_models import ( # type: ignore[attr-defined] + Cookies, DataUse, PrivacyDeclaration, System, @@ -112,23 +115,88 @@ async def upsert_privacy_declarations( for privacy_declaration in resource.privacy_declarations: # prepare our 'payload' for either create or update data = privacy_declaration.dict() + cookies: List[Dict] = data.pop("cookies", None) data["system_id"] = system.id # include FK back to system # if we find matching declaration, remove it from our map - if existing_declaration := existing_declarations.pop( + if declaration := existing_declarations.pop( privacy_declaration_logical_id(privacy_declaration), None ): # and update existing declaration *in place* - existing_declaration.update(db, data=data) + declaration.update(db, data=data) else: # otherwise, create a new declaration record - PrivacyDeclaration.create(db, data=data) + declaration = PrivacyDeclaration.create(db, data=data) + + # Upsert cookies for the given privacy declaration + await upsert_cookies(db, cookies, declaration, system) # delete any existing privacy declarations that have not been "matched" in the request for existing_declarations in existing_declarations.values(): await db.delete(existing_declarations) +async def upsert_cookies( + async_session: AsyncSession, + cookies: Optional[List[Dict]], # CookieSchema + privacy_declaration: PrivacyDeclaration, + system: System, +) -> None: + """Upsert cookies for the given privacy declaration: retrieve cookies by name/system/privacy declaration + Remove any existing cookies that aren't specified here. + """ + cookie_list: List[CookieSchema] = cookies or [] + for cookie_data in cookie_list: + # Check if cookie exists for this name/system/privacy declaration + result = await async_session.execute( + select(Cookies).where( + and_( + Cookies.name == cookie_data["name"], + Cookies.system_id == system.id, + Cookies.privacy_declaration_id == privacy_declaration.id, + ) + ) + ) + row: Optional[Cookies] = result.scalars().first() + if row: + await async_session.execute( + update(Cookies).where(Cookies.id == row.id).values(cookie_data) + ) + + else: + await async_session.execute( + insert(Cookies).values( + { + "name": cookie_data.get("name"), + "path": cookie_data.get("path"), + "domain": cookie_data.get("domain"), + "privacy_declaration_id": privacy_declaration.id, + "system_id": system.id, + } + ) + ) + + # Select cookies which are currently on the privacy declaration but not included in this request + delete_result = await async_session.execute( + select(Cookies).where( + and_( + Cookies.name.notin_([cookie["name"] for cookie in cookie_list]), + Cookies.system_id == system.id, + Cookies.privacy_declaration_id == privacy_declaration.id, + ) + ) + ) + + # Remove those cookies altogether + await async_session.execute( + delete(Cookies).where( + Cookies.id.in_( + [cookie.id for cookie in delete_result.scalars().unique().all()] + ) + ) + ) + + async def update_system(resource: SystemSchema, db: AsyncSession) -> Dict: """Helper function to share core system update logic for wrapping endpoint functions""" system: System = await get_resource( @@ -184,9 +252,13 @@ async def create_system( for privacy_declaration in privacy_declarations: data = privacy_declaration.dict() data["system_id"] = created_system.id # add FK back to system - PrivacyDeclaration.create( + cookies: List[Dict] = data.pop("cookies", []) + privacy_declaration = PrivacyDeclaration.create( db, data=data ) # create the associated PrivacyDeclaration + await upsert_cookies( + db, cookies, privacy_declaration, created_system + ) # Create the associated cookies except Exception as e: log.error( f"Error adding privacy declarations, reverting system creation: {str(privacy_declaration_exception)}" diff --git a/src/fides/api/models/privacy_notice.py b/src/fides/api/models/privacy_notice.py index 903a1222ad..babdee213f 100644 --- a/src/fides/api/models/privacy_notice.py +++ b/src/fides/api/models/privacy_notice.py @@ -8,14 +8,18 @@ from fideslang.validation import FidesKey from sqlalchemy import Boolean, Column from sqlalchemy import Enum as EnumColumn -from sqlalchemy import Float, ForeignKey, String +from sqlalchemy import Float, ForeignKey, String, or_ from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.orm import Session, relationship from sqlalchemy.util import hybridproperty from fides.api.common_exceptions import ValidationError from fides.api.db.base_class import Base, FidesBase -from fides.api.models.sql_models import System # type: ignore[attr-defined] +from fides.api.models.sql_models import ( # type: ignore[attr-defined] + Cookies, + PrivacyDeclaration, + System, +) class UserConsentPreference(Enum): @@ -255,6 +259,26 @@ def default_preference(self) -> UserConsentPreference: raise Exception("Invalid notice consent mechanism.") + @property + def cookies(self) -> List[Cookies]: + """Return relevant cookie names (via the data use)""" + db = Session.object_session(self) + return ( + db.query(Cookies) + .join( + PrivacyDeclaration, + PrivacyDeclaration.id == Cookies.privacy_declaration_id, + ) + .filter( + or_( + *[ + PrivacyDeclaration.data_use.like(f"{notice_use}%") + for notice_use in self.data_uses + ] + ) + ) + ).all() + @classmethod def create( cls: Type[PrivacyNotice], diff --git a/src/fides/api/models/sql_models.py b/src/fides/api/models/sql_models.py index 9ed6a8d8e1..0e8b03b310 100644 --- a/src/fides/api/models/sql_models.py +++ b/src/fides/api/models/sql_models.py @@ -346,6 +346,10 @@ class System(Base, FidesBase): lazy="selectin", ) + cookies = relationship( + "Cookies", back_populates="system", lazy="selectin", uselist=True, viewonly=True + ) + @classmethod def get_data_uses( cls: Type[System], systems: List[System], include_parents: bool = True @@ -392,6 +396,9 @@ class PrivacyDeclaration(Base): index=True, ) system = relationship(System, back_populates="privacy_declarations") + cookies = relationship( + "Cookies", back_populates="privacy_declaration", lazy="joined", uselist=True + ) @classmethod def create( @@ -596,3 +603,45 @@ class AuditLogResource(Base): request_type = Column(String, nullable=True) fides_keys = Column(ARRAY(String), nullable=True) extra_data = Column(JSON, nullable=True) + + +class Cookies(Base): + """ + Stores cookies. Cookies have a FK to system and privacy declaration. If a privacy declaration is deleted, + the cookie can still remain linked to the system but unassociated with a data use. + """ + + name = Column(String, index=True, nullable=False) + path = Column(String) + domain = Column(String) + + system_id = Column( + String, ForeignKey(System.id_field_path, ondelete="CASCADE"), index=True + ) # If system is deleted, remove the associated cookies. + + privacy_declaration_id = Column( + String, + ForeignKey(PrivacyDeclaration.id_field_path, ondelete="SET NULL"), + index=True, + ) # If privacy declaration is deleted, just set to null and still keep this connected to the system. + + system = relationship( + "System", + back_populates="cookies", + cascade="all,delete", + uselist=False, + lazy="selectin", + ) + + privacy_declaration = relationship( + "PrivacyDeclaration", + back_populates="cookies", + uselist=False, + lazy="joined", # Joined is intentional, instead of selectin + ) + + __table_args__ = ( + UniqueConstraint( + "name", "privacy_declaration_id", name="_cookie_name_privacy_declaration_uc" + ), + ) diff --git a/src/fides/api/schemas/dataset.py b/src/fides/api/schemas/dataset.py index bc00dd5d56..7b4683dea7 100644 --- a/src/fides/api/schemas/dataset.py +++ b/src/fides/api/schemas/dataset.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional +from typing import Any, List, Optional, Type from fideslang.models import Dataset, DatasetCollection, DatasetField from fideslang.validation import FidesKey @@ -30,7 +30,7 @@ def validate_data_categories_against_db( class DataCategoryValidationMixin(BaseModel): @validator("data_categories", check_fields=False, allow_reuse=True) def valid_data_categories( - cls, v: Optional[List[FidesKey]] + cls: Type["DataCategoryValidationMixin"], v: Optional[List[FidesKey]] ) -> Optional[List[FidesKey]]: """Validate that all annotated data categories exist in the taxonomy""" return _valid_data_categories(v, defined_data_categories) diff --git a/src/fides/api/schemas/privacy_notice.py b/src/fides/api/schemas/privacy_notice.py index f8e97aef88..ae8a229356 100644 --- a/src/fides/api/schemas/privacy_notice.py +++ b/src/fides/api/schemas/privacy_notice.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional +from fideslang.models import Cookies as CookieSchema from fideslang.validation import FidesKey from pydantic import Extra, conlist, root_validator, validator @@ -140,6 +141,7 @@ class PrivacyNoticeResponse(PrivacyNoticeWithId): updated_at: datetime version: float privacy_notice_history_id: str + cookies: List[CookieSchema] class PrivacyNoticeResponseWithUserPreferences(PrivacyNoticeResponse): diff --git a/src/fides/api/schemas/privacy_request.py b/src/fides/api/schemas/privacy_request.py index c4f1228edb..5034c33012 100644 --- a/src/fides/api/schemas/privacy_request.py +++ b/src/fides/api/schemas/privacy_request.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum as EnumType -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Type, Union from fideslang.validation import FidesKey from pydantic import Field, validator @@ -84,7 +84,7 @@ class PrivacyRequestCreate(FidesSchema): @validator("encryption_key") def validate_encryption_key( - cls: "PrivacyRequestCreate", value: Optional[str] = None + cls: Type["PrivacyRequestCreate"], value: Optional[str] = None ) -> Optional[str]: """Validate encryption key where applicable""" if value: diff --git a/src/fides/api/schemas/system.py b/src/fides/api/schemas/system.py index 82016e051a..9111005698 100644 --- a/src/fides/api/schemas/system.py +++ b/src/fides/api/schemas/system.py @@ -1,6 +1,6 @@ from typing import List, Optional -from fideslang.models import PrivacyDeclaration, System +from fideslang.models import Cookies, PrivacyDeclaration, System from pydantic import Field from fides.api.schemas.connection_configuration.connection_config import ( @@ -14,6 +14,7 @@ class PrivacyDeclarationResponse(PrivacyDeclaration): id: str = Field( description="The database-assigned ID of the privacy declaration on the system. This is meant to be a read-only field, returned only in API responses" ) + cookies: Optional[List[Cookies]] = [] class SystemResponse(System): @@ -26,3 +27,5 @@ class SystemResponse(System): connection_configs: Optional[ConnectionConfigurationResponse] = Field( description=ConnectionConfigurationResponse.__doc__, ) + + cookies: Optional[List[Cookies]] = [] diff --git a/tests/conftest.py b/tests/conftest.py index b99d202bff..ebb44ffb27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,7 @@ from fides.api.db.ctl_session import sync_engine from fides.api.main import app from fides.api.models.privacy_request import generate_request_callback_jwe -from fides.api.models.sql_models import DataUse, PrivacyDeclaration +from fides.api.models.sql_models import Cookies, DataUse, PrivacyDeclaration from fides.api.oauth.jwt import generate_jwe from fides.api.oauth.roles import ( APPROVER, @@ -411,6 +411,7 @@ def resources_dict(): system_type="SYSTEM", name="Test System", description="Test Policy", + cookies=[], privacy_declarations=[ models.PrivacyDeclaration( name="declaration-name", @@ -419,6 +420,7 @@ def resources_dict(): data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[], ) ], ), @@ -1018,7 +1020,7 @@ def system(db: Session) -> System: }, ) - PrivacyDeclaration.create( + privacy_declaration = PrivacyDeclaration.create( db=db, data={ "name": "Collect data for marketing", @@ -1033,6 +1035,17 @@ def system(db: Session) -> System: }, ) + Cookies.create( + db=db, + data={ + "name": "test_cookie", + "path": "/", + "privacy_declaration_id": privacy_declaration.id, + "system_id": system.id, + }, + check_name=False, + ) + db.refresh(system) return system diff --git a/tests/ctl/core/test_api.py b/tests/ctl/core/test_api.py index 18e4686c6e..727119e3bd 100644 --- a/tests/ctl/core/test_api.py +++ b/tests/ctl/core/test_api.py @@ -449,6 +449,13 @@ def system_create_request_body(self) -> SystemSchema: data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[ + { + "name": "essential_cookie", + "path": "/", + "domain": "example.com", + } + ], ), models.PrivacyDeclaration( name="declaration-name-2", @@ -551,12 +558,24 @@ async def test_system_create( assert result.status_code == HTTP_201_CREATED assert result.json()["name"] == "Test System" + assert result.json()["cookies"] == [ + {"name": "essential_cookie", "path": "/", "domain": "example.com"} + ] + assert result.json()["privacy_declarations"][0]["cookies"] == [ + {"name": "essential_cookie", "path": "/", "domain": "example.com"} + ] + assert result.json()["privacy_declarations"][1]["cookies"] == [] assert len(result.json()["privacy_declarations"]) == 2 systems = System.all(db) assert len(systems) == 1 assert systems[0].name == "Test System" assert len(systems[0].privacy_declarations) == 2 + assert [cookie.name for cookie in systems[0].cookies] == ["essential_cookie"] + assert [ + cookie.name for cookie in systems[0].privacy_declarations[0].cookies + ] == ["essential_cookie"] + assert systems[0].privacy_declarations[1].cookies == [] async def test_system_create_custom_metadata_saas_config( self, @@ -734,6 +753,31 @@ def system_update_request_body(self, system) -> SystemSchema: ], ) + @pytest.fixture(scope="function") + def system_update_request_body_with_cookies(self, system) -> SystemSchema: + return SystemSchema( + organization_fides_key=1, + registryId=1, + fides_key=system.fides_key, + system_type="SYSTEM", + name=self.updated_system_name, + description="Test Policy", + privacy_declarations=[ + models.PrivacyDeclaration( + name="declaration-name", + data_categories=[], + data_use="essential", + data_subjects=[], + data_qualifier="aggregated_data", + dataset_references=[], + cookies=[ + {"name": "my_cookie", "domain": "example.com"}, + {"name": "my_other_cookie"}, + ], + ) + ], + ) + def test_system_update_not_authenticated( self, test_config, system_update_request_body ): @@ -1093,6 +1137,40 @@ def test_system_update_privacy_declaration_invalid_duplicate( and system.privacy_declarations[1].name == "new declaration 1" ) + def test_system_update_privacy_declaration_cookies( + self, + test_config, + system_update_request_body_with_cookies, + system, + db, + generate_system_manager_header, + ): + assert system.name != self.updated_system_name + + auth_header = generate_system_manager_header([system.id]) + result = _api.update( + url=test_config.cli.server_url, + headers=auth_header, + resource_type="system", + json_resource=system_update_request_body_with_cookies.json( + exclude_none=True + ), + ) + assert result.status_code == HTTP_200_OK + assert result.json()["name"] == self.updated_system_name + assert result.json()["cookies"] == [ + {"name": "my_cookie", "path": None, "domain": "example.com"}, + {"name": "my_other_cookie", "path": None, "domain": None}, + {"name": "test_cookie", "path": "/", "domain": None}, + ] + + db.refresh(system) + assert system.name == self.updated_system_name + assert ( + len(system.cookies) == 3 + ) # Two from the current privacy declaration, one from the previous privacy declaration that was deleted, but still linked to the system + assert len(system.privacy_declarations[0].cookies) == 2 + @pytest.mark.parametrize( "update_declarations", [ @@ -1105,6 +1183,7 @@ def test_system_update_privacy_declaration_invalid_duplicate( data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[], ) ] ), @@ -1118,6 +1197,7 @@ def test_system_update_privacy_declaration_invalid_duplicate( data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[], ), models.PrivacyDeclaration( name="declaration-name-2", @@ -1126,6 +1206,7 @@ def test_system_update_privacy_declaration_invalid_duplicate( data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[], ), ] ), @@ -1139,6 +1220,7 @@ def test_system_update_privacy_declaration_invalid_duplicate( data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[], ), models.PrivacyDeclaration( name="Collect data for marketing", @@ -1147,6 +1229,7 @@ def test_system_update_privacy_declaration_invalid_duplicate( data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[], ), ] ), @@ -1160,6 +1243,7 @@ def test_system_update_privacy_declaration_invalid_duplicate( data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[], ), models.PrivacyDeclaration( name="declaration-name-2", @@ -1168,6 +1252,7 @@ def test_system_update_privacy_declaration_invalid_duplicate( data_subjects=[], data_qualifier="aggregated_data", dataset_references=[], + cookies=[], ), ] ), diff --git a/tests/ctl/core/test_system.py b/tests/ctl/core/test_system.py index d801987a4f..a6eaaf5fc0 100644 --- a/tests/ctl/core/test_system.py +++ b/tests/ctl/core/test_system.py @@ -1,11 +1,16 @@ # pylint: disable=missing-docstring, redefined-outer-name import os from typing import Generator, List +from uuid import uuid4 import pytest +from fideslang.models import PrivacyDeclaration as PrivacyDeclarationSchema from fideslang.models import System, SystemMetadata from py._path.local import LocalPath +from sqlalchemy import delete +from fides.api.db.system import create_system, upsert_cookies +from fides.api.models.sql_models import Cookies, PrivacyDeclaration from fides.api.models.sql_models import System as sql_System from fides.config import FidesConfig from fides.connectors.models import OktaConfig @@ -332,3 +337,208 @@ def test_scan_system_okta_fail(tmpdir: LocalPath, test_config: FidesConfig) -> N url=test_config.cli.server_url, headers=test_config.user.auth_header, ) + + +class TestUpsertCookies: + @pytest.fixture() + async def test_cookie_system( + self, + async_session_temp, + generate_auth_header, + test_config, + generate_role_header, + db, + ): + resource = System( + fides_key=str(uuid4()), + organization_fides_key="default_organization", + name="test_system_1", + system_type="test", + privacy_declarations=[ + PrivacyDeclarationSchema( + name="declaration-name", + data_categories=[], + data_use="essential", + data_subjects=[], + data_qualifier="aggregated_data", + dataset_references=[], + ), + PrivacyDeclarationSchema( + name="declaration-name-2", + data_categories=[], + data_use="improve", + data_subjects=[], + data_qualifier="aggregated_data", + dataset_references=[], + ), + ], + ) + + system = await create_system(resource, async_session_temp) + + Cookies.create( + db=db, + data={ + "name": "strawberry", + "path": "/", + "privacy_declaration_id": sorted( + system.privacy_declarations, key=lambda x: x.name + )[1].id, + "system_id": system.id, + }, + check_name=False, + ) + await async_session_temp.refresh(system) + yield system + delete(sql_System).where(sql_System.id == system.id) + + async def test_new_cookies(self, test_cookie_system, async_session_temp): + """Test adding a new cookie to a privacy declaration. The other privacy declaration on the + system already has a cookie.""" + + new_cookies = [{"name": "apple"}] + privacy_declaration = sorted( + test_cookie_system.privacy_declarations, key=lambda x: x.name + )[0] + + await upsert_cookies( + async_session_temp, + new_cookies, + privacy_declaration, + test_cookie_system, + ) + await async_session_temp.refresh(test_cookie_system) + assert len(test_cookie_system.cookies) == 2 + + assert {cookie.name for cookie in test_cookie_system.cookies} == { + "strawberry", + "apple", + } + assert len(privacy_declaration.cookies) == 1 + assert privacy_declaration.cookies[0].name == "apple" + + new_cookie = privacy_declaration.cookies[0] + assert new_cookie.created_at is not None + assert new_cookie.updated_at is not None + assert new_cookie.name == "apple" + assert new_cookie.system_id == test_cookie_system.id + assert new_cookie.privacy_declaration_id == privacy_declaration.id + + async def test_no_change_to_cookies(self, test_cookie_system, async_session_temp): + """Test specified cookies already exist on given privacy declaration, so no change required""" + new_cookies = [{"name": "strawberry"}] + privacy_declaration = sorted( + test_cookie_system.privacy_declarations, key=lambda x: x.name + )[1] + existing_cookie = privacy_declaration.cookies[0] + assert existing_cookie.name == "strawberry" + + await upsert_cookies( + async_session_temp, + new_cookies, + privacy_declaration, + test_cookie_system, + ) + await async_session_temp.refresh(test_cookie_system) + assert len(test_cookie_system.cookies) == 1 + + assert {cookie.name for cookie in test_cookie_system.cookies} == { + "strawberry", + } + assert len(privacy_declaration.cookies) == 1 + assert privacy_declaration.cookies[0].name == "strawberry" + + new_cookie = privacy_declaration.cookies[0] + assert new_cookie.created_at is not None + assert new_cookie.updated_at is not None + assert new_cookie.name == "strawberry" + assert new_cookie.system_id == test_cookie_system.id + assert new_cookie.privacy_declaration_id == privacy_declaration.id + + async def test_update_cookies(self, test_cookie_system, async_session_temp): + """Test cookie exists but path has changed""" + """Test specified cookies already exist on given privacy declaration, so no change required""" + + new_cookies = [{"name": "strawberry", "path": "/"}] + privacy_declaration = sorted( + test_cookie_system.privacy_declarations, key=lambda x: x.name + )[1] + existing_cookie = privacy_declaration.cookies[0] + assert existing_cookie.name == "strawberry" + + await upsert_cookies( + async_session_temp, + new_cookies, + privacy_declaration, + test_cookie_system, + ) + await async_session_temp.refresh(test_cookie_system) + assert len(test_cookie_system.cookies) == 1 + assert test_cookie_system.cookies[0].name == "strawberry" + assert test_cookie_system.cookies[0].path == "/" + + assert len(privacy_declaration.cookies) == 1 + assert privacy_declaration.cookies[0].name == "strawberry" + assert privacy_declaration.cookies[0].path == "/" + + new_cookie = privacy_declaration.cookies[0] + assert new_cookie.created_at is not None + assert new_cookie.updated_at is not None + assert new_cookie.name == "strawberry" + assert new_cookie.system_id == test_cookie_system.id + assert new_cookie.privacy_declaration_id == privacy_declaration.id + + async def test_remove_cookies(self, test_cookie_system, async_session_temp): + """Test cookie list is missing a cookie currently on the privacy declaration so add the new + cookie and we remove the existing one""" + + new_cookies = [{"name": "apple"}] + privacy_declaration = sorted( + test_cookie_system.privacy_declarations, key=lambda x: x.name + )[1] + existing_cookie = privacy_declaration.cookies[0] + assert existing_cookie.name == "strawberry" + + await upsert_cookies( + async_session_temp, + new_cookies, + privacy_declaration, + test_cookie_system, + ) + await async_session_temp.refresh(test_cookie_system) + assert len(test_cookie_system.cookies) == 1 + + assert {cookie.name for cookie in test_cookie_system.cookies} == { + "apple", + } + assert len(privacy_declaration.cookies) == 1 + assert privacy_declaration.cookies[0].name == "apple" + + new_cookie = privacy_declaration.cookies[0] + assert new_cookie.created_at is not None + assert new_cookie.updated_at is not None + assert new_cookie.name == "apple" + assert new_cookie.system_id == test_cookie_system.id + assert new_cookie.privacy_declaration_id == privacy_declaration.id + + async def test_delete_privacy_declaration( + self, test_cookie_system, async_session_temp + ): + """Test if a privacy declaration is deleted, its cookie is still linked to the system""" + + privacy_declaration = sorted( + test_cookie_system.privacy_declarations, key=lambda x: x.name + )[1] + existing_cookie = privacy_declaration.cookies[0] + + assert existing_cookie.privacy_declaration_id == privacy_declaration.id + assert existing_cookie.system_id == test_cookie_system.id + + stmt = delete(PrivacyDeclaration).where( + PrivacyDeclaration.id == privacy_declaration.id + ) + await async_session_temp.execute(stmt) + await async_session_temp.refresh(existing_cookie) + + assert existing_cookie.privacy_declaration_id is None + assert existing_cookie.system_id == test_cookie_system.id diff --git a/tests/ops/api/v1/endpoints/test_privacy_experience_config_endpoints.py b/tests/ops/api/v1/endpoints/test_privacy_experience_config_endpoints.py index 4feb6ebc94..6a4f724e00 100644 --- a/tests/ops/api/v1/endpoints/test_privacy_experience_config_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_privacy_experience_config_endpoints.py @@ -13,6 +13,7 @@ ComponentType, PrivacyExperience, PrivacyExperienceConfig, + PrivacyExperienceConfigHistory, ) from fides.api.models.privacy_notice import PrivacyNoticeRegion from fides.common.api import scope_registry as scopes @@ -1167,14 +1168,18 @@ def test_update_experience_config_while_ignoring_regions( experience_config = get_experience_config_or_error(db, resp["id"]) assert experience_config.experiences.all() == [privacy_experience_overlay] assert experience_config.histories.count() == 2 - history = experience_config.histories[0] + history = experience_config.histories.order_by( + PrivacyExperienceConfigHistory.created_at + )[0] assert history.version == 1.0 assert history.component == ComponentType.overlay assert history.banner_enabled == BannerEnabled.enabled_where_required assert history.experience_config_id == experience_config.id assert history.disabled is False - history = experience_config.histories[1] + history = experience_config.histories.order_by( + PrivacyExperienceConfigHistory.created_at + )[1] assert history.version == 2.0 assert history.disabled is True @@ -1224,14 +1229,18 @@ def test_update_experience_config_with_no_regions( experience_config = get_experience_config_or_error(db, resp["id"]) assert experience_config.experiences.all() == [] assert experience_config.histories.count() == 2 - history = experience_config.histories[0] + history = experience_config.histories.order_by( + PrivacyExperienceConfigHistory.created_at + )[0] assert history.version == 1.0 assert history.component == ComponentType.overlay assert history.banner_enabled == BannerEnabled.enabled_where_required assert history.experience_config_id == experience_config.id assert history.disabled is False - history = experience_config.histories[1] + history = experience_config.histories.order_by( + PrivacyExperienceConfigHistory.created_at + )[1] assert history.version == 2.0 assert history.disabled is True @@ -1448,7 +1457,9 @@ def test_update_experience_config_experience_also_updated( db.refresh(overlay_experience_config) # ExperienceConfig was disabled - this is a change, so another historical record is created assert overlay_experience_config.histories.count() == 2 - experience_config_history = overlay_experience_config.histories[1] + experience_config_history = overlay_experience_config.histories.order_by( + PrivacyExperienceConfigHistory.created_at + )[1] assert experience_config_history.version == 2.0 assert experience_config_history.disabled assert experience_config_history.component == ComponentType.overlay diff --git a/tests/ops/api/v1/endpoints/test_privacy_notice_endpoints.py b/tests/ops/api/v1/endpoints/test_privacy_notice_endpoints.py index 4815798aa9..9090daa892 100644 --- a/tests/ops/api/v1/endpoints/test_privacy_notice_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_privacy_notice_endpoints.py @@ -5,6 +5,7 @@ from uuid import uuid4 import pytest +from fideslang.models import Cookies as CookieSchema from sqlalchemy.orm import Session from starlette.testclient import TestClient @@ -87,6 +88,7 @@ def test_get_privacy_notices_defaults( assert "created_at" in notice_detail assert "updated_at" in notice_detail assert "name" in notice_detail + assert "name" in notice_detail assert "description" in notice_detail assert "regions" in notice_detail assert "consent_mechanism" in notice_detail @@ -755,6 +757,9 @@ def test_get_privacy_notice_by_data_use_wrong_scope( displayed_in_overlay=True, displayed_in_privacy_center=False, displayed_in_api=False, + cookies=[ + CookieSchema(name="test_cookie", path="/", domain=None) + ], ) ], }, @@ -824,6 +829,9 @@ def test_get_privacy_notice_by_data_use_wrong_scope( displayed_in_overlay=True, displayed_in_privacy_center=False, displayed_in_api=False, + cookies=[ + CookieSchema(name="test_cookie", path="/", domain=None) + ], ), PrivacyNoticeResponse( id=f"{PRIVACY_NOTICE_NAME}-2", @@ -844,6 +852,9 @@ def test_get_privacy_notice_by_data_use_wrong_scope( displayed_in_overlay=True, displayed_in_privacy_center=False, displayed_in_api=False, + cookies=[ + CookieSchema(name="test_cookie", path="/", domain=None) + ], ), ], }, @@ -911,6 +922,9 @@ def test_get_privacy_notice_by_data_use_wrong_scope( version=1.0, privacy_notice_history_id="placeholder_id", displayed_in_overlay=True, + cookies=[ + CookieSchema(name="test_cookie", path="/", domain=None) + ], ), PrivacyNoticeResponse( id=f"{PRIVACY_NOTICE_NAME}-2", @@ -929,6 +943,9 @@ def test_get_privacy_notice_by_data_use_wrong_scope( version=1.0, privacy_notice_history_id="placeholder_id", displayed_in_overlay=True, + cookies=[ + CookieSchema(name="test_cookie", path="/", domain=None) + ], ), ], "third_party_sharing": [ @@ -950,6 +967,9 @@ def test_get_privacy_notice_by_data_use_wrong_scope( version=1.0, privacy_notice_history_id="placeholder_id", displayed_in_overlay=True, + cookies=[ + CookieSchema(name="test_cookie", path="/", domain=None) + ], ), ], }, @@ -1019,6 +1039,9 @@ def test_get_privacy_notice_by_data_use_wrong_scope( displayed_in_overlay=True, displayed_in_privacy_center=False, displayed_in_api=False, + cookies=[ + CookieSchema(name="test_cookie", path="/", domain=None) + ], ), ], "third_party_sharing": [], @@ -1153,6 +1176,9 @@ def test_get_privacy_notice_by_data_use_wrong_scope( displayed_in_overlay=True, displayed_in_privacy_center=False, displayed_in_api=False, + cookies=[ + CookieSchema(name="test_cookie", path="/", domain=None) + ], ) ], "essential.service.operations.support.optimization": [ @@ -1177,6 +1203,7 @@ def test_get_privacy_notice_by_data_use_wrong_scope( displayed_in_overlay=True, displayed_in_privacy_center=False, displayed_in_api=False, + cookies=[], ), PrivacyNoticeResponse( id=f"{PRIVACY_NOTICE_NAME}-3", @@ -1197,6 +1224,7 @@ def test_get_privacy_notice_by_data_use_wrong_scope( displayed_in_overlay=True, displayed_in_privacy_center=False, displayed_in_api=False, + cookies=[], ), PrivacyNoticeResponse( id=f"{PRIVACY_NOTICE_NAME}-2", @@ -1217,6 +1245,7 @@ def test_get_privacy_notice_by_data_use_wrong_scope( displayed_in_overlay=True, displayed_in_privacy_center=False, displayed_in_api=False, + cookies=[], ), ], }, diff --git a/tests/ops/models/test_privacy_experience.py b/tests/ops/models/test_privacy_experience.py index 78e3e87dcd..27f6ee539a 100644 --- a/tests/ops/models/test_privacy_experience.py +++ b/tests/ops/models/test_privacy_experience.py @@ -7,6 +7,7 @@ ComponentType, PrivacyExperience, PrivacyExperienceConfig, + PrivacyExperienceConfigHistory, upsert_privacy_experiences_after_config_update, upsert_privacy_experiences_after_notice_update, ) @@ -123,11 +124,15 @@ def test_update_privacy_experience_config(self, db): assert config.updated_at > config_updated_at assert config.histories.count() == 2 - history = config.histories[1] + history = config.histories.order_by(PrivacyExperienceConfigHistory.created_at)[ + 1 + ] assert history.component == ComponentType.privacy_center assert config.experience_config_history_id == history.id - old_history = config.histories[0] + old_history = config.histories.order_by( + PrivacyExperienceConfigHistory.created_at + )[0] assert old_history.version == 1.0 assert old_history.component == ComponentType.overlay diff --git a/tests/ops/models/test_privacy_notice.py b/tests/ops/models/test_privacy_notice.py index 6c519f4ced..c56c02cc80 100644 --- a/tests/ops/models/test_privacy_notice.py +++ b/tests/ops/models/test_privacy_notice.py @@ -1,4 +1,5 @@ import pytest +from fideslang.models import Cookies as CookieSchema from fideslang.validation import FidesValidationError from sqlalchemy.orm import Session @@ -12,6 +13,7 @@ check_conflicting_data_uses, new_data_use_conflicts_with_existing_use, ) +from fides.api.models.sql_models import Cookies class TestPrivacyNoticeModel: @@ -697,6 +699,69 @@ def test_conflicting_data_uses( existing_privacy_notices=existing_privacy_notices, ) + @pytest.mark.parametrize( + "privacy_notice_data_use,declaration_cookies,expected_cookies,description", + [ + ( + ["marketing.advertising", "third_party_sharing"], + [{"name": "test_cookie"}], + [CookieSchema(name="test_cookie")], + "Data uses overlap exactly", + ), + ( + ["marketing.advertising.first_party", "third_party_sharing"], + [{"name": "test_cookie"}], + [], + "Privacy notice use more specific than system's. Too big a leap to assume system should be adjusted here.", + ), + ( + ["marketing", "third_party_sharing"], + [{"name": "test_cookie"}], + [CookieSchema(name="test_cookie")], + "Privacy notice use more general than system's, so system's data use is under the scope of the notice", + ), + ( + ["marketing.advertising", "third_party_sharing"], + [{"name": "test_cookie"}, {"name": "another_cookie"}], + [CookieSchema(name="test_cookie"), CookieSchema(name="another_cookie")], + "Test multiple cookies", + ), + (["marketing.advertising"], [], [], "No cookies returns an empty set"), + ], + ) + def test_relevant_cookies( + self, + privacy_notice_data_use, + declaration_cookies, + expected_cookies, + description, + privacy_notice, + db, + system, + ): + """Test different combinations of data uses and cookies between the Privacy Notice and the Privacy Declaration""" + db.query(Cookies).delete() + privacy_notice.data_uses = privacy_notice_data_use + privacy_notice.save(db) + + privacy_declaration = system.privacy_declarations[0] + assert privacy_declaration.data_use == "marketing.advertising" + + for cookie in declaration_cookies: + Cookies.create( + db, + data={ + "name": cookie["name"], + "privacy_declaration_id": privacy_declaration.id, + "system_id": system.id, + }, + check_name=False, + ) + + assert [ + CookieSchema.from_orm(cookie) for cookie in privacy_notice.cookies + ] == expected_cookies, description + def test_calculate_relevant_systems( self, db,