diff --git a/CHANGELOG.md b/CHANGELOG.md index 72198ee6e3..e8b1bffb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,14 +24,13 @@ The types of changes are: - New purposes endpoint and indices to improve system lookups [#4452](https://github.com/ethyca/fides/pull/4452) - Cypress tests for fides.js GPP extension [#4476](https://github.com/ethyca/fides/pull/4476) - Add support for global TCF Purpose Overrides [#4464](https://github.com/ethyca/fides/pull/4464) +- TCF override management [#4484](https://github.com/ethyca/fides/pull/4484) - Readonly consent management table and modal [#4456](https://github.com/ethyca/fides/pull/4456), [#4477](https://github.com/ethyca/fides/pull/4477) - Access and erasure support for Gong [#4461](https://github.com/ethyca/fides/pull/4461) ### Changed - Increased max number of preferences allowed in privacy preference API calls [#4469](https://github.com/ethyca/fides/pull/4469) - Reduce size of tcf_consent payload in fides_consent cookie [#4480](https://github.com/ethyca/fides/pull/4480) - -### Changed - Change log level for FidesUserPermission retrieval to `debug` [#4482](https://github.com/ethyca/fides/pull/4482) ### Fixed diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts index efab538397..e27dbd3a63 100644 --- a/clients/admin-ui/src/features/common/api.slice.ts +++ b/clients/admin-ui/src/features/common/api.slice.ts @@ -51,6 +51,7 @@ export const baseApi = createApi({ "Roles", "User", "Configuration Settings", + "TCF Purpose Override", ], endpoints: () => ({}), }); diff --git a/clients/admin-ui/src/features/common/form/inputs.tsx b/clients/admin-ui/src/features/common/form/inputs.tsx index 0f5ba38c59..34f9e0e759 100644 --- a/clients/admin-ui/src/features/common/form/inputs.tsx +++ b/clients/admin-ui/src/features/common/form/inputs.tsx @@ -989,7 +989,7 @@ export const CustomNumberInput = ({ }; interface CustomSwitchProps { - label: string; + label?: string; tooltip?: string; variant?: "inline" | "condensed" | "stacked"; isDisabled?: boolean; @@ -998,6 +998,7 @@ export const CustomSwitch = ({ label, tooltip, variant = "inline", + onChange, isDisabled, ...props }: CustomSwitchProps & FieldHookConfig) => { @@ -1008,7 +1009,13 @@ export const CustomSwitch = ({ { + field.onChange(e); + if (onChange) { + // @ts-ignore - it got confused between select/input element events + onChange(e); + } + }} onBlur={field.onBlur} colorScheme="purple" mr={2} diff --git a/clients/admin-ui/src/features/common/nav/v2/nav-config.ts b/clients/admin-ui/src/features/common/nav/v2/nav-config.ts index 2a3873f8ee..2af3de3754 100644 --- a/clients/admin-ui/src/features/common/nav/v2/nav-config.ts +++ b/clients/admin-ui/src/features/common/nav/v2/nav-config.ts @@ -175,6 +175,16 @@ export const NAV_CONFIG: NavConfigGroup[] = [ ScopeRegistryEnum.CONFIG_UPDATE, ], }, + { + title: "Consent", + path: routes.GLOABL_CONSENT_CONFIG_ROUTE, + requiresPlus: true, + requiresFidesCloud: false, + scopes: [ + ScopeRegistryEnum.TCF_PUBLISHER_OVERRIDE_READ, + ScopeRegistryEnum.TCF_PUBLISHER_OVERRIDE_UPDATE, + ], + }, { title: "About Fides", path: routes.ABOUT_ROUTE, diff --git a/clients/admin-ui/src/features/common/nav/v2/routes.ts b/clients/admin-ui/src/features/common/nav/v2/routes.ts index a8b5f7c175..8796d299d5 100644 --- a/clients/admin-ui/src/features/common/nav/v2/routes.ts +++ b/clients/admin-ui/src/features/common/nav/v2/routes.ts @@ -30,3 +30,4 @@ export const CUSTOM_FIELDS_ROUTE = "/management/custom-fields"; export const EMAIL_TEMPLATES_ROUTE = "/management/email-templates"; export const DOMAIN_RECORDS_ROUTE = "/management/domain-records"; export const CORS_CONFIGURATION_ROUTE = "/management/cors-configuration"; +export const GLOABL_CONSENT_CONFIG_ROUTE = "/management/consent"; diff --git a/clients/admin-ui/src/features/plus/plus.slice.ts b/clients/admin-ui/src/features/plus/plus.slice.ts index f78ee9246d..c8e3c35207 100644 --- a/clients/admin-ui/src/features/plus/plus.slice.ts +++ b/clients/admin-ui/src/features/plus/plus.slice.ts @@ -40,6 +40,7 @@ import { SystemScannerStatus, SystemScanResponse, SystemsDiff, + TCFPurposeOverrideSchema, } from "~/types/api"; import { DataUseDeclaration, @@ -428,6 +429,24 @@ const plusApi = baseApi.injectEndpoints({ // Creating a connection config also creates a dataset behind the scenes invalidatesTags: () => ["Datastore Connection", "Datasets", "System"], }), + getTcfPurposeOverrides: build.query({ + query: () => ({ + url: `plus/tcf/purpose_overrides`, + method: "GET", + }), + providesTags: ["TCF Purpose Override"], + }), + patchTcfPurposeOverrides: build.mutation< + TCFPurposeOverrideSchema[], + TCFPurposeOverrideSchema[] + >({ + query: (overrides) => ({ + url: `plus/tcf/purpose_overrides`, + method: "PATCH", + body: overrides, + }), + invalidatesTags: ["TCF Purpose Override"], + }), }), }); @@ -465,6 +484,8 @@ export const { useUpdateCustomAssetMutation, usePatchPlusSystemConnectionConfigsMutation, useCreatePlusSaasConnectionConfigMutation, + useGetTcfPurposeOverridesQuery, + usePatchTcfPurposeOverridesMutation, } = plusApi; export const selectHealth: (state: RootState) => HealthCheck | undefined = diff --git a/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts b/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts index 3aa68a7e04..05ab2330b9 100644 --- a/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts +++ b/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts @@ -374,11 +374,14 @@ export const privacyRequestApi = baseApi.injectEndpoints({ }), invalidatesTags: ["Configuration Settings"], }), - getConfigurationSettings: build.query, void>({ - query: () => ({ + getConfigurationSettings: build.query< + Record, + { api_set: boolean } + >({ + query: ({ api_set }) => ({ url: `/config`, method: "GET", - params: { api_set: true }, + params: { api_set }, }), providesTags: ["Configuration Settings"], }), @@ -502,7 +505,9 @@ export const selectCORSOrigins = () => createSelector( [ (state) => state, - privacyRequestApi.endpoints.getConfigurationSettings.select(), + privacyRequestApi.endpoints.getConfigurationSettings.select({ + api_set: true, + }), ], (_, { data }) => { const hasCorsOrigins = narrow( @@ -525,7 +530,9 @@ export const selectApplicationConfig = () => createSelector( [ (state) => state, - privacyRequestApi.endpoints.getConfigurationSettings.select(), + privacyRequestApi.endpoints.getConfigurationSettings.select({ + api_set: true, + }), ], (_, { data }) => data as ApplicationConfig ); diff --git a/clients/admin-ui/src/pages/management/consent.tsx b/clients/admin-ui/src/pages/management/consent.tsx new file mode 100644 index 0000000000..c4548ed8a8 --- /dev/null +++ b/clients/admin-ui/src/pages/management/consent.tsx @@ -0,0 +1,523 @@ +/* eslint-disable react/no-array-index-key */ +import { + Badge, + Box, + Button, + Flex, + Heading, + Spinner, + Switch, + Text, + useToast, +} from "@fidesui/react"; +import { SerializedError } from "@reduxjs/toolkit"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; +import { FieldArray, Form, Formik, FormikHelpers } from "formik"; +import type { NextPage } from "next"; +import { ChangeEvent, FC, useMemo } from "react"; + +import { useAppSelector } from "~/app/hooks"; +import DocsLink from "~/features/common/DocsLink"; +import { useFeatures } from "~/features/common/features"; +import { CustomSwitch } from "~/features/common/form/inputs"; +import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; +import Layout from "~/features/common/Layout"; +import { + selectPurposes, + useGetPurposesQuery, +} from "~/features/common/purpose.slice"; +import QuestionTooltip from "~/features/common/QuestionTooltip"; +import { errorToastParams, successToastParams } from "~/features/common/toast"; +import { + useGetHealthQuery, + useGetTcfPurposeOverridesQuery, + usePatchTcfPurposeOverridesMutation, +} from "~/features/plus/plus.slice"; +import { + useGetConfigurationSettingsQuery, + usePatchConfigurationSettingsMutation, +} from "~/features/privacy-requests/privacy-requests.slice"; +import { TCFLegalBasisEnum, TCFPurposeOverrideSchema } from "~/types/api"; + +const LegalBasisContainer: FC<{ + purpose: number; + endCol?: boolean; +}> = ({ children, purpose, endCol }) => { + const hiddenPurposes = [1, 3, 4, 5, 6]; + + return ( + + {hiddenPurposes.includes(purpose) ? null : {children}} + + ); +}; + +type FormPurposeOverride = { + purpose: number; + is_included: boolean; + is_consent: boolean; + is_legitimate_interest: boolean; +}; + +type FormValues = { purposeOverrides: FormPurposeOverride[] }; + +const ConsentConfigPage: NextPage = () => { + const { isLoading: isHealthCheckLoading } = useGetHealthQuery(); + const { tcf: isTcfEnabled } = useFeatures(); + const { data: tcfPurposeOverrides, isLoading: isTcfPurposeOverridesLoading } = + useGetTcfPurposeOverridesQuery(undefined, { + skip: isHealthCheckLoading || !isTcfEnabled, + }); + const [ + patchTcfPurposeOverridesTrigger, + { isLoading: isLoadingPatchMutation }, + ] = usePatchTcfPurposeOverridesMutation(); + const { data: apiConfigSet, isLoading: isApiConfigSetLoading } = + useGetConfigurationSettingsQuery({ api_set: true }); + const { data: configSet, isLoading: isConfigSetLoading } = + useGetConfigurationSettingsQuery({ api_set: false }); + const [ + patchConfigSettingsTrigger, + { isLoading: isPatchConfigSettingsLoading }, + ] = usePatchConfigurationSettingsMutation(); + + const isOverrideEnabled = useMemo(() => { + if ( + apiConfigSet && + apiConfigSet?.consent && + "override_vendor_purposes" in apiConfigSet.consent + ) { + return apiConfigSet.consent.override_vendor_purposes; + } + if ( + configSet && + configSet?.consent && + "override_vendor_purposes" in configSet.consent + ) { + return configSet.consent.override_vendor_purposes; + } + + return false; + }, [apiConfigSet, configSet]); + + const { isLoading: isPurposesLoading } = useGetPurposesQuery(); + const { purposes: purposeMapping } = useAppSelector(selectPurposes); + + const toast = useToast(); + + const handleSubmit = async ( + values: FormValues, + formikHelpers: FormikHelpers + ) => { + const handleResult = ( + result: { data: {} } | { error: FetchBaseQueryError | SerializedError } + ) => { + toast.closeAll(); + if (isErrorResult(result)) { + const errorMsg = getErrorMessage( + result.error, + `An unexpected error occurred while saving vendor overrides. Please try again.` + ); + toast(errorToastParams(errorMsg)); + } else { + toast(successToastParams("TCF Purpose Overrides saved successfully")); + // Reset state such that isDirty will be checked again before next save + formikHelpers.resetForm({ values }); + } + }; + + const payload: TCFPurposeOverrideSchema[] = [ + ...values.purposeOverrides.map((po) => { + let requiredLegalBasis; + if (po.is_consent) { + requiredLegalBasis = TCFLegalBasisEnum.CONSENT; + } + + if (po.is_legitimate_interest) { + requiredLegalBasis = TCFLegalBasisEnum.LEGITIMATE_INTERESTS; + } + + return { + purpose: po.purpose, + is_included: po.is_included, + required_legal_basis: requiredLegalBasis, + }; + }), + ]; + + const result = await patchTcfPurposeOverridesTrigger(payload); + + handleResult(result); + }; + + const handleOverrideOnChange = async (e: ChangeEvent) => { + const handleResult = ( + result: { data: {} } | { error: FetchBaseQueryError | SerializedError } + ) => { + toast.closeAll(); + if (isErrorResult(result)) { + const errorMsg = getErrorMessage( + result.error, + `An unexpected error occurred while saving vendor override settings. Please try again.` + ); + toast(errorToastParams(errorMsg)); + } + }; + + const result = await patchConfigSettingsTrigger({ + consent: { + override_vendor_purposes: e.target.checked, + }, + }); + + if (e.target.checked) { + await patchTcfPurposeOverridesTrigger( + tcfPurposeOverrides!.map((po) => ({ + ...po, + is_included: true, + required_legal_basis: undefined, + })) + ); + } + + handleResult(result); + }; + + const initialValues = useMemo( + () => ({ + purposeOverrides: tcfPurposeOverrides + ? tcfPurposeOverrides.map( + (po) => + ({ + purpose: po.purpose, + is_included: po.is_included, + is_consent: + po.required_legal_basis === TCFLegalBasisEnum.CONSENT, + is_legitimate_interest: + po.required_legal_basis === + TCFLegalBasisEnum.LEGITIMATE_INTERESTS, + } as FormPurposeOverride) + ) + : [], + }), + [tcfPurposeOverrides] + ); + + return ( + + {isHealthCheckLoading || + isPurposesLoading || + isTcfPurposeOverridesLoading || + isApiConfigSetLoading || + isConfigSetLoading ? ( + + + + ) : ( + + + Consent settings + + + + + Transparency & Consent Framework settings + + + TCF status{" "} + {isTcfEnabled ? ( + Enabled + ) : ( + Disabled + )} + + + To {isTcfEnabled ? "disable" : "enable"} TCF, please contact + your Fides administrator or{" "} + + Ethyca support + + . + + + + + + Vendor overrides + + {isTcfEnabled ? ( + <> + + Configure overrides for TCF related purposes. + + + + + Override vendor purposes + + + + + {isOverrideEnabled + ? "The table below allows you to adjust which TCF purposes you allow as part of your user facing notices and business activites." + : null} + + + ) : null} + {isOverrideEnabled && isTcfEnabled ? ( + + To configure this section, select the purposes you allow and + where available, the appropriate legal bases (either Consent + or Legitimate Interest).{" "} + + Read the guide on vendor overrides here.{" "} + + + ) : null} + + + + {isOverrideEnabled ? ( + + + initialValues={initialValues} + enableReinitialize + onSubmit={handleSubmit} + > + {({ values, dirty, isValid, setFieldValue }) => ( +
+ ( + + + + TCF purpose + + + + Allowed + + + + + Consent + + + + + Legitimate interest + + + + {values.purposeOverrides.map((po, index) => ( + + + Purpose {po.purpose}:{" "} + {purposeMapping[po.purpose].name} + + + + + + ) => { + if (!e.target.checked) { + setFieldValue( + `purposeOverrides[${index}].is_consent`, + false + ); + setFieldValue( + `purposeOverrides[${index}].is_legitimate_interest`, + false + ); + } + }} + /> + + + + + + + + + + ))} + + )} + /> + + + + + )} + +
+ ) : null} +
+ )} +
+ ); +}; +export default ConsentConfigPage; diff --git a/clients/admin-ui/src/pages/management/cors-configuration.tsx b/clients/admin-ui/src/pages/management/cors-configuration.tsx index 6171909e7d..10f607e569 100644 --- a/clients/admin-ui/src/pages/management/cors-configuration.tsx +++ b/clients/admin-ui/src/pages/management/cors-configuration.tsx @@ -36,7 +36,9 @@ import { ApplicationConfig } from "~/types/api"; type FormValues = CORSOrigins; const CORSConfigurationPage: NextPage = () => { - const { isLoading: isLoadingGetQuery } = useGetConfigurationSettingsQuery(); + const { isLoading: isLoadingGetQuery } = useGetConfigurationSettingsQuery({ + api_set: true, + }); const corsOrigins = useAppSelector(selectCORSOrigins()); const applicationConfig = useAppSelector(selectApplicationConfig()); const [putConfigSettingsTrigger, { isLoading: isLoadingPutMutation }] = diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index f15f6ebd29..160aadcfe7 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -65,6 +65,7 @@ export { ConnectionTestStatus } from "./models/ConnectionTestStatus"; export { ConnectionType } from "./models/ConnectionType"; export type { ConnectorParam } from "./models/ConnectorParam"; export type { Consent } from "./models/Consent"; +export type { ConsentConfig } from "./models/ConsentConfig"; export { ConsentMechanism } from "./models/ConsentMechanism"; export { ConsentMethod } from "./models/ConsentMethod"; export type { ConsentOptionCreate } from "./models/ConsentOptionCreate"; @@ -321,8 +322,10 @@ export { SystemType } from "./models/SystemType"; export type { TCDecode } from "./models/TCDecode"; export type { TCFFeatureRecord } from "./models/TCFFeatureRecord"; export type { TCFFeatureSave } from "./models/TCFFeatureSave"; +export { TCFLegalBasisEnum } from "./models/TCFLegalBasisEnum"; export type { TCFPurposeConsentRecord } from "./models/TCFPurposeConsentRecord"; export type { TCFPurposeLegitimateInterestsRecord } from "./models/TCFPurposeLegitimateInterestsRecord"; +export type { TCFPurposeOverrideSchema } from "./models/TCFPurposeOverrideSchema"; export type { TCFPurposeSave } from "./models/TCFPurposeSave"; export type { TCFSpecialFeatureRecord } from "./models/TCFSpecialFeatureRecord"; export type { TCFSpecialFeatureSave } from "./models/TCFSpecialFeatureSave"; diff --git a/clients/admin-ui/src/types/api/models/ApplicationConfig.ts b/clients/admin-ui/src/types/api/models/ApplicationConfig.ts index 8fc8725780..57ff2dca40 100644 --- a/clients/admin-ui/src/types/api/models/ApplicationConfig.ts +++ b/clients/admin-ui/src/types/api/models/ApplicationConfig.ts @@ -2,6 +2,7 @@ /* tslint:disable */ /* eslint-disable */ +import type { ConsentConfig } from "./ConsentConfig"; import type { ExecutionApplicationConfig } from "./ExecutionApplicationConfig"; import type { NotificationApplicationConfig } from "./NotificationApplicationConfig"; import type { SecurityApplicationConfig } from "./SecurityApplicationConfig"; @@ -19,4 +20,5 @@ export type ApplicationConfig = { notifications?: NotificationApplicationConfig; execution?: ExecutionApplicationConfig; security?: SecurityApplicationConfig; + consent?: ConsentConfig; }; diff --git a/clients/admin-ui/src/types/api/models/ConsentConfig.ts b/clients/admin-ui/src/types/api/models/ConsentConfig.ts new file mode 100644 index 0000000000..5f43583c39 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ConsentConfig.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * A base template for all other Fides Schemas to inherit from. + */ +export type ConsentConfig = { + override_vendor_purposes?: boolean; +}; diff --git a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts index 57134239df..fecf04aea8 100644 --- a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts +++ b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts @@ -140,6 +140,8 @@ export enum ScopeRegistryEnum { TAXONOMY_CREATE = "taxonomy:create", TAXONOMY_DELETE = "taxonomy:delete", TAXONOMY_UPDATE = "taxonomy:update", + TCF_PUBLISHER_OVERRIDE_READ = "tcf_publisher_override:read", + TCF_PUBLISHER_OVERRIDE_UPDATE = "tcf_publisher_override:update", USER_PERMISSION_ASSIGN_OWNERS = "user-permission:assign_owners", USER_PERMISSION_CREATE = "user-permission:create", USER_PERMISSION_READ = "user-permission:read", diff --git a/clients/admin-ui/src/types/api/models/TCFLegalBasisEnum.ts b/clients/admin-ui/src/types/api/models/TCFLegalBasisEnum.ts new file mode 100644 index 0000000000..59a20d7b0f --- /dev/null +++ b/clients/admin-ui/src/types/api/models/TCFLegalBasisEnum.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Restricts the Legal Basis to just those that are relevant for TCF + */ +export enum TCFLegalBasisEnum { + CONSENT = "Consent", + LEGITIMATE_INTERESTS = "Legitimate interests", +} diff --git a/clients/admin-ui/src/types/api/models/TCFPurposeOverrideSchema.ts b/clients/admin-ui/src/types/api/models/TCFPurposeOverrideSchema.ts new file mode 100644 index 0000000000..53f4cfd769 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/TCFPurposeOverrideSchema.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { TCFLegalBasisEnum } from "./TCFLegalBasisEnum"; + +/** + * TCF Purpose Override Schema + */ +export type TCFPurposeOverrideSchema = { + purpose: number; + is_included?: boolean; + required_legal_basis?: TCFLegalBasisEnum; +}; diff --git a/src/fides/api/schemas/application_config.py b/src/fides/api/schemas/application_config.py index 1a2d13b2ac..84e2a62106 100644 --- a/src/fides/api/schemas/application_config.py +++ b/src/fides/api/schemas/application_config.py @@ -67,6 +67,13 @@ class Config: extra = Extra.forbid +class ConsentConfig(FidesSchema): + override_vendor_purposes: Optional[bool] + + class Config: + extra = Extra.forbid + + class SecurityApplicationConfig(FidesSchema): # only valid URLs should be set as cors_origins # for advanced usage of non-URLs, e.g. wildcards (`*`), the related @@ -94,6 +101,7 @@ class ApplicationConfig(FidesSchema): notifications: Optional[NotificationApplicationConfig] execution: Optional[ExecutionApplicationConfig] security: Optional[SecurityApplicationConfig] + consent: Optional[ConsentConfig] @root_validator(pre=True) def validate_not_empty(cls, values: Dict) -> Dict: diff --git a/src/fides/config/utils.py b/src/fides/config/utils.py index 3dc7a45945..c14884f242 100644 --- a/src/fides/config/utils.py +++ b/src/fides/config/utils.py @@ -63,4 +63,5 @@ def get_dev_mode() -> bool: "storage": [ "active_default_storage_type", ], + "consent": ["override_vendor_purposes"], }