From f2f729a97583f43bc20104bbe31e80953d3f7286 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Sep 2022 09:43:47 -0500 Subject: [PATCH 01/30] Bump pre-commit from 2.9.3 to 2.20.0 (#853) (#1373) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.9.3 to 2.20.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.9.3...v2.20.0) --- updated-dependencies: - dependency-name: pre-commit dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- clients/ops/privacy-center/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/clients/ops/privacy-center/package-lock.json b/clients/ops/privacy-center/package-lock.json index 98886d4678..89b47d4542 100644 --- a/clients/ops/privacy-center/package-lock.json +++ b/clients/ops/privacy-center/package-lock.json @@ -8843,9 +8843,9 @@ } }, "node_modules/jose": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.5.0.tgz", - "integrity": "sha512-GFcVFQwYQKbQTUOo2JlpFGXTkgBw26uzDsRMD2q1WgSKNSnpKS9Ug7bdQ8dS+p4sZHNH6iRPu6WK2jLIjspaMA==", + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.3.tgz", + "integrity": "sha512-f8E/z+T3Q0kA9txzH2DKvH/ds2uggcw0m3vVPSB9HrSkrQ7mojjifvS7aR8cw+lQl2Fcmx9npwaHpM/M3GD8UQ==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -17976,9 +17976,9 @@ } }, "jose": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.5.0.tgz", - "integrity": "sha512-GFcVFQwYQKbQTUOo2JlpFGXTkgBw26uzDsRMD2q1WgSKNSnpKS9Ug7bdQ8dS+p4sZHNH6iRPu6WK2jLIjspaMA==" + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.3.tgz", + "integrity": "sha512-f8E/z+T3Q0kA9txzH2DKvH/ds2uggcw0m3vVPSB9HrSkrQ7mojjifvS7aR8cw+lQl2Fcmx9npwaHpM/M3GD8UQ==" }, "js-levenshtein": { "version": "1.1.6", From b2aa8a6df8fa26644b238d4ba743dc35e1d4676b Mon Sep 17 00:00:00 2001 From: chriscalhoun1974 <68459950+chriscalhoun1974@users.noreply.github.com> Date: Tue, 27 Sep 2022 14:56:22 -0400 Subject: [PATCH 02/30] 1016 frontend ability for users to manually enter pii to an in progress subject request (#1377) * On successful connector creation the empty yaml text input displays a validation error * Updated caniuse-lite NPM dependency * Added min/max character limitation validation for Manual Webhook DSR Customization * Prevented Chakra Divider component opacity from being overridden at runtime. User could not see visible horizontal line on UI before. * Updated the Manual Processing detail form to not be submitted until the form is dirty. * Removed form dirty check on ManualProcessingDetail component * Update URL when user is navigating to DSR Customization screen when creating a Manual Webhook --- CHANGELOG.md | 1 + clients/ops/admin-ui/package-lock.json | 26 +- .../add-connection/ConfigureConnector.tsx | 20 +- .../add-connection/forms/YamlEditorForm.tsx | 4 +- .../manual/DSRCustomization.tsx | 8 +- .../manual/DSRCustomizationForm.tsx | 14 +- .../datastore-connection.slice.ts | 11 + .../features/datastore-connections/types.ts | 3 + .../privacy-requests.slice.ts | 54 +++- .../src/features/privacy-requests/types.ts | 11 + .../subject-request/RequestDetails.tsx | 2 +- .../subject-request/SubjectIdentities.tsx | 2 +- .../subject-request/SubjectRequest.tsx | 26 +- .../events-and-logs/EventsAndLogs.tsx | 2 +- .../ManualProcessingDetail.tsx | 184 ++++++++++++ .../ManualProcessingList.tsx | 270 ++++++++++++++++++ .../manual-processing/types.ts | 15 + .../src/pages/subject-request/[id].tsx | 3 +- clients/ops/admin-ui/src/theme/index.ts | 5 + 19 files changed, 616 insertions(+), 45 deletions(-) create mode 100644 clients/ops/admin-ui/src/features/subject-request/manual-processing/ManualProcessingDetail.tsx create mode 100644 clients/ops/admin-ui/src/features/subject-request/manual-processing/ManualProcessingList.tsx create mode 100644 clients/ops/admin-ui/src/features/subject-request/manual-processing/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b1a67748..f4e0d63bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ The types of changes are: * Access support for Rollbar [#1361](https://github.com/ethyca/fidesops/pull/1361) * Adds a new Timescale connector [#1327](https://github.com/ethyca/fidesops/pull/1327) * Allow querying the non-default schema with the Postgres Connector [#1375](https://github.com/ethyca/fidesops/pull/1375) +* Frontend - ability for users to manually enter PII to an IN PROGRESS subject request [#1016](https://github.com/ethyca/fidesops/pull/1377) ### Removed diff --git a/clients/ops/admin-ui/package-lock.json b/clients/ops/admin-ui/package-lock.json index 64df1bd127..68b2a7af19 100644 --- a/clients/ops/admin-ui/package-lock.json +++ b/clients/ops/admin-ui/package-lock.json @@ -6078,13 +6078,19 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001314", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001314.tgz", - "integrity": "sha512-0zaSO+TnCHtHJIbpLroX7nsD+vYuOVjl3uzFbJO1wMVbuveJA0RK2WcQA9ZUIOiO0/ArMiMgHJLxfEZhQiC0kw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } + "version": "1.0.30001406", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001406.tgz", + "integrity": "sha512-bWTlaXUy/rq0BBtYShc/jArYfBPjEV95euvZ8JVtO43oQExEN/WquoqpufFjNu4kSpi5cy5kMbNvzztWDfv1Jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ] }, "node_modules/chalk": { "version": "4.1.2", @@ -17191,9 +17197,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001314", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001314.tgz", - "integrity": "sha512-0zaSO+TnCHtHJIbpLroX7nsD+vYuOVjl3uzFbJO1wMVbuveJA0RK2WcQA9ZUIOiO0/ArMiMgHJLxfEZhQiC0kw==" + "version": "1.0.30001406", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001406.tgz", + "integrity": "sha512-bWTlaXUy/rq0BBtYShc/jArYfBPjEV95euvZ8JVtO43oQExEN/WquoqpufFjNu4kSpi5cy5kMbNvzztWDfv1Jg==" }, "chalk": { "version": "4.1.2", diff --git a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/ConfigureConnector.tsx b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/ConfigureConnector.tsx index 9c3f4c6227..971957aa88 100644 --- a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/ConfigureConnector.tsx +++ b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/ConfigureConnector.tsx @@ -26,7 +26,7 @@ const ConfigureConnector: React.FC = () => { const [steps, setSteps] = useState([STEPS[0], STEPS[1], STEPS[2]]); const [canRedirect, setCanRedirect] = useState(false); - const { connection, connectionOption } = useAppSelector( + const { connection, connectionOption, step } = useAppSelector( selectConnectionTypeState ); @@ -43,11 +43,10 @@ const ConfigureConnector: React.FC = () => { (value: string) => { switch (value) { case ConfigurationSettings.DATASET_CONFIGURATION: + case ConfigurationSettings.DSR_CUSTOMIZATION: dispatch(setStep(STEPS[3])); setSteps([STEPS[0], STEPS[1], STEPS[3]]); break; - case ConfigurationSettings.DSR_CUSTOMIZATION: - break; case ConfigurationSettings.CONNECTOR_PARAMETERS: default: dispatch(setStep(STEPS[2])); @@ -68,6 +67,12 @@ const ConfigureConnector: React.FC = () => { }, [dispatch]); useEffect(() => { + if (step) { + setSelectedItem( + connector?.options[Number(step.stepId) - connector.options.length] + ); + } + // If a connection has been initially created, then auto redirect the user accordingly. if (connection?.key && canRedirect) { handleNavChange( @@ -77,7 +82,14 @@ const ConfigureConnector: React.FC = () => { ); setCanRedirect(false); } - }, [canRedirect, connection, connectionOption?.type, handleNavChange]); + }, [ + canRedirect, + connection?.key, + connectionOption?.type, + connector?.options, + handleNavChange, + step, + ]); return ( <> diff --git a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/forms/YamlEditorForm.tsx b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/forms/YamlEditorForm.tsx index 50b05947ee..3771efca44 100644 --- a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/forms/YamlEditorForm.tsx +++ b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/forms/YamlEditorForm.tsx @@ -48,6 +48,7 @@ const YamlEditorForm: React.FC = ({ const [yamlError, setYamlError] = useState( undefined as unknown as YAMLException ); + const [isTouched, setIsTouched] = useState(false); const [isEmptyState, setIsEmptyState] = useState(!yamlData); const validate = (value: string) => { @@ -57,6 +58,7 @@ const YamlEditorForm: React.FC = ({ const handleChange = (value: string | undefined) => { try { + setIsTouched(true); validate(value as string); setIsEmptyState(!!(!value || value.trim() === "")); } catch (error) { @@ -131,7 +133,7 @@ const YamlEditorForm: React.FC = ({ - {(isEmptyState || yamlError) && ( + {isTouched && (isEmptyState || yamlError) && ( diff --git a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/manual/DSRCustomization.tsx b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/manual/DSRCustomization.tsx index 556245c8ec..8f93fc6687 100644 --- a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/manual/DSRCustomization.tsx +++ b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/manual/DSRCustomization.tsx @@ -15,6 +15,7 @@ import { useRouter } from "next/router"; import React, { useEffect, useRef, useState } from "react"; import { DATASTORE_CONNECTION_ROUTE } from "src/constants"; +import { replaceURL } from "../helpers"; import DSRCustomizationForm from "./DSRCustomizationForm"; import { Field } from "./types"; @@ -26,7 +27,7 @@ const DSRCustomization: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); const [fields, setFields] = useState([] as Field[]); - const { connection } = useAppSelector(selectConnectionTypeState); + const { connection, step } = useAppSelector(selectConnectionTypeState); const { data, isFetching, isLoading, isSuccess } = useGetAccessManualHookQuery(connection!.key); @@ -65,10 +66,13 @@ const DSRCustomization: React.FC = () => { if (isSuccess && data) { setFields(data.fields); } + if (connection?.key) { + replaceURL(connection.key, step.href); + } return () => { mounted.current = false; }; - }, [data, isSuccess]); + }, [connection?.key, data, isSuccess, step.href]); return ( diff --git a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/manual/DSRCustomizationForm.tsx b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/manual/DSRCustomizationForm.tsx index f5efea5a58..d6c46c8510 100644 --- a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/manual/DSRCustomizationForm.tsx +++ b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/manual/DSRCustomizationForm.tsx @@ -57,10 +57,16 @@ const DSRCustomizationForm: React.FC = ({ validationSchema={Yup.object({ fields: Yup.array().of( Yup.object().shape({ - pii_field: Yup.string().required("PII Field is required"), - dsr_package_label: Yup.string().required( - "DSR Package Label is required" - ), + pii_field: Yup.string() + .required("PII Field is required") + .min(1, "PII Field must have at least one character") + .max(200, "PII Field has a maximum of 200 characters") + .label("PII Field"), + dsr_package_label: Yup.string() + .required("DSR Package Label is required") + .min(1, "DSR Package Label must have at least one character") + .max(200, "DSR Package Label has a maximum of 200 characters") + .label("DSR Package Label"), }) ), })} diff --git a/clients/ops/admin-ui/src/features/datastore-connections/datastore-connection.slice.ts b/clients/ops/admin-ui/src/features/datastore-connections/datastore-connection.slice.ts index 88221d91e4..5690191f58 100644 --- a/clients/ops/admin-ui/src/features/datastore-connections/datastore-connection.slice.ts +++ b/clients/ops/admin-ui/src/features/datastore-connections/datastore-connection.slice.ts @@ -26,6 +26,7 @@ import { DatastoreConnectionsResponse, DatastoreConnectionStatus, GetAccessManualWebhookResponse, + GetAllEnabledAccessManualWebhooksResponse, PatchAccessManualWebhookRequest, PatchAccessManualWebhookResponse, PatchDatasetsRequest, @@ -207,6 +208,15 @@ export const datastoreConnectionApi = createApi({ }), providesTags: () => ["DatastoreConnection"], }), + getAllEnabledAccessManualHooks: build.query< + GetAllEnabledAccessManualWebhooksResponse, + void + >({ + query: () => ({ + url: `access_manual_webhook`, + }), + providesTags: () => ["DatastoreConnection"], + }), getDatastoreConnectionByKey: build.query({ query: (key) => ({ url: `${CONNECTION_ROUTE}/${key}`, @@ -322,6 +332,7 @@ export const { useCreateAccessManualWebhookMutation, useCreateSassConnectionConfigMutation, useGetAccessManualHookQuery, + useGetAllEnabledAccessManualHooksQuery, useGetAllDatastoreConnectionsQuery, useGetDatasetsQuery, useGetDatastoreConnectionByKeyQuery, diff --git a/clients/ops/admin-ui/src/features/datastore-connections/types.ts b/clients/ops/admin-ui/src/features/datastore-connections/types.ts index 3e3e49818c..d9cc5cb0aa 100644 --- a/clients/ops/admin-ui/src/features/datastore-connections/types.ts +++ b/clients/ops/admin-ui/src/features/datastore-connections/types.ts @@ -42,6 +42,9 @@ export type CreateAccessManualWebhookResponse = { id: string; }; +export type GetAllEnabledAccessManualWebhooksResponse = + GetAccessManualWebhookResponse[]; + export type GetAccessManualWebhookResponse = CreateAccessManualWebhookResponse; export type PatchAccessManualWebhookRequest = CreateAccessManualWebhookRequest; diff --git a/clients/ops/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts b/clients/ops/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts index a4ced98344..aa0d277379 100644 --- a/clients/ops/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts +++ b/clients/ops/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts @@ -7,6 +7,8 @@ import { BASE_URL } from "../../constants"; import { selectToken } from "../auth"; import { DenyPrivacyRequest, + GetUpdloadedManualWebhookDataRequest, + PatchUploadManualWebhookDataRequest, PrivacyRequest, PrivacyRequestParams, PrivacyRequestResponse, @@ -64,17 +66,6 @@ export const privacyRequestApi = createApi({ }), tagTypes: ["Request"], endpoints: (build) => ({ - getAllPrivacyRequests: build.query< - PrivacyRequestResponse, - Partial - >({ - query: (filters) => ({ - url: `privacy-request?${decodeURIComponent( - new URLSearchParams(mapFiltersToSearchParams(filters)).toString() - )}`, - }), - providesTags: () => ["Request"], - }), approveRequest: build.mutation< PrivacyRequest, Partial & Pick @@ -99,6 +90,32 @@ export const privacyRequestApi = createApi({ }), invalidatesTags: ["Request"], }), + getAllPrivacyRequests: build.query< + PrivacyRequestResponse, + Partial + >({ + query: (filters) => ({ + url: `privacy-request?${decodeURIComponent( + new URLSearchParams(mapFiltersToSearchParams(filters)).toString() + )}`, + }), + providesTags: () => ["Request"], + }), + getUploadedManualWebhookData: build.query< + any, + GetUpdloadedManualWebhookDataRequest + >({ + query: (params) => ({ + url: `privacy-request/${params.privacy_request_id}/access_manual_webhook/${params.connection_key}`, + }), + }), + resumePrivacyRequestFromRequiresInput: build.mutation({ + query: (privacy_request_id) => ({ + url: `privacy-request/${privacy_request_id}/resume_from_requires_input`, + method: "POST", + }), + invalidatesTags: ["Request"], + }), retry: build.mutation>({ query: ({ id }) => ({ url: `privacy-request/${id}/retry`, @@ -106,14 +123,27 @@ export const privacyRequestApi = createApi({ }), invalidatesTags: ["Request"], }), + uploadManualWebhookData: build.mutation< + any, + PatchUploadManualWebhookDataRequest + >({ + query: (params) => ({ + url: `privacy-request/${params.privacy_request_id}/access_manual_webhook/${params.connection_key}`, + method: "PATCH", + body: params.body, + }), + }), }), }); export const { - useGetAllPrivacyRequestsQuery, useApproveRequestMutation, useDenyRequestMutation, + useGetAllPrivacyRequestsQuery, + useGetUploadedManualWebhookDataQuery, + useResumePrivacyRequestFromRequiresInputMutation, useRetryMutation, + useUploadManualWebhookDataMutation, } = privacyRequestApi; export const requestCSVDownload = async ({ diff --git a/clients/ops/admin-ui/src/features/privacy-requests/types.ts b/clients/ops/admin-ui/src/features/privacy-requests/types.ts index 198a44a570..e915ddb947 100644 --- a/clients/ops/admin-ui/src/features/privacy-requests/types.ts +++ b/clients/ops/admin-ui/src/features/privacy-requests/types.ts @@ -45,12 +45,23 @@ export interface ExecutionLog { updated_at: string; } +export type GetUpdloadedManualWebhookDataRequest = { + connection_key: string; + privacy_request_id: string; +}; + export interface Rule { name: string; key: string; action_type: ActionType; } +export type PatchUploadManualWebhookDataRequest = { + body: object; + connection_key: string; + privacy_request_id: string; +}; + export type PrivacyRequestResults = Record; export interface PrivacyRequest { diff --git a/clients/ops/admin-ui/src/features/subject-request/RequestDetails.tsx b/clients/ops/admin-ui/src/features/subject-request/RequestDetails.tsx index a6a5de377e..5d77e43403 100644 --- a/clients/ops/admin-ui/src/features/subject-request/RequestDetails.tsx +++ b/clients/ops/admin-ui/src/features/subject-request/RequestDetails.tsx @@ -50,7 +50,7 @@ const RequestDetails = ({ subjectRequest }: RequestDetailsProps) => { return ( <> - + Request details diff --git a/clients/ops/admin-ui/src/features/subject-request/SubjectIdentities.tsx b/clients/ops/admin-ui/src/features/subject-request/SubjectIdentities.tsx index 7e2398ef5b..0bf53d6bf4 100644 --- a/clients/ops/admin-ui/src/features/subject-request/SubjectIdentities.tsx +++ b/clients/ops/admin-ui/src/features/subject-request/SubjectIdentities.tsx @@ -15,7 +15,7 @@ const SubjectIdentities = ({ subjectRequest }: SubjectIdentitiesProps) => { return ( <> - + Subject identities diff --git a/clients/ops/admin-ui/src/features/subject-request/SubjectRequest.tsx b/clients/ops/admin-ui/src/features/subject-request/SubjectRequest.tsx index 352ca36a07..6b2b9d337f 100644 --- a/clients/ops/admin-ui/src/features/subject-request/SubjectRequest.tsx +++ b/clients/ops/admin-ui/src/features/subject-request/SubjectRequest.tsx @@ -1,7 +1,8 @@ -import React from "react"; +import { Box, VStack } from "@fidesui/react"; import { PrivacyRequest } from "../privacy-requests/types"; import EventsAndLogs from "./events-and-logs/EventsAndLogs"; +import ManualProcessingList from "./manual-processing/ManualProcessingList"; import RequestDetails from "./RequestDetails"; import SubjectIdentities from "./SubjectIdentities"; @@ -9,12 +10,23 @@ type SubjectRequestProps = { subjectRequest: PrivacyRequest; }; -const SubjectRequest = ({ subjectRequest }: SubjectRequestProps) => ( - <> - - - - +const SubjectRequest: React.FC = ({ subjectRequest }) => ( + + + + + + + + {subjectRequest.status === "requires_input" && ( + + + + )} + + + + ); export default SubjectRequest; diff --git a/clients/ops/admin-ui/src/features/subject-request/events-and-logs/EventsAndLogs.tsx b/clients/ops/admin-ui/src/features/subject-request/events-and-logs/EventsAndLogs.tsx index 5d2ae989c0..4c3f76efd6 100644 --- a/clients/ops/admin-ui/src/features/subject-request/events-and-logs/EventsAndLogs.tsx +++ b/clients/ops/admin-ui/src/features/subject-request/events-and-logs/EventsAndLogs.tsx @@ -14,7 +14,7 @@ const EventsAndLogs = ({ subjectRequest }: EventsAndLogsProps) => { return ( <> - + Events and logs diff --git a/clients/ops/admin-ui/src/features/subject-request/manual-processing/ManualProcessingDetail.tsx b/clients/ops/admin-ui/src/features/subject-request/manual-processing/ManualProcessingDetail.tsx new file mode 100644 index 0000000000..8f68391175 --- /dev/null +++ b/clients/ops/admin-ui/src/features/subject-request/manual-processing/ManualProcessingDetail.tsx @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + Box, + Button, + ButtonGroup, + Divider, + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + FormControl, + FormLabel, + HStack, + Input, + Text, + useDisclosure, + VStack, +} from "@fidesui/react"; +import { Field, Form, Formik } from "formik"; +import { PatchUploadManualWebhookDataRequest } from "privacy-requests/types"; +import React, { useRef } from "react"; +import * as Yup from "yup"; + +import { ManualInputData } from "./types"; + +type ManualProcessingDetailProps = { + connectorName: string; + data: ManualInputData; + isSubmitting: boolean; + onSaveClick: (params: PatchUploadManualWebhookDataRequest) => void; +}; + +const ManualProcessingDetail: React.FC = ({ + connectorName, + data, + isSubmitting = false, + onSaveClick, +}) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const firstField = useRef(); + + const handleSubmit = async (values: any, _actions: any) => { + const params: PatchUploadManualWebhookDataRequest = { + connection_key: data.connection_key, + privacy_request_id: data.privacy_request_id, + body: { ...values } as object, + }; + onSaveClick(params); + onClose(); + }; + + return ( + <> + {data?.checked && ( + + )} + {!data?.checked && ( + + )} + + {/* @ts-ignore */} + {(_props: FormikProps) => ( + + + + + + + {connectorName} + + + + PII Requirements + + + + Please complete the following PII fields that have been + collected for the selected subject. + + + + +
+ + {Object.entries(data.fields).map(([key], index) => ( + + + {({ field }: { field: any }) => ( + + + {key} + + + + )} + + + ))} + +
+
+ + + + + + +
+
+ )} +
+ + ); +}; + +export default ManualProcessingDetail; diff --git a/clients/ops/admin-ui/src/features/subject-request/manual-processing/ManualProcessingList.tsx b/clients/ops/admin-ui/src/features/subject-request/manual-processing/ManualProcessingList.tsx new file mode 100644 index 0000000000..1b0cf6a2f9 --- /dev/null +++ b/clients/ops/admin-ui/src/features/subject-request/manual-processing/ManualProcessingList.tsx @@ -0,0 +1,270 @@ +import { + Box, + Button, + Center, + Divider, + Heading, + Spinner, + Table, + TableContainer, + Tbody, + Td, + Text, + Tfoot, + Th, + Thead, + Tr, + VStack, +} from "@fidesui/react"; +import { useAlert, useAPIHelper } from "common/hooks"; +import { useGetAllEnabledAccessManualHooksQuery } from "datastore-connections/datastore-connection.slice"; +import { useRouter } from "next/router"; +import { + privacyRequestApi, + useResumePrivacyRequestFromRequiresInputMutation, + useUploadManualWebhookDataMutation, +} from "privacy-requests/privacy-requests.slice"; +import { + PatchUploadManualWebhookDataRequest, + PrivacyRequest, +} from "privacy-requests/types"; +import React, { useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import { INDEX_ROUTE } from "src/constants"; + +import ManualProcessingDetail from "./ManualProcessingDetail"; +import { ManualInputData } from "./types"; + +type ManualProcessingListProps = { + subjectRequest: PrivacyRequest; +}; + +const ManualProcessingList: React.FC = ({ + subjectRequest, +}) => { + const dispatch = useDispatch(); + const router = useRouter(); + const { errorAlert, successAlert } = useAlert(); + const { handleError } = useAPIHelper(); + const [dataList, setDataList] = useState([] as unknown as ManualInputData[]); + const [isCompleteDSRLoading, setIsCompleteDSRLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data, isFetching, isLoading, isSuccess } = + useGetAllEnabledAccessManualHooksQuery(); + + const [resumePrivacyRequestFromRequiresInput] = + useResumePrivacyRequestFromRequiresInputMutation(); + + const [uploadManualWebhookData] = useUploadManualWebhookDataMutation(); + + const handleCompleteDSRClick = async () => { + try { + setIsCompleteDSRLoading(true); + await resumePrivacyRequestFromRequiresInput(subjectRequest.id).unwrap(); + successAlert(`Manual request has been received. Request now processing.`); + router.push(INDEX_ROUTE); + } catch (error) { + handleError(error); + } finally { + setIsCompleteDSRLoading(false); + } + }; + + const handleSubmit = async (params: PatchUploadManualWebhookDataRequest) => { + try { + setIsSubmitting(true); + await uploadManualWebhookData(params).unwrap(); + const response = { + connection_key: params.connection_key, + fields: {}, + }; + Object.entries(params.body).forEach(([key, value]) => { + // @ts-ignore + response.fields[key] = value || ""; + }); + // Update the manual input data state + const newState = dataList.map((item) => { + if (item.connection_key === response.connection_key) { + return { ...item, checked: true, fields: { ...response.fields } }; + } + return item; + }); + setDataList(newState); + successAlert(`Manual input successfully saved!`); + } catch (error) { + handleError(error); + } finally { + setIsSubmitting(false); + } + }; + + useEffect(() => { + const fetchUploadedManuaWebhookData = () => { + if (dataList.length > 0) return; + const promises: any[] = []; + const keys = data?.map((item) => item.connection_config.key); + keys?.every((k) => + promises.push( + dispatch( + privacyRequestApi.endpoints.getUploadedManualWebhookData.initiate({ + connection_key: k, + privacy_request_id: subjectRequest.id, + }) + ) + ) + ); + Promise.allSettled(promises).then((results) => { + const list: ManualInputData[] = []; + results.forEach((result, index) => { + if ( + result.status === "fulfilled" && + result.value.isSuccess && + result.value.data + ) { + const item = { + checked: result.value.data.checked, + fields: {}, + connection_key: result.value.originalArgs.connection_key, + privacy_request_id: result.value.originalArgs.privacy_request_id, + } as ManualInputData; + Object.entries(result.value.data.fields).forEach(([key, value]) => { + // @ts-ignore + item.fields[key] = value || ""; + }); + list.push(item); + } else { + errorAlert( + `An error occurred while loading manual input data for ${ + data![index].connection_config.name + }` + ); + } + }); + setDataList(list); + }); + }; + + if (isSuccess && data!.length > 0 && dataList.length === 0) { + fetchUploadedManuaWebhookData(); + } + + return () => {}; + }, [ + data, + dataList.length, + dispatch, + errorAlert, + isSuccess, + subjectRequest.id, + ]); + + return ( + + + + Manual Processing + + + + + + The following table details the integrations that require manual input + from you. + + + + {(isFetching || isLoading) && ( +
+ +
+ )} + {isSuccess && data ? ( + + + + + + + + + + {data.length > 0 && + data.map((item) => ( + + + + + + ))} + {data.length === 0 && ( + + + + )} + + {dataList.length > 0 && dataList.every((item) => item.checked) ? ( + + + + + + ) : null} +
+ Connector name + + Description + +
{item.connection_config.name}{item.connection_config.description} + {dataList.length > 0 ? ( + + i.connection_key === + item.connection_config.key + ) as ManualInputData + } + isSubmitting={isSubmitting} + onSaveClick={handleSubmit} + /> + ) : null} +
+
+ + You don‘t have any Manual Webhook connections + set up yet. + +
+
+ + + +
+
+ ) : null} +
+
+ ); +}; + +export default ManualProcessingList; diff --git a/clients/ops/admin-ui/src/features/subject-request/manual-processing/types.ts b/clients/ops/admin-ui/src/features/subject-request/manual-processing/types.ts new file mode 100644 index 0000000000..c7b0b131cd --- /dev/null +++ b/clients/ops/admin-ui/src/features/subject-request/manual-processing/types.ts @@ -0,0 +1,15 @@ +export type ManualInputData = { + checked: boolean; + connection_key: string; + fields: ManualInputDataFieldMap; + privacy_request_id: string; +}; + +export type ManualInputDataFieldMap = { + [key: string]: any; +}; + +export type SaveCompleteResponse = { + connection_key: string; + fields: ManualInputDataFieldMap; +}; diff --git a/clients/ops/admin-ui/src/pages/subject-request/[id].tsx b/clients/ops/admin-ui/src/pages/subject-request/[id].tsx index 834518308f..9e1d910d3e 100644 --- a/clients/ops/admin-ui/src/pages/subject-request/[id].tsx +++ b/clients/ops/admin-ui/src/pages/subject-request/[id].tsx @@ -10,14 +10,13 @@ import { } from "@fidesui/react"; import type { NextPage } from "next"; import { useRouter } from "next/router"; -import React from "react"; +import SubjectRequest from "subject-request/SubjectRequest"; import { INDEX_ROUTE } from "../../constants"; import ProtectedRoute from "../../features/auth/ProtectedRoute"; import Head from "../../features/common/Head"; import NavBar from "../../features/common/NavBar"; import { useGetAllPrivacyRequestsQuery } from "../../features/privacy-requests"; -import SubjectRequest from "../../features/subject-request/SubjectRequest"; const useSubjectRequestDetails = () => { const router = useRouter(); diff --git a/clients/ops/admin-ui/src/theme/index.ts b/clients/ops/admin-ui/src/theme/index.ts index f581108e42..843108204b 100644 --- a/clients/ops/admin-ui/src/theme/index.ts +++ b/clients/ops/admin-ui/src/theme/index.ts @@ -12,6 +12,11 @@ const theme = extendTheme({ }, }, components: { + Divider: { + baseStyle: { + opacity: 1, + }, + }, Spinner: { baseStyle: { color: "secondary.500", From 85e992d795ee878e1123995ac4e6edb1cdd8abd5 Mon Sep 17 00:00:00 2001 From: Eduardo Armendariz Date: Tue, 27 Sep 2022 18:38:37 -0700 Subject: [PATCH 03/30] Enable retries on saas connectors for failures at the http request level (#1376) * Add decorator to send method that retries throttles * Fix black/mypy * Fix pylint * Add tests for authenticated client * Small fixes and typos * Update CHANGELOG.md * Small changes. Update retry logic to not retry general exceptions Co-authored-by: Eduardo Armendariz --- CHANGELOG.md | 1 + .../connectors/saas/authenticated_client.py | 125 +++++++++++++++--- .../saas/test_zendesk_task.py | 2 - .../saas/test_authenticated_client.py | 122 +++++++++++++++++ 4 files changed, 231 insertions(+), 19 deletions(-) create mode 100644 tests/ops/service/connectors/saas/test_authenticated_client.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e0d63bdd..6b1b741a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ The types of changes are: * Adds a new Timescale connector [#1327](https://github.com/ethyca/fidesops/pull/1327) * Allow querying the non-default schema with the Postgres Connector [#1375](https://github.com/ethyca/fidesops/pull/1375) * Frontend - ability for users to manually enter PII to an IN PROGRESS subject request [#1016](https://github.com/ethyca/fidesops/pull/1377) +* Enable retries on saas connectors for failures at the http request level [#1376](https://github.com/ethyca/fidesops/pull/1376) ### Removed diff --git a/src/fidesops/ops/service/connectors/saas/authenticated_client.py b/src/fidesops/ops/service/connectors/saas/authenticated_client.py index 5ef6c5aa4b..9dec542e25 100644 --- a/src/fidesops/ops/service/connectors/saas/authenticated_client.py +++ b/src/fidesops/ops/service/connectors/saas/authenticated_client.py @@ -1,13 +1,19 @@ from __future__ import annotations +import email import logging -from typing import TYPE_CHECKING, Optional +import re +import time +from functools import wraps +from time import sleep +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union from requests import PreparedRequest, Request, Response, Session from fidesops.ops.common_exceptions import ( ClientUnsuccessfulException, ConnectionException, + FidesopsException, ) from fidesops.ops.core.config import config @@ -75,6 +81,69 @@ def get_authenticated_request( # otherwise just return the prepared request return req + def retry_send( # type: ignore + retry_count: int, + backoff_factor: float, + retry_status_codes: List[int] = [429, 502, 503, 504], + ) -> Callable: + """ + Retry decorator for http requests, backing off exponentially or listening to server retry-after header + + Exponential backoff factor uses the following formula: + backoff_factor * (2 ** (retry_attempt)) + For an backoff_factor of 1 it will sleep for 2,4,8 seconds + + General exceptions are not retried. RequestFailureResponseException exceptions are only retried + if the status code is in retry_status_codes. + """ + + def decorator(func: Callable) -> Callable: + @wraps(func) + def result(*args: Any, **kwargs: Any) -> Response: + self = args[0] + last_exception: Optional[Union[BaseException, Exception]] = None + + for attempt in range(retry_count + 1): + sleep_time = backoff_factor * (2 ** (attempt + 1)) + try: + return func(*args, **kwargs) + except RequestFailureResponseException as exc: # pylint: disable=W0703 + response: Response = exc.response + status_code: int = response.status_code + last_exception = ClientUnsuccessfulException( + status_code=status_code + ) + + if status_code not in retry_status_codes: + break + + # override sleep time if retry after header is found + retry_after_time = get_retry_after(response) + sleep_time = ( + retry_after_time if retry_after_time else sleep_time + ) + except Exception as exc: # pylint: disable=W0703 + dev_mode_log = f" with error: {exc}" if config.dev_mode else "" + last_exception = ConnectionException( + f"Operational Error connecting to '{self.key}'{dev_mode_log}" + ) + # requests library can raise ConnectionError, Timeout or TooManyRedirects + # we will not retry these as they don't usually point to intermittent issues + break + + if attempt < retry_count: + logger.warning( + "Retrying http request in %s seconds", sleep_time + ) + sleep(sleep_time) + + raise last_exception # type: ignore + + return result + + return decorator + + @retry_send(retry_count=3, backoff_factor=1.0) # pylint: disable=E1124 def send( self, request_params: SaaSRequestParams, ignore_errors: Optional[bool] = False ) -> Response: @@ -82,20 +151,10 @@ def send( Builds and executes an authenticated request. Optionally ignores non-200 responses if ignore_errors is set to True """ - try: - prepared_request: PreparedRequest = self.get_authenticated_request( - request_params - ) - response = self.session.send(prepared_request) - except Exception as exc: # pylint: disable=W0703 - if config.dev_mode: # pylint: disable=R1720 - raise ConnectionException( - f"Operational Error connecting to '{self.key}' with error: {exc}" - ) - else: - raise ConnectionException( - f"Operational Error connecting to '{self.key}'." - ) + prepared_request: PreparedRequest = self.get_authenticated_request( + request_params + ) + response = self.session.send(prepared_request) log_request_and_response_for_debugging( prepared_request, response @@ -108,10 +167,18 @@ def send( response.status_code, ) return response + raise RequestFailureResponseException(response=response) + return response - raise ClientUnsuccessfulException(status_code=response.status_code) - return response +class RequestFailureResponseException(FidesopsException): + """Exception class which preserves http response""" + + response: Response + + def __init__(self, response: Response): + super().__init__("Received failure response from server") + self.response = response def log_request_and_response_for_debugging( @@ -131,3 +198,27 @@ def log_request_and_response_for_debugging( prepared_request.body, response._content, # pylint: disable=W0212 ) + + +def get_retry_after(response: Response, max_retry_after: int = 300) -> Optional[float]: + """Given a Response object, parses Retry-After header and calculates how long we should sleep for""" + retry_after = response.headers.get("Retry-After", None) + + if retry_after is None: + return None + + seconds: float + # if a number value is provided the server is telling us to sleep for X seconds + if re.match(r"^\s*[0-9]+\s*$", retry_after): + seconds = int(retry_after) + # else we will attempt to parse a timestamp and diff with current time + else: + retry_date_tuple = email.utils.parsedate_tz(retry_after) + if retry_date_tuple is None: + return None + + retry_date = email.utils.mktime_tz(retry_date_tuple) + seconds = retry_date - time.time() + + seconds = max(seconds, 0) + return min(seconds, max_retry_after) diff --git a/tests/ops/integration_tests/saas/test_zendesk_task.py b/tests/ops/integration_tests/saas/test_zendesk_task.py index f07f11748b..7e05e2adf5 100644 --- a/tests/ops/integration_tests/saas/test_zendesk_task.py +++ b/tests/ops/integration_tests/saas/test_zendesk_task.py @@ -201,8 +201,6 @@ async def test_zendesk_erasure_request_task( merged_graph = zendesk_dataset_config.get_graph() graph = DatasetGraph(merged_graph) - # Since we sometimes get response: b'Number of allowed API requests per minute exceeded' so adding this line to avoid it - time.sleep(60) v = await graph_task.run_access_request( privacy_request, policy, diff --git a/tests/ops/service/connectors/saas/test_authenticated_client.py b/tests/ops/service/connectors/saas/test_authenticated_client.py new file mode 100644 index 0000000000..fb11d801fa --- /dev/null +++ b/tests/ops/service/connectors/saas/test_authenticated_client.py @@ -0,0 +1,122 @@ +import time +import unittest.mock as mock +from email.utils import formatdate +from typing import Any, Dict + +import pytest +from requests import ConnectionError, Response, Session + +from fidesops.ops.common_exceptions import ( + ClientUnsuccessfulException, + ConnectionException, +) +from fidesops.ops.models.connectionconfig import ConnectionConfig, ConnectionType +from fidesops.ops.schemas.saas.shared_schemas import HTTPMethod, SaaSRequestParams +from fidesops.ops.service.connectors.saas.authenticated_client import ( + AuthenticatedClient, + get_retry_after, +) +from fidesops.ops.util.saas_util import load_config_with_replacement + + +@pytest.fixture +def test_saas_config() -> Dict[str, Any]: + return load_config_with_replacement( + "data/saas/config/segment_config.yml", + "", + "test_config", + ) + + +@pytest.fixture +def test_connection_config(test_saas_config) -> ConnectionConfig: + return ConnectionConfig( + key="test_config", + connection_type=ConnectionType.saas, + saas_config=test_saas_config, + secrets={"access_token": "test_token"}, + ) + + +@pytest.fixture +def test_saas_request() -> SaaSRequestParams: + return SaaSRequestParams( + method=HTTPMethod.GET, + path="test_path", + query_params={}, + ) + + +@pytest.fixture +def test_authenticated_client(test_connection_config) -> AuthenticatedClient: + return AuthenticatedClient("https://test_uri", test_connection_config) + + +@pytest.mark.unit_saas +@mock.patch.object(Session, "send") +class TestAuthenticatedClient: + def test_client_returns_ok_response( + self, send, test_authenticated_client, test_saas_request + ): + test_response = Response() + test_response.status_code = 200 + send.return_value = test_response + returned_response = test_authenticated_client.send(test_saas_request) + assert returned_response == test_response + + def test_client_retries_429_and_throws( + self, send, test_authenticated_client, test_saas_request + ): + test_response = Response() + test_response.status_code = 429 + send.return_value = test_response + with pytest.raises(ClientUnsuccessfulException): + test_authenticated_client.send(test_saas_request) + assert send.call_count == 4 + + def test_client_retries_429_with_success( + self, send, test_authenticated_client, test_saas_request + ): + test_response_1 = Response() + test_response_1.status_code = 429 + test_response_2 = Response() + test_response_2.status_code = 200 + send.side_effect = [test_response_1, test_response_2] + returned_response = test_authenticated_client.send(test_saas_request) + returned_response == test_response_2 + assert send.call_count == 2 + + def test_client_does_not_retry_connection_error( + self, send, test_authenticated_client, test_saas_request + ): + test_side_effect_1 = ConnectionError() + send.side_effect = [test_side_effect_1] + with pytest.raises(ConnectionException): + test_authenticated_client.send(test_saas_request) + assert send.call_count == 1 + + +@pytest.mark.unit_saas +class TestRetryAfterHeaderParsing: + def test_retry_after_parses_seconds_response(self): + test_response = Response() + test_response.status_code = 429 + test_response.headers = {"Retry-After": "30"} + retry_after_sleep = get_retry_after(test_response) + assert retry_after_sleep == 30 + + def test_retry_after_parses_timestamp_in_future(self): + test_response = Response() + test_response.status_code = 429 + time_in_future = time.time() + 30 + test_response.headers = {"Retry-After": formatdate(timeval=time_in_future)} + retry_after_sleep = get_retry_after(test_response) + assert retry_after_sleep > 20 + + def test_retry_after_parses_timestamp_in_past(self): + test_response = Response() + test_response.status_code = 429 + time_in_past = time.time() - 30 + test_response.headers = {"Retry-After": formatdate(timeval=time_in_past)} + retry_after_sleep = get_retry_after(test_response) + assert retry_after_sleep == 0 From 406d3c85a21e6c79555d1ec84126817e9b650154 Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Wed, 28 Sep 2022 09:59:47 -0400 Subject: [PATCH 04/30] Add Consent Request API (#1387) Co-authored-by: Paul Sanders --- CHANGELOG.md | 11 +- .../postman/Fidesops.postman_collection.json | 200 ++++++++-- src/fidesops/ops/api/v1/api.py | 2 + .../v1/endpoints/consent_request_endpoints.py | 238 ++++++++++++ .../v1/endpoints/privacy_request_endpoints.py | 32 +- src/fidesops/ops/api/v1/urn_registry.py | 6 + .../c4df5d585029_data_use_unique_together.py | 31 ++ src/fidesops/ops/models/privacy_request.py | 44 ++- src/fidesops/ops/schemas/privacy_request.py | 36 ++ src/fidesops/ops/service/_verification.py | 37 ++ .../service/email/email_dispatch_service.py | 2 + .../test_consent_request_endpoints.py | 360 ++++++++++++++++++ .../test_privacy_request_endpoints.py | 4 +- tests/ops/conftest.py | 9 + 14 files changed, 941 insertions(+), 71 deletions(-) create mode 100644 src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py create mode 100644 src/fidesops/ops/migrations/versions/c4df5d585029_data_use_unique_together.py create mode 100644 src/fidesops/ops/service/_verification.py create mode 100644 tests/ops/api/v1/endpoints/test_consent_request_endpoints.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1b741a57..87cfc38816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,14 @@ The types of changes are: * `Fixed` for any bug fixes. * `Security` in case of vulnerabilities. - ## [Unreleased](https://github.com/ethyca/fidesops/compare/1.7.2...main) ### Changed + * Refactor privacy center to be more modular [#1363](https://github.com/ethyca/fidesops/pull/1363) ### Fixed + * Distinguish whether webhook has been visited and no fields were found, versus never visited [#1339](https://github.com/ethyca/fidesops/pull/1339) * Fix Redis Cache Early Expiration in Tests [#1358](https://github.com/ethyca/fidesops/pull/1358) * Limit values for the offset pagination strategy are now cast to integers before use [#1364](https://github.com/ethyca/fidesops/pull/1364) @@ -35,6 +36,7 @@ The types of changes are: * Allow querying the non-default schema with the Postgres Connector [#1375](https://github.com/ethyca/fidesops/pull/1375) * Frontend - ability for users to manually enter PII to an IN PROGRESS subject request [#1016](https://github.com/ethyca/fidesops/pull/1377) * Enable retries on saas connectors for failures at the http request level [#1376](https://github.com/ethyca/fidesops/pull/1376) +* Add consent request api [#1387](https://github.com/ethyca/fidesops/pull/1387) ### Removed @@ -131,6 +133,7 @@ The types of changes are: ## [1.7.1](https://github.com/ethyca/fidesops/compare/1.7.0...1.7.1) ### Breaking Changes + The `oauth2` strategy has been renamed to `oauth2_authorization_code` in order to make a distinction between the newly introduced `oauth2_client_credentials` strategy [#1159](https://github.com/ethyca/fidesops/pull/1159) ### Added @@ -143,7 +146,7 @@ The `oauth2` strategy has been renamed to `oauth2_authorization_code` in order t * SaaS Connector Configuration - Testing a Connection [#985](https://github.com/ethyca/fidesops/pull/1099) * Add an endpoint for verifying the user's identity before queuing the privacy request. [#1111](https://github.com/ethyca/fidesops/pull/1111) * Adds tests for email endpoints and service [#1112](https://github.com/ethyca/fidesops/pull/1112) -* Adds the ability to verify a subject's identity before processing a Privacy Request [#1115](https://github.com/ethyca/fidesops/pull/1115) +* Adds the ability to verify a subject's identity before processing a Privacy Request [#1115](https://github.com/ethyca/fidesops/pull/1115) * Add option to login as root user from config[#1116](https://github.com/ethyca/fidesops/pull/1116) * Added email templates [#1123](https://github.com/ethyca/fidesops/pull/1123) * Add Retry button back into the subject request detail view [#1128](https://github.com/ethyca/fidesops/pull/1131) @@ -158,7 +161,7 @@ The `oauth2` strategy has been renamed to `oauth2_authorization_code` in order t * Bump fideslib to fix issue where the authenticate button in the FastAPI docs did not work [#1092](https://github.com/ethyca/fidesops/pull/1092) * Escape the Redis user and password to make them URL friendly [#1104](https://github.com/ethyca/fidesops/pull/1104) * Reduced number of connections opened against app db during health checks [#1107](https://github.com/ethyca/fidesops/pull/1107) -* Fix FIDESOPS__ROOT_USER__ANALYTICS_ID generation when env var is set [#1113](https://github.com/ethyca/fidesops/pull/1113) +* Fix FIDESOPS**ROOT_USER**ANALYTICS_ID generation when env var is set [#1113](https://github.com/ethyca/fidesops/pull/1113) * Set localhost to None for non-endpoint events [#1130](https://github.com/ethyca/fidesops/pull/1130) * Fixed docs build in CI [#1138](https://github.com/ethyca/fidesops/pull/1138) * Added future annotations to privacy_request.py for backwards compatibility [#1136](https://github.com/ethyca/fidesops/pull/1136) @@ -471,7 +474,7 @@ The `oauth2` strategy has been renamed to `oauth2_authorization_code` in order t * GET routes for users [#405](https://github.com/ethyca/fidesops/pull/405) * Username based search on GET route [#444](https://github.com/ethyca/fidesops/pull/444) -* FIDESOPS__DEV_MODE for Easier SaaS Request Debugging [#363](https://github.com/ethyca/fidesops/pull/363) +* FIDESOPS\_\_DEV_MODE for Easier SaaS Request Debugging [#363](https://github.com/ethyca/fidesops/pull/363) * Track user privileges across sessions [#425](https://github.com/ethyca/fidesops/pull/425) * Add first_name and last_name fields. Also add them along with created_at to FidesUser response [#465](https://github.com/ethyca/fidesops/pull/465) * Denial reasons for DSR and user `AuditLog` [#463](https://github.com/ethyca/fidesops/pull/463) diff --git a/docs/fidesops/docs/postman/Fidesops.postman_collection.json b/docs/fidesops/docs/postman/Fidesops.postman_collection.json index 0c44874704..adb6e11c17 100644 --- a/docs/fidesops/docs/postman/Fidesops.postman_collection.json +++ b/docs/fidesops/docs/postman/Fidesops.postman_collection.json @@ -1,9 +1,8 @@ { "info": { - "_postman_id": "296aa41f-49f7-4988-bbfb-b57c480a695f", + "_postman_id": "8f48b8e3-0a39-4e5e-b505-8087064dc1af", "name": "Fidesops", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "1984786" + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { @@ -940,35 +939,6 @@ } }, "response": [] - }, - { - "name": "Restart failed node", - "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{client_token}}", - "type": "string" - } - ] - }, - "method": "POST", - "header": [], - "url": { - "raw": "{{host}}/privacy-request/{{privacy_request_id}}/retry", - "host": [ - "{{host}}" - ], - "path": [ - "privacy-request", - "{{privacy_request_id}}", - "retry" - ] - } - }, - "response": [] } ] }, @@ -4443,6 +4413,40 @@ } ] }, + { + "name": "Restart from Failure", + "item": [ + { + "name": "Restart failed node", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{client_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "url": { + "raw": "{{host}}/privacy-request/{{privacy_request_id}}/retry", + "host": [ + "{{host}}" + ], + "path": [ + "privacy-request", + "{{privacy_request_id}}", + "retry" + ] + } + }, + "response": [] + } + ] + }, { "name": "ConnectionType", "item": [ @@ -4753,6 +4757,116 @@ "response": [] } ] + }, + { + "name": "Consent Request", + "item": [ + { + "name": "Create Verification Code for Consent Request", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"phone_number\": \"{{phone_number}}\",\n \"email\": \"{{email}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{host}}/consent-request", + "host": [ + "{{host}}" + ], + "path": [ + "consent-request" + ] + } + }, + "response": [] + }, + { + "name": "Verify Code and Return Current Preferences", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"code\": \"{{verification_code}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{host}}/consent-request/{{consent_request_id}}/verify", + "host": [ + "{{host}}" + ], + "path": [ + "consent-request", + "{{consent_request_id}}", + "verify" + ] + } + }, + "response": [] + }, + { + "name": "Verify Code and Save Preferences", + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"identity\": {\n \"phone_number\": \"{{phone_number}}\",\n \"email\": \"{{email}}\"\n },\n \"consent\": [\n {\n \"data_use\": \"{{data_use}}\",\n \"data_use_description\": \"{{data_use_description}}\",\n \"opt_in\": {{opt_in}}\n }\n ],\n \"code\": \"{{verification_code}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{host}}/consent-request/{{consent_request_id}}/preferences", + "host": [ + "{{host}}" + ], + "path": [ + "consent-request", + "{{consent_request_id}}", + "preferences" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] } ], "event": [ @@ -5020,10 +5134,30 @@ "value": "manual_webhook_key", "type": "string" }, + { + "key": "consent_request_id", + "value": "", + "type": "default" + }, { "key": "timescale_key", "value": "", "type": "string" + }, + { + "key": "phone_number", + "value": "", + "type": "string" + }, + { + "key": "email", + "value": "", + "type": "string" + }, + { + "key": "verification_code", + "value": "", + "type": "string" } ] -} \ No newline at end of file +} diff --git a/src/fidesops/ops/api/v1/api.py b/src/fidesops/ops/api/v1/api.py index 2c8435930f..1002767f22 100644 --- a/src/fidesops/ops/api/v1/api.py +++ b/src/fidesops/ops/api/v1/api.py @@ -2,6 +2,7 @@ config_endpoints, connection_endpoints, connection_type_endpoints, + consent_request_endpoints, dataset_endpoints, drp_endpoints, email_endpoints, @@ -25,6 +26,7 @@ api_router.include_router(config_endpoints.router) api_router.include_router(connection_type_endpoints.router) api_router.include_router(connection_endpoints.router) +api_router.include_router(consent_request_endpoints.router) api_router.include_router(dataset_endpoints.router) api_router.include_router(drp_endpoints.router) api_router.include_router(encryption_endpoints.router) diff --git a/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py b/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py new file mode 100644 index 0000000000..0fd8461b97 --- /dev/null +++ b/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import logging + +from fastapi import Depends, HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from starlette.status import ( + HTTP_200_OK, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_500_INTERNAL_SERVER_ERROR, +) + +from fidesops.ops.api.deps import get_db +from fidesops.ops.api.v1.urn_registry import ( + CONSENT_REQUEST, + CONSENT_REQUEST_PREFERENCES, + CONSENT_REQUEST_VERIFY, + V1_URL_PREFIX, +) +from fidesops.ops.common_exceptions import ( + EmailDispatchException, + FunctionalityNotConfigured, + IdentityVerificationException, +) +from fidesops.ops.core.config import config +from fidesops.ops.models.privacy_request import ( + Consent, + ConsentRequest, + ProvidedIdentity, + ProvidedIdentityType, +) +from fidesops.ops.schemas.privacy_request import Consent as ConsentSchema +from fidesops.ops.schemas.privacy_request import ( + ConsentPreferences, + ConsentPreferencesWithVerificationCode, + ConsentRequestResponse, + VerificationCode, +) +from fidesops.ops.schemas.redis_cache import Identity +from fidesops.ops.service._verification import send_verification_code_to_user +from fidesops.ops.util.api_router import APIRouter +from fidesops.ops.util.logger import Pii + +router = APIRouter(tags=["Consent"], prefix=V1_URL_PREFIX) + +logger = logging.getLogger(__name__) + + +@router.post( + CONSENT_REQUEST, + status_code=HTTP_200_OK, + response_model=ConsentRequestResponse, +) +def create_consent_request( + *, + db: Session = Depends(get_db), + data: Identity, +) -> ConsentRequestResponse: + """Creates a verification code for the user to verify access to manage consent preferences.""" + if not config.redis.enabled: + raise FunctionalityNotConfigured( + "Application redis cache required, but it is currently disabled! Please update your application configuration to enable integration with a redis cache." + ) + + if not config.execution.subject_identity_verification_required: + raise FunctionalityNotConfigured( + "Subject identity verification is required, but it is currently disabled! Please update your application configuration to enable subject identity verification." + ) + + if not data.email: + raise HTTPException(HTTP_400_BAD_REQUEST, detail="An email address is required") + + identity = ProvidedIdentity.filter( + db=db, + conditions=( + (ProvidedIdentity.field_name == ProvidedIdentityType.email) + & (ProvidedIdentity.hashed_value == ProvidedIdentity.hash_value(data.email)) + & (ProvidedIdentity.privacy_request_id.is_(None)) + ), + ).first() + + if not identity: + provided_identity_data = { + "privacy_request_id": None, + "field_name": "email", + "hashed_value": ProvidedIdentity.hash_value(data.email), + "encrypted_value": {"value": data.email}, + } + identity = ProvidedIdentity.create(db, data=provided_identity_data) + + consent_request_data = { + "provided_identity_id": identity.id, + } + consent_request = ConsentRequest.create(db, data=consent_request_data) + try: + send_verification_code_to_user(db, consent_request, data.email) + except EmailDispatchException as exc: + logger.error("Error sending the verification code email: %s", str(exc)) + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error sending the verification code email: {str(exc)}", + ) + return ConsentRequestResponse( + identity=data, + consent_request_id=consent_request.id, + ) + + +@router.post( + CONSENT_REQUEST_VERIFY, + status_code=HTTP_200_OK, + response_model=ConsentPreferences, +) +def consent_request_verify( + *, + consent_request_id: str, + db: Session = Depends(get_db), + data: VerificationCode, +) -> ConsentPreferences: + """Verifies the verification code and returns the current consent preferences if successful.""" + provided_identity = _get_consent_request_and_provided_identity( + db=db, consent_request_id=consent_request_id, verification_code=data.code + ) + + if not provided_identity.hashed_value: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, detail="Provided identity missing email" + ) + + return _prepare_consent_preferences(db, provided_identity) + + +@router.patch( + CONSENT_REQUEST_PREFERENCES, + status_code=HTTP_200_OK, + response_model=ConsentPreferences, +) +def set_consent_preferences( + *, + consent_request_id: str, + db: Session = Depends(get_db), + data: ConsentPreferencesWithVerificationCode, +) -> ConsentPreferences: + """Verifies the verification code and saves the user's consent preferences if successful.""" + provided_identity = _get_consent_request_and_provided_identity( + db=db, + consent_request_id=consent_request_id, + verification_code=data.code, + ) + + if not provided_identity.hashed_value: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, detail="Provided identity missing email" + ) + + for preference in data.consent: + current_preference = Consent.filter( + db=db, + conditions=(Consent.provided_identity_id == provided_identity.id) + & (Consent.data_use == preference.data_use), + ).first() + + if current_preference: + current_preference.update(db, data=dict(preference)) + else: + preference_dict = dict(preference) + preference_dict["provided_identity_id"] = provided_identity.id + try: + Consent.create(db, data=preference_dict) + except IntegrityError as exc: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=Pii(str(exc)) + ) + + return _prepare_consent_preferences(db, provided_identity) + + +def _get_consent_request_and_provided_identity( + db: Session, + consent_request_id: str, + verification_code: str, +) -> ProvidedIdentity: + """Verifies the consent request and verification code, then return the ProvidedIdentity if successful.""" + consent_request = ConsentRequest.get_by_key_or_id( + db=db, data={"id": consent_request_id} + ) + + if not consent_request: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, detail="Consent request not found" + ) + + try: + consent_request.verify_identity(verification_code) + except IdentityVerificationException as exc: + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=exc.message) + except PermissionError as exc: + logger.info("Invalid verification code provided for %s.", consent_request.id) + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=exc.args[0]) + + provided_identity: ProvidedIdentity | None = ProvidedIdentity.get_by_key_or_id( + db, data={"id": consent_request.provided_identity_id} + ) + + # It shouldn't be possible to hit this because the cascade delete of the identity + # data would also delete the consent_request, but including this as a safety net. + if not provided_identity: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail="No identity found for consent request id", + ) + + return provided_identity + + +def _prepare_consent_preferences( + db: Session, provided_identity: ProvidedIdentity +) -> ConsentPreferences: + consent = Consent.filter( + db=db, conditions=Consent.provided_identity_id == provided_identity.id + ).all() + + if not consent: + return ConsentPreferences(consent=None) + + return ConsentPreferences( + consent=[ + ConsentSchema( + data_use=x.data_use, + data_use_description=x.data_use_description, + opt_in=x.opt_in, + ) + for x in consent + ], + ) diff --git a/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py b/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py index 4cd891f456..ff6ceb8e3e 100644 --- a/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py +++ b/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py @@ -73,7 +73,6 @@ from fidesops.ops.graph.traversal import Traversal from fidesops.ops.models.connectionconfig import ConnectionConfig from fidesops.ops.models.datasetconfig import DatasetConfig -from fidesops.ops.models.email import EmailConfig from fidesops.ops.models.manual_webhook import AccessManualWebhook from fidesops.ops.models.policy import ActionType, CurrentStep, Policy, PolicyPreWebhook from fidesops.ops.models.privacy_request import ( @@ -92,7 +91,6 @@ FidesopsEmail, RequestReceiptBodyParams, RequestReviewDenyBodyParams, - SubjectIdentityVerificationBodyParams, ) from fidesops.ops.schemas.external_https import PrivacyRequestResumeFormat from fidesops.ops.schemas.privacy_request import ( @@ -109,12 +107,9 @@ RowCountRequest, VerificationCode, ) -from fidesops.ops.service.email.email_dispatch_service import ( - dispatch_email, - dispatch_email_task, -) +from fidesops.ops.service._verification import send_verification_code_to_user +from fidesops.ops.service.email.email_dispatch_service import dispatch_email_task from fidesops.ops.service.privacy_request.request_runner_service import ( - generate_id_verification_code, queue_privacy_request, ) from fidesops.ops.service.privacy_request.request_service import ( @@ -236,7 +231,7 @@ async def create_privacy_request( ) if config.execution.subject_identity_verification_required: - _send_verification_code_to_user( + send_verification_code_to_user( db, privacy_request, privacy_request_data.identity.email ) created.append(privacy_request) @@ -287,27 +282,6 @@ async def create_privacy_request( ) -def _send_verification_code_to_user( - db: Session, privacy_request: PrivacyRequest, email: Optional[str] -) -> None: - """Generate and cache a verification code, and then email to the user""" - EmailConfig.get_configuration( - db=db - ) # Validates Fidesops is currently configured to send emails - verification_code: str = generate_id_verification_code() - privacy_request.cache_identity_verification_code(verification_code) - # synchronous call for now since failure to send verification code is fatal to request - dispatch_email( - db=db, - action_type=EmailActionType.SUBJECT_IDENTITY_VERIFICATION, - to_email=email, - email_body_params=SubjectIdentityVerificationBodyParams( - verification_code=verification_code, - verification_code_ttl_seconds=config.redis.identity_verification_code_ttl_seconds, - ), - ) - - def _send_privacy_request_receipt_email_to_user( policy: Optional[Policy], email: Optional[str] ) -> None: diff --git a/src/fidesops/ops/api/v1/urn_registry.py b/src/fidesops/ops/api/v1/urn_registry.py index 87ed9ea8e9..eab71907a0 100644 --- a/src/fidesops/ops/api/v1/urn_registry.py +++ b/src/fidesops/ops/api/v1/urn_registry.py @@ -5,6 +5,12 @@ # Config URLs CONFIG = "/config" +# Consent request URLs +CONSENT_REQUEST = "/consent-request" +CONSENT_REQUEST_PREFERENCES = "/consent-request/{consent_request_id}/preferences" +CONSENT_REQUEST_VERIFY = "/consent-request/{consent_request_id}/verify" + + # Oauth Client URLs TOKEN = "/oauth/token" CLIENT = "/oauth/client" diff --git a/src/fidesops/ops/migrations/versions/c4df5d585029_data_use_unique_together.py b/src/fidesops/ops/migrations/versions/c4df5d585029_data_use_unique_together.py new file mode 100644 index 0000000000..174114e355 --- /dev/null +++ b/src/fidesops/ops/migrations/versions/c4df5d585029_data_use_unique_together.py @@ -0,0 +1,31 @@ +"""Data use unique together + +Revision ID: c4df5d585029 +Revises: cf88efa1ad89 +Create Date: 2022-09-26 23:12:00.816657 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c4df5d585029" +down_revision = "cf88efa1ad89" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("consent_data_use_key", "consent", type_="unique") + op.create_unique_constraint( + "uix_identity_data_use", "consent", ["provided_identity_id", "data_use"] + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("uix_identity_data_use", "consent", type_="unique") + op.create_unique_constraint("consent_data_use_key", "consent", ["data_use"]) + # ### end Alembic commands ### diff --git a/src/fidesops/ops/models/privacy_request.py b/src/fidesops/ops/models/privacy_request.py index d3ffb3c0bd..be5df5d7d4 100644 --- a/src/fidesops/ops/models/privacy_request.py +++ b/src/fidesops/ops/models/privacy_request.py @@ -18,7 +18,7 @@ from fideslib.oauth.jwt import generate_jwe from sqlalchemy import Boolean, Column, DateTime from sqlalchemy import Enum as EnumColumn -from sqlalchemy import ForeignKey, String +from sqlalchemy import ForeignKey, String, UniqueConstraint from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.mutable import MutableDict, MutableList from sqlalchemy.orm import Session, backref, relationship @@ -787,12 +787,14 @@ class Consent(Base): provided_identity_id = Column( String, ForeignKey(ProvidedIdentity.id), nullable=False ) - data_use = Column(String, nullable=False, unique=True) + data_use = Column(String, nullable=False) data_use_description = Column(String) opt_in = Column(Boolean, nullable=False) provided_identity = relationship(ProvidedIdentity, back_populates="consent") + UniqueConstraint(provided_identity_id, data_use, name="uix_identity_data_use") + class ConsentRequest(Base): """Tracks consent requests.""" @@ -806,6 +808,44 @@ class ConsentRequest(Base): back_populates="consent_request", ) + def cache_identity_verification_code(self, value: str) -> None: + """Cache the generated identity verification code for later comparison.""" + cache: FidesopsRedis = get_cache() + cache.set_with_autoexpire( + f"IDENTITY_VERIFICATION_CODE__{self.id}", + value, + config.redis.identity_verification_code_ttl_seconds, + ) + + def get_cached_identity_data(self) -> Dict[str, Any]: + """Retrieves any identity data pertaining to this request from the cache.""" + prefix = f"id-{self.id}-identity-*" + cache: FidesopsRedis = get_cache() + keys = cache.keys(prefix) + return {key.split("-")[-1]: cache.get(key) for key in keys} + + def get_cached_verification_code(self) -> Optional[str]: + """Retrieve the generated identity verification code if it exists""" + cache = get_cache() + values = cache.get_values([f"IDENTITY_VERIFICATION_CODE__{self.id}"]) or {} + if not values: + return None + + return values.get(f"IDENTITY_VERIFICATION_CODE__{self.id}", None) + + def verify_identity(self, provided_code: str) -> ConsentRequest: + """Verify the identification code supplied by the user.""" + code: Optional[str] = self.get_cached_verification_code() + if not code: + raise IdentityVerificationException( + f"Identification code expired for {self.id}." + ) + + if code != provided_code: + raise PermissionError(f"Incorrect identification code for '{self.id}'") + + return self + # Unique text to separate a step from a collection address, so we can store two values in one. PAUSED_SEPARATOR = "__fidesops_paused_sep__" diff --git a/src/fidesops/ops/schemas/privacy_request.py b/src/fidesops/ops/schemas/privacy_request.py index 479cc5039d..b112bb1cd5 100644 --- a/src/fidesops/ops/schemas/privacy_request.py +++ b/src/fidesops/ops/schemas/privacy_request.py @@ -214,3 +214,39 @@ class BulkPostPrivacyRequests(BulkResponse): class BulkReviewResponse(BulkPostPrivacyRequests): """Schema with mixed success/failure responses for Bulk Approve/Deny of PrivacyRequest responses.""" + + +class Consent(BaseSchema): + """Schema for consent.""" + + data_use: str + data_use_description: Optional[str] = None + opt_in: bool + + +class ConsentPreferences(BaseSchema): + """Schema for consent prefernces.""" + + consent: Optional[List[Consent]] = None + + +class ConsentPreferencesWithVerificationCode(BaseSchema): + """scheam for consent preferences including the verification code.""" + + code: str + consent: List[Consent] + + +class ConsentRequestResponse(BaseSchema): + """Schema for consent request response.""" + + consent_request_id: str + + +class ConsentRequestVerification(BaseSchema): + """Schema for consent requests.""" + + identity: Identity + data_use: str + data_use_description: Optional[str] = None + opt_in: bool diff --git a/src/fidesops/ops/service/_verification.py b/src/fidesops/ops/service/_verification.py new file mode 100644 index 0000000000..b0b067ba98 --- /dev/null +++ b/src/fidesops/ops/service/_verification.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from fidesops.ops.core.config import config +from fidesops.ops.models.email import EmailConfig +from fidesops.ops.models.privacy_request import ConsentRequest, PrivacyRequest +from fidesops.ops.schemas.email.email import ( + EmailActionType, + SubjectIdentityVerificationBodyParams, +) +from fidesops.ops.service.email.email_dispatch_service import dispatch_email +from fidesops.ops.service.privacy_request.request_runner_service import ( + generate_id_verification_code, +) + + +def send_verification_code_to_user( + db: Session, request: ConsentRequest | PrivacyRequest, email: str | None +) -> str: + """Generate and cache a verification code, and then email to the user""" + EmailConfig.get_configuration( + db=db + ) # Validates Fidesops is currently configured to send emails + verification_code = generate_id_verification_code() + request.cache_identity_verification_code(verification_code) + dispatch_email( + db, + action_type=EmailActionType.SUBJECT_IDENTITY_VERIFICATION, + to_email=email, + email_body_params=SubjectIdentityVerificationBodyParams( + verification_code=verification_code, + verification_code_ttl_seconds=config.redis.identity_verification_code_ttl_seconds, + ), + ) + + return verification_code diff --git a/src/fidesops/ops/service/email/email_dispatch_service.py b/src/fidesops/ops/service/email/email_dispatch_service.py index 908a1e2243..7f81940d9f 100644 --- a/src/fidesops/ops/service/email/email_dispatch_service.py +++ b/src/fidesops/ops/service/email/email_dispatch_service.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import Any, Dict, List, Optional, Union diff --git a/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py b/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py new file mode 100644 index 0000000000..5ca3f96057 --- /dev/null +++ b/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any +from unittest.mock import patch + +import pytest + +from fidesops.ops.api.v1.urn_registry import ( + CONSENT_REQUEST, + CONSENT_REQUEST_PREFERENCES, + CONSENT_REQUEST_VERIFY, + V1_URL_PREFIX, +) +from fidesops.ops.core.config import config +from fidesops.ops.models.privacy_request import ( + Consent, + ConsentRequest, + ProvidedIdentity, +) + + +@pytest.fixture +def provided_identity_and_consent_request(db): + provided_identity_data = { + "privacy_request_id": None, + "field_name": "email", + "hashed_value": ProvidedIdentity.hash_value("test@email.com"), + "encrypted_value": {"value": "test@email.com"}, + } + provided_identity = ProvidedIdentity.create(db, data=provided_identity_data) + + consent_request_data = { + "provided_identity_id": provided_identity.id, + } + consent_request = ConsentRequest.create(db, data=consent_request_data) + + yield provided_identity, consent_request + + +@pytest.fixture +def disable_redis(): + current = config.redis.enabled + config.redis.enabled = False + yield + config.redis.enabled = current + + +class TestConsentRequest: + @pytest.mark.usefixtures( + "email_config", + "email_connection_config", + "email_dataset_config", + "subject_identity_verification_required", + ) + @patch("fidesops.ops.service._verification.dispatch_email") + def test_consent_request(self, mock_dispatch_email, api_client): + data = {"email": "test@example.com"} + response = api_client.post(f"{V1_URL_PREFIX}{CONSENT_REQUEST}", json=data) + assert response.status_code == 200 + assert mock_dispatch_email.called + + @pytest.mark.usefixtures( + "email_config", + "email_connection_config", + "email_dataset_config", + "subject_identity_verification_required", + ) + @patch("fidesops.ops.service._verification.dispatch_email") + def test_consent_request_identity_present( + self, mock_dispatch_email, provided_identity_and_consent_request, api_client + ): + provided_identity, _ = provided_identity_and_consent_request + data = {"email": provided_identity.encrypted_value["value"]} + response = api_client.post(f"{V1_URL_PREFIX}{CONSENT_REQUEST}", json=data) + assert response.status_code == 200 + assert mock_dispatch_email.called + + @pytest.mark.usefixtures( + "email_config", + "email_connection_config", + "email_dataset_config", + "subject_identity_verification_required", + "disable_redis", + ) + def test_consent_request_redis_disabled(self, api_client): + data = {"email": "test@example.com"} + response = api_client.post(f"{V1_URL_PREFIX}{CONSENT_REQUEST}", json=data) + assert response.status_code == 500 + assert "redis cache required" in response.json()["message"] + + @pytest.mark.usefixtures( + "email_config", + "email_connection_config", + "email_dataset_config", + ) + def test_consent_request_subject_verification_disabled(self, api_client): + data = {"email": "test@example.com"} + response = api_client.post(f"{V1_URL_PREFIX}{CONSENT_REQUEST}", json=data) + assert response.status_code == 500 + assert "identity verification" in response.json()["message"] + + @pytest.mark.usefixtures( + "email_config", + "email_connection_config", + "email_dataset_config", + "subject_identity_verification_required", + ) + def test_consent_request_no_email(self, api_client): + data = {"phone_number": "336-867-5309"} + response = api_client.post(f"{V1_URL_PREFIX}{CONSENT_REQUEST}", json=data) + assert response.status_code == 400 + assert "email address is required" in response.json()["detail"] + + +class TestConsentVerify: + def test_consent_verify_no_consent_request_id( + self, + api_client, + ): + data = {"code": "12345"} + + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_VERIFY.format(consent_request_id='abcd')}", + json=data, + ) + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + def test_consent_verify_no_consent_code( + self, provided_identity_and_consent_request, api_client + ): + data = {"code": "12345"} + + _, consent_request = provided_identity_and_consent_request + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_VERIFY.format(consent_request_id=consent_request.id)}", + json=data, + ) + assert response.status_code == 400 + assert "code expired" in response.json()["detail"] + + def test_consent_verify_invalid_code( + self, provided_identity_and_consent_request, api_client + ): + _, consent_request = provided_identity_and_consent_request + consent_request.cache_identity_verification_code("abcd") + + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_VERIFY.format(consent_request_id=consent_request.id)}", + json={"code": "1234"}, + ) + assert response.status_code == 403 + assert "Incorrect identification" in response.json()["detail"] + + def test_consent_verify_no_email_provided(self, db, api_client): + provided_identity_data = { + "privacy_request_id": None, + "field_name": "email", + "hashed_value": None, + "encrypted_value": None, + } + provided_identity = ProvidedIdentity.create(db, data=provided_identity_data) + + consent_request_data = { + "provided_identity_id": provided_identity.id, + } + consent_request = ConsentRequest.create(db, data=consent_request_data) + verification_code = "abcd" + consent_request.cache_identity_verification_code(verification_code) + + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_VERIFY.format(consent_request_id=consent_request.id)}", + json={"code": verification_code}, + ) + + assert response.status_code == 404 + assert "missing email" in response.json()["detail"] + + def test_consent_verify_no_consent_present( + self, provided_identity_and_consent_request, api_client + ): + _, consent_request = provided_identity_and_consent_request + verification_code = "abcd" + consent_request.cache_identity_verification_code(verification_code) + + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_VERIFY.format(consent_request_id=consent_request.id)}", + json={"code": verification_code}, + ) + assert response.status_code == 200 + assert response.json()["consent"] is None + + def test_consent_verify_consent_preferences( + self, provided_identity_and_consent_request, db, api_client + ): + verification_code = "abcd" + provided_identity, consent_request = provided_identity_and_consent_request + consent_request.cache_identity_verification_code(verification_code) + + consent_data: list[dict[str, Any]] = [ + { + "data_use": "email", + "data_use_description": None, + "opt_in": True, + }, + { + "data_use": "location", + "data_use_description": "Location data", + "opt_in": False, + }, + ] + + for data in deepcopy(consent_data): + data["provided_identity_id"] = provided_identity.id + Consent.create(db, data=data) + + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_VERIFY.format(consent_request_id=consent_request.id)}", + json={"code": verification_code}, + ) + assert response.status_code == 200 + assert response.json()["consent"] == consent_data + + +class TestSaveConsent: + def test_set_consent_preferences_no_consent_request_id(self, api_client): + data = { + "code": "12345", + "identity": {"email": "test@email.com"}, + "consent": [{"data_use": "email", "opt_in": True}], + } + + response = api_client.patch( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id='abcd')}", + json=data, + ) + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + def test_set_consent_preferences_no_consent_code( + self, provided_identity_and_consent_request, api_client + ): + _, consent_request = provided_identity_and_consent_request + + data = { + "code": "12345", + "identity": {"email": "test@email.com"}, + "consent": [{"data_use": "email", "opt_in": True}], + } + + response = api_client.patch( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + json=data, + ) + assert response.status_code == 400 + assert "code expired" in response.json()["detail"] + + def test_set_consent_preferences_invalid_code( + self, provided_identity_and_consent_request, api_client + ): + _, consent_request = provided_identity_and_consent_request + consent_request.cache_identity_verification_code("abcd") + + data = { + "code": "12345", + "identity": {"email": "test@email.com"}, + "consent": [{"data_use": "email", "opt_in": True}], + } + response = api_client.patch( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + json=data, + ) + assert response.status_code == 403 + assert "Incorrect identification" in response.json()["detail"] + + def test_set_consent_preferences_no_email_provided(self, db, api_client): + provided_identity_data = { + "privacy_request_id": None, + "field_name": "email", + "hashed_value": None, + "encrypted_value": None, + } + provided_identity = ProvidedIdentity.create(db, data=provided_identity_data) + + consent_request_data = { + "provided_identity_id": provided_identity.id, + } + consent_request = ConsentRequest.create(db, data=consent_request_data) + verification_code = "abcd" + consent_request.cache_identity_verification_code(verification_code) + + data = { + "code": verification_code, + "identity": {"email": "test@email.com"}, + "consent": [{"data_use": "email", "opt_in": True}], + } + response = api_client.patch( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + json=data, + ) + + assert response.status_code == 404 + assert "missing email" in response.json()["detail"] + + def test_set_consent_preferences_no_consent_present( + self, provided_identity_and_consent_request, api_client + ): + _, consent_request = provided_identity_and_consent_request + verification_code = "abcd" + consent_request.cache_identity_verification_code(verification_code) + + data = { + "code": verification_code, + "identity": {"email": "test@email.com"}, + "consent": None, + } + response = api_client.patch( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + json=data, + ) + assert response.status_code == 422 + + def test_set_consent_consent_preferences( + self, provided_identity_and_consent_request, db, api_client + ): + provided_identity, consent_request = provided_identity_and_consent_request + verification_code = "abcd" + consent_request.cache_identity_verification_code(verification_code) + + consent_data: list[dict[str, Any]] = [ + { + "data_use": "email", + "data_use_description": None, + "opt_in": True, + }, + { + "data_use": "location", + "data_use_description": "Location data", + "opt_in": False, + }, + ] + + for data in deepcopy(consent_data): + data["provided_identity_id"] = provided_identity.id + Consent.create(db, data=data) + + consent_data[1]["opt_in"] = False + + data = { + "code": verification_code, + "identity": {"email": "test@email.com"}, + "consent": consent_data, + } + response = api_client.patch( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + json=data, + ) + assert response.status_code == 200 + assert response.json()["consent"] == consent_data diff --git a/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py b/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py index cebb53ce5b..50a1484b9d 100644 --- a/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py @@ -2957,9 +2957,7 @@ def test_create_privacy_request_no_email_config( @mock.patch( "fidesops.ops.service.privacy_request.request_runner_service.run_privacy_request.delay" ) - @mock.patch( - "fidesops.ops.api.v1.endpoints.privacy_request_endpoints.dispatch_email" - ) + @mock.patch("fidesops.ops.service._verification.dispatch_email") def test_create_privacy_request_with_email_config( self, mock_dispatch_email, diff --git a/tests/ops/conftest.py b/tests/ops/conftest.py index b8645b1564..6436c4d395 100644 --- a/tests/ops/conftest.py +++ b/tests/ops/conftest.py @@ -248,6 +248,15 @@ def require_manual_request_approval(): config.execution.require_manual_request_approval = original_value +@pytest.fixture(scope="function") +def subject_identity_verification_required(): + """Enable identity verification.""" + original_value = config.execution.subject_identity_verification_required + config.execution.subject_identity_verification_required = True + yield + config.execution.subject_identity_verification_required = original_value + + @pytest.fixture(autouse=True, scope="function") def subject_identity_verification_not_required(): """Disable identity verification for most tests unless overridden""" From 687940393ac73dca06c6cea9d1381d9262f61093 Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Wed, 28 Sep 2022 12:42:04 -0400 Subject: [PATCH 05/30] Add new template for consent requets emails (#1405) * Add new template for consent requets emails * Remove analytics id * Update subject Co-authored-by: Paul Sanders --- .../ops/email_templates/get_email_template.py | 3 +++ .../ops/email_templates/template_names.py | 1 + .../templates/consent_request_verification.html | 15 +++++++++++++++ src/fidesops/ops/schemas/email/email.py | 1 + src/fidesops/ops/service/_verification.py | 7 ++++++- .../ops/service/email/email_dispatch_service.py | 11 +++++++++++ 6 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/fidesops/ops/email_templates/templates/consent_request_verification.html diff --git a/src/fidesops/ops/email_templates/get_email_template.py b/src/fidesops/ops/email_templates/get_email_template.py index c5e967d5a9..14b1652e2e 100644 --- a/src/fidesops/ops/email_templates/get_email_template.py +++ b/src/fidesops/ops/email_templates/get_email_template.py @@ -5,6 +5,7 @@ from fidesops.ops.common_exceptions import EmailTemplateUnhandledActionType from fidesops.ops.email_templates.template_names import ( + CONSENT_REQUEST_VERIFICATION_TEMPLATE, EMAIL_ERASURE_REQUEST_FULFILLMENT, PRIVACY_REQUEST_COMPLETE_ACCESS_TEMPLATE, PRIVACY_REQUEST_COMPLETE_DELETION_TEMPLATE, @@ -28,6 +29,8 @@ def get_email_template( # pylint: disable=too-many-return-statements action_type: EmailActionType, ) -> Template: + if action_type == EmailActionType.CONSENT_REQUEST: + return template_env.get_template(CONSENT_REQUEST_VERIFICATION_TEMPLATE) if action_type == EmailActionType.SUBJECT_IDENTITY_VERIFICATION: return template_env.get_template(SUBJECT_IDENTITY_VERIFICATION_TEMPLATE) if action_type == EmailActionType.EMAIL_ERASURE_REQUEST_FULFILLMENT: diff --git a/src/fidesops/ops/email_templates/template_names.py b/src/fidesops/ops/email_templates/template_names.py index 39325a900a..8ceb5cff02 100644 --- a/src/fidesops/ops/email_templates/template_names.py +++ b/src/fidesops/ops/email_templates/template_names.py @@ -1,3 +1,4 @@ +CONSENT_REQUEST_VERIFICATION_TEMPLATE = "consent_request_verification.html" SUBJECT_IDENTITY_VERIFICATION_TEMPLATE = "subject_identity_verification.html" EMAIL_ERASURE_REQUEST_FULFILLMENT = "erasure_request_email_fulfillment.html" PRIVACY_REQUEST_RECEIPT_TEMPLATE = "privacy_request_receipt.html" diff --git a/src/fidesops/ops/email_templates/templates/consent_request_verification.html b/src/fidesops/ops/email_templates/templates/consent_request_verification.html new file mode 100644 index 0000000000..8208af3dac --- /dev/null +++ b/src/fidesops/ops/email_templates/templates/consent_request_verification.html @@ -0,0 +1,15 @@ + + + + ID Code + + +
+

+ Your consent request verification code is {{code}}. + Please return to the consent request page and enter the code to + continue. This code will expire in {{minutes}} minutes +

+
+ + diff --git a/src/fidesops/ops/schemas/email/email.py b/src/fidesops/ops/schemas/email/email.py index 64aa6abb02..129f022d6a 100644 --- a/src/fidesops/ops/schemas/email/email.py +++ b/src/fidesops/ops/schemas/email/email.py @@ -19,6 +19,7 @@ class EmailActionType(str, Enum): """Enum for email action type""" # verify email upon acct creation + CONSENT_REQUEST = "consent_request" SUBJECT_IDENTITY_VERIFICATION = "subject_identity_verification" EMAIL_ERASURE_REQUEST_FULFILLMENT = "email_erasure_fulfillment" PRIVACY_REQUEST_RECEIPT = "privacy_request_receipt" diff --git a/src/fidesops/ops/service/_verification.py b/src/fidesops/ops/service/_verification.py index b0b067ba98..4f454cf790 100644 --- a/src/fidesops/ops/service/_verification.py +++ b/src/fidesops/ops/service/_verification.py @@ -24,9 +24,14 @@ def send_verification_code_to_user( ) # Validates Fidesops is currently configured to send emails verification_code = generate_id_verification_code() request.cache_identity_verification_code(verification_code) + email_action_type = ( + EmailActionType.CONSENT_REQUEST + if isinstance(request, ConsentRequest) + else EmailActionType.SUBJECT_IDENTITY_VERIFICATION + ) dispatch_email( db, - action_type=EmailActionType.SUBJECT_IDENTITY_VERIFICATION, + action_type=email_action_type, to_email=email, email_body_params=SubjectIdentityVerificationBodyParams( verification_code=verification_code, diff --git a/src/fidesops/ops/service/email/email_dispatch_service.py b/src/fidesops/ops/service/email/email_dispatch_service.py index 7f81940d9f..f62d2a4ad0 100644 --- a/src/fidesops/ops/service/email/email_dispatch_service.py +++ b/src/fidesops/ops/service/email/email_dispatch_service.py @@ -94,6 +94,17 @@ def _build_email( # pylint: disable=too-many-return-statements action_type: EmailActionType, body_params: Any, ) -> EmailForActionType: + if action_type == EmailActionType.CONSENT_REQUEST: + template = get_email_template(action_type) + return EmailForActionType( + subject="Your one-time code", + body=template.render( + { + "code": body_params.verification_code, + "minutes": body_params.get_verification_code_ttl_minutes(), + } + ), + ) if action_type == EmailActionType.SUBJECT_IDENTITY_VERIFICATION: template = get_email_template(action_type) return EmailForActionType( From 23cdaa5d0fb56a8284909cb583e57a95ac98f193 Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Wed, 28 Sep 2022 13:52:15 -0400 Subject: [PATCH 06/30] Add authenticated route to get consent preferences (#1402) Co-authored-by: Paul Sanders --- CHANGELOG.md | 1 + clients/ops/admin-ui/src/constants.ts | 4 + .../postman/Fidesops.postman_collection.json | 38 +++++++- .../v1/endpoints/consent_request_endpoints.py | 40 ++++++++- src/fidesops/ops/api/v1/scope_registry.py | 3 + src/fidesops/ops/api/v1/urn_registry.py | 5 +- .../test_consent_request_endpoints.py | 88 +++++++++++++++++-- 7 files changed, 167 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87cfc38816..53896f9910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The types of changes are: * Frontend - ability for users to manually enter PII to an IN PROGRESS subject request [#1016](https://github.com/ethyca/fidesops/pull/1377) * Enable retries on saas connectors for failures at the http request level [#1376](https://github.com/ethyca/fidesops/pull/1376) * Add consent request api [#1387](https://github.com/ethyca/fidesops/pull/1387) +* Add authenticated route to get consent preferences [#1402](https://github.com/ethyca/fidesops/pull/1402) ### Removed diff --git a/clients/ops/admin-ui/src/constants.ts b/clients/ops/admin-ui/src/constants.ts index 63cda62ba8..1c2104598a 100644 --- a/clients/ops/admin-ui/src/constants.ts +++ b/clients/ops/admin-ui/src/constants.ts @@ -41,6 +41,10 @@ export const USER_PRIVILEGES: UserPrivileges[] = [ privilege: "Delete datastore connections", scope: "connection:delete", }, + { + privilege: "View user consent preferences", + scope: "consent:read", + }, { privilege: "View Datasets", scope: "dataset:read", diff --git a/docs/fidesops/docs/postman/Fidesops.postman_collection.json b/docs/fidesops/docs/postman/Fidesops.postman_collection.json index adb6e11c17..3ec2a4ff02 100644 --- a/docs/fidesops/docs/postman/Fidesops.postman_collection.json +++ b/docs/fidesops/docs/postman/Fidesops.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "8f48b8e3-0a39-4e5e-b505-8087064dc1af", + "_postman_id": "002813f4-12b7-4467-9377-a57706b6dbc8", "name": "Fidesops", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -72,7 +72,7 @@ "header": [], "body": { "mode": "raw", - "raw": "[\n \"client:create\",\n \"client:update\",\n \"client:read\",\n \"client:delete\",\n \"config:read\",\n \"connection_type:read\",\n \"connection:read\",\n \"connection:create_or_update\",\n \"connection:delete\",\n \"connection:instantiate\",\n \"dataset:create_or_update\",\n \"dataset:delete\",\n \"dataset:read\",\n \"encryption:exec\",\n \"email:create_or_update\",\n \"email:read\",\n \"email:delete\",\n \"policy:create_or_update\",\n \"policy:read\",\n \"policy:delete\",\n \"privacy-request:read\",\n \"privacy-request:delete\",\n \"rule:create_or_update\",\n \"rule:read\",\n \"rule:delete\",\n \"scope:read\",\n \"storage:create_or_update\",\n \"storage:delete\",\n \"storage:read\",\n \"privacy-request:resume\",\n \"webhook:create_or_update\",\n \"webhook:read\",\n \"webhook:delete\",\n \"saas_config:create_or_update\",\n \"saas_config:read\",\n \"saas_config:delete\",\n \"privacy-request:review\",\n \"user:create\",\n \"user:delete\"\n]", + "raw": "[\n \"client:create\",\n \"client:update\",\n \"client:read\",\n \"client:delete\",\n \"config:read\",\n \"connection_type:read\",\n \"connection:read\",\n \"connection:create_or_update\",\n \"connection:delete\",\n \"connection:instantiate\",\n \"consent:read\",\n \"dataset:create_or_update\",\n \"dataset:delete\",\n \"dataset:read\",\n \"encryption:exec\",\n \"email:create_or_update\",\n \"email:read\",\n \"email:delete\",\n \"policy:create_or_update\",\n \"policy:read\",\n \"policy:delete\",\n \"privacy-request:read\",\n \"privacy-request:delete\",\n \"rule:create_or_update\",\n \"rule:read\",\n \"rule:delete\",\n \"scope:read\",\n \"storage:create_or_update\",\n \"storage:delete\",\n \"storage:read\",\n \"privacy-request:resume\",\n \"webhook:create_or_update\",\n \"webhook:read\",\n \"webhook:delete\",\n \"saas_config:create_or_update\",\n \"saas_config:read\",\n \"saas_config:delete\",\n \"privacy-request:review\",\n \"user:create\",\n \"user:delete\"\n]", "options": { "raw": { "language": "json" @@ -4815,6 +4815,38 @@ }, "response": [] }, + { + "name": "Authenticated Get Consent Preferences", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{client_token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"phone_number\": \"{{phone_number}}\",\n \"email\": \"{{email}}\"\n}" + }, + "url": { + "raw": "{{host}}/consent-request/preferences", + "host": [ + "{{host}}" + ], + "path": [ + "consent-request", + "preferences" + ] + } + }, + "response": [] + }, { "name": "Verify Code and Save Preferences", "request": { @@ -5160,4 +5192,4 @@ "type": "string" } ] -} +} \ No newline at end of file diff --git a/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py b/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py index 0fd8461b97..a280d238fe 100644 --- a/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py +++ b/src/fidesops/ops/api/v1/endpoints/consent_request_endpoints.py @@ -2,7 +2,7 @@ import logging -from fastapi import Depends, HTTPException +from fastapi import Depends, HTTPException, Security from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from starlette.status import ( @@ -14,9 +14,11 @@ ) from fidesops.ops.api.deps import get_db +from fidesops.ops.api.v1.scope_registry import CONSENT_READ from fidesops.ops.api.v1.urn_registry import ( CONSENT_REQUEST, CONSENT_REQUEST_PREFERENCES, + CONSENT_REQUEST_PREFERENCES_WITH_ID, CONSENT_REQUEST_VERIFY, V1_URL_PREFIX, ) @@ -43,6 +45,7 @@ from fidesops.ops.service._verification import send_verification_code_to_user from fidesops.ops.util.api_router import APIRouter from fidesops.ops.util.logger import Pii +from fidesops.ops.util.oauth_util import verify_oauth_client router = APIRouter(tags=["Consent"], prefix=V1_URL_PREFIX) @@ -133,8 +136,41 @@ def consent_request_verify( return _prepare_consent_preferences(db, provided_identity) -@router.patch( +@router.post( CONSENT_REQUEST_PREFERENCES, + dependencies=[Security(verify_oauth_client, scopes=[CONSENT_READ])], + status_code=HTTP_200_OK, + response_model=ConsentPreferences, +) +def get_consent_preferences( + *, db: Session = Depends(get_db), data: Identity +) -> ConsentPreferences: + """Gets the consent preferences for the specified user.""" + if data.email: + lookup = data.email + elif data.phone_number: + lookup = data.phone_number + else: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail="No identity information provided" + ) + + identity = ProvidedIdentity.filter( + db, + conditions=( + (ProvidedIdentity.hashed_value == ProvidedIdentity.hash_value(lookup)) + & (ProvidedIdentity.privacy_request_id.is_(None)) + ), + ).first() + + if not identity: + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Identity not found") + + return _prepare_consent_preferences(db, identity) + + +@router.patch( + CONSENT_REQUEST_PREFERENCES_WITH_ID, status_code=HTTP_200_OK, response_model=ConsentPreferences, ) diff --git a/src/fidesops/ops/api/v1/scope_registry.py b/src/fidesops/ops/api/v1/scope_registry.py index 15e5ef99a1..9b9e8d24e3 100644 --- a/src/fidesops/ops/api/v1/scope_registry.py +++ b/src/fidesops/ops/api/v1/scope_registry.py @@ -17,6 +17,8 @@ CONNECTION_AUTHORIZE = "connection:authorize" SAAS_CONNECTION_INSTANTIATE = "connection:instantiate" +CONSENT_READ = "consent:read" + PRIVACY_REQUEST_READ = "privacy-request:read" PRIVACY_REQUEST_DELETE = "privacy-request:delete" PRIVACY_REQUEST_CALLBACK_RESUME = ( @@ -75,6 +77,7 @@ CONNECTION_DELETE, CONNECTION_AUTHORIZE, SAAS_CONNECTION_INSTANTIATE, + CONSENT_READ, CONNECTION_TYPE_READ, DATASET_CREATE_OR_UPDATE, DATASET_DELETE, diff --git a/src/fidesops/ops/api/v1/urn_registry.py b/src/fidesops/ops/api/v1/urn_registry.py index eab71907a0..ee67d0a3ed 100644 --- a/src/fidesops/ops/api/v1/urn_registry.py +++ b/src/fidesops/ops/api/v1/urn_registry.py @@ -7,7 +7,10 @@ # Consent request URLs CONSENT_REQUEST = "/consent-request" -CONSENT_REQUEST_PREFERENCES = "/consent-request/{consent_request_id}/preferences" +CONSENT_REQUEST_PREFERENCES = "/consent-request/preferences" +CONSENT_REQUEST_PREFERENCES_WITH_ID = ( + "/consent-request/{consent_request_id}/preferences" +) CONSENT_REQUEST_VERIFY = "/consent-request/{consent_request_id}/verify" diff --git a/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py b/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py index 5ca3f96057..e062e29629 100644 --- a/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_consent_request_endpoints.py @@ -6,9 +6,11 @@ import pytest +from fidesops.ops.api.v1.scope_registry import CONNECTION_READ, CONSENT_READ from fidesops.ops.api.v1.urn_registry import ( CONSENT_REQUEST, CONSENT_REQUEST_PREFERENCES, + CONSENT_REQUEST_PREFERENCES_WITH_ID, CONSENT_REQUEST_VERIFY, V1_URL_PREFIX, ) @@ -232,7 +234,7 @@ def test_set_consent_preferences_no_consent_request_id(self, api_client): } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id='abcd')}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id='abcd')}", json=data, ) assert response.status_code == 404 @@ -250,7 +252,7 @@ def test_set_consent_preferences_no_consent_code( } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) assert response.status_code == 400 @@ -268,7 +270,7 @@ def test_set_consent_preferences_invalid_code( "consent": [{"data_use": "email", "opt_in": True}], } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) assert response.status_code == 403 @@ -296,7 +298,7 @@ def test_set_consent_preferences_no_email_provided(self, db, api_client): "consent": [{"data_use": "email", "opt_in": True}], } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) @@ -316,7 +318,7 @@ def test_set_consent_preferences_no_consent_present( "consent": None, } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) assert response.status_code == 422 @@ -353,8 +355,82 @@ def test_set_consent_consent_preferences( "consent": consent_data, } response = api_client.patch( - f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES.format(consent_request_id=consent_request.id)}", + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES_WITH_ID.format(consent_request_id=consent_request.id)}", json=data, ) assert response.status_code == 200 assert response.json()["consent"] == consent_data + + +class TestGetConsentPreferences: + def test_get_consent_peferences_wrong_scope(self, generate_auth_header, api_client): + auth_header = generate_auth_header(scopes=[CONNECTION_READ]) + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES}", + headers=auth_header, + json={"email": "test@user.com"}, + ) + + assert response.status_code == 403 + + def test_get_consent_preferences_no_identity_data( + self, generate_auth_header, api_client + ): + auth_header = generate_auth_header(scopes=[CONSENT_READ]) + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES}", + headers=auth_header, + json={"email": None}, + ) + + assert response.status_code == 400 + assert "No identity information" in response.json()["detail"] + + def test_get_consent_preferences_identity_not_found( + self, generate_auth_header, api_client + ): + auth_header = generate_auth_header(scopes=[CONSENT_READ]) + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES}", + headers=auth_header, + json={"email": "test@email.com"}, + ) + + assert response.status_code == 404 + assert "Identity not found" in response.json()["detail"] + + def test_get_consent_preferences( + self, + provided_identity_and_consent_request, + db, + generate_auth_header, + api_client, + ): + provided_identity, _ = provided_identity_and_consent_request + + consent_data: list[dict[str, Any]] = [ + { + "data_use": "email", + "data_use_description": None, + "opt_in": True, + }, + { + "data_use": "location", + "data_use_description": "Location data", + "opt_in": False, + }, + ] + + for data in deepcopy(consent_data): + data["provided_identity_id"] = provided_identity.id + Consent.create(db, data=data) + + auth_header = generate_auth_header(scopes=[CONSENT_READ]) + response = api_client.post( + f"{V1_URL_PREFIX}{CONSENT_REQUEST_PREFERENCES}", + headers=auth_header, + json={"email": provided_identity.encrypted_value["value"]}, + ) + + assert response.status_code == 200 + assert response.json()["consent"] == consent_data From 592e1f0aeceeef009e8c5e0a8a2e076a2728799c Mon Sep 17 00:00:00 2001 From: Dawn Pattison Date: Wed, 28 Sep 2022 14:22:12 -0500 Subject: [PATCH 07/30] Backend: Updating or Deleting Access Webhooks [#1388][#1389] (#1394) * If a manual webhook is deleted or disabled, check if there are any remaining active manual webhooks configured. If not, queue any Privacy Requests stuck in "requires_input" for processing. * In the "view_uploaded_manual_webhook_data", load cached webhook data for a privacy request in strict mode. If it fails (no data saved, extra field saved, field missing), return checked=True, so the user knows they need to reupload data for this webhook before it can be submitted. Return the data in non-strict mode, so we just show the overlap between the data saved and the fields defined. * Update changelog. * Move queue_requires_input_requests to the connection_endpoints where this is the only module it's being called - both where you update and delete a connection config. * Clarify docstring. --- CHANGELOG.md | 1 + .../api/v1/endpoints/connection_endpoints.py | 39 +++++++++ .../v1/endpoints/privacy_request_endpoints.py | 35 +++++--- src/fidesops/ops/common_exceptions.py | 4 + src/fidesops/ops/models/manual_webhook.py | 10 ++- src/fidesops/ops/models/privacy_request.py | 62 ++++++++++--- .../privacy_request/request_runner_service.py | 9 +- .../test_connection_config_endpoints.py | 67 +++++++++++++- .../test_privacy_request_endpoints.py | 41 +++++++-- tests/ops/fixtures/manual_webhook_fixtures.py | 5 +- tests/ops/models/test_privacy_request.py | 87 +++++++++++++++++-- 11 files changed, 318 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53896f9910..9bbed510c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The types of changes are: * Distinguish whether webhook has been visited and no fields were found, versus never visited [#1339](https://github.com/ethyca/fidesops/pull/1339) * Fix Redis Cache Early Expiration in Tests [#1358](https://github.com/ethyca/fidesops/pull/1358) * Limit values for the offset pagination strategy are now cast to integers before use [#1364](https://github.com/ethyca/fidesops/pull/1364) +* Allow `requires_input` PrivacyRequests to be addressed if a webhook is deleted, disabled, or updated [#1394](https://github.com/ethyca/fidesops/pull/1394) ### Added diff --git a/src/fidesops/ops/api/v1/endpoints/connection_endpoints.py b/src/fidesops/ops/api/v1/endpoints/connection_endpoints.py index b159d7d32c..1a6a02c149 100644 --- a/src/fidesops/ops/api/v1/endpoints/connection_endpoints.py +++ b/src/fidesops/ops/api/v1/endpoints/connection_endpoints.py @@ -39,6 +39,8 @@ ConnectionException, ) from fidesops.ops.models.connectionconfig import ConnectionConfig, ConnectionType +from fidesops.ops.models.manual_webhook import AccessManualWebhook +from fidesops.ops.models.privacy_request import PrivacyRequest, PrivacyRequestStatus from fidesops.ops.schemas.api import BulkUpdateFailed from fidesops.ops.schemas.connection_configuration import ( connection_secrets_schemas, @@ -58,6 +60,9 @@ ) from fidesops.ops.schemas.shared_schemas import FidesOpsKey from fidesops.ops.service.connectors import get_connector +from fidesops.ops.service.privacy_request.request_runner_service import ( + queue_privacy_request, +) from fidesops.ops.util.api_router import APIRouter from fidesops.ops.util.logger import Pii from fidesops.ops.util.oauth_util import verify_oauth_client @@ -222,6 +227,9 @@ def patch_connections( ) ) + # Check if possibly disabling a manual webhook here causes us to need to queue affected privacy requests + requeue_requires_input_requests(db) + return BulkPutConnectionConfiguration( succeeded=created_or_updated, failed=failed, @@ -238,9 +246,15 @@ def delete_connection( ) -> None: """Removes the connection configuration with matching key.""" connection_config = get_connection_config_or_error(db, connection_key) + connection_type = connection_config.connection_type logger.info("Deleting connection config with key '%s'.", connection_key) connection_config.delete(db) + # Access Manual Webhooks are cascade deleted if their ConnectionConfig is deleted, + # so we queue any privacy requests that are no longer blocked by webhooks + if connection_type == ConnectionType.manual_webhook: + requeue_requires_input_requests(db) + def validate_secrets( request_body: connection_secrets_schemas, connection_config: ConnectionConfig @@ -356,3 +370,28 @@ async def test_connection_config_secrets( connection_config = get_connection_config_or_error(db, connection_key) msg = f"Test completed for ConnectionConfig with key: {connection_key}." return connection_status(connection_config, msg, db) + + +def requeue_requires_input_requests(db: Session) -> None: + """ + Queue privacy requests with request status "requires_input" if they are no longer blocked by + access manual webhooks. + + For use when all access manual webhooks have been either disabled or deleted, leaving privacy requests + lingering in a "requires_input" state. + """ + if not AccessManualWebhook.get_enabled(db): + for pr in PrivacyRequest.filter( + db=db, + conditions=(PrivacyRequest.status == PrivacyRequestStatus.requires_input), + ): + logger.info( + "Queuing privacy request '%s with '%s' status now that manual inputs are no longer required.", + pr.id, + pr.status.value, + ) + pr.status = PrivacyRequestStatus.in_processing + pr.save(db=db) + queue_privacy_request( + privacy_request_id=pr.id, + ) diff --git a/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py b/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py index ff6ceb8e3e..cf34c09ec8 100644 --- a/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py +++ b/src/fidesops/ops/api/v1/endpoints/privacy_request_endpoints.py @@ -62,6 +62,7 @@ FunctionalityNotConfigured, IdentityNotFoundException, IdentityVerificationException, + ManualWebhookFieldsUnset, NoCachedManualWebhookEntry, PolicyNotFoundException, TraversalError, @@ -1331,6 +1332,11 @@ def view_uploaded_manual_webhook_data( ) -> Optional[ManualWebhookData]: """ View uploaded data for this privacy request for the given access manual webhook + + If no data exists for this webhook, we just return all fields as None. + If we have missing or extra fields saved, we'll just return the overlap between what is saved and what is defined on the webhook. + + If checked=False, data must be reviewed before submission. The privacy request should not be submitted as-is. """ privacy_request: PrivacyRequest = get_privacy_request_or_error( db, privacy_request_id @@ -1342,7 +1348,8 @@ def view_uploaded_manual_webhook_data( if not privacy_request.status == PrivacyRequestStatus.requires_input: raise HTTPException( status_code=HTTP_400_BAD_REQUEST, - detail=f"Invalid access manual webhook upload request: privacy request '{privacy_request.id}' status = {privacy_request.status.value}.", # type: ignore + detail=f"Invalid access manual webhook upload request: privacy request " + f"'{privacy_request.id}' status = {privacy_request.status.value}.", # type: ignore ) try: @@ -1351,20 +1358,20 @@ def view_uploaded_manual_webhook_data( connection_config.key, privacy_request.id, ) - data: Dict[str, Any] = privacy_request.get_manual_webhook_input( + data: Dict[str, Any] = privacy_request.get_manual_webhook_input_strict( access_manual_webhook ) checked = True - except NoCachedManualWebhookEntry as exc: + except ( + PydanticValidationError, + ManualWebhookFieldsUnset, + NoCachedManualWebhookEntry, + ) as exc: logger.info(exc) - data = access_manual_webhook.empty_fields_dict - checked = False - except PydanticValidationError: - raise HTTPException( - status_code=HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Saved fields differ from fields specified on webhook '{access_manual_webhook.connection_config.key}'. " - f"Re-upload manual data using '{PRIVACY_REQUEST_ACCESS_MANUAL_WEBHOOK_INPUT.format(connection_key=connection_config.key, privacy_request_id=privacy_request.id)}'.", + data = privacy_request.get_manual_webhook_input_non_strict( + manual_webhook=access_manual_webhook ) + checked = False return ManualWebhookData(checked=checked, fields=data) @@ -1398,8 +1405,12 @@ async def resume_privacy_request_from_requires_input( ) try: for manual_webhook in access_manual_webhooks: - privacy_request.get_manual_webhook_input(manual_webhook) - except (NoCachedManualWebhookEntry, PydanticValidationError) as exc: + privacy_request.get_manual_webhook_input_strict(manual_webhook) + except ( + NoCachedManualWebhookEntry, + PydanticValidationError, + ManualWebhookFieldsUnset, + ) as exc: raise HTTPException( status_code=HTTP_400_BAD_REQUEST, detail=f"Cannot resume privacy request. {exc}", diff --git a/src/fidesops/ops/common_exceptions.py b/src/fidesops/ops/common_exceptions.py index 0b93ab7acc..749bc596b1 100644 --- a/src/fidesops/ops/common_exceptions.py +++ b/src/fidesops/ops/common_exceptions.py @@ -109,6 +109,10 @@ class NoCachedManualWebhookEntry(BaseException): """No manual data exists for this webhook on the given privacy request.""" +class ManualWebhookFieldsUnset(BaseException): + """Manual webhook has fields that are not explicitly set: Likely new field has been added""" + + class PrivacyRequestErasureEmailSendRequired(BaseException): """Erasure requests will need to be fulfilled by email send. Exception is raised to change ExecutionLog details""" diff --git a/src/fidesops/ops/models/manual_webhook.py b/src/fidesops/ops/models/manual_webhook.py index c1d05eaddd..1129df5471 100644 --- a/src/fidesops/ops/models/manual_webhook.py +++ b/src/fidesops/ops/models/manual_webhook.py @@ -2,7 +2,7 @@ from fideslib.db.base_class import Base from fideslib.schemas.base_class import BaseSchema -from pydantic import create_model +from pydantic import BaseConfig, create_model from sqlalchemy import Column, ForeignKey, String, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.mutable import MutableList @@ -50,6 +50,14 @@ class Config: ) return ManualWebhookValidationModel + @property + def fields_non_strict_schema(self) -> BaseSchema: + """Returns a dynamic Pydantic Schema for webhook fields that can keep the overlap between + fields that are saved and fields that are defined here.""" + schema: BaseSchema = self.fields_schema + schema.__config__ = BaseConfig # Extra is "ignore" on BaseConfig + return schema + @property def empty_fields_dict(self) -> Dict[str, None]: """Return a dictionary that maps defined dsr_package_labels to None diff --git a/src/fidesops/ops/models/privacy_request.py b/src/fidesops/ops/models/privacy_request.py index be5df5d7d4..e1fa94c0d0 100644 --- a/src/fidesops/ops/models/privacy_request.py +++ b/src/fidesops/ops/models/privacy_request.py @@ -30,6 +30,7 @@ from fidesops.ops.api.v1.scope_registry import PRIVACY_REQUEST_CALLBACK_RESUME from fidesops.ops.common_exceptions import ( IdentityVerificationException, + ManualWebhookFieldsUnset, NoCachedManualWebhookEntry, PrivacyRequestPaused, ) @@ -505,27 +506,50 @@ def cache_manual_webhook_input( parsed_data.dict(), ) - def get_manual_webhook_input( + def get_manual_webhook_input_strict( self, manual_webhook: AccessManualWebhook ) -> Dict[str, Any]: - """Retrieve manually added data that matches fields supplied in the specified manual webhook. + """ + Retrieves manual webhook fields saved to the privacy request in strict mode. + Fails either if extra saved fields are detected (webhook definition had fields removed) or fields were not + explicitly set (webhook definition had fields added). This mode lets us know if webhooks data needs to be re-uploaded. - This is for use by the *manual_webhook* connector which is *NOT* integrated with the garph. + This is for use by the *manual_webhook* connector which is *NOT* integrated with the graph. """ - cache: FidesopsRedis = get_cache() - cached_results: Optional[ - Optional[Dict[str, Any]] - ] = cache.get_encoded_objects_by_prefix( - f"WEBHOOK_MANUAL_INPUT__{self.id}__{manual_webhook.id}" + cached_results: Optional[Dict[str, Any]] = _get_manual_input_from_cache( + privacy_request=self, manual_webhook=manual_webhook ) + if cached_results: - return manual_webhook.fields_schema.parse_obj( - list(cached_results.values())[0] - ).dict() + data: Dict[str, Any] = manual_webhook.fields_schema.parse_obj( + cached_results + ).dict(exclude_unset=True) + if set(data.keys()) != set(manual_webhook.fields_schema.__fields__.keys()): + raise ManualWebhookFieldsUnset( + f"Fields unset for privacy_request_id '{self.id}' for connection config '{manual_webhook.connection_config.key}'" + ) + return data raise NoCachedManualWebhookEntry( f"No data cached for privacy_request_id '{self.id}' for connection config '{manual_webhook.connection_config.key}'" ) + def get_manual_webhook_input_non_strict( + self, manual_webhook: AccessManualWebhook + ) -> Dict[str, Any]: + """Retrieves manual webhook fields saved to the privacy request in non-strict mode. + Returns None for any fields not explicitly set and ignores extra fields. + + This is for use by the *manual_webhook* connector which is *NOT* integrated with the graph. + """ + cached_results: Optional[Dict[str, Any]] = _get_manual_input_from_cache( + privacy_request=self, manual_webhook=manual_webhook + ) + if cached_results: + return manual_webhook.fields_non_strict_schema.parse_obj( + cached_results + ).dict() + return manual_webhook.empty_fields_dict + def cache_manual_input( self, collection: CollectionAddress, manual_rows: Optional[List[Row]] ) -> None: @@ -713,6 +737,22 @@ def error_processing(self, db: Session) -> None: ) +def _get_manual_input_from_cache( + privacy_request: PrivacyRequest, manual_webhook: AccessManualWebhook +) -> Optional[Dict[str, Any]]: + """Get raw manual input uploaded to the privacy request for the given webhook + from the cache without attempting to coerce into a Pydantic schema""" + cache: FidesopsRedis = get_cache() + cached_results: Optional[ + Optional[Dict[str, Any]] + ] = cache.get_encoded_objects_by_prefix( + f"WEBHOOK_MANUAL_INPUT__{privacy_request.id}__{manual_webhook.id}" + ) + if cached_results: + return list(cached_results.values())[0] + return None + + class ProvidedIdentityType(EnumType): """Enum for privacy request identity types""" diff --git a/src/fidesops/ops/service/privacy_request/request_runner_service.py b/src/fidesops/ops/service/privacy_request/request_runner_service.py index 3a4009e406..b78d1a5cf5 100644 --- a/src/fidesops/ops/service/privacy_request/request_runner_service.py +++ b/src/fidesops/ops/service/privacy_request/request_runner_service.py @@ -16,6 +16,7 @@ ClientUnsuccessfulException, EmailDispatchException, IdentityNotFoundException, + ManualWebhookFieldsUnset, NoCachedManualWebhookEntry, PrivacyRequestPaused, ) @@ -94,9 +95,13 @@ def get_access_manual_webhook_inputs( try: for manual_webhook in AccessManualWebhook.get_enabled(db): manual_inputs[manual_webhook.connection_config.key] = [ - privacy_request.get_manual_webhook_input(manual_webhook) + privacy_request.get_manual_webhook_input_strict(manual_webhook) ] - except (NoCachedManualWebhookEntry, ValidationError) as exc: + except ( + NoCachedManualWebhookEntry, + ValidationError, + ManualWebhookFieldsUnset, + ) as exc: logger.info(exc) privacy_request.status = PrivacyRequestStatus.requires_input privacy_request.save(db) diff --git a/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py b/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py index e9340c455f..5b8350062f 100644 --- a/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_connection_config_endpoints.py @@ -22,7 +22,11 @@ from fidesops.ops.models.connectionconfig import ConnectionConfig, ConnectionType from fidesops.ops.models.manual_webhook import AccessManualWebhook from fidesops.ops.models.policy import CurrentStep -from fidesops.ops.models.privacy_request import CheckpointActionRequired, ManualAction +from fidesops.ops.models.privacy_request import ( + CheckpointActionRequired, + ManualAction, + PrivacyRequestStatus, +) from fidesops.ops.schemas.email.email import EmailActionType from fidesops.ops.tasks import EMAIL_QUEUE_NAME @@ -188,6 +192,51 @@ def test_patch_connections_bulk_create_limit_exceeded( == "ensure this value has at most 50 items" ) + @mock.patch( + "fidesops.ops.api.v1.endpoints.connection_endpoints.queue_privacy_request" + ) + def test_disable_manual_webhook( + self, + mock_queue, + db, + url, + generate_auth_header, + api_client, + privacy_request_requires_input, + integration_manual_webhook_config, + access_manual_webhook, + ): + auth_header = generate_auth_header(scopes=[CONNECTION_CREATE_OR_UPDATE]) + + # Update resources + payload = [ + { + "name": integration_manual_webhook_config.name, + "key": integration_manual_webhook_config.key, + "connection_type": ConnectionType.manual_webhook.value, + "access": "write", + "disabled": True, + } + ] + + response = api_client.patch( + V1_URL_PREFIX + CONNECTIONS, headers=auth_header, json=payload + ) + + assert 200 == response.status_code + + assert ( + mock_queue.called + ), "Disabling this last webhook caused 'requires_input' privacy requests to be queued" + assert ( + mock_queue.call_args.kwargs["privacy_request_id"] + == privacy_request_requires_input.id + ) + db.refresh(privacy_request_requires_input) + assert ( + privacy_request_requires_input.status == PrivacyRequestStatus.in_processing + ) + def test_patch_connections_bulk_update( self, url, api_client: TestClient, db: Session, generate_auth_header, payload ) -> None: @@ -821,14 +870,19 @@ def test_delete_connection_config( is None ) + @mock.patch( + "fidesops.ops.api.v1.endpoints.connection_endpoints.queue_privacy_request" + ) def test_delete_manual_webhook_connection_config( self, + mock_queue, url, api_client: TestClient, db: Session, generate_auth_header, integration_manual_webhook_config, access_manual_webhook, + privacy_request_requires_input, ) -> None: """Assert both the connection config and its webhook are deleted""" assert ( @@ -859,6 +913,17 @@ def test_delete_manual_webhook_connection_config( .first() is None ) + assert ( + mock_queue.called + ), "Deleting this last webhook caused 'requires_input' privacy requests to be queued" + assert ( + mock_queue.call_args.kwargs["privacy_request_id"] + == privacy_request_requires_input.id + ) + db.refresh(privacy_request_requires_input) + assert ( + privacy_request_requires_input.status == PrivacyRequestStatus.in_processing + ) class TestPutConnectionConfigSecrets: diff --git a/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py b/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py index 50a1484b9d..43ccddfc25 100644 --- a/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_privacy_request_endpoints.py @@ -3148,7 +3148,7 @@ def test_patch_inputs_for_manual_webhook( assert response.json() is None assert ( - privacy_request_requires_input.get_manual_webhook_input( + privacy_request_requires_input.get_manual_webhook_input_strict( access_manual_webhook ) == payload @@ -3275,7 +3275,7 @@ def test_no_manual_webhook_data_exists( "fields": {"email": None, "last_name": None}, } - def test_cached_data_differs_from_webhook_fields( + def test_cached_data_extra_saved_webhook_field( self, api_client: TestClient, db, @@ -3293,11 +3293,40 @@ def test_cached_data_differs_from_webhook_fields( ] access_manual_webhook.save(db) response = api_client.get(url, headers=auth_header) - assert response.status_code == 422 - assert ( - f"Saved fields differ from fields specified on webhook '{integration_manual_webhook_config.key}'." - in response.json()["detail"] + assert response.status_code == 200 + assert response.json() == { + "checked": False, + "fields": {"id_number": None}, + }, "Response has checked=False, so this data needs to be re-uploaded before we can run the privacy request." + + def test_cached_data_missing_saved_webhook_field( + self, + api_client: TestClient, + db, + url, + generate_auth_header, + access_manual_webhook, + integration_manual_webhook_config, + privacy_request_requires_input, + cached_input, + ): + auth_header = generate_auth_header([PRIVACY_REQUEST_VIEW_DATA]) + + access_manual_webhook.fields.append( + {"pii_field": "id_no", "dsr_package_label": "id_number"} ) + access_manual_webhook.save(db) + response = api_client.get(url, headers=auth_header) + + assert response.status_code == 200 + assert response.json() == { + "checked": False, + "fields": { + "id_number": None, + "email": "customer-1@example.com", + "last_name": "McCustomer", + }, + }, "Response has checked=False. A new field has been defined on the webhook, so we should re-examine to see if that is more data we need to retrieve." def test_get_inputs_for_manual_webhook( self, diff --git a/tests/ops/fixtures/manual_webhook_fixtures.py b/tests/ops/fixtures/manual_webhook_fixtures.py index 3516c43163..d221c3923f 100644 --- a/tests/ops/fixtures/manual_webhook_fixtures.py +++ b/tests/ops/fixtures/manual_webhook_fixtures.py @@ -42,7 +42,10 @@ def access_manual_webhook(db, integration_manual_webhook_config) -> ConnectionCo }, ) yield manual_webhook - manual_webhook.delete(db) + try: + manual_webhook.delete(db) + except ObjectDeletedError: + pass @pytest.fixture(scope="function") diff --git a/tests/ops/models/test_privacy_request.py b/tests/ops/models/test_privacy_request.py index 38febb9079..9b2102e559 100644 --- a/tests/ops/models/test_privacy_request.py +++ b/tests/ops/models/test_privacy_request.py @@ -9,6 +9,7 @@ from fidesops.ops.common_exceptions import ( ClientUnsuccessfulException, + ManualWebhookFieldsUnset, NoCachedManualWebhookEntry, PrivacyRequestPaused, ) @@ -572,30 +573,34 @@ def test_cache_template_contents(self, privacy_request): class TestCacheManualWebhookInput: def test_cache_manual_webhook_input(self, privacy_request, access_manual_webhook): with pytest.raises(NoCachedManualWebhookEntry): - privacy_request.get_manual_webhook_input(access_manual_webhook) + privacy_request.get_manual_webhook_input_strict(access_manual_webhook) privacy_request.cache_manual_webhook_input( manual_webhook=access_manual_webhook, input_data={"email": "customer-1@example.com", "last_name": "Customer"}, ) - assert privacy_request.get_manual_webhook_input(access_manual_webhook) == { + assert privacy_request.get_manual_webhook_input_strict( + access_manual_webhook + ) == { "email": "customer-1@example.com", "last_name": "Customer", } - def test_cache_no_fields(self, privacy_request, access_manual_webhook): + def test_cache_no_fields_supplied(self, privacy_request, access_manual_webhook): privacy_request.cache_manual_webhook_input( manual_webhook=access_manual_webhook, input_data={}, ) - assert privacy_request.get_manual_webhook_input(access_manual_webhook) == { + assert privacy_request.get_manual_webhook_input_strict( + access_manual_webhook + ) == { "email": None, "last_name": None, - } + }, "Missing fields persisted as None" - def test_cache_field_missing(self, privacy_request, access_manual_webhook): + def test_cache_some_fields_supplied(self, privacy_request, access_manual_webhook): privacy_request.cache_manual_webhook_input( manual_webhook=access_manual_webhook, input_data={ @@ -603,10 +608,12 @@ def test_cache_field_missing(self, privacy_request, access_manual_webhook): }, ) - assert privacy_request.get_manual_webhook_input(access_manual_webhook) == { + assert privacy_request.get_manual_webhook_input_strict( + access_manual_webhook + ) == { "email": "customer-1@example.com", "last_name": None, - } + }, "Missing fields saved as None" def test_cache_extra_fields_not_in_webhook_specs( self, privacy_request, access_manual_webhook @@ -634,6 +641,70 @@ def test_cache_manual_webhook_no_fields_defined( input_data={"email": "customer-1@example.com", "last_name": "Customer"}, ) + def test_fields_added_to_webhook_definition( + self, db, privacy_request, access_manual_webhook + ): + """Test the use case where new fields have been added to the webhook definition + since the webhook data was saved to the privacy request""" + privacy_request.cache_manual_webhook_input( + manual_webhook=access_manual_webhook, + input_data={"last_name": "Customer", "email": "jane@example.com"}, + ) + + access_manual_webhook.fields.append( + {"pii_field": "Phone", "dsr_package_label": "phone"} + ) + access_manual_webhook.save(db) + + with pytest.raises(ManualWebhookFieldsUnset): + privacy_request.get_manual_webhook_input_strict(access_manual_webhook) + + def test_fields_removed_from_webhook_definition( + self, db, privacy_request, access_manual_webhook + ): + """Test the use case where fields have been removed from the webhook definition + since the webhook data was saved to the privacy request""" + privacy_request.cache_manual_webhook_input( + manual_webhook=access_manual_webhook, + input_data={"last_name": "Customer", "email": "jane@example.com"}, + ) + + access_manual_webhook.fields = [ + {"pii_field": "last_name", "dsr_package_label": "last_name"} + ] + access_manual_webhook.save(db) + + with pytest.raises(ValidationError): + privacy_request.get_manual_webhook_input_strict(access_manual_webhook) + + def test_non_strict_retrieval_from_cache( + self, db, privacy_request, access_manual_webhook + ): + """Test non-strict retrieval, we ignore extra fields saved and serialize missing fields as None""" + privacy_request.cache_manual_webhook_input( + manual_webhook=access_manual_webhook, + input_data={"email": "customer-1@example.com", "last_name": "Customer"}, + ) + + access_manual_webhook.fields = [ # email field deleted + {"pii_field": "First Name", "dsr_package_label": "first_name"}, # New Field + { + "pii_field": "Last Name", + "dsr_package_label": "last_name", + }, # Existing Field + {"pii_field": "Phone", "dsr_package_label": "phone"}, # New Field + ] + access_manual_webhook.save(db) + + overlap_input = privacy_request.get_manual_webhook_input_non_strict( + access_manual_webhook + ) + assert overlap_input == { + "first_name": None, + "last_name": "Customer", + "phone": None, + }, "Ignores 'email' field saved to privacy request" + class TestCanRunFromCheckpoint: def test_can_run_from_checkpoint(self): From e4a5816a9023629c3098bdca4e42ac8971f1b04e Mon Sep 17 00:00:00 2001 From: Noonari Date: Thu, 29 Sep 2022 09:32:07 +0500 Subject: [PATCH 08/30] Braze Connector: Access Endpoints (#1248) --- CHANGELOG.md | 1 + data/saas/config/braze_config.yml | 98 ++++++++ data/saas/dataset/braze_dataset.yml | 111 +++++++++ pytest.ini | 1 + src/fidesops/ops/schemas/saas/saas_config.py | 1 + .../test_connection_template_endpoints.py | 4 +- tests/ops/conftest.py | 1 + tests/ops/fixtures/saas/braze_fixtures.py | 232 ++++++++++++++++++ .../integration_tests/saas/test_braze_task.py | 204 +++++++++++++++ 9 files changed, 651 insertions(+), 2 deletions(-) create mode 100644 data/saas/config/braze_config.yml create mode 100644 data/saas/dataset/braze_dataset.yml create mode 100644 tests/ops/fixtures/saas/braze_fixtures.py create mode 100644 tests/ops/integration_tests/saas/test_braze_task.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbed510c4..850d1f0807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ The types of changes are: * Enable retries on saas connectors for failures at the http request level [#1376](https://github.com/ethyca/fidesops/pull/1376) * Add consent request api [#1387](https://github.com/ethyca/fidesops/pull/1387) * Add authenticated route to get consent preferences [#1402](https://github.com/ethyca/fidesops/pull/1402) +* Access and erasure support for Braze [#1248](https://github.com/ethyca/fidesops/pull/1248) ### Removed diff --git a/data/saas/config/braze_config.yml b/data/saas/config/braze_config.yml new file mode 100644 index 0000000000..0e5c0f727b --- /dev/null +++ b/data/saas/config/braze_config.yml @@ -0,0 +1,98 @@ +saas_config: + fides_key: + name: Braze SaaS Config + type: braze + description: A sample schema representing the Braze connector for Fidesops + version: 0.0.1 + + connector_params: + - name: domain + - name: api_key + + client_config: + protocol: https + host: + authentication: + strategy: bearer + configuration: + token: + + test_request: + method: GET + path: /email/hard_bounces/ + query_params: + - name: email + value: test@test.com + + endpoints: + - name: users + requests: + read: + method: POST + path: /users/export/ids + body: | + { + "email_address": "", + "fields_to_export": [ + "apps", + "attributed_campaign", + "attributed_source", + "attributed_adgroup", + "attributed_ad", + "braze_id", + "campaigns_received", + "canvases_received", + "cards_clicked", + "country", + "created_at", + "custom_attributes", + "custom_events", + "devices", + "dob", + "email", + "email_subscribe", + "external_id", + "first_name", + "gender", + "home_city", + "language", + "last_coordinates", + "last_name", + "phone", + "purchases", + "push_subscribe", + "push_tokens", + "random_bucket", + "time_zone", + "total_revenue", + "uninstalled_at", + "user_aliases" + ] + } + param_values: + - name: email + identity: email + data_path: users + update: + method: POST + path: /users/track + body: | + { + "attributes": [ + { + + } + ] + } + - name: subscription_groups_email + requests: + read: + method: GET + path: /subscription/user/status + query_params: + - name: email + value: + param_values: + - name: email + identity: email + data_path: users diff --git a/data/saas/dataset/braze_dataset.yml b/data/saas/dataset/braze_dataset.yml new file mode 100644 index 0000000000..ddd918a4d9 --- /dev/null +++ b/data/saas/dataset/braze_dataset.yml @@ -0,0 +1,111 @@ +dataset: + - fides_key: + name: Braze Dataset + description: A sample dataset representing the Braze connector for Fidesops + collections: + - name: users + fields: + - name: created_at + data_categories: [system.operations] + fidesops_meta: + data_type: string + - name: external_id + data_categories: [user.unique_id] + fidesops_meta: + data_type: string + read_only: True + - name: braze_id + data_categories: [user.unique_id] + fidesops_meta: + data_type: string + primary_key: True + - name: first_name + data_categories: [user.name] + fidesops_meta: + data_type: string + - name: last_name + data_categories: [user.name] + fidesops_meta: + data_type: string + - name: random_bucket + data_categories: [system.operations] + fidesops_meta: + data_type: string + - name: email + data_categories: [user.contact.email] + fidesops_meta: + data_type: string + - name: dob + data_categories: [user.date_of_birth] + fidesops_meta: + data_type: string + - name: country + data_categories: [user.contact.address.country] + fidesops_meta: + data_type: string + - name: home_city + data_categories: [user.contact.address.city] + fidesops_meta: + data_type: string + - name: language + data_categories: [system.operations] + fidesops_meta: + data_type: string + - name: gender + data_categories: [user.gender] + fidesops_meta: + data_type: string + - name: phone + data_categories: [user.contact.phone_number] + fidesops_meta: + data_type: string + - name: time_zone + data_categories: [system.operations] + fidesops_meta: + data_type: string + - name: total_revenue + data_categories: [system.operations] + fidesops_meta: + data_type: string + - name: push_subscribe + data_categories: [system.operations] + fidesops_meta: + data_type: string + - name: email_subscribe + data_categories: [system.operations] + fidesops_meta: + data_type: string + - name: email_opted_in_at + data_categories: [system.operations] + fidesops_meta: + data_type: string + - name: user_aliases + fidesops_meta: + data_type: object[] + read_only: True + fields: + - name: alias_name + data_categories: [system.operations] + fides_meta: + data_type: string + - name: alias_label + data_categories: [system.operations] + fides_meta: + data_type: string + - name: subscription_groups_email + fields: + - name: email + data_categories: [user.contact.email] + fides_meta: + data_type: string + - name: phone + data_categories: [user.contact.phone_number] + fides_meta: + data_type: string + - name: external_id + data_categories: [system.operations] + fides_meta: + data_type: string + - name: subscription_groups + fides_meta: + data_type: object[] diff --git a/pytest.ini b/pytest.ini index 408a6edf75..1d9ef90316 100644 --- a/pytest.ini +++ b/pytest.ini @@ -29,6 +29,7 @@ markers = integration_outreach integration_salesforce integration_adobe_campaign + integration_braze unit_saas filterwarnings = diff --git a/src/fidesops/ops/schemas/saas/saas_config.py b/src/fidesops/ops/schemas/saas/saas_config.py index 69bd908b14..f788d35bee 100644 --- a/src/fidesops/ops/schemas/saas/saas_config.py +++ b/src/fidesops/ops/schemas/saas/saas_config.py @@ -263,6 +263,7 @@ class SaaSType(Enum): sendgrid = "sendgrid" datadog = "datadog" rollbar = "rollbar" + braze = "braze" class SaaSConfigBase(BaseModel): diff --git a/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py b/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py index ae280ad068..f050805322 100644 --- a/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_connection_template_endpoints.py @@ -48,7 +48,7 @@ def test_get_connection_types( resp = api_client.get(url, headers=auth_header) data = resp.json()["items"] assert resp.status_code == 200 - assert len(data) == 24 + assert len(data) == 25 assert { "identifier": ConnectionType.postgres.value, @@ -152,7 +152,7 @@ def test_search_system_type(self, api_client, generate_auth_header, url): resp = api_client.get(url + "?system_type=saas", headers=auth_header) assert resp.status_code == 200 data = resp.json()["items"] - assert len(data) == 14 + assert len(data) == 15 resp = api_client.get(url + "?system_type=database", headers=auth_header) assert resp.status_code == 200 diff --git a/tests/ops/conftest.py b/tests/ops/conftest.py index 6436c4d395..8c25d8daca 100644 --- a/tests/ops/conftest.py +++ b/tests/ops/conftest.py @@ -42,6 +42,7 @@ from .fixtures.redshift_fixtures import * from .fixtures.saas.adobe_campaign_fixtures import * from .fixtures.saas.auth0_fixtures import * +from .fixtures.saas.braze_fixtures import * from .fixtures.saas.connection_template_fixtures import * from .fixtures.saas.datadog_fixtures import * from .fixtures.saas.hubspot_fixtures import * diff --git a/tests/ops/fixtures/saas/braze_fixtures.py b/tests/ops/fixtures/saas/braze_fixtures.py new file mode 100644 index 0000000000..643f294111 --- /dev/null +++ b/tests/ops/fixtures/saas/braze_fixtures.py @@ -0,0 +1,232 @@ +import uuid +from typing import Any, Dict, Generator + +import pydash +import pytest +import requests +from fideslib.cryptography import cryptographic_util +from fideslib.db import session +from sqlalchemy.orm import Session + +from fidesops.ops.models.connectionconfig import ( + AccessLevel, + ConnectionConfig, + ConnectionType, +) +from fidesops.ops.models.datasetconfig import DatasetConfig +from fidesops.ops.util.saas_util import ( + load_config_with_replacement, + load_dataset_with_replacement, +) +from tests.ops.test_helpers.saas_test_utils import poll_for_existence +from tests.ops.test_helpers.vault_client import get_secrets + +secrets = get_secrets("braze") + + +@pytest.fixture(scope="session") +def braze_secrets(saas_config): + return { + "domain": pydash.get(saas_config, "braze.domain") or secrets["domain"], + "api_key": pydash.get(saas_config, "braze.api_key") or secrets["api_key"], + } + + +@pytest.fixture(scope="session") +def braze_identity_email(saas_config): + return pydash.get(saas_config, "braze.identity_email") or secrets["identity_email"] + + +@pytest.fixture(scope="session") +def braze_erasure_identity_email(): + return f"{cryptographic_util.generate_secure_random_string(13)}@email.com" + + +@pytest.fixture +def braze_config() -> Dict[str, Any]: + return load_config_with_replacement( + "data/saas/config/braze_config.yml", + "", + "braze_instance", + ) + + +@pytest.fixture +def braze_dataset() -> Dict[str, Any]: + return load_dataset_with_replacement( + "data/saas/dataset/braze_dataset.yml", + "", + "braze_instance", + )[0] + + +@pytest.fixture(scope="function") +def braze_connection_config(db: session, braze_config, braze_secrets) -> Generator: + fides_key = braze_config["fides_key"] + connection_config = ConnectionConfig.create( + db=db, + data={ + "key": fides_key, + "name": fides_key, + "connection_type": ConnectionType.saas, + "access": AccessLevel.write, + "secrets": braze_secrets, + "saas_config": braze_config, + }, + ) + yield connection_config + connection_config.delete(db) + + +@pytest.fixture +def braze_dataset_config( + db: Session, + braze_connection_config: ConnectionConfig, + braze_dataset, + braze_config, +) -> Generator: + fides_key = braze_config["fides_key"] + braze_connection_config.name = fides_key + braze_connection_config.key = fides_key + braze_connection_config.save(db=db) + dataset = DatasetConfig.create( + db=db, + data={ + "connection_config_id": braze_connection_config.id, + "fides_key": fides_key, + "dataset": braze_dataset, + }, + ) + yield dataset + dataset.delete(db=db) + + +@pytest.fixture(scope="function") +def braze_erasure_data( + braze_connection_config, braze_erasure_identity_email, braze_secrets +) -> Generator: + base_url = f"https://{braze_secrets['domain']}" + external_id = uuid.uuid4().hex + body = { + "attributes": [ + { + "external_id": external_id, + "first_name": "Walter", + "last_name": "White", + "email": braze_erasure_identity_email, + "phone": "+16175551212", + "country": "US", + "date_of_first_session": "2022-09-14T16:14:56+00:00", + "dob": "1980-12-21", + "email_subscribe": "opted_in", + "gender": "M", + "home_city": "New York", + "language": "en", + "time_zone": "US/Eastern", + "user_aliases": [ + { + "external_id": external_id, + "alias_name": "Breaking", + "alias_label": "Bad", + } + ], + "app_id": f"app_identifier.{external_id}", + } + ], + "events": [ + { + "external_id": external_id, + "app_id": f"app_identifier.{external_id}", + "name": "watched_trailer", + "time": "2013-07-16T19:20:30+1:00", + }, + { + "external_id": external_id, + "app_id": "app_identifier.{{external_id}}", + "name": "this is test event to create", + "time": "2022-07-16T19:20:30+1:00", + }, + ], + "purchases": [ + { + "external_id": external_id, + "product_id": "Car", + "currency": "USD", + "price": 12.12, + "quantity": 6, + "time": "2017-05-12T18:47:12Z", + "properties": { + "integer_property": 3, + "string_property": "Some Property", + "date_property": "2014-02-02T00:00:00Z", + }, + } + ], + } + + headers = {"Authorization": f"Bearer {braze_secrets['api_key']}"} + + response = requests.post(url=f"{base_url}/users/track", json=body, headers=headers) + response_data = response.json() + + assert response.ok + + error_message = ( + f"User with email {braze_erasure_identity_email} could not be added to Braze" + ) + poll_for_existence( + _user_exists, + (braze_erasure_identity_email, braze_secrets), + error_message=error_message, + ) + + yield response_data + + # Remove Data + + export_data = requests.post( + url=f"{base_url}/users/export/ids", + json={ + "external_ids": [ + external_id, + ], + "fields_to_export": [ + "braze_id", + ], + }, + headers=headers, + ) + assert export_data.ok + + braze_ids = [i["braze_id"] for i in export_data.json().get("users")] + delete_user = requests.post( + url=f"{base_url}/users/delete", json={"braze_ids": braze_ids}, headers=headers + ) + assert delete_user.status_code == 201 + + +def _user_exists(braze_erasure_identity_email: str, braze_secrets): + """ + Confirm whether user exists by calling user search by email api and comparing resulting firstname str. + Returns user ID if it exists, returns None if it does not. + """ + base_url = f"https://{braze_secrets['domain']}" + headers = { + "Authorization": f"Bearer {braze_secrets['api_key']}", + } + body = { + "email_address": braze_erasure_identity_email, + "fields_to_export": ["email"], + } + + user_response = requests.post( + url=f"{base_url}/users/export/ids", + json=body, + headers=headers, + ) + + # we expect 404 if user doesn't exist + if 404 == user_response.status_code: + return None + + return user_response.json() diff --git a/tests/ops/integration_tests/saas/test_braze_task.py b/tests/ops/integration_tests/saas/test_braze_task.py new file mode 100644 index 0000000000..f3f0563243 --- /dev/null +++ b/tests/ops/integration_tests/saas/test_braze_task.py @@ -0,0 +1,204 @@ +import random +import time + +import pytest +import requests + +from fidesops.ops.core.config import config +from fidesops.ops.graph.graph import DatasetGraph +from fidesops.ops.models.privacy_request import PrivacyRequest +from fidesops.ops.schemas.redis_cache import Identity +from fidesops.ops.service.connectors import get_connector +from fidesops.ops.task import graph_task +from fidesops.ops.task.graph_task import get_cached_data_for_erasures +from tests.ops.graph.graph_test_util import assert_rows_match + + +@pytest.mark.integration_saas +@pytest.mark.integration_braze +def test_braze_connection_test(braze_connection_config) -> None: + get_connector(braze_connection_config).test_connection() + + +@pytest.mark.integration_saas +@pytest.mark.integration_braze +@pytest.mark.asyncio +async def test_saas_access_request_task( + db, + policy, + braze_connection_config, + braze_dataset_config, + braze_identity_email, +) -> None: + """Full access request based on the Braze SaaS config""" + + privacy_request = PrivacyRequest( + id=f"test_braze_access_request_task_{random.randint(0, 250)}" + ) + identity_attribute = "email" + identity_value = braze_identity_email + identity_kwargs = {identity_attribute: identity_value} + identity = Identity(**identity_kwargs) + privacy_request.cache_identity(identity) + + dataset_name = braze_connection_config.get_saas_config().fides_key + merged_graph = braze_dataset_config.get_graph() + graph = DatasetGraph(merged_graph) + + v = await graph_task.run_access_request( + privacy_request, + policy, + graph, + [braze_connection_config], + {identity_attribute: braze_identity_email}, + db, + ) + key_users = f"{dataset_name}:users" + assert_rows_match( + v[key_users], + min_size=1, + keys=[ + "external_id", + "user_aliases", + "braze_id", + "first_name", + "last_name", + identity_attribute, + "dob", + "country", + "home_city", + "language", + "gender", + "phone", + ], + ) + + for entry in v[key_users]: + assert identity_value == entry.get(identity_attribute) + + key_subscription_groups = f"{dataset_name}:subscription_groups_email" + assert_rows_match( + v[key_subscription_groups], + min_size=1, + keys=[ + identity_attribute, + "phone", + "external_id", + "subscription_groups", + ], + ) + + for entry in v[key_subscription_groups]: + assert identity_value == entry.get(identity_attribute) + + +@pytest.mark.integration_saas +@pytest.mark.integration_braze +@pytest.mark.asyncio +async def test_saas_erasure_task( + db, + policy, + erasure_policy_string_rewrite, + braze_connection_config, + braze_dataset_config, + braze_erasure_identity_email, + braze_erasure_data, +) -> None: + privacy_request = PrivacyRequest( + id=f"test_braze_erasure_request_task_{random.randint(0, 1000)}" + ) + identity_attribute = "email" + identity_value = braze_erasure_identity_email + identity_kwargs = {identity_attribute: identity_value} + identity = Identity(**identity_kwargs) + + privacy_request.cache_identity(identity) + + dataset_name = braze_connection_config.get_saas_config().fides_key + merged_graph = braze_dataset_config.get_graph() + graph = DatasetGraph(merged_graph) + + v = await graph_task.run_access_request( + privacy_request, + policy, + graph, + [braze_connection_config], + identity_kwargs, + db, + ) + key_users = f"{dataset_name}:users" + assert_rows_match( + v[key_users], + min_size=1, + keys=[ + "external_id", + "user_aliases", + "braze_id", + "first_name", + "last_name", + identity_attribute, + "dob", + "country", + "home_city", + "language", + "gender", + "phone", + ], + ) + + temp_masking = config.execution.masking_strict + config.execution.masking_strict = True + + x = await graph_task.run_erasure( + privacy_request, + erasure_policy_string_rewrite, + graph, + [braze_connection_config], + identity_kwargs, + get_cached_data_for_erasures(privacy_request.id), + db, + ) + + assert x == { + f"{dataset_name}:users": 1, + f"{dataset_name}:subscription_groups_email": 0, + } + + time.sleep(10) + + # Verifying field is masked + braze_secrets = braze_connection_config.secrets + base_url = f"https://{braze_secrets['domain']}" + headers = { + "Authorization": f"Bearer {braze_secrets['api_key']}", + } + body = { + "email_address": braze_erasure_identity_email, + "fields_to_export": [ + "braze_id", + "country", + "dob", + "email", + "external_id", + "first_name", + "gender", + "home_city", + "language", + "last_name", + "phone", + "user_aliases", + ], + } + + user_response = requests.post( + url=f"{base_url}/users/export/ids", + json=body, + headers=headers, + ) + users = user_response.json().get("users") + + for user in users: + assert user["first_name"] == "MASKED" + assert user["last_name"] == "MASKED" + + config.execution.masking_strict = temp_masking From effa4cbf24f86e6c3e9b48e9f256012192724453 Mon Sep 17 00:00:00 2001 From: Sean Preston Date: Thu, 29 Sep 2022 09:30:00 -0400 Subject: [PATCH 09/30] [#1393] Update Fidesops config with sane defaults where necessary (#1395) * add sane defaults * make subsections of config with complete defaults optional * lowercase database.enabled, set defaults for optional configs * update return type * updates changelog * make PORT an env var * cast env var to int * remove unnecessary unpinned dependency * bump fideslib version * bump fideslib to 3.1.4 * add defaults for the non optional config subclasses * set empty dict to default for config subclasses that require some fields * use .get() in assemble URL for correct error message, correct comment * update jwt_key type annotation --- CHANGELOG.md | 5 ++ requirements.txt | 3 +- .../ops/api/v1/endpoints/drp_endpoints.py | 2 +- src/fidesops/ops/core/config.py | 57 +++++++++++++------ 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 850d1f0807..7eeee73b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ The types of changes are: * Removed `query_param` auth strategy as `api_key` auth strategy now supersedes it [#1331](https://github.com/ethyca/fidesops/pull/1331) +### Developer Experience + +* Update Fidesops config with sane defaults where necessary [#1393](https://github.com/ethyca/fidesops/pull/1395) + + ## [1.8.0](https://github.com/ethyca/fidesops/compare/1.8.0...main) ### Developer Experience diff --git a/requirements.txt b/requirements.txt index d69e6d27bf..c0f68568a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,12 +5,11 @@ celery[pytest]==5.2.7 click==8.1.3 cryptography~=3.4.8 dask==2022.8.0 -emails fastapi-caching[redis] fastapi-pagination[sqlalchemy]~= 0.10.0 fastapi[all]==0.82.0 fideslang==1.2.0 -fideslib==3.1.2 +fideslib==3.1.4 fideslog==1.2.3 hvac==0.11.2 Jinja2==3.1.2 diff --git a/src/fidesops/ops/api/v1/endpoints/drp_endpoints.py b/src/fidesops/ops/api/v1/endpoints/drp_endpoints.py index 20f073487e..ca705c390d 100644 --- a/src/fidesops/ops/api/v1/endpoints/drp_endpoints.py +++ b/src/fidesops/ops/api/v1/endpoints/drp_endpoints.py @@ -66,7 +66,7 @@ async def create_drp_privacy_request( a corresponding Fidesops PrivacyRequest """ - jwt_key: str = config.security.drp_jwt_secret + jwt_key: Optional[str] = config.security.drp_jwt_secret if jwt_key is None: raise HTTPException( status_code=HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/src/fidesops/ops/core/config.py b/src/fidesops/ops/core/config.py index 33af4100dd..c295520791 100644 --- a/src/fidesops/ops/core/config.py +++ b/src/fidesops/ops/core/config.py @@ -25,7 +25,7 @@ class FidesopsDatabaseSettings(DatabaseSettings): """Configuration settings for Postgres.""" - ENABLED: bool = True + enabled: bool = True class Config: env_prefix = "FIDESOPS__DATABASE__" @@ -35,9 +35,10 @@ class ExecutionSettings(FidesSettings): """Configuration settings for execution.""" privacy_request_delay_timeout: int = 3600 - task_retry_count: int - task_retry_delay: int # In seconds - task_retry_backoff: int + # By default Fidesops will not retry graph nodes + task_retry_count: int = 0 + task_retry_delay: int = 0 # In seconds + task_retry_backoff: int = 0 subject_identity_verification_required: bool = False require_manual_request_approval: bool = False masking_strict: bool = True @@ -77,7 +78,7 @@ def assemble_connection_url( # If the whole URL is provided via the config, preference that return v - return f"redis://{quote_plus(values.get('user', ''))}:{quote_plus(values['password'])}@{values['host']}:{values['port']}/{values.get('db_index', '')}" + return f"redis://{quote_plus(values.get('user', ''))}:{quote_plus(values.get('password', ''))}@{values.get('host', '')}:{values.get('port', '')}/{values.get('db_index', '')}" class Config: env_prefix = "FIDESOPS__REDIS__" @@ -118,15 +119,21 @@ class Config: class RootUserSettings(FidesSettings): """Configuration settings for Analytics variables.""" - analytics_opt_out: Optional[bool] - analytics_id: Optional[str] + analytics_opt_out: Optional[bool] = True + analytics_id: Optional[str] = None @validator("analytics_id", pre=True) - def populate_analytics_id(cls, v: Optional[str]) -> str: + def populate_analytics_id( + cls, + v: Optional[str], + values: Dict[str, str], + ) -> Optional[str]: """ Populates the appropriate value for analytics id based on config """ - return v or cls.generate_and_store_client_id() + if not v and not values.get("analytics_opt_out"): + v = cls.generate_and_store_client_id() + return v @staticmethod def generate_and_store_client_id() -> str: @@ -164,15 +171,29 @@ class Config: class FidesopsConfig(FidesSettings): """Configuration variables for the FastAPI project""" - database: FidesopsDatabaseSettings - redis: RedisSettings - security: FidesopsSecuritySettings - execution: ExecutionSettings - root_user: RootUserSettings - admin_ui: AdminUiSettings - notifications: FidesopsNotificationSettings - - port: int + # Pydantic doesn't initialise subsections automatically if + # only environment variables are provided at runtime. If the + # config subclass is instantiated with no args, Pydantic runs + # validation before loading in environment variables, which + # always fails if any config vars in the subsection are non-optional. + # Using the empty dict allows Python to load in the environment + # variables _before_ validating them against the Pydantic schema. + database: FidesopsDatabaseSettings = {} # type: ignore + redis: RedisSettings = {} # type: ignore + security: FidesopsSecuritySettings = {} # type: ignore + execution: Optional[ExecutionSettings] = ExecutionSettings() + root_user: Optional[RootUserSettings] = RootUserSettings() + admin_ui: Optional[AdminUiSettings] = AdminUiSettings() + notifications: Optional[ + FidesopsNotificationSettings + ] = FidesopsNotificationSettings() + + port: int = int( + os.getenv( + "FIDESOPS__PORT", + "8080", # Run the webserver on port 8080 by default + ) + ) is_test_mode: bool = os.getenv("TESTING", "").lower() == "true" hot_reloading: bool = os.getenv("FIDESOPS__HOT_RELOAD", "").lower() == "true" dev_mode: bool = os.getenv("FIDESOPS__DEV_MODE", "").lower() == "true" From 060152bd92da2627a317210e84be05b12bfbd713 Mon Sep 17 00:00:00 2001 From: Dawn Pattison Date: Thu, 29 Sep 2022 09:05:46 -0500 Subject: [PATCH 10/30] Explain Privacy Request Execution [#1397] (#1396) Co-authored-by: Cole --- .../docs/guides/email_communications.md | 2 +- .../fidesops/docs/guides/fidesops_workflow.md | 130 ++++++++++++++++++ docs/fidesops/docs/guides/policy_webhooks.md | 10 +- docs/fidesops/docs/guides/query_execution.md | 2 +- docs/fidesops/docs/img/access_execution.png | Bin 0 -> 45094 bytes docs/fidesops/docs/img/access_graph.png | Bin 0 -> 105901 bytes docs/fidesops/docs/img/erasure_graph.png | Bin 0 -> 71915 bytes docs/fidesops/mkdocs.yml | 1 + 8 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 docs/fidesops/docs/guides/fidesops_workflow.md create mode 100644 docs/fidesops/docs/img/access_execution.png create mode 100644 docs/fidesops/docs/img/access_graph.png create mode 100644 docs/fidesops/docs/img/erasure_graph.png diff --git a/docs/fidesops/docs/guides/email_communications.md b/docs/fidesops/docs/guides/email_communications.md index ede01a7c30..ead151cf7a 100644 --- a/docs/fidesops/docs/guides/email_communications.md +++ b/docs/fidesops/docs/guides/email_communications.md @@ -61,7 +61,7 @@ Fidesops currently supports Mailgun for email integrations. Ensure you register |---|----| | `mailgun_api_key` | Your Mailgun Domain Sending Key. | -## Email third-party services +## Email third party services to mask data Once your email server is configured, you can create an email connector to send automatic erasure requests to third-party services. Fidesops will gather details about each collection described in the connector, and send a single email to the service after all collections have been visited. diff --git a/docs/fidesops/docs/guides/fidesops_workflow.md b/docs/fidesops/docs/guides/fidesops_workflow.md new file mode 100644 index 0000000000..fc784b6753 --- /dev/null +++ b/docs/fidesops/docs/guides/fidesops_workflow.md @@ -0,0 +1,130 @@ +# Fidesops Privacy Request Execution + +When a [Privacy Request](privacy_requests.md) is submitted, fidesops performs several prerequisite checks, and then visits your collections in two passes: first, to retrieve relevant data for the subject across all your collections, and again to mask the subject's data, if necessary. + +The following guide outlines the steps fidesops takes to fulfill a privacy request from end to end, including optional configurations and manual data retrieval. + +## Privacy request submission + +Prior to processing a privacy request, fidesops first creates records to store the relevant information, and can perform a number of other actions based on your configuration: + +| Step | Description | +| --- | --- | +| **Persist** | Fidesops creates a privacy request in long-term storage to capture high-level information (e.g. date created, current status). Fidesops saves the identity of the subject to both short- and long-term storage. | +| **Verify** | If configured, Fidesops sends an [email](./privacy_requests.md#subject-identity-verification) to the user to verify their identity before proceeding. | +| **Notify** | If configured, the user will receive an [email](./email_communications.md) verifying that their request has been received. | +| **Approve** | If configured, Fidesops will require a system administrator to [approve](./configuration_reference.md) the request before proceeding. | + +## Privacy request execution + +Once the required submission steps have been completed, the request status is updated to `in_processing` status, and the privacy request is dispatched to a separate queue for processing. + +Request execution involves gathering data from multiple sources, and/or masking data in multiple locations. Fidesops will follow the steps below in order, skipping any that are not applicable for the given request: + + 1. Respond to manual webhooks + 2. Run policy pre-execution webhooks + 3. Access request automation + 4. Upload results + 5. Erasure request automation + 6. Send erasure request emails + 7. Run policy post-execution webhooks + 8. Send email notifications + +### Respond to manual webhooks + +Manual webhooks allow data pertaining a subject to be manually uploaded by a fidesops admin. If manual webhooks are enabled, request execution will exit with a status of `requires_input` until a submission has been received for each manual webhook configured. The privacy request can then be resumed, and request execution will continue from this step. See [Manual Webhooks](manual_webhooks.md) for more information on configuration options and resuming a `requires_input` request. + +Data uploaded for manual webhooks will be returned to the data subject directly at the end of request execution. Data gathered here is not used to locate data from other sources. + +### Run pre-execution webhooks +Policy pre-execution webhooks let your system take care of prerequisite tasks, or locate additional identities for the data subject. Examples include turning on a specific database in your infrastructure, or locating a phone number for a subject from a table for which you do not want to give Fidesops direct access. Configuration involves defining endpoint(s) for fidesops to call in order. See [Policy Webhooks](policy_webhooks.md) for more details. + +Fidesops sends a request to each pre-execution webhook with a [policy webhooks request format](policy_webhooks.md#policy-webhook-request-format), which your endpoints should be prepared to unpack. If you need more time to carry out an action, your webhook can instruct fidesops to `halt`, which will cause execution to exit with a status of `paused`. Request execution can be continued when ready using a token supplied in the original request. + +No data uploaded by policy webhooks is returned to the data subject, but identities discovered can be used to later locate data pertaining to the subject during access request automation. + +If a request to a pre-execution webhook fails, request execution will exit with a status of `error`. Retrying the privacy request will resume from this step and attempt to re-run all pre-execution webhooks. + + +### Access request automation +Access request automation is performed regardless of whether there are access or erasure Rules defined, as both Rules require this data. See how to [configure policies, rules, and rule targets](policies.md) for additional information. + +This step visits all Collections and retrieves all Fields that you've defined in your [Datasets](datasets.md). Fidesops builds a graph in accordance with how you've designated your Collections are related, visits each Collection in turn, and gathers all the results together. + +#### Graph building +Fidesops builds a Directed Acyclic Graph, or DAG, where each location or node corresponds to a Collection in one of your Datasets. The graph helps determine the order in which nodes will be visited. Fidesops begins with any Collections that can be queried using the supplied identity data, and then points those Collections toward dependent Collections, etc. If fidesops can't determine how to reach a Collection, it will exit early with a status of `error`. To remedy an errored access request, you update how your Collections are related to each other in your Datasets, and resubmit the privacy request. + +![Access Graph](../img/access_graph.png) + +#### Graph Execution +After the graph is built, Fidesops passes the result to [Dask](https://www.dask.org/) to execute sequentially. Fidesops visits one Collection at a time, following the graph created, and uses Dask to determine ordering for ties. + +For the first Collections in the graph connected to the root, Fidesops uses the customers' provided identity to locate subject data, by either making database queries or HTTP requests to a configured API endpoint. The details on how to access your data are determined by the [Connection](connection_types.md) type. Fidesops retrieves all Fields that have been configured on the Collection, and caches the results in temporary storage for usage later. Fidesops then passes the results of that Collection to downstream Collections that similarly make queries, temporarily cache the results, and return their results to their own downstream Collections. + +A Collection isn't visited until Fidesops has searched for data across all of its upstream Collections. This continues until all Collections have been visited. See [Query Execution](query_execution.md) for more information. + +![Access Execution](../img/access_execution.png) + +If there is a failure trying to retrieve data on any Collections, the request is retried the number of times [configured](./configuration_reference.md) by `task_retry_count` until the request exits with status `error`. Both the `access` step and errored Collection are cached in temporary storage. +Restarting the privacy request will restart from this step and failed Collection. Collections that have already been visited will not be visited again. + +#### Final result retrieval +The final step of an automated access request gathers all the results for each Collection from temporary storage. + + +### Upload results +If configured, Fidesops uploads the results retrieved from access automation for the data subject. + +For each configured access Rule, Fidesops filter the graph results to match targeted Data Categories. See [Datasets](datasets.md) for more details. +Fidesops also supplements the results with any data manually uploaded from [manual webhooks](#respond-to-manual-webhooks). Each data package is uploaded in JSON +or CSV format to a specified storage location like Amazon S3. See [Storage](storage.md) for more information. + + +### Erasure request automation +If applicable, (erasure [Rules](policies.md#Rule-attributes) are configured on your execution policy), Fidesops builds a simpler version of the access request graph, and visits each Collection in turn, performing masking requests as necessary. + +#### Graph building +The "graph" for an erasure runs on the data from the access request, which is kept in temporary storage, and can be used to locate data for each Collection individually. Because the data has already been found, each Collection could be visited in any order or run in parallel. The graph is configured so each Collection has its previous access request results passed in as inputs, and each Collection returns a count of records masked when complete. + +![Erasure Graph](../img/erasure_graph.png) + +#### Graph execution +Fidesops visits each Collection sequentially, using a deterministic order set by Dask. For each row of data retrieved in the access request step, Fidesops attempts to mask the data targeting the fields specified on your execution policy, using the [masking strategies](masking_strategies.md) you've defined. If no rows exist from the access request, or no Fields on that Collection match the targeted Data Categories, no masking occurs. Fidesops caches a count of the records +that had fields masked in temporary storage. + +The masking request might involve an `update` database query or an `update` or `delete` HTTP request depending on the [Connection Type](connection_types.md). The Email Connector type doesn't mask any data itself, but instead persists how to locate and mask that Collection in temporary storage for use later. + +If masking fails on a given Collection, Fidesops retries the requests for a configured number of times, and then request execution will exit with a status of `error`. Fidesops will cache both the failed Collection and that the failure occurred on the `erasure` step. Retrying the privacy request will resume from the `erasure` step at the failed Collection. Previously masked Collections will not be masked again. + + +### Send erasure request emails +After the access and erasure steps have both executed, Fidesops checks if there are any third parties that need to be additionally emailed to complete erasure requests on your behalf. See [emailing third party services to mask data](email_communications.md#Email-third-party-services-to-mask-data) for more information. + +Fidesops retrieves any masking instructions cached by Email Connectors in the erasure request step, and combines them into a single email per Dataset. + +This step is only performed if you have Email Connectors configured. If the email send fails for any reason, the request will exit with status `error`. Fidesops will cache this step in temporary storage, so retrying the request will resume from this point. + + +### Run policy post-execution webhooks +After automated access and erasures have completed, post-execution webhooks can be used to perform any cleanup steps in your system. Examples include setting up a webhook to shut down a database, or to delete user data from a source you don't want Fidesops to access directly. Post-execution webhooks are more limited than Pre-execution webhooks. They currently cannot pause the graph, and should be configured as a series of API endpoints you would like Fidesops to call. See [policy webhooks](policy_webhooks.md) for more details on configuration. + +If a request to a post-execution webhook fails, request execution will exit with a status of `error`. Retrying the privacy request will resume from this step and attempt to re-run all the post-execution webhooks. + + +### Send email notifications +If configured, Fidesops will send a followup email to the data subject to let them know their request has finished processing. For access Rules, the emails will contain links to where the data subject can retrieve data. For erasure Rules, the emails will simplify notify them that their request is complete. + +Request execution will then exit with the status `complete`. + +## Additional notes +- Fidesops uses Redis as temporary storage to support executing your request. Data automatically retrieved from each Collection, manually uploaded data, and details about where the Privacy Request may be paused or where it failed may all be temporarily stored. This information will expire in accordance with the `FIDESOPS__REDIS__DEFAULT_TTL_SECONDS` [setting](./configuration_reference.md). +- The current fidesops execution strategy prioritizes being able to erase as many of the original Collections requested as possible. If Fidesops masks +some Collections and then registers a failure, the current logic will mask the original remaining Collections using the temporarily saved data retrieved in the original access step instead of re-querying the Collections. Once data is masked in one Collection, it could potentially prevent us from being able to locate data in downstream Collections, and so will use temporarily stored data. + - Data added in the interim, or data related to newly added Collections, can be missed. + - If the automated access step fails part of the way through, a new Collection is added, and then the request is restarted from failure, + Fidesops may miss data from already completed Collections downstream, and any Collections further downstream of that set. + - If the erasure step fails, a new Collection is added, and the request is restarted from failure, Fidesops may miss masking data from the new + Collection and data downstream of the new Collection. +- Nodes on the graph correspond to individual Collections within Datasets, not Datasets. The graph built may result in Fidesops +visiting a Collection in one Dataset to be able to find data on a Collection in a separate Dataset, which is used to find data on a Collection in the original Dataset. +- Automated access requests often select more Fields than may be returned specifically to the user because this data has multiple uses. Fidesops selects all Fields defined to be able to potentially query downstream Collections, filter data according to multiple access Rules, and mask data in accordance with multiple erasure Rules. diff --git a/docs/fidesops/docs/guides/policy_webhooks.md b/docs/fidesops/docs/guides/policy_webhooks.md index 623a56cd33..7ef9b88476 100644 --- a/docs/fidesops/docs/guides/policy_webhooks.md +++ b/docs/fidesops/docs/guides/policy_webhooks.md @@ -48,10 +48,16 @@ See API docs on how to [Set a ConnectionConfig's Secrets](/fidesops/api#operatio ```json title="PUT /v1/connection/test_webhook_connection_config" { "url": "https://www.example.com", - "authorization": "test_authorization" + "authorization": "Bearer test_38234823482348" } ``` +Note that the authorization secret specified here will be added directly to an authorization header when fidesops later +makes a call to the configured webhook: +```json +{"Authorization": "Bearer test_38234823482348"} +``` + ### Define pre-execution or post-execution webhooks After you've defined a `ConnectionConfig`, you can create lists of webhooks to run *before* (`PolicyPreWebhooks`) @@ -141,7 +147,7 @@ PATCH /policy/{policy_key}/webhook/post_execution/{post_execution_key} See API docs for more information on how to [PATCH a PolicyPreWebhook](/fidesops/api#operations-Policy_Webhooks-update_pre_execution_webhook_api_v1_policy__policy_key__webhook_pre_execution__pre_webhook_key__patch) and how to [PATCH a PolicyPostWebhook](/fidesops/api#operations-Policy_Webhooks-update_post_execution_webhook_api_v1_policy__policy_key__webhook_post_execution__post_webhook_key__patch). -## Webhook request format +## Policy Webhook request format Before and after running access or erasure requests, fidesops will send requests to any configured webhooks in sequential order with the following request body: diff --git a/docs/fidesops/docs/guides/query_execution.md b/docs/fidesops/docs/guides/query_execution.md index 82531faf17..10143889e0 100644 --- a/docs/fidesops/docs/guides/query_execution.md +++ b/docs/fidesops/docs/guides/query_execution.md @@ -101,7 +101,7 @@ dataset: We trigger a retrieval with identity data, such as an email address or user ID, that's provided by the user. What we do is... -1. Identify the collections that contain the identity data that the user. +1. Identify the collections that contain the identity data that belong to the user. 2. Find all related records. 3. Use the data to find all connected data. 4. Continue until we've found all related data. diff --git a/docs/fidesops/docs/img/access_execution.png b/docs/fidesops/docs/img/access_execution.png new file mode 100644 index 0000000000000000000000000000000000000000..b724160380d404a8be243672b478b43de178d9b0 GIT binary patch literal 45094 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 z znn*MU#U8mq)K|qL3iim_Y>(yiBT8@KP^1eIPs4!bCDno5yWM>MmbE-3oAy}DuS?G^ zRGdNEcRaqNA(|L=+qDCZ-I5B7(uoKlY%u1@pN36Wm=K-aFi# z(|QMq1c7J{HVPYSEw*b4W+n@))`u4);rc$QlOEXS;rkpou^hZ0AD2)-M7lPeP>PMAbF|`c3zWDg-M9AeQi^RgcEam<&RqskJ}TIMNf} zbHye6`nhY!9c@4+m=dy)W9X2MD~;v9+m6=kHb@pFwJ6Simg4T>X2*?sGw{J7p4Joj zF`DUm?4j0FBsN=CIlzPy^d#XoK4jm>ySQ`i$lzTSnK19MHdJH)t+P5Zj4kiomII+B z5luq8j}Xkva?yN~XCTh)j zKRY}i(egWF6{tdL^Wothetlz3dd%i<>Db`m3;j@@VT!jw}};M=;=#l{VV97X6B$9NLw6*Jz+e!xA|mup|Y| z3}MG{PH}WQrz}$z!mX`Az%Zi-yy-UUs#m0g#2Y>|#pmmzQ=^K$FE^eU`|_tn{6Iy#{c6CvZ83;KXG8dWxTt0&h#(?S zegd(mYpLAk@Z>IXwt7)nQWSZgQM%SI=LN+gqa9~sQ(3(Qlz@i!S4MxCT>|-13{$R2dqjdy8bqoo0(XXHFQADR6nc{h(3)*ph z*%C;7hk+wTk!JQJ6(0*TjV35x~naeo?lHmy0_TSFeL#f{hQYDJO{ z-GYXgnQY{AbFmY0dLpii&wBP}6f}Yux@3R;kkR_XZCDI<$ z>g6ksZZoH<3CFolAu7NL?qNIX`LjK@3179ab*c3(E*KS&603zC1)8+s=FpozsH0PI zDYGYHcHhw=ts}e^ku7HYJnZ2YAeFhy@+Su5W`d=JF}_YRUi%TTyV2e zYx@h~^s0jb@1U($fE8P%i-xd8a{RIWu!c2M zHZ|e`4Z8dKUAW%9`BRQ7y|#Nv@ZM{soL+I%{9+V;Gww3XP3TXgBIfHwKo*@1%BHH5 zXIa@yQElao{pYwBrq5=ZKD=AN);=x$Ad=wBDP6LG6y?2E9i8<>uuRQy=HsKmMA}ap z#%dPLt_Fq6y@W0ARR&-8W?Q!+xJP^uYl5*px6SIpj`o+Wm=g1d^$5PdW%eNfXvcVo?-VnWbpJHLIj}gyzh4-c=bv1_+N9wqK(0pO+ z$}7=eFi~B7CE6|(_{^|mqf>zgpfrTEdj)C)t^3esc^9Y6izKFzzCAQVz%<3tfnmp6`#LMDXnqWwu6$X zmaNLITsmFNeM|k%CG>~~Hz;Fm_YopX40;y30^W1!gnS!*dF3+@Loz5gYs^+X=IE5_ zt=AmC7a;&ik>x#sDP{_ltF#NRx)jcrEHDut9k*Yir6-(F2hpGCxGQ9oUul=OjTQ3$ zv=FS=S=jl5E9Ue#fg?Hh^3|Jng2|hl_J{a=W1QLUZY%%X7*qjouG>JBJ zgl38pn`9(YoppPZSxkyx^^3ERJ4k+fYBM9=ujqsg3n4yYmO~;L|Ffu=h=sqO5AX$T z^pV7?UMa@~ovnD77t_7uC50Hy%#Ft&F!k>fycQU~!J~|{G~3vn z0Goc0REj!kswfqUxhFtT#T8eF{8dJH85`Vw6ik>T)=1<{q?f#3hj59Du|@`>rk`~0 zWq`=r#LX(g?l?RklrP5DMtYR4!;lUW@JGLvhwjUJ52q2F2Qx2dR!By@maIJ?JVWFS z;nhwhCpOT|Z&ycP`>Cgu7Wj>XbCgn!aG|Jzu~Q&7q5rQ#YaS1B`xoX_`3jD+AOo@A zVlL2EuH@XpXYL7GZU~@`)QZLl6HLOh#Ek)26jxnrYhmffDO$82J7cYW0_6Kc(5|BY zEdPIlzN8LV+MGw3KlO6Cq3}mO(H=(xeu@bbftF zjeti4=Rk#H6#GvS(V4LMowM>ua&Y*% zf^=^_R+fL|G9slC-*t*Zq!Ix$#9jiprC3}cX}dXv7qOFRO!m1ABn*YoI5s#iZQ~_t zFj1l~rYvFhQr|@*daq@g*@L3W048D|qVOtk9DOO4>fagYWq>R`{8;9^y+o-4yR&tL z>7L%g1#VYEK>?FMQowim?nTA%@OM;YnCysBIp|9cljZWoBNl*;p}W*^(96ZKEJ<-T z%lTL5tAD3fGM=!q_*aYW56(jQr{PXUA>hTe5Sf&c{4k}F3ywn)hP;&|aZ;{OWdGSX z`XM24+&ySC5q>HyOhn_C79AJyQBnh1jXe*rWL{Dz4u}pcqk@>bO8aW)vDN5(}Q%`Lp)riu`(g)B@ z4t`P+7&9T}wyq53&AGpiuQ}oP3SaB*k$MS3!at(63Vx3eK*x=8t*F_u1ofd($}K$K z01_6zReCRim&kCUBQm8oP{9bg^+xdji>nGMkr*}efHsSFR~3VXK}&CW}9T36K`O{!EBj5(w3jF3_!mS$WP)Wg$kwxh=0Hsk%#{0gw>XTS=DRh&EW?0xIb9 zBbB*3eCOYe-GL3fln%8@nzq#Jzg&ax^5Ek@Q_^cPPU>3@Giigtd?Ik?jLsYP@ay4a<~HdOnVW4BE~1l@1zTxzA_2 z?sY8vt_38YgY(My<;>I#Hmo2FSw9XBMjjFD3%rR}fLjce3`}%6`Zn`8n|n@breX@Y zh7Nze>aCIQuA&c)85o1e~XmT}~se&Efz^Zv8r=b`8CbZGLqn&$)7 zpDxIVwrzX*Iz;l}ccuOxQ(qYt)%$f#j4<>NO2Z5wEiK(3U5b>_jevA_cS$#rDoP6o zNDD|ah;-*5-OYPOf6xDVKk=0_=RWt|d#$zC>img}*Gg6w&#abR5?l@?K9Yl4qvm<8 zL|unLl6;r6fNa;=P#6{mcG&|XiA_(ahCe3N>N5gd4_#{H^ZAXo>*J; z2PLokH;@Dp0#DZV{ETUx+njoHs84bw2|cBhJzMbS_s~8fpsHm&$fPud1Si@GURA&= zmRQPi-Z3pz|BQ>1N+V5ITTgcq=l^?J9wAQ)&Lzt?PU=79m91G5JI}ucEQgWuVD){@ zu5;f?nXpNw(Q)sis|G)NC1ojv4<9SMbPE>RtE`0QGannWjpBJnBJZ!uhl?v z!dAq_fCM5rLuIm1lZOI?LB@KfID5tdQ5T1H?#Us6t(bX{W?1*Wv;Y?WQbb1Bk61-g z6sQ1~A~n119k(3qd1v53tW)t5wG7aUoSDZMO1Qq@p(-qQr z-742j+wc*NV&AEgJNU6+C(`?WwF$sM>VRM!_SgeoFs0}*&=b_k73AqK7ztG|iW!Ey zw$!G+T3)Vv5K*9VjhGH<%neT=X!75#pMXvJP{QP zFNl_FqH7QC3XD(>I|gRtei5u|Uhq?_(NNeXoy#$lL=>=Mgah|a{=O8ZxEj8SlPGZHsh)IZG9W80t8SSe;GWKxrV}hxuE#QXFSw$96I_zF7+Q|uUU|=gTwQ{7qZtwAz z6hrB@)`vEjKD;iI2l2Y0osqpd=XGZrB|sd zU8`Tu4W`=TUm(6C{17c!`QkzKRM*O$c!k#c2jf>9p9C-GkR2b_E;!{jG9F90Qaqw6 zmnk1!F3MH9_*|lgffTUqm(<>o#D>%I$UUb4Bpkm@YGP7Vtn$1pUy-drR|}kdSH$gS z0)F`W({s5XOR&&qrXw|6?CH-dKp#9X^Cr;^;^CU(48$DSF@D}UAnTSwlg5ZHE<{h! z+5FV9-Qr?3)b5|}N3b^_nL^zP?IDYVUphOuwLqdF0Fbhg8LZP<-jLC6_;GLf|}uhetl8Oj+ZuNmsE@^pKyYMD#J&}jE&eWk%_QTy);#JA}7 z!W5$_fn15f#n1i(`~3S}fWZX?^^|hG+B}43a5ZKt0UR|cbekMMV4KqWO<(Bk`^9%h zXxW!i;2P;@v;Hnv**L85gbpm3;lXA5sxv zj)Yih7WYT<;^X60S&_YhXka)>F?um^=lk6Tz)Hn+7;t1diW>$G;bYk1f-uKeoH#L} zTs=J_r4)G6hQtAtnAapH+>uk*fNyK`=o!e0wdaf?;*7b5dm9nQiO7FN~N}eDbLHW)d z%8+i4$?wi+MbA`MWwSq>6B!hjgTbe=Gup%R!36W!On_*zWcPa$#TiTd8-!-Y))IAr zO{C=mS^D>N1JB4_;O~;I!+VioSPc=lpFhO>?SYVtI}z#?xzOpg8_ZdTsdZ5KTaI!C zqik!zNa4M-h=%(E#3Whml0pQPx={~Vm_P9g$Vd;pzGkLUKCkwjlY>x?8M3w$dmt4D zp+yy`7A(U^D0LjEZ9w>)i-`&4(r4KKt{x6F5co1zRnJ(`doPcU2c&Bq&`n%?%x5=W z%ekmwxop&&r%34Kcd?l!idz#d5yDQ7#}`l+TL8)+^k44hRKMKZRzm6Mbun<%Rx4L1!o znfRnN&49{49bX^nPW?nQQrw1z9XHNl7!%>iR5y=Xkg@ju$>uy4B1C7gs~8_ZQ~iiP z?US1u=%@YbLsc-)w)ed#;@90O6uZ@cPM>oiDMz)>EscOzydxBYIIh|BVe%F4p0cpH zCu7}Km#)~4ZV5kj`N(udN6)_ z*X-?$VFp-*svBbK&up66Y$F8*98Ft=b4~yVZN;CRiMiD-W|dj;%sx9 z%1{s~RgtW=FTITNX_qdxU4gz}KU&F|UCN-_=p%U__hIpP!((axtUB}#FrH*x5}*QI zp2O#M>=@hQ$}lDh{t4zgv`)8tQJZyD-Hokklg6Z}&ZuO)>E%%8gPTzSqz~vz@O`i$ z{h#dR7ff;yR%X@-SN4b6ZT>7fVO6gY;1QZEu9LhEi=tPM>S0xBWFhb)U8C)W-g4Bp zlLCtyk9vwJv(8wPo6_AZa1VDK0z_2Cl~FX*EzfbhO-J=>$9 z2;L6Dq)`CUVXc2d-lG?do76{VGV-q$)CQrvWE!8R4$sb#5}%=m*ovYn+Y6P^7bl15 zUk_S|CqGrRbk-;z&Py!uu;wa!aB(uOz8UmnCI(o27y`K7Z=+GkY2K^P&Z1BlUQ+E# z?MovfVrrBH(2AlYFLROS?f<}*GAWV7&5~RzMUG@fWbQNLl(opHT;fcC~a z$v1RY^im-N3DBnVmKNSy=u6Y$vNQsT17D_^1&dxvRS6wr=T zP)6W~1<~g+F$(x9B1m#ZN|qAQU&2bvCk2$F%k?GbE!ZH206` zKP+pA@Iya7fVKHSoI-Ow_e!0y9v2bQ1#oe+7eP^AeHmf?c(cob@Ue7mZ7JpZwPf|- z(51p>G0y(lZ^uaclk!~C^_>+N(VwqC)eI3-0PN1?hpeTo9t&f{QdHeZjRh80BDLG zdLk@IRd1K-GNPw(ba$P8X;FIJ_hf;#7@WGbiLvsl2Nw$kuGSG2I{C}Ww3+--u z4VB-BasQ#6;I>Tju=&Wm$71fR&kV&3@gSk(jfs5fdfV!fC(Vpo8eeVH!(BGza2w*x zD(OGjc%Qc#EOCyV+Q;1fqd!6jHE4$)0=Jghy&{!~NF~6cK+0O95_x2?3lHPrD8*35 z&`2F`#$B?uCGrNYYNqT~p|MtH#B~N)k&%@q(H^YQ71^l){T^^&Ca?FlY{;pQ=o9d4A0K%Jx#8cL5Yi|y*r4=1ev2ev+5+8sPsQAM|9$F z4rb9MswXdzfeW1?9zoGPET^G}t(WAVUE=g={V`*=6A>5W1vR)AfFJk(?A-L@Q+M&L zoM|_`GvLp&Gbx3%Rt7Z1qImsfh89|h5JEBxv~__(GwD$K-1Oe=WrhNo1*u=Wu+D}u ze)Nz<_C*uAFb9fC=M>s&3E%4-3^no$293*NmINW%a5Kq{a7rNkI^gQ=yeFr36O02G&zX(dAf-x?dcD%73_F)pW+(?7LO5HaSkH{#mGtJ$UgfIhqdp=>}AN0+tVrfHvef zc4B$u6!Fc?0$Aw(sgd^6)Ri|6zVG{S&VCAkbaQjv+-yqZM}2atelT?eVZ^e$!33+Q~-O%id# zJzj-?8wIEXQc)Q2IhTX15=SzT>`+iWSYd0Tp{REi4XI#ry9}K z5ceCp?ilBg#UGgIJNU#HJJ3CRyAuhY_-E|nJ zFRbA_1RhKyciYTDd^2_D1~ zMiu`7VG6&Y0->Ti@S{l)TLX}DCRZR-a-0YaLYL^Xx)u5@zps@QtM933C#sEdKF$G> z2@sZ(TqHoMLj8@_xhEZodP=DK&Idg7Ch%pU5!N!sR&;^$H&9d?(nh&iR=g#B$D466 zSMd*tKBn(bo=DEn?{%S|WJ?)c&rH^6cNFYBWnDjrT}t>3Du&u~hdv=T*cnz6!G%q! z&Wf{+YX+tUA zu``(+30$-yWqi2Y zC#J9FRV$dV)y)CHKa}n-4fJwiSz?`Fuf2;pKH~yuUhqWXegZC%8RCyH5%qw?Whu`%HUB}=Tw6fJfPWWa?0J6X4Vos*7*QRZ8 znFaiZs|kT|tFiYYJC|6{JAXhkY(u5rVXpYO(L08dI`$It+(&;|$$7fgk0I7Y@;p$l|3Mzl-GjHv)>q42BH;-s4)TYtFkB&ZAOYU_X4= zdLIRngj>G+&qP?E*?FH5@w)U!2)#z4&g?bQ#_V0cpdmR(}J2i5h$ z^@#2USiE;W)Mv^CVT4&w{~f)T2uB-w1O}@?&hhI}$z^ep%%m`d*^Z-t3Ntzs%Fk}` zN7GWC&)M}X^zhMy*8jA+c?rjvIR*-$@dwTT#8?H+E0nt9aRoGNfrANIf_+=SO4$qY z1B110B;CM0^C|j+wExICGFG$`Mdyh-T0KcaXYl69ejl|UiO^Q_6j{t6Wgd5K4-qr{ zlA??#jAosnrH7}9z(kK}V(9PBfK*}q^eLkjBg}$KeMzPe8tB%o>BmhH3B(9pLP4CT zR{B1+jWb@1A#b3)-I7Oe-XC#}M=|AP5Z83a+8Rf{H%z-8o36RR$CKnkCHI7XTiF_% zkcvSefiVNDsNnX}dD45}D&<-QAM7eJ|K?6zy8e9iLPVn&n#?{p)!EMgp zBOlI$*Qw9H%jHf#K93jK>$4|vD-r9YS^Bn__gYmvbu;m+b^CEe)gss==C^>oII+N+ zkTN1FiMoF$VGtW_!ry4~J|zF@?K$nD8^ZM}#ym;rW(t*A^+;la$HW--zqa#i{d z% {8MSX!Pc(UXkGvQtDL+OkbzS6r=ppIAaIU_>O#CdMD0+*y|3iVK)O(43)7~s$>M4h2((`GI5=p%d z?I@e?0FRRMC|FI?nA0|^nqJVp*0e?!2hugXpg&Ra3<8meAV=d;u&)8Sx3us~joBmA z5BM4JbGZi{7a~32STr=E=<>x6txiAaQ)-E6DLUDIK3n~qTlA#IlFc5w`7+JEdT+J@ z*T9MgjrhUh{oVPoEJ1zH6!pc7ZKW1b#5Dxvb)V!Qe^m(A2JPsUTsD#Y+Zk6RZ18tN ztc?pak8Sdfgsf%tP|{s)^+NASwsj|;V!({F@;)}FuOf#%Do_ibr}Kh^y!sJ9=2;R! zq#W6$5QBVZ*+|YA5>|`l~V54JY#Q&`JvjzI4Jf>%s}5zlz!Tu&wjIYE z{WE${N;OC-$u8|&{cSA&b~@kf^{-#Wo79C-QDReVm)UJUlSQWNWwYPiCDqUTM~26? zGeZM+p7%rhuoJy0T(~ZozlGMl)8bpuP!RW8Nl9~jK_v(!g~f0$v#RH)G&;sI*-xZr zr1W;iL3R>}*SttOU0yhzxTtWTr8zR4&(oZkvwKPVsEX(s2Hc)Tt$(4}q5^5zgKBM% z1?M-mtj`+KdIH^pPk!`sH$-9}5TW7dMh$XW`VyZ3DZ^*Xzd%Y8=;Kfp+0f`=YKz=X zR7Fy;_8Mthg;KaekydCJK_u!ka9Dvf2+nAxnQ|t#zAbKXwavXgR=)_egkG6!ySM{} zV;6EQ<_!al5lzeuz(4hGrGi160FxfyL7nhfi$)oz7B{oti5Nvz ziis7-5*^7k1YNPuCCeok1Y3|oj-nrbhUmEG(zm0~?o!yHzqzCh*+nL2sXfX;*i9&Y zkb$y`;8m1sZtPLbA{DUW;FoQBej{My?&H9KYBdwKkPcBWk;dZ`RuaWXeweZrkYqpm zkSryHaTb}KGMl^_FT&w>HJ(3QY^w7>)y8yz(%pdj9woe`q3!;Y1Gsk*5C z$C?>~2qia|u^8gXLsHb7qogYk8C84wPM0g}w=#Jq()iO@F4R=%G(N3;FG3#^GG8je z)?adGM#--jaP5Vluq#T&D-$KIcHxV#`}C~NoMxJNsjm%pQ)Vyt0OR~SkAzaJ5LpU& z3W`2<+`e-93?i~Cf0YAMdBrrW+QBR}@DQP$PxY@?pnPFeK&61sK$DeE)6R*xCIVZtNXqf}*gK4sNNe%emfNkw(>;eq31OAQfRH^gbwYY^bKs&b){V{yC`MG`^DhsRIG+B%gamSSKdG0 z{E{*}qUxs4bIHRSsr~9aM;WXkTL6~afxDt~L*N8`+5 zKoZI{ig4WY<8=LZy8ws$z9C(Hb%0|4Qa-h6TGhzr4t|Cc*^{Fd8vgwk+Gls3Ij>TY zxrMyn-++(aIzo~~&^h+tr+_+T%xENiyqV~3Ra(fXT4IH;CVNNnk>J}2LoQfHH)xme z{8`bcJL>HBuOXGkfCe75dEal}M-M%y^|OOtsqS=9VS1DaVK7+Y8!sO5G@$sR5v_q-tR_Ah1Z0Kg+_y zl_gVR(*T65l3|b#I@E{3*?e?D81$m$Bx{M3UjC8oX7NIn66Ae{Na>VVFC>y8fEAET zX#hf$Dp@nM1RG<$CZ&*=5RmL@2cAp-C*@5=%Td3mZt>y5Ly92lRpWH!Eb-#^=}=wLXCQtU?vb84<5lG*oqcZ(d#xNyt0uBDhHV+qm~Ot;+MR}x8uqzkTN`tStH~Ho zN}%yoSjThKJ}ZjFz!C|AL&l-wgdak*r|u05f`NBb6dBm+(dq}}KRr8$y3qt<>0);m zYl#mTd%!IhJ34YP{V|g1=G0@dJQFo=XSrQ<}tS*q6t{ zPL+TU3q`1BAl0i+Ua$MBGk6A=45usc8m-3)?aJ%4XpH{gqSir>NzPN&I8wfV0|x>S ztd4ZLT0Is_{l7E)Qxe51tQx;m!O&|xmC89b0!21kl?Sf|$L~WO9DEp?U_KeI5lx&N zG`f_JMg{wG5WBdZ$fk$ac|pU;1Cg%!6c*J}P=^7dgn?ja&%k-UM?Wv;W`dM7vLI(ecm1#uGUVTRiV~NGT%=o|@)h#?&hFqOAr7_K7C>CVB~% z-VMcnlQZ;60u;#J6VD&s{l7=zmPP4|YIOiS!>47Kop1@ksA0QiR5jSed1>#Ju0J!NBt0jKnK?~B;i2j)mzs3~HQTenc&1`L*X2?Yfs4c-xwd4V9oHdan_`>qxJfAnF8 z>)e}bS|6NU^EgTFr&5va^FZw(cyjOnsU3QTO?wuDQoETkK2>L@Qcxa;OMQ^qzqaJtISy z&+b?3)*x>vEyW_PI~mDXy-X{IK~r7GrYq6^RiM=%6h^M9^9F!VxdlP9p@o-BnGf{P z?Irr?6zHzxM4cdVzv(D*J>qb&$Yb&a91Iy9$H-*km&I(O1Q<9sKht$PEVhcx+R2VX zcoJLeQZS}tcIBFXA~(FuzmMFici9bBO#Ms?&()*kul~R*+awV};4K#zPuO+DS31~= z)T4MQlAm$^rDw!JGfAHF&=)$7q=nF^lg0GQzL?Zl{idQotA5GsxA~g-B}}1RMy80v z^2@lB$??X?50+TWAD3f<65i5`w~VhAAwh|iQ`mfjQysSM|2-m^4Jh$3^h%c%8#Qt> z!=i-!>5nl?X*4NZDU>%XECc?kxN#?c#0s1f>)0=U@LJvilfaRWTzyi+#YByRKj~-o z?h1NYXz7assvBrkS%hT-rgfioi+KLik*Uc@VZ284NDng+j< z^{Ah2`F^(kGW`IJtbV?ZyCk=n(sAzUDX!Zoo@!JzANMuKuLpmN*c*s3V|wN*D%puX zn-A5$&dubXt-rBD+`?vNM_)Qz5WMi_9Mlm#vW|8eW|?<2Q!=_ePEiy6xbe}73A^y2 z6#1+?dr8M0M=(!k)&KBfC#gU!)6a&c>qM3uP?T!Ih%;IWL+eY$Yh?zg6QfVhI^Maq z`WlsjnCKA$#rr;?#2`3P81veBa3P`lAt~U&b51L`8p~T#`oSxFp(=k@9}s0-bROl_pSB=2=UMbf*f;ru1anmgV*+4g+>E zE<)$aSrTwr`9hd64JRSeY=i2HL!@Mtd`ze8Qe+F2PS?K<;1yksb6zSdFrCpAbMDiQV7T^)IbuVC`*PW-EyoG#K>5I00y_>Y z#8f?Vk&qv2w+9mmfgV=~SFo@yLY{|0o_{#vNvx)%K^rVK5eZRC!+T}TY(u6B!Y6P^ zw*!?DqPp*x)dcZ}KkgyhvYZ}P5aL4}Nmy@X6CZ1OWoLQX{+-3|&uhFFka0W$iFfcE z&%Cw^E5A%TYroaK3V1-_>FuZtfkm_MU^H@)`nPvu0jjdaK?R$HL<~y#wlF71bX=SPo-g<_*fz)}k4U@3p)9lE%A^I<*V!B7@p)h_v!jN`_l4yOn3 zapSz9&%=D`U>T5sFcBiI6~3J*eskGp?bRJvM=y_gQg&D?!Vuf(kyWPE5yDY9@qI?0 zc$t+BLihXcN5Z85@~7Rv)2%^;0b7r_J5y?cTrR#{WiN!i)RgXFb`5W1)`$1Q<(9sm z7oYr$jDS#~_TmZ+lw=$b-m#Y)4Ij`SIO{+7_gI1`sDlwzpbWepP;00I6onk8<`SLw zH8UHeu-o`a1^Uvl;sey%w->ty&C3lx`oVR|SbF?}`?$`Df_zvoP-byG-HQiSCWzt|!cuseDnmANsbjv#)%@N8K`>+#;#7B|~Y{-tD= z@3(47K3yNa&+VGb`A-lZikggXyW|thzBF-clMk-xrbxBHi2Yj(gQVyrn`@YGg;${{ z@@kY-vk?~KaW2EEi2QpkrTdM5Hz&N6_dF#cW|;>8r>7)#uNxlYi;97uGsM=)&;y4}cP7C2t>=>H!Vd#hse;v<^9*M&1Zk7 zyloQ;!*|@-x~NWxmw=Tx_|t>yCZs-ppRf8xN*kb?EmpP1DVh&oyjf(i2h$&rh2BH? z))Yc8mIt^K=g(e}MFliUVce14TPT%ba8JD%omBTyw+9pxvP;+1H!ATK)_iKre1=|H zN=!MwcnNh)Rn0zl6ukboFn!@-xxc#={Ry_7d27^PKt;EG_a!HHZMke5} z9i7E*qn5tb34F`^YH{1h3zHG;6z%_(-t6d5mN74n0IuGWE^)#{u&xnc^6f`AYHp9F zeU+149M;sei=iRX97R}Q)FB6mP}CwUJI+{X9XV09eJ_gt#EhegPi1z^0PLWjO+2j> z%nedt2J9uX!8h}*xzvks9n-I+Zf+y)>D@i&Aj*v^R4)h`IIVNK@1M3QASCWpmDC*) z=3(>_d!egS-xwkr&oyVhHdljiUjSu>-hdtJdh*^(NjdFza*$tonYT7;)*o7@)Ghr* zj{Emd3QJ6_W2#&S|L^Ov))^J-8Cx*EGohPXuI}h7ldknWosnxhI(z*-iY9A~z9z&m z>ghg;)a3t)mK`0C5D@2_-_jKf^{dG>G2;x)h56^@Q zPZg%{vznhEF_(1Q{D8Yltc2V4Rvp%O2BK9x7T}`0TzK{S>id)%;i==?GIt}z2jRWj z^O6%e>-%bzi<{8}L0iSH`0>p5Lrznzxxr-Zet#S^XDBEFrhaDAy64^f2*`z#Tghd0 zQHY$HlK7jkPu$8ShE?`BFXu|(`U~Lp+?ThBdoBq?9*G}+OQ~q(=<6K?-z?sxPgNh4 zV1(aywW*ECglikKOW;hbf2N*lgr5db@W&Pv1}DVH37hWlIlujD)jBZave%i00dD3rU%F>xm-<>B>2&|)e!hYWTm$XvpW6O5k7FOy zu9{0PX+w$@uP%Be^1jx$fIQy>(D9xvL4v2U&7Hl+3ZHpJaZyGY3qXb>*R(rhIGUL6 zIiA0)$@T+C)8RW2GEY3{`{KBsS9d`~LF@>;8n)Y*N`c6)qro_DBx1Fe&u??8c1c9Q zPZioVHLv!A=S229NKN8AZcbN$4$OR*hsth6{z-xKV z+#yLD!{w9GFSGDvz!J-NcRRl==&EE6asVhOC>~N^Z&HI9vGl`yq+9TcaA$99Plpo|~BtlD1^qnfPtF0nE%7fgh#o;#$btonF zZgWn(c#6 z6^Rxgr*xL z!DR@ZMz-fRo9gMZQ>Ib6da-&az+?-k_Je|&|NfiC5O5^8n0@9{Mo&#daWBEllm&xp zdMshro=k)`OrHI4L+s|P!Gxykf1flito?;Czeym-oEU=yziKEPV=4L}8ys*DY9T(r z{f@I(Du&jlng~a#kqr4`G1%(a$FTJq-Wt?@js0%n-)6`Aqle3AH-Ob)@}q77k= zY6~KMKK8SFa%L`VVSv-GQK(O7V5%aT(AJjfk(r2Neh;+ySu435plm6m=Gi)WHCeo0 z{&->CXRFzm6zD?Q0e#DLA;7ApJFl3~wVOCA@^+n2mXJpfdP+VqVOi_~zZ}r|4}!vy zM>#SF#FL1_UfL1mAVk6xF6dpDf!>z~g3A(t-2S3zILEJlls;7spO!R7CcRHvRS^7kYNrD+Ke3Z_+laVsIGiVme{)Xv(udRpFTTg{h&C+*0TJ2x$fKkH{* z&vhUZ62IQQ^YW(mJ*()}5yex=nR{oUgZ8i75!$w-NX2d=lxr**FCY@DVAQiOKB~a1 z({Ks=4Zcvpzee$X`y1=O;g!n-Ji56*UCSf}&@`D~b0+O4%`Gd@Y%O)poeSs1;`l8f z^HZc8Pp0k_pz?heFpJDMulMs?Pt_^?=CZlB$;J3E=D9Zqu!C{JkMWW;!-Fu0-G~)4 z-fwY5eU(YGfkF2{+to}2VD{3&b+<4qg;FCcXvbxtDv1%?w%E|(HM;(_TsePIxnpoB|^tLMlawrli|&UoQII4HW8T1->gh4lF07! zd!Zjh{lF-~C52|{qsr5v@Nz1Z3(X$Vp7*#}FMyq&(gK~|f0CU*ZakIy^PTrs73)g|8SnHZSpyQ@!Ts9rtpMAuFIP=t^d zh#6>Vk^3lfsU8tWXvFudo%Y(G1mma1+ihv2@QW2BD>=4M<`pB+;^hjQE!JYJ~N?+n2UeJlk!Ug~>PpN}yct>K1f~-*s zpGL;D8STa$$lyhnlC=Cy?9#ZCY2x83JLXvI(0IG?_dgc~ zW#}67rzW~@+-69^aw0oW7-nlnicii&;?5~VhD%(AsWUzF_egN>*oWS1dI|_0-SO~` zc>_Vb$IT&w&mg5#)Gmgn>tEf^;ipj-Pqs{u8bLs=OM>dIlgbEXfHqgwxDvg7!(I*e zBdo@v)L@&R^{2Ac>R)TvQS=je@)IsoFf0S&jh;)w-oibgBUyRsrla`Fo?K%IxxkqD z7;Uv^m|?*|nm2Ji#DcSGg1+V8CW11S%3HyVmuRfQ52Av?DfPbs!Kobz! zLzwIYL>Z_Ph+h~F4~P`wiuO(%AHB+yvw4^%3v-${Zfb1en>cE;Dho9&L%a%JkNNjH zt7uUf6gjRYGdP1;W@$r zI}dsoVa_2O>g)J0+0`NjG;+s(jWb3RN+%IIDh4J*MMbiJh3j`bR?((3<-r{$MZ)3O z7P|DG_wanzJl`zW*gL;8wn($IJ5LG!>~#w5G)tS4dcL}C*}C$3UtpRppI z>vPXnk+QcXoa36L!-rHJO3dzA6;BG({tanTpapCZ#14K7;4w%h{XIDyU72{4VF}#I zrSs7NbYWoRd-%iav>WLfo{n{VZPj`Dsl1|}$6-;x3H2*Dw=!;1x6V z=nh(rHHD8NbsybWD)|_y+H^IgOxau%sn#(q;Gl642boY}lYVMH{2>s3DY11V&hE7& zKP3P`c$geteEf03 zk0XatCu4t!gb$+IQtdBIna%E7eJFd52-tliJ&4sH#a?>61)5;w2DjDqv%^eS0AeoCg&eH0z7nWL8S1 z(M5&V(d+Gus7(8Q*HPlKffKQbK~pnUnFqkC<2a}PVayF4Bw^mKxdXXkagZ_W9VJiW zW!_1@!;{_3q4~K>UXrFMyA4{xR-uAmlEAWWhhDZ`17eKIb+#A6#Ycp-gna>52_~eh zbquB&DQdaN@W<&TuAY!6;B+JiujM9~5fkqSe7}%ZQXWx5RGppWy)3bW)#I7B-{mb8&x}^Q{Rm6i{Ap8uShA@>^R(1>>iyW zMvCi5x~*J8Id}^->~Aauwce zWH&mT5ACmv`jY8MNcTq=t66oN-H+NvSDrsw^w-l|wzd@^2lp?OJk{BCyoYan34q%p zJOO3*9zet3W(i_x>GCy)tALm`T>zt6wS9SYUt~}m4YCN>W!P+RK)c9wi0K@j=+zXL zW3@~NWF3A80&Iai)eRNlB=jGJTb(O1BlW1%K_Qw-{ht2xpaufDUEoC6^P zi9Ro`Jnnsz1OkNXu7_hf<_mjxmZ+uf#I6*kLcQ7HVf;(9KTr-zvtI~bri9ByvI=h< zO(C3~|1$A_1Ws|^942%onzEk@i(~voHb%YDlt~@d>G4vJD1;$F;KL>qY!-#%7p%Y!ZhlL3{)Ha9+-cp*ONF+? zCCp8heljJkih;a*GdD5b!apyUC+l*Wj^-JO^cs>j*0t%k{j%a3S9RjH_Fh4jk#L~S zt=Ho2sA;7cM%=78JLK@&2Kbn^s8IP(0L4i*5(=L?%W?ayhlpmYsEVr_hEb^~?fl-y z+r8f^hew%|XE?E#J#QwL3XFQ|{-cLv0J&PJ`pKA$I8!-!iT6bk%eSb2Y2vFw*xi-} z6fM)_Rki7lK}gV&*9-PPSLOGX`SnJ)AU(R9+)>P$=1lwq3&4WC^z<@5!6|5 zowh7^oYbSVnLuxW5O#uh`#Ym~dS9EQKznl!(SLuK%@3_#D;$3z%v$$cVZ6lbH(Is< zuJQzS`;)1BPIlM|9EE_IkARlIAL35d48rR zIHfsNOAYsocQX|G?G|Ps{3D8Fjmk)@GG?D4?&zb+glb#9`+I+*UHD}aVa6knbqvxx zqz(w2XhEI&SeBP1fMLSExo;63@Sxm32~M2wgX~a%#lXW)N#I5=mM|vu4X=K#O*3`d z`Q`EECZ257_XHlm(mVSIzY`KV-)A@;ZooNWOIJnus|PxQ1aA)`V3$aRxGnFX?pIhl zM+3EdeHluFCskF5Cc{kkE4a5e$QchdL!w@0jZd^Q{wu zKI!0+c)JSw|23}(X(FJ1Oqy>bpj-4!2)AP-B!9w9}?yC^HLpl{G0BnWjrcAQh z&wp)dRNK>3m6?Bbw?*NkHEE;&ly-Biq&3l}gmbmu4E}NicmwMbC4}6}aE;C432xXX z9ZC6QirQp3LTHo1pE1YZwx!`61A9XGeMu$w$7YglnAd zk@Pk#T9kR^zHK2ah+CSK8Q3f0?%;D+Xh~xDUA2jig_Iwy;}KOcA$8Q7i?Ot^Bu9G0w6&8<)?9K9%nUJFz32oQV(K+HC4!-vD7B z7O#VVt>B>8N?7kM22xYkVIuV6@q6yhS3bfIao-jW1-`OZcmLCRk(wgzWhRrnADQvc zxL27o1bC&Me7f~b>g~HTie~f z=2h9)R9h3K%(-?cF|Wfvzc2$0tuQqB;6sD zn-%KA^de-&RdULSNnFuCoG=?!)+wcL^)@V>a5Bhh5-j2D(nf4oOaSl6&0GJSdjELr z;Kv@Fk<>Pdckx#%&$bb{5bcCN!@!HYguz@1e#DTh<(T<;eQwk~w8%PYWT3N3_Wb40 zN+Ep^BnHd-qonEaC00{7OrMxrmnf_|-EgwMQQJ$)KQp0G&7iEULv6sNX^)V3madf` zjZC`!i9r+J?7SI|>b|1Mxd*ul{#E*}31f(Vj`w(*F=f@n@QL3dc@216q1i(qx{y{} zxb6s%zuQ9@gyq$s;-2rb_svU-o{aan20Ipe;JZjM@zR{v=<-Cy-m|fmB!cYrqYz|HAZ77r6fryE|98j+Scld!5P zVjQUmBNrBQd(wL?vfeape|^Wimhs0z_q$yeEcLiFMeNEt<6naUJ3&q zUqs!9@j4Ngf_R8AoY_tiRJg3}HwuzUcy01wipuEW5nx!-HxoXebAP3H7mtH!3YI(@oFb;v2ZEvrI ztF30GJ|KK?DjKpP7(QCwgYY)cEnRQwU&^Q<<79&XFfC&9=l2&c%?~E?F0Tf{jGrZgB6f8!;&TNt zQgC-XbwY)iI5(Zwoyr9b=s$1Rco?S3uYc`-{r$Ic-W&4UJOQ7ynz^IK8P8&(WVxv# z71k@vls`OBr6+15%g-7&oElIXCBHx#YEY{&+5cy~8c^KAnR6?g7OyT*Jz_(pv*dU?k)=JZ3MqSwQq457GhFgkm5WG2r;QPA?NiHMqA5>HVhH>k`GoHmJ0G`gQd z#VWEY^z_wQ_Ki5bQrb8wD+qrZRK~C!yL#hZhHx5Ce}miT>*4qIW7_C=Z!i84S`_j6 z)4xP<*ymmRe1>OD*U-jwK~Z7a4e@D2Jl5r4%`b|~jM;39mdWt=q|& zDxhbam2OoO@!;F(@AXkSCM|Y#o=Pbrm_O9-O3rh!n;bF9kWS^%&I_Xp-@e^uJN(QU z_T)lGbB0p6;5l6IE>un^g|yh=3wjYWYMgeZl_`e=QIxmNn|dm2=G3*oJ>c~7Txsr4 zdSyu!6Z`||`Kpj!f3)If_jpw%57Aj>wzSR-kM8e!&pl*6%N8t5F=M!hmG$pz&Yv9{ zy4}OV`kL5NCAj^~tdD9oJIW8$0QMHckvJ%%!^vFPq~HR{z*5Q&dhf>eq;=rd8oJ-Y z0yQp>I_P()@6rJAHf5;^4*u=L%s1qW4FW;pb>XhS2IHDV2jE!15~TXDxdYm+cA!#V za-Z5ku3X>rP<-I7KvWwKA>ZWoeQivvX=s+!uiSR75_!jc+1A})NqD{fv0NszK~!4^ z*v%P=pG{r-ccN;he9MS{vTy;85L#weu2U; z!q7txJwpkKba#W2iUJ}HN{8go4TIFEbR(tG3P=o{f(Rl4(%m_<)Omd0_xE4t%lUBD zn#C9Rz&y`=U$L)!?LG1<&^5a!dBv?P0IFG3ay^rytnh1vJ?B%uBO89qxK}sN-?q|} zxmsgV$gJxHyoZ&d}Xnv#hmdY$>Wd3#@*)IBXop&KYtdXdo~yMTHv5_ zGiQV>eKz>BkYBuQH|>?!?(%Q|L!;Py5BzfX+IND0x%IVx$l zp;XZ&a3xo)uk49#q32B?PfD5S=3#0u#R|9qpQ5i;o%Mb-6+m$~vkI^GU#w2bL1JEC z$A>{8n3|48l3f0Hb{1caO&q7L#gTH2^TPyi2YRtRlRP2*Gb4Q8BXWFQ!6kesgkAZ!;r+k+=&Sw}q_yh1GYYsi@5VjHF`c z?cTU$#oJo8X|=}4@=R0YdsSI=zX~-hNV87@(Iw}4^1f~>Ij$xqSPftb^)El!}X zTlpp0(YWRH@_CLSsfR;nLB!&&=;c`>@Ie4;3K}(UyN4C7bE8PpQK2G{c_mLSBEZCwa@N z?6n7ONV;DP|0stV6j)XoaFBmV$B=YTV22Jf3BDQUkv*|{Ab)WIZwmW%G3ct?bZ;H6 zPY&|YTs3`Y*Y2!JJ=c5h!N#FEnRfkfF~D@M!R8P;i)#q=eQMg(+BnILI(cTdbEOQ8 zm)N~a@Vj0X0?6ZN+K+$7|9Wzm8#!jWeASmv3Zg9UG4C$E-D=Eg?=4!hKT-Im@!IN1 z__uHzEk(B$`qQF{sRA-+c&Klqg>Sg$s4U@+v}xE-d!Y4F%F)Fz39sqX%C&c8UWzz! zX9{*KYJu%5s7^*kz1?`fW(u1v{V8QlfeOV>W;JZPhRXQwR?eDl_)$Z>kN$W;bLWX# zSfz=$n({5rCix3oDhq3*cB4<`a&MmZPlh^q$84R@8^TVq9l z-}4=VPgyd%0^j|v5Kg&(MAYTA5m(PhlJIv4>#?n0uDATo%a7lvA8)UmYUc!*d^yi7 za-}sNE9cWTH4*pCSgDMy>`4Y{4c}lujAiAe`1nVAX~{}W{dmVGtRwyZA7M`TzRV+A zikOHj;q|eX$BiRmv4(O*ca@VhjbvTC%-$rjG4Q0yNExPQEqySTR%b}!sslze7tg70 zSJhUCp|_-53=NvLjs&&H9*K+Pg>A$BIFujqp*TM&K5jmAf$GkYhTVY(POr(&4VzpP zz)Ow36WMb{dryD47E39^|9z^ih%xc?eZXo?JL#f`#$WaQeUHOLOQolAFv;bjX?Rvz zq^NS5!PtpapNAmIuKD4tqaylV8ms0jWkDzy`qa<(l}+0mxp6XL1$Mzhbx>vx=9gct``r{ z<%!}NZJhZV-p`XnPe+IE$b1SF)NFVv7dSLS%61f5&(9EwE?38Yiu!xBmYT&Jg!XR& z=(xr5Jv+t2ZQYw^dYthp`@pnzY;GWPhvU~eucAQDLbp>?2~OMa$Za|^Jzcz;5mf*f zgI9wKj8uihHJTMSOw=sOD2C@j(kd{4sI!9d$g?RbCBbD@&0wf8?Ar{WcHqmVb`E=d zBvl`hXO-7c;XqPsLfe65~W-60lQYQ+t};en-;}8@6u6C+>o}R;OhJX_4EVjeq&z%pbc*M z0Z7$W#0vCvFDV_8g?*;`a{K|l6b5ZR(B~}M5iv%S3=m(m?C2AUH*I5v!@ILQBM;BtTE&ip);5%%jWSA3Q}?`dq=+(u{npqt9-2`PO5#lRyPr3N3!28iDqMP;({SG!Gn#lX^& zPpcR(Bs(E~5c(n7D*G>;Tu?D26c>+l5@dg6xuxF4+S7`-MW2+EK(8$_koIet0gspz z)$cjOWStOF$HW-Q>#THf(GM+izFWmeO|BJ-CM4`@igfBWpjKPnQDY4?Ybi0qLI@|r zTn^usu#P)l8*Dr?ElBVh-&*Wn`_N0UNnKj!UV={6) zOwuc4D$M?-#L{BNWP@|RuRrfpR){wF%v|-$WV0NSlDLCBvp%f-PzOmK1PohKO zmF)I5N}fvI64Wr)p5gvH<=Xs9c0gOph(};lTgmuI;cwB&4;OnegECh3j`L}9)ogvW z{E_%`VR)&RN)CgWbMRR>_~|TB`e~{c9*B&fY&oIs&KEl^X^2xZ@Ov0r#PE}-}~{vrn4IB>$Brv2Ys`b z_1x0-<#{^?d>sCuvQdgNMcrB+4e$i8E+d|cQRimT4Z(@_{jT7rUtI9=SYmTFoeqvC zwedg`ITmhbKXb1|$X))D$}!(!==rIPX0AYRTp!iWcqw^Jpk_<|Q1y5F-koANBpT{0 zIEmxUq_RYwI7m4eVUTb_%c#JH%TqenehZJLhVDDrGciUnr@ozwziKlrC$*K`U4Db# z{7Q-b7zag9pgLyR!sWyW8vbmS9)=9>!$R+XnxS|=?H%ZE&)@emaZ4=tpPsJwZ&9;y zIBE(YrAyxDAHCkMmgkgWgj!@=49$-HjxkT3dYx9pZWYFD25$8~G@&;ba52yUil>T! z$}O+)&BY`$g#A7=6zdfzJcn2n)sa0O-A*6w6@J(YhWe`FJTjA?E8jGTN~-jGDSxRU znKBba_;;SHczLVcOCN3QVMXYwWicj|f>CmuGFM;fxj=DJ4L-0^-_l{v3mRVjbCfK_?I+}A3o!Xfxs=v1AvCa+`n3!3omzp5lp zj{}WPvuoU|tNZxgd=qd;XSJEnOC`}_a0y~z7x)gb+t0_vpA6;|wYu#Mext)w$drMv z#ei0y3<-x>)9Ok+>Lqn^zWM71N)l%eCOY{x=Wb7@hlv~nFKa1_!331{S&5nLY3l-r$KN$mf=@o@a;8X<2GVu7+?DRw_&py}7K zJ{^MOhpQ+bTK^p5(zt2l&frV#Vzw>tt1M3_tqoeVyE-OCa#cp2J7jMl+Udd_5 z*AR4GYWaza2oc82?b~$q`u_LKYrj4VVlVQx$aHF%#s^5@&Nvx010=M9ehFf5u`(F% z2;$v=z|qXd9+(zG9$;_VI=~=4!K9d0zuUNl5hE&)q# z?Dyxqh(LCypO1egD+pAf2NADB%12UtrDb2di=ILK_yY{bed-L)4s&m00vCwQVX{-o z*>a;K#n$r>7mFxm4=StV=hlBA#BU^^=01HO!2LvVkNDoUY<2eea{jEy)hpiC-n;o} zz-O6XN52kVoPRv{6#(F$OaHzKH67}TYjiV86e3=8P@PG9-n)z8B`h|l%@2tom05yg zP>Lh-7w7RG%zi;RK|GWKgx!1{>?=Z+)SAvOT_azxW5|5Ax` z31K2O0sNZqyxm=iR2cOpLz(CDdLyfaNkjH>`YM2C6#ZYxD#3A2sI8!^b^mTzJo0)4kB@;sV= z(Eix~oh~08&|MHS3SOJzhNklk9?a#;9~{#BTr|TQbfNu z;Fr_(4c_*pToK=yhl*S4ebqUov%N6EhX_L4J}IW1&`EIQAl92fSCYidrR99}DBd-I z-Ie6Hi%Qpqjaa&Pbi9HEca|q3`^${?v)O}mv7GF*-!=mPbuaX*YF_HemsA-gv|QoT zrI%1q{}#vge`zN5#dv17_<+8)xvXn)VvSI%qSPPrN%xu?*pgeePspXtGu=52?n)1( z5wt-L7Jmgz@Sc%O5+wg9;ZI(Blr)FaPiA_r9hC%$J7QbtaBfXe6{CcYF@`M;XC>KC zmkS}Uhfc?0rnvtmX}Mu6nV}ySL61@zK@Qov*~lR6Z@V^mzf)3sw~FVj=%Kd z<>ij6xVOe`__ZKY6XjWm+{N#q7tb`d;TXW| zVPfVodSmG3a_I2UdzZ+hYP`GwBH_MX7MQdKn?e;PgPBA0ZOP&T#g+4k!Hsc2svZ)K zI*oqE9zzsB^v&t^-UR;}<-LfO0vTpBlu!za(iIL|-+-KEPD1vS^S1AW0g zyE(tFx-Dj=Uwr>L^nZStmIOz&y|u!#r;U`AGB_M|U8*HHJoPXcfXhI6J`Ksr|?OtG+M*3DSOlax5kc|kz4WCA5&@`;Yu!XpVyysUy zL0qY5^(;B{cYkz61tOgmY(y4kFeTo4K3GQBjQJ!9z<8>S`2Ic`;BU=} zj0OM+cE^}uNHQoi0!qjv?Y-UC1n2}@-)uKK*3kTFIF;00v!hmVxkINYE{@v}9-@HZ zP#aX$e%BM1^`$pcZm&rG1a?)c-dZqJa;oVQy#1q~7kQBtCs%Ocsk1An77j<@s;KCp zL&ByXiqX;FK78vrzW4juhijnr_ZP1ZwPV3#ubws;JSqQZ{_CUPYOvgp@Ob$%Iu^+Y z%cX}#_E{tgRhSsUG=;Z>!_qP0qG30ocmy3kL)N%wLm|Pw?H2hbsq7?hXBES5@Ay=rDnB(Zo5-%3jI<`9 zMA<$ht40}0+?%4^kS1y{_egF4U)2e38TKaqCr|l3ndzV>$a-1x<*8;P68D7>@zBob zM-*>{*>~gbVeYk_?^t=RJ`E%}S-fzTC<=xYz)D0U*LO9&2JrFShbyV*S(|ui&ML^+QLQV0Q0gRo?R)3L`O1i0>u+!Q z-2mlb%h5xpqG7ZJ8k%#9_h*-I#88dSx6yrh2K_=#1rinN<=@|hNh@8Y{G^)qr#Y!( zF_i)cLQzts3Pb^TYtj=KgtZc;g%BoXD->Du&SCI4!6TB<6TQMM?n;+S9dkYvAPkt&b2AKRqh=}a@hhPQ*l(YJ*CiDab)NY!Lgct(cN3Znq%}8B&cV9cFWNJ&om)`^QoN4=%usu(Ki2Ts$5AEbrl8RS-)8}?e#}L z%=2A4*!C90{pYAUgD`WWK_6;rkJD#6KT2A!GCtW?Dj96MfFaP|Rh?q#g7IEu(b&uT1&OHpy zc(EtbQ2FBCdAHyADG9pRw6Oy2ru_Ow_uD~zm-K#$((;HY&r6IaO=A#8lRs zv)+YUy~H0L;W%zk?JmtzT>TB!e+f%S1pB6Tx)3Lp>!epf33sZB6&ngD-|(^+N0{tQ zec`7ZVQ1^>PM;1%#T{u_A*&MA7~p{x`&Lu}p+ki0KHBlOnehmmCu?}zqu6Q$M;b9W z$5XcShgz^xB=IT>frthN{urYOh|X7gWBvTNGMIVj1X!c(u9m$%Tl!J@G3rrsViC#3 znv@WhX`u~OuuQOg9&7Qymb@XuM5_%9e2gW7TNulDC(N1`5FZ|t)1GPbk^R~VI?ZEJRW@oW$D(IDSJ z?dkQFd#qR~$qC$@pDj01DxLz$7)71Y+&aBNj<_jxGf(gi>kxdk!V?k3b_AY2XUB(=o9}h@6=LdzEY=q5y8gQKW8T zgb0RYg-0;FT?^?dIr@X370QDw_?Pipcat|<+g*52z2^_TJpE>&h$Iq5K(M@p9wBRN z2qH?{4X{PdW$9(kMy(1-lDGwnch?W`MW650LazkOULgYy%aln}pvsr;gx^s~`PK8u z_yB7LltX^_{`vXc9yyAiAann(D`Myytnb(|NxyOEkSpp=zY{LKTh9(ApM6+EQ-w&| zG=QG3&ZZNMpRpkJ$%8dEmSdCx?x+!6?_FOcxnzfhK0=nzG)%M{Hs_${WnGIGZ)E_& zhSZDC8G`i@VA`-z0@mWidHo;n?{9o&3-J2REXz(JTm_k18WG3E-0a>sybiCnAnL6h zqO;lzr4EmDy9WiuULqrvOiU83#8v%ecIHw?G9yn{Zs*b#kltYG4fsor%x|WWV9(PW z#n6sZY~#sxQbFW30~_yGd={(bTU<->1FR5k+@q78j2#np6C+4KJ};OdjE2-=_2s~o zfnWwjJf~UC&g1={E~+;k95y6)RxAC#{SvNZpHQ*$+cF;a> zr85h5rLlVQoZd_ML1}8?(XhCGN7#XuM}rh@me#!LY^zr!1Z6uvtF&(Y$Z~mg@tRxd z^wFO`%H3XfWJD;)bXzh<2B!SR{En(w5PDN2%abXWjU)EjM0As@moGK~dQVnQ7avK3 z%>P8c8+GxS)i0&W6O1Jo0Bj@JWK!_i7hwx!B22zK2j>di)k25xxwgIFn~QxiL?SC+ zpU4EO-+I0VY)Hc_TvC^WEC!S;N-ijaxA9d0row9he+?`Vz_A32&%1a9d9&e48FiU$!5HJtPV{t100s1)4piCL|G^98761^V%KC%6c zZW%iQ-S|3kk82vmbQj5Wc-cN|1*J&HUD-t0Edx6VP4x+wR-}*M8C5)Q1^ti(kY!Lc zw=HBVLr$GRTo#}~_O`N?jC@`qU_y4PmZ?Wije`ANny4;6Hhx^D%PoAq_mSpA*u&s~ zPhg8nt&IO9*+Ds~>%l+rnLRZ66xnTUh%a;lt3#R_rRLfj?MgV9)9WOFbKLMkoK_O+((=JJn{Dv1 zarLLQjL0dAgKWHpx-9j36?fN+mO5NRv&IS+5jHt>hlVZKWK5o9oiD`J0<2y=m5u>jX!mjMyT)D!ZhNEF990}E7=@e)h&%iTanh zb>5_h5RPWyv}zKGS{58BVK3QvLv`?^6lm`mQ@?fpdeF2?-}hN*h`7#nQ8)$BcAp~& z+J}8{RqV^f%&nxmf5f#N=AnvFnGaZgt^RI7(N?9D?mN|xBlZ&|r8c+7G{rRx9-Bl5 z$>jr1z~9fX69`i|22i=hT&7{p1Ttg3w}-MMKewDHSHy9zDad?l^fG}E_eG6s(Azj{@16kNIS2lytU&wV zvV!B9grK3p%X^1R?}Wo}Ro2mHjh;jNL_2(;cRvY2t9}u!^}yk%EJy%f2AWe`HVzJ<5}!F9vCWvXR4J)1+mCvL$v}{7(WPy zELKAwm3mTn1Rpc1~fvXo8*tZL5XM8QJ8TJn==j!q6hEG3h)2k?aPxffrqPKjzBp>+46J`Pl^Sm6k44aQJ6j~c2dPhH= zXuICD*TU$FLwIYIxx0bQVR1v7TOcPfD9W9#9_T9mHWrZ~vjY=k%QfA~Xhlly8k$MT zMAxSkp@{_zU=;c<`*O0*`Psyh=WI|dB);c*)o0<6KZq9OI8?K9qEV2dhRAW?_}uLg zPe;ArF2ZE*wBXfFp4P*;m6E@p(Z{I7SpZ*}3CGdU;l&)<4YOa%9&}M5+{w78>nCp+ zbiOe@bT}#99ZjaDPV*=tggPc?Ew z#zlr$NCXHo@z|&8YwNu?6%YjTe>EdD1O`2*5{YDZ0LZxohC6$X!{}prsr~Ow4QCG- zROqFti|JAr5jnW-<_Y%>i6yLI$KBS(;N0lNS;Wh}YF0B-U^}T&Hr-Ti%Wpqkn5^?J zdZ}7K{N*@am4#SF-AJu7B}z!vfcp%Yf!<4?qvNkrqe+N#d#1*~wj3W#*gN2ERtDfH z?dqNAu5%`S(j#LULgLX3YTB%5V#gdc-nT@vA#r!URcnH~rYK!$m8eQ|b6Ln$2nj#d zHah}^c__v3-rEcv5_~X%^^PSW#OCL#<==5E-|c2$3zuUYsQZv439i?PUK8eKM~vlV zhhF^xLmqE(jJR@Sk>d}M6^ECK(8yc(=_)^7FpJ0e7MJS3O5pZGOS%w^N3L;!_?YEP zBPTZgcZJFoLcU8(8u8N<9F5$+Pj(1QO~q$B3tzYkhI+GwKv1N;vWcV#;omYu9XlAI z>h!69D1f3@zRtPX7PDg~A;iE=+6zcjtB&~UWT?SNRsAZUQyQMJw-JZOb_!S=MKniJ zVwM>ySOiLc%ZQDUoJK9C&~nJG4S8=&KQ9TUFes>JNCme4_Q_-mPCJa#JMac7OIb`= z0numpj0%O6$-pG9KgAL|U$O6ht>wiHO3Z-HeE|?BfRaHO1|Q5>;!Y)Jux{O)q5Js&@Hdy-h{A_sSn{X^e z7ZZ%}hnc=6V@;qM@+2CMww2vuaL2`#jn;k`7xZoYFU0FPf-p-+x>mqBj&@~ZS1qo;o{zG^rMc01Mf^(e{UE}bqlai zll97n7{oW^AFU^P%7Xm@L_8n@k!0iSJ$nTHYq;E{kOhL`&?|slc}C1eH%+CF3XW2^ z1Vlj7b2ZKz5$X_*cj039cAiPO-ydAM@V9d!_?#Ts);^4F45Z9!S5qb(BEeuG%srZV z@#@a+dukZQQ0`hH%Xak$7?xf>h+%U(HS3AiH^WzU46h9@N{Ywh+O_Qp7wJ2~Z&7@D znuADQ#0%b%c`wPYeL3j+nT$ljm0?+==j%&|IdUfGVy78^$7rp3&M(>tZ_!cJzfriB zUOw&FdnEvt>0h@|(0J3QQ-a&dUVNF}?eVJhoD?hbLn=UD`g#}1f@rZUTnJQGCmHh0 zX_B(-lFw0xgvq$tXYKP2J$-_uyjwv?H6$ZTbp z?^HOCcsy~>>tCcoAk^S?+XaQ-K47`Cy;IfYYXvajH*)-U@0+R?LiY^msO&gF6aEwP zo?Ux@@qv@3lN;Nq#>{hys+Wd8l`NKwe7eLBM&98@ho~-Ps4*d$BI>b1ou3PgABG#( zj(uf(Wo+>DaDP5u5h4VrP+N;YDQ?_D%iNpPt_OYfJmp4zF|>+={erK05NWw(r})4t zeu{K)ns7f(#II(U_-IXVJp`7@QnBVoEK5?Pf=9ti%z^!Uz5(Mm>xRubl#Q)fRt*C^yU=O_eUR)y$2In#Vxc^&qV$# zKWAGgW#GWbK&A>_>ny#gvSP@H2>Su=x_ir2iHqUgb7$&~2&KXEk7}21Z^v~hIFxsu zx4mYeX30X8azKkM;~)>Xrvd+#McjSZpLW0dbz zQZj5@&OA_!xA~PNO|>=%PR*hkFiMR{m055{(MJ%!y&a1$!6F`^{PtPsYoP<$Z=_)X zEy+gbM%oq<;3LKYu2!;E`Zumi`QL=xQ{E({<#=Jfon`M&f1t)W#w-Y5r938IJ2MV^ z_EOe_WK5;SK&Fj?@R)bpLu<&>%V`~B$-OGmE85PA3tdQbvzC&!+jncO$N#@#+8*qN zGXab#kpzp-HfdVdJ2Fev&ozFuH~s7e_p5|>-|=R{n}a?HdmnE)c=ez~bJzTxx(S1B z8*mBIFBmW23I&he8a!04W@enpx*KUsYC{-CiFX)P16HiFlq7YHAXQOR+O$w9csd)3 z)Evp9mHy?lX9e2Pfdf?GvgE=k)RRBu!*3I8>7#@PM!)LufZ61pf|(|mJ3WZ_T}fme z82BQt?j#uvNg%Bu9SjsHdbg!SmXG7t#0O0O#k_!@evsiGYTw;t@V@qbm!^#-d}M>R zj$g-@DRnx$NU#^f-F9lI<_*nwu2cor>>t9YxP^_lq^cjd~Lg9 z+pfsaJWv!^sq)=-k<2UHP9DKu&P2GviB>>x514Hic1kY-)x=Cn4NK@9}^%+Bheah{oXF z4?N4neRcOJD4*7Zz-%bP)3_WucL=3-syy-@MC?-Nf_O_sp_S1gNEp@$Lo9PFdgl+;TVI4WH&ai;;_wm2QNN(tFDSVzQwqmAsYzc21;t%`!&xT~^Pwr*#W_`i?)?-N&~YTJ4> z9SR9U2?CXBLLiz2WzttjV)aheg1lXE=@>r{+P3-vT@O@&-CeRM+dHcHn}8MSD}U&- z=^_)L7E0OjJG0Y*LzX-6iQV@Uy5tB$KXT%KVt#iE9~Vf!Vd{x2JERmn>_md#hc??2 z)zuX$WWQR#T*D8**f}Bu;gOP%kiY^YqTeG|n2laZ;xOr22KW4)odYMWoWVb*;_tt? zK+#sw_A?>0qU!T=atmpJ%En1-|BzA1!^pah-=A%j(UzW;OdydoIE5l4tOhR)a`yz% z8Q&IMP!I+c_9bs?T%-r^+|~#k!@I0D%RJ8N0fc4WUHJI zw#z3(VaCPIMyYPNe)cDObF4kMxvx`0XX)P%&UsHczZTbydbK%=w;xBCloqn<<$$#m za{CTwAuR>ZDb10r8GoxM;4_-0;G#dA z-vZRFN5J_oc>?KjK|8-5V#f$tu!?+6fr80*O$bj z&lZnP_)$w}65j^cfmpdQWNBA5kh97$v4mWDSAG85Y<%ZJ<{CS-Fno0nc!8*58gEy( z1O-y|ky05b&0D8O$ot;<_(C#Q_+aF=2qk-nUMRbILPRQceme{YNg&h#`x^DUZJ#+_ z$l#2RjtWK;dg-HBDSIlO6EeMZsRjwQv=NGX4UB4A#`S=Eu)5I36uWnmZ$jZ}{a@E! zuBlKbESen)@a;5)WKT_y_++bPu(WNUW7GMo4l=>^iRUr-3-ivWsU{1B#E&MpD5xM| zNe>Iu07TNQGeW{BT|GczZpV2l8b?etrwA1^F`-pB|D!nqM?>zxwsV&&76;4d#v_kQ z9JE%$tRGVPWRZQ1ctu%UF?d1%ynr(5d@ae9^GnIIHC~> zT$3QA`wtek4JT0Hc>WHGijWOX6%3fF@>7B0P+IRLzjH!DT3n5{a;l*d#^VF(Qwv~s zTrf2%katUyBnjRpB~x>dwWz4`9!HdKb{VpunBW6PBt_^?N>_Ed)==ikoqpoNz6P!p z``bJYpX~t$DE#sO2)q!^Z1GM0vWHC_+QRFQlMDej< zl{gGduB)(Zk9lX~xKDqgCQJ+>QzBD>OU^*{lB{uh)g}a(KNON$Et=R-SqL|H;^TJo ze-*ypDOw&u8nNQ_bb~Xr;jMEGu76$WYXEdyz(!C> z-~kkOBRT0n+jN8Q{SYrm?Bdm*i~r(o9uwH+?m)r|1u&aE0MRjKuU#>7+Is`=WTi^4 z)VmBR)@o6a<1dM70Xj`MxKLgGNWVov6anlO0WMfUMRA+wS5~fSyP8RUPY}T(EA!Sg z9x6!do3-sBW8VCRxG#1YdJVl<_xz$UJ+bvT(e+}=Z+rAY1Q(dw;6_NQP&f=8#tVtO zr;tYeK5S4?I`)1ePs$$2(G$n3H4F6%fj;TG9R@>jYpe@gJhU|D;~FLbQQ|g4(}Sb^ zy#Qw956u`mZ9C9H+w|0Q$t6A%hZE#5%Vr^5k<ZAG1S>}K&eVyd?kUN>uO8vaTJRUkp*R{&dxc4 zMA_i%%1SXLfL`O#5ghU{T3F3rtqAmlzQV&FhW8~4a#4+8H0tiCs;qpuIKSOD#AK6(P%yGiDwZJ z{bb72UEklJ-ZSJwpOt-EGc?$Zm*SfYifE*wNYO0=Bj5+cBc!t6;3i=-X{9jQCo5co zTB~)$Y&Pa30mhABtbfKnc=w?i{_JO4-6JD5BM(=;r=K{w94(&sf%+k-i0^@=9|{)! zEdTF>b`mao4lPqO45#p72?FErHUzAYkA zKN^j*BU3@M&@x>xTC*Wi9Vg&3b2^=B-4hq1=h`K&)UvW*zxVx*NrBMrFC`25%2$bB z!BO`W$o(A5mPauhX~0=C9(Wp^D!G8WO!T|S$2IOwlnC#=qQD-62>XO(c$hk#r6v#GuJ{cgb?mS)!7hK zC@N%vt$}o;|Ab&IaX7-3J^_SGzRzSMN0%<4Yt#m%07^bLs~m=)DsOrw++9=571X*R z`a?SOLPU2^s&URsyFRs{q`Dw|c`#ktmt@xt<9(N^3OdnmIMnL`GDPG>%hz(5vA_hh zC_x`;wYCmA^27<3Ad2W&c}&$h{a>E6GStQm-`8qG!1#CH56OBLzR=4SPLk)RyE7DI zaT0@9szaOz31kVSU#AesjsqJUpX=?e=C46n<>03O%ZcoSeRXZKssfg187Gr>7SNii zm(`s67VP)P8h-4fmCBTuY^<&T(XM-S>g$=Jjs_}R@M3rX0!PR)vyZXL)EO$nMUhGE z{*mD!`XpQpJ|?P0T|}ywM8O8Hm=zLUVtY3>*A#w5(G$ zgKk%K*ES{)>A(gSLIAGVnznzx|L^fR+c{jY&7~eY_~Wi7iH+nIJY)Y6!d}1%o}Z3E z8YP6PaD(&Hk9dDeSK+x7;%B|veX^@hqJW=7z&2bt8t#PMM)oO zQR4l=o2$S_;_*fP-;p=Y3E3u+YNmMK7ao5W{XW{R+uezvi^7Tco%y<3m+}K|AvOe6 z7_kz3>NA;SO?5Oy#WJZ856CK7Z?b`%GBbXiG_tTG-M8aWx@|`$`WPEF6E%E?0=fp` zn)qyTs1md(=_S=&3$2XI>n5trG1u<@%962vbIGEKnUX1z~XPjpMj5(Dq&_Q_MajePi)>Xx$I z$@cF9wjt8&fR-YQbBjzRg;PdR*@3@Q{Y-J!1YZ(IwMw%F_vf%V%0KTpukS-fZDG=l z1W&|Qx-N_Ut062vq@3_fP$q5W;(U8J;u?X&re}GvSR4I?Iy(-Ca@C2yTQah>$P}@J zR&NCmS}U_5Sa26@`^oU=@LN}GYP)uRX$>oO|H<`OmsHuar&VjvD-MWICE?h&*an9R zyzOc_&Yn$%$Gfew!5({V!3$cU>bVYJc`gj+6JdS|v~NUPz(?v!3GeM^Fz#>03*&#L z^2d)`I(9w>6Nm12+zaI58s^6})1ju90J@P!ha>r8h0X%SFv++8XV@dmgrJ*1);<}B z5Bw0*#))~qd)kIkQm2|{`Pr<2A_Kvd;5}?TWR~kl5M^O|%B#`=P7alSh$ zIvMx_?$v5_o7gJyosv#|O_U|FmZ#>)Lb-4K-2o$l7Pm?R3u0U38r1+4bQp7@A-7V; zcJZSy!P>O)GGil0FFUr=;r2^cftN!c)GkZ2B={o~Hv>L$MY-=X5v-wXIZiW`K73eF z{qVdD4-Y8aNq_=5Jb%SYJC78yzVb1H4$am4?Y(dNhcRMwD(51J8ci6yAo}1@XGn&y ztqzjl(V>-Uv@yM4>lnyH`3Z1aW6H zM`5}gyR%i6$|Ph;NN_&s;o4AQsC`<=V+JpncZ1;VrFgHvR>k)2|MK5tDvlVK3Pkb2 zasB-ZC7iCQxS{njE~{GyiA*7o5Tq{f^~ok+$!rwMM*MWaXPM4b@>R-JUYrv5A6rRJ zA@pDY?Oc!3oLIyLWClcE4vHq?m`GOVh7!4l%lEf@nP(XZ4DIV}F20}t&3EwX>)c0* zKR`z*4}b~$!1vrz9lH<>rGl6=!C`tR>5 zsKK~wZ}F=pKj=gS9s~+?8CAM7J8w10Le_%{R&(;#fQ)x+Ki2{jn2J``9fNedM$qQm zY4T}h*d$CM5#1ympu7*C%z4izMIO+Wz|;c@A=Ga=_^71wad(Albau zq2i{;Z9Y1p{6@;+3prU~?rS{iSY+0qd>(0`l_r3Y!K3gL`DiTKiP|eXO-be2I#7D@ zp{_;FV zHsxWHNG{HJjAQW$pw0E8?qT`Xv!`q0!ns40lH+D4++ueQc@JfWFSqvC*K>iE>6+e` zlxiIU!r<_He7?V~`ho7f!||(6a{Mw_|32yczf*<^-Nf;-r=R_r@#ud);MWhNYcR$j18UGU}j(jea3_WU+#~n1I#Dwhh=>qMJZ*m+s&%5yw z_6^7E*WUTZGUzn<>?OEasLd}KQ#F1DjIZl$Aq5unS^{NvR24|drzadD{fn|I%ZCR4 zJM^21c_ZEmIE8rWqavTgt8uG7K(3Mr;9|B#rkqWn3q|jzW&=RKOx)7yZY7^lQ*m8E zfVcNgN8_M9UkSX{+0Uo80I}i`Sil1N$bMqq*9DG%d{if`hFUrTJ4b@=@eny$5F3L7X@aRZ`ob2TQ%?2%M4XSEg;=THgj- z@>0fWyP{WT-L$=u}lGm~K z0TT5h-IfK3;3v9pd9*atmA^x@FI>=L^&9Ncau(zJS{hw7Ja_&7Fm)Z^RKM^0EFAkB zBZsnuN=8Wbu}4Iy$Q~7G*g57o$Cj;O?}~=eAY_vnM^TirBBQeR{J$US`}<#4U)NRN z3+MBB-}ia$=YH{45nAUeJe;l(FZ~xznQkY z?8Gs6$w%8h<(x|PinZkbh|D4(QX#&6rRirPOcyPAR;d3x zWi2Z479HX)yTbv&FH+4bJG2E z+TuaDq526QHI&0HfnI=4-2AoZg7^YAuBg!1bI5h)8?!~(4L!){CS4*kD_x7e zQ2Js}iYC4jG)n5bDh4KRg(}vyIQ<3r+pKlg*|v_@oAs@=3os!rt|cP8^{|}kM-w`h`&4vpZ!@#U9Xy4TewoD;m(x_m{!o)JAkm5as~~SY+96iW4I5B z+nGUDm_gdm{IQp0JO&J;=oE{uFJj6uuQ2cCc;3@jSH`JqIHjr*0|_lJm<`u`my;)y z=FVw8A5F&oI;GvE6Us9Q=>JDW6u%rPUF@&RmKW(RRwSV1q(ay?#}!k;dmt6bNHeas zWc2N&tf2WQKD3;AiD|KQhVYqm+|V z+03%@)Cb|r-6cI?{&;ca#Xf5ZzA~i4ZX;M%Fd5DL__19LloC*}^ke!_p5^*5p?t~P zAvYLn3{R>*f<;*6T+zV7Xaa?;_s+d}IK&54TvR#7>U0#&X>p>z;PBSj346Z92+InR zwo6eJ@=E^&MgHj5z;i#uF*`(@)BDrs;&&%da!RZ~5CFuT>g;y43Xai6A$m%j^1>+j zs%=RF?2a?P{=Ai|;8-ZFU3|T1>kG6T>ip-Cx9Ee-FyVV#7jeypPG7oHezp;N)}t|u z(M>#^V??n;$Wf@_Xy~uZC{k)tN$L=?blB>^MU{VC1Q22NqhGQ{MA!XP+fsBL#-oG! zu$Y&=}hG|IWV+yJDM+QG7N6Y9311|x7aDy19)UX zHXJ@gBiGv>kS}(8uhN4=1cLbZ8zFab zTt|uX)wWsYSnfui8iKj|H-d2kTd{U*#;bhxd zMOvHrub^)uPBO_(om-1$GExB7U!6&nIYOOy*YHRh<{(+`q=xnNir)-hFkf#^Iu0L) zLPhp6L^Et(pOls)hq1Q4S8 zHJEdrZMLr1fBaZA!lF7hzj*uSDpjt;=n(KO$BZ6cg;j639u`X8B1yh8PFfS;kFUM* z&BP?Nt+se^+5Nx%0T~PC7L`2~#@b34F+g3_EIj6M1F5Ns!c)V~vNZ#}_P5*d-y<0L zH_7IUNJabNE6~~ha~kv=H?1wQKqEKsNF>J}T4_l+P7>Z@92In|DF;_pi0o(nF6UZY%t8O9WQiN}JSwGwW z?YFSuz;iIDw$!pA%lUTE&G6expjrgYtFL##*<7hAQaQ%zgo8)R+=-TT_M!Qy&gTQ? z6hj!D3@uynj_=7gVm^BvwCDMq+miEm^y^^(KT{k(f`LP)OpJ^#+w{jcFubo_*!<-ajY5&UU)xH9`{@K^*A~p3trX&y3WP>cLzV*W3ZZ^1-EW)h+&nwY0&l zYwyq<5u|A9PN}-&%8#tZSr@qGmrq^Y$3#&7Fna~%ZV^8T`PoN|%n|K%;_-(37DgwLC)L68rM^~iyhrKVVW`%Dwzv~BNHGIH6gI>Rve{7X zpzaE#abQS17;h;VaWNzuliLv@8g3RrxKJ3+`!hj@Coh{*0e*OCR#B6c=d^13_CPOxQsur^Vl@QQqf3d{c@t|mE#l>YQ?_TvpKXE zgo*s}9JGa*fZd589YXd+U8lJoNlJZ#HtTpc8USRvd+*+Xc5H=reP?dtE)5@d2?I|U zqvX~1$;VLm8+VOU9NuUXKL5S{)#@AQTk*f3HS_IzuX6+ONF<(K zlknUQW{(rVoE2oy$BaowlG_irp7b%35_~_(uA3?oiVKVe)SvF3N#7qHkOmPPpdV4K0cd}G_- z>5xITL-Uy;S7>=iR$6FD9uhjJ>Q|}q;&vqQ<*pd)^KRwFLFm9iNPnfaSw#aHwqsBf zX3z)`U{FJ;D#1NmKm%39(aM>Yd?P=ggmTdNrTw`d4 z2u*cWHR9YKbK4lHo#Ne%V(cBHQG+Yae|*qyHl}1GCsVO6RK0UKw~q)iUv(bN;Tt7O zRTVOC)jo06ioxFpt`8e?i6V8o{nIFY7T~jh9sC1GTRV?dWPJVm5@tdR>%!?Z3D)40 z7#?MXGv^0ZMPTh`;@5Nx?cP?!s%~ZNt#KWc_YO%OEe425(injw0P0Z+O?yEryImRP z!Edb1ubrtWNIlc7X;z{s1a8Vh9gB;y^aA(U@3I63%>~?8?Q{v0*5+$gJQH?#{Pv$E zxf?@ZCJ}>d>ui**GhvpRduFpe9p8kv5_B#S$-@`h)T(UH#}T7zrPkWsM$qI(Z*Tcd zSK9Y?luvLuJB#e7aQuz@PI8Es$P%fpOy#seV1%x_sXoo2V0)?SZKj$kDBiksZ_=|? zufD)uW(Wv}a!UDRYxUjZV(tKV;1P#4fnS|e{vf*CU{Z>|UXoHQsQz5jvgld#rha2!cFX88 z6e9NiaOvPra?SdoR22~Ou*C%Q=f)ZzD{|Rk33rKH8FZ8%NNcocuG1O-SBPt(=)-} zm9-u}zZw^`!OOt_I0g>U0?qQxJK#DRdsmFoDX;DFu7F6=$?46!8Op!@NCi*=e;{{c z+X6tjl_xBOI~W^l-Vpd!g#*?$5xxDc%)e{Xx`DU!#@njT?uBUs=u{VR+I=4SH3wLq zKVHfKCxwlVB5*DzrX|-zaehmO1Tk&8_i~;n3^7SX-n^o@;uS=AJF*@o!Z%WfJ~yH(X=L-h|i}H#>r)z2kI{wR4Tt@g6Tp7FkE@lV4dzdY`m0m@J zC@r7Fj(D$B#*uXic4JF+Q7#okxH?-jI5iB%i?8qDMYJ+n_2_#+7CXN7*5}VJC0)7k z?9XA8bIMA7>N0SWKPbQ+oWOSjl4E0^er*%TUNaV?R#_jr%TF!vU{tv+qJ7LDT_wQR zG6{&+7q4qfQda&9^ziP?h0oFPp(6eO_ax3t7|TZa#rv70%D@BgW3-zte@}@hGrW3r z%2?TLRI`|d4>1g!&l|<-iUe5j2)-w3C2#x7RN=;DLBG|$`1IbZ`CYYuSxDH=tI$m8 zN_i|`g+#gG4C?Hh51w8MEcLl55-cgl_AFAbxN1S4fMh9C=NB0UCz?ZWyuG|vMX@4G zK4?&NyGK>aiJ5v6AfJT5r957(*xK~_Ma;66a-dqZ1}$JTRRg{4=jWG3Xd`G~eVW?U zT6Je?8vSSXN$5gd{H(t_j8`?Vst-8_BsEwqQUJcD))ICZk5Qm1J^o9wjmKDo?>2mK z!{Tbhl($~5>{U6caXBr_Ai~St8co?f?_{DZuP} z(9=cF0X?x`zU1r;YV$M&FJ}VmoFHtW#?eo>`iBSH7yvhwo`=iwoBlNwGFyM3x6lp5 zYh2$h^$j>)2aG(4oG;*TmN)^1F{z)Z6kjyG)hOD8K94I#Uj(_(0FqxY#GkFWRl4mp zdZOIm*upw=6_!*9T}2=u2@dcCdVlvab*aG@g{zliw{&+pPbk5Y#H~`n0pM8cw|N9> z9hzRB@DTnth6C*Z60J-+>GyHxh5+m~4t9BWXM2QE_YV7D-$OQjW>-^FQ#AP@i`x4< zCtjA_^QYHLj?c%LuaIuQKQYn_OMoB*;EV17L;x%FgF^7f8CCv7xnA%s!lI^LG_%H+ zurr)Vs-d;bmexfhrlTZkaMe5Lr(>IHbF4ND0ZsViA~5Tcq3n0^r(LI`>jQLwg51uR zxW_ed%~|^&4t(uRBQ+^HQ|}5SBH$RsJN_gvbmGTd&kV<;k$i!4VqV-)QF!pz^L)Jw z;`*jd*4YlG?8gN~tLwhI`x`9)YA?whBmz>W8;=N8{`^*Z!{nUy+CF6w?~sZh`b$jo{-UMWYQIj|%V%?y~Hn;mv~a=L})@z#K^j_3-yB zqu(tnFt)uZTp)q?W-s32HFp*~Dnk&gSu77FNPR*Ts^<9#W336n{ZXde%D$eyYa`(f zFl^O!D-~fP!}~FiSA^E(HwWw5H9hmLO~oY3!TOuEK@%T3AkZ0UX6aT;XHpuRkKQ`Q z4Cs=O|3809O5AEhAeh0t+UZ1wh9g}>G0&dBNiU3Y z-0Dtn?t*ybuYH!AxTg9yT!E4k6CV-Y?fvt83Ml4wwf2Yz!itSYr|igh+*(UWOKmP( zh)-a0Dj+M0RJFP+SNb{c@#J=KCBMiYiG8?(V}p`=!}xs=xl;{?Ur|&}r=7fcXYN4s zCqSMkda>I*!b}1R=Den>L;UU5wYp z)BtmXG3%-yv5NMWZKH{F)T)eB`h&XN{)3o^`+t!c1f;*y>cWKjc;$Cnw@>Hu$jZal zx4?y1r$b;pZg{^FQ+O*~>4RJIOv5=y%mSQ}L<-q}C4KfI53lvfZYNG(9Oz*&Jj zJgtYiH>p#o3!^7pshn0h0KMM=M(}n@M<$`dPKE?){sRmk>_Zf*V5ZN_5^j}`gi-r@ zY!ctC&Hr$yW3NI;SeD0I2P}V*0pzO8?ce>!H1WD)%Dbti-*+DRhy6W#-AV`+m@1fh z*VJcbgt@QHjd<>Ct)G&-k*9>hQHbZrB{v_@WH+gzRK0few(=gB6|5?i{!G)UAG)ze z5lS9{x=tFD0g%>Z?$uo{u0Bxx&HqTwb8wUU-D>!CPb$e3pvp>tszMB6_4gN6`B2=Y z@>HGdn$I!t1lPT3Q}9`q`AH<59N|!!jNAp7lKYr$)f*q)9=&#mcC1?enI zMqI>Ih?feIXzGBpo@J1PCPNZg^J?Exs=lxME17?xZMF@f0;XG|TD1dW-2s6^$=))v zzkvn9G9Aez|4dBnrA&H~XJ&h&ku|Y>H;LG+fDTMOrAtrRwmw?CN7f(Lt1Fy*P;~<` z_E16)@;@vZUMjIAzk8za9RZeJ50Z>vL*jT>p1b~iyVWmW3Q*|{ADb=X*Sj8n@BG=f58fgj#EdtKStFr9nxZwCK=1kr0o_`--S z1972EB;nUpKx$8cTj*SN)~o$aJnFmsaLFwW@ACqQ$+WMT$$2_)+U_?PtlWof z*V+j;r!Qrm%KS*#k$p7Nv&~DLI+S!rXQs68;nKK7zKp|Febo(M05E&>K~l>=tTs&j z7r5!%H?$5Av}WkvyL5hx|Dqimcz?u=o~#DsZie^Z)G`dBh3)0-s_qIK>O<01#0f6R z;Gpw&h*h;OV%#aU{+X2I*E}>wM~s%UnFf#T_tRfPQSZ3ll>e}Bw>+vfCfJ*c%0;&7 zv;J8+q;h~K|08A!^N#CafCPI=jmbvb?oIvEv(s1_n%bnw8wt`6M6O!Zv?~6k#mG!T zd|2k3;$o9<;tJYwp-NXL(rtaq#MB ztPxo+p;zw-6@T0T>j4YG-CU?Sw2)YGklNN2rRjFFoiyG@0&WzJxZg&^GgIzhBwGIN z$IS0Xsy##>8L1df4cFh>>D!fmv^!7KzqS)}-gA@aHXtlk|6}22noDeRTPQt8?#i&3 zVL4_;fA1xd%LMq~@y0glfqJ!SXh~WAdbEH4DOQm)41P3lSNDW7mqEe|pd(fEW&J!% zr!RJISf_Bk4Dsvkzf?wcf2}~{)oT3(P)T>uwqiXNMO&vrTDnhLw=dlHi!vS>h|<_h zyVJP2dv4>j6nh0x9kcb0a&Dv3<($IqS^fMwC8HCWdQ)ZgZ|#?APq1-eWR#rJCGj0# zUNC8<{0CFqN6WCN9}fHJ%}E|a%c)-f)$iU>fpx*O%y@M&CtC=mE#NuE7KQ&ANg96T z%tKuqoqUv zD3366E8OeKQsouk4R-sm{`f436N4(QBcn-l@G{t`x$C2{`@!-KUPc3Y7V-&JzVq4w zY$H$mEKL~7K1^TfEx7ly^YpceE*}2)dTlhfcvF}9xY)ZdSUR!Kj(6g(F5oKu#z_+G zk`XA6GOo46@z$!tgvP14Q^z(1XF(#paY%sYDJ^MXLt{>3t7G>n=cY;Lhk5U7iiVpl z6SDgeZ`uHy%nZ2SDmbW(g80^G%>vL`d}Bm#b`zgUrSR5Aa~TBIB}R}eU$|){pNL_k zvwvQsCN}r?V1w^6#o+7mj0IN`hLXP#39u7HFj9t@Xl+#Vn(oEGo?u}^!L%9o`E68{f`Dxr2^P0*ruwGSs(QFVT=c zF1G0j@SMTKju~L-wtTS%{GXs{9zVBkY+HVxG)8O)(mQviF-V)iUW}1lPDbCI?2p@z zcZP@g=9vzLGQEP_T=*=|BoU)bv2!pYOD<0kFM^N1uGgU@HpctNz_03~WC@OKnPSTM zMV`+nSZz|8@4>abDn9!#)Y@q4_!)eX+{I5qA9j(UrmM%?`VwUNo&4t9#=PpI*WNZ# zcU%*^U8W0Xw!@^QKD}?s71l%XrEsXHw z{@1x}iQSfoAnSJ|UV_Yl%otjuy4(-(U2Lp*b48X3ALDjZ9$o7>w^8id;M(DNSeA=l z-8PWB-7|V0Izgup1%h=IE9EaAW(}X~ePS}7IaV$2HrfO@*>g8_w|<;@;UF~E_N(~i z=LvJufpOV?*-luJ4Z=Cr?$0Qp(GUE$_pm%C3?)$q zdJQd`39vPTr#*YzHSvYaW?z(_4sewjb?u?b zY<@6}auW5bwh1^;6@W(TnsBp77NLNK}a4agbXN(4y&_VB>dWvymwTq1ljA2Zx`S42nUhd$hxCy-@#!9459 z=gkM^i-mgKI{WC&_#${W9llrmEy05Ulzb`7b*lQ~&LZ*5lUC&Ur6J<{bbxt8ND^=- z`q6UE*XO$afX(rS5LMBTI{WT;=AEQ_J3XpP1eyn;H1<49={DgKndwP?V+|NUh=;9R zn;#6EtzNZ9**2+$;N5{WM({PmRGBAosN{95pmxZqWIvexw~kQN&c&3$!uK7YU%>i# zRud?DTHSUi-lP=A0vi|jHtT%Q6lic$J%2`$d>5shR2=e@ryMpU$6TeJkqJaLoEJ#lzXE{ zpr6)a-yMgfVQPZ$jUg_dw^h;k-T2Fhhr}vufIb@!)KN{=32P!bMh#i&q*BXY?aeu# z{j0k^Z2s$L0Zwcw06;opXGFAR6xOi9bL!kIy5W(MQ`P#$!lcHbu~z~K1gS$V`f>gu z>@T#BY_GgIVkN3X)mLmZ)t0X>i27KNBNgI^+@dAs&X4X1F~Exafhvv`USfJIuj=42 z*a@yV2;%1ZHZsaBg~9G3G4Ob%6J^z@G<>7Hv&NCc-UG0KshRn`Xpg_n0tkTAzD}^z z+#0qZ-c=*msC`rjx~DZntLQ#9XdSsp$k9P7*~py4T_7wJD%;!LHDda3zeJQvUWO#p zd~&^HYx(bfmjreq1EReg64nNwi%So81&qm5Yl7elhWeT2gDzqx0u4Y$Mb)Lb)W}Oo z>dM07S1*^+&KsG`EGJQzH$$qB|8N%yB-CKi_yrKexr}VzCrgF0T2b<*f!m7iSmE_F zcRy2oo0_5tPp5lqdDsIYW_0{>;6mBiE6TTjo6(bRdi1PoLb)j0gLVw$u_!R86$8iP zsFHoE5ZFoWI3Di2T^2+%>{Q^^T8UQJG1yC&JJVg&EGi!RnB^+;QVceBZh<@+NXw`G zVe^dLy_zx>1by+_r6kqe`)VUTrsa%V%d^5=r z!$9{E`$Tf?>p{6&mc_7BAcL)?jaR@5jBOrtAdS~|7bt6%)=-3Go)W4`__y66Koa93 zFWmx9F|C2OJM}x`!zP4`H~h~^uT*;I7@0{9Z9f`y^Y1wR#8^4aO~_`g3se)!FKZs1 z5|4agWA=gmU-|J6B?L=sVK(wc;k9AHsARaBE}2a5?lF{GW`YI5bY&2^+yl~5aV1Gn z|EmMC?FRoP!*+FWye<`m#5IB!dXx;NnGOp_yEG6WG@@52Rc=vPwV^NNKKJ!}`gzK= z=hi77C+(Yt6J-wc;zx&pCc}xppR%*j2n|EbYf^P8^-TthK-UYJ4M2 zUdD$Iiy}68rk=MVCSPlgKPn9csvb>!;?8OPKC85k-aHc83_K=xOmat>S|bVi-4SHf zJi*F;@h1g}jWMk$0<*WoVYj`#TSyLsaw8xT@bdBsRdxgRE!yD@fz}lz;r*sJ?KAw9 z%aZ2u6^8O5r-HpbCI(Fkj_a?T0)N{OBF()4d02PU(2TM$-qCRcwUfV_gWLMi@rja$ z>H8>>^-$gF^pt(V!T2ZW3;I9k=4Xd?!|(m1NWA0YKcqBB0?u8cOtTUrjI9NcGPnPl z(HUe%qsHpPVG+aNzg1pqsQX5l>~5#RD^%PPbzgM0Xi^jgvJDnk@dG4=2W+OKgaj}z zu)hT~>(sM#%K`NzdK~Wi2Q*iDZKF3jk|dHA&6s(QnBF#KM2@MvxC1w^cueRUypTqDj3WC_z|Y z#bmYy>^xc$F&r_P(NX3<2!snj_Me;m`96f~om(dUq5xrIZ78b{F8CXO@L`InBRP(l z`>spgy=e)^{$96*K`h<3vG6)q+g zoJK=5kr;|D8hDUE!1o-KQ1nKw#TmK|*O4bhArpgm$`t;+GOW$}bjt&(x^tH5ydirJ zfoFvPk$^uDD7)(F(3nJNP*8h*n5I4Svs6;TQ{zLE;MR#>)Mb)&1hJ7t%2Jeq62r`^{O~ zHZVcM1<2X?Q8Rje(0k<209v?|*HB z)#|FI`xg>#Ls-_T=pm&M^)($9w%sW=bV#ojfUSf4_#e$N9{tXY#fo~Lp` zo_z7?as3MHi6Wl=_Wbhl%E*-*>a(4oX8$+)eH4V?6NwVsfuNTTBLJmNQym}rHuxmW z^ME%E;+s8M=4hX}oP0F2R)3y?u)R+|qP73tZbJ3|12*))Fh36HLS0OggYacWC2X^X4c}4c(!}F+P1eQ+X`$d5@F(UnYv|i)jV1 zYHc#QVSpD4;qrvX;2tZ$0vK3jF+)k{3_g;0C`q%rMhSKt_W8%OPuBkP7ec@In2j>; zTUt*(GA*mHW>FpFp;v_fBBW$UIEsw)k0M%dW?*YYs zA0VBiskHDRQ{F>ZVF|OFd?rgJV~*jWhXgM?*=O+djnfnNn^c`?19}Z2!da-Eq`X#X zsrR>T;RWU)`!Q4jO2|x={VEq&ooEoab>N!mvUk{U5jw*_>y!#+fK|d@GL0alS$+Vi zn_o2?3!kqcc@RUXQ@GB*H(Bx~4s85|7hQXl$k5EpOzUp=nhwt%8pmbpLsc9^EO{DV zpd+!Y3GIxRs+bh6vXuT*jH;r11|MZR@^-;LF97J16@gygoIcMEAfL0T(2}`-lvlsq z%?#X_(iN?_^}P~ehUXX#MUXxr^7~6PG*L?aYpx--{4l1bgT}eMG(J#$d)dQ<@xj{{ zRKP}NuY{&0Wd&|ri#7l*0%%EYpE1=Gi(HP}BAr7~?n?T+oM83mc7Ux3l&*rO%s$gaJroX!f?EJA%M)B4Z-Gg8@TWBMpiMx+(w^ka$pukG zlPqpila5=`K=4(-IN6gam`!1$%x{Wl^pq-wYCRfbH^^UQBBQsIcM&ZKwWVkM+8lu@ z8-m^z;51+F={S!n*pR4xaVdK&5zQpWp z;G%9QbXCNZ)%qT!n&eWveWNx;M0U=p;DyTQF&}Cy9O^yennLGBFTJ{|Wi7(xZ~%Fl zczO*6AsJgU6rszcK*G8z8IYj|cu1ZH64ZmPR59PGns1&IJBOCE9`ii(!pa7b9fJQ9 zK}>c3Ad$uUxsBlPFHU-ZH#@L}*jzHm&Y&IUblu%v4R5LM%24b9J@+07;(7wAV%jdTt)8r=e=-W7PliSMQ09J5Sk?Jy^N^shI;&D`WTrEkiK=Zk z&oVi^)Z?e+wHd#h@(=WFqy49N@0_ds?nl|%6*hZ4i^bcUy!Qg{bPp7udc3o0x^#1ep{+SE~cAh{&EJ&Y<3SpCn8luum zYzL9QgUy3@GyFWorRHz-4bjnDt$r3scEkS{grHn@0I{hx#tx2t`xzK^8X{e>Xj&6x z0I1cYbL;CJ4`lil@%gaLCktIuSK<6_7>E;s&dqH!9izP;m|5BB|u_ zrU~uAt1tBTHbW$dh4wh?PWnFl0t~1MDBc)zsi<_1ODdX_HI4;TZb zQWlo`t!HUIiFy3j@WJtrYmZoWE9^)n-n|=hflnpbLd`2JpofrK^n zQvHj$ZaNA5I>?0Cw$M6DhRe#@F-_7KW)}4_+96@f8=a}LZpY_%JcC_IOdEbda2p?h z)@{mL41=pO@hIUaF3uls&RIIfiz>n9cO2|99p9FXr*sLCjYe&(PqX#3JW;{gS7iwfhyf%PneO1)%8<*%g2#ekN1emXpF^$Jj&4#@9Q~1Gbzdo2stf z=Aet+UP!VvdNDs&#G1+Rbh`T#W(A8w>4%ePXbIBfUFsWSz8h3tEt7}aD zIGP4@EJ2;6ITY6}Uu;=%0%hD#h8YR7=$&*>MU#QI#HS{_O1@;GWM7c+2!KZtpGD%> zTC~e01&%Xng1)YU!|vY{%z^ODT&%Hm6#+D4Ip}M!@htnR0NkcqNM7X~T!z@RV*Jg4 zBv`~+i=xhGR_6RudH$OgNp}~|{H?j`P)`9vW5yoE0lLz!>ETtqt!G8QY$*?hb=VkR%O8}4cfV=h@k<4eAD7BE_;XA;=)#?_7U3aPZLVuO&T7zX`AH{WF>K8dNP^`}FjJo1xNE*TSOs)jc1W0M_^jZ}5LSx7OwfssgZu z8;^fblVkmfRmTp)5}D&y@08t-5Xq5X-u|SNrMG6%dG^{sOz(9l;pt z0WdaoAmf`4k>ux5eY8HvdCX-QyO1xC3wMDeeas_Smut3NiT@_PW3Qco9{S_i7HM!`QouWJbM;VsU2 z+7TBZALB1bXr!%=(f$m$k)B3o)k8U!72#IOMHdE3F7wpKMLK+V_>H^~?I1L@1xP}m zli(yLo(R}jDrW-5Oms$SvbZOIy~@J0`G{wF)`KR)!Kl?0Fq~EPKuSC{o@B$s ztiUBAq$Xm-jg0&TbbTLSxoRe5>cI@mvEYJ&hd|XDnjO~N>|O<%zhzlPaEnNbS#llQ z{A6}-ScKnLw&jVjUHQkghy3~lk_}>6KT;y&E5Jp>ts3v-3+4+dZ}c^_>}{hfLh-71 zIZxfGB37x+H8)GADOFZYOfqO}-33ZCB^MyAW?4I`yt`(R_2g=Rq3i$XID>2@h;W)P zU(*F2o=X#!LpO`B5|}G!JdGj8YcmA#*_|ak;zyszrs6-_DSn8zr2^boJ18_GmX~u9 z*wKW@3FgfSTpbo6 z)0)gv zS22bnH72|^QaJs=k1O(wvZxcNSDR7wc#K$Ep-x_5iWZ};voK1HE6+eiMgF#-SkFeZ zP!&k~7{z%@HGeRIaVvt{D^zHjcg{v&-)ZH!P)tUTnXAOz_@;gy#u} zD~B{ycb}YnVs6hTU@MM(X*m%JL;F#)o`!?^62pVC+coJ+V&{fd{ z_Z1W3=6(Z~4hR7!j?M}Wo@IgggpDc}-Si1NVbzH=2K^`x(}B;wwyJFYu7@Of1G+wk zw`lMgcp7?Ee6l)56LndVUOjyNe516{=z;ujJ};;?>#V90F$<&N56aOz#&!AkDu_E$ zQdxd&^Kvor55_~`cyx?axvL$4e{?$>eZ(l*|NDuqC=OW$VCpoT{v>5dei>-6)FVPw zbC2XsfO_jAuoM0m%_&_p*L*(jb@arn15~Y%%~^-#+ckA)ud$D*#ftD{==nGM*?)Yf zS1k`qnUH@v@my0W($AMTE}Jzk_v=KlV_{k0vX9UI8s0GwyC#*seH;LwIa%OVExUNK}fyzG zpo3d`s;e=$agRTu!>0>WOPw?_vgm4_&!o{ikI53Ri zI%;CMau134yT`TaOBrv8105UWMwin-s3~FO|2U>2WSN|9dY|#rOOLsE=r;p7`Q(?j zx1nc5eL#ClGV>Q()jOY)w*yqr=vlTw{;wxFVB^azuS^Dizkg*(G5Pe{V&KI7oH7I` zyEj07<5Of5j5# z`SGw543ilw_Ty3uiI>6pFf-o$>4LbOpKv6BWkU$@Q#R|`#|^%Y2@6aPGZ3}>?IJ*9 zPMfeF@1PG1GWm@aC~iX&_$08McG1dl!6;H1(JF8PiO3z`I7)))QsOnzc! z((^_cGq^G3%%?(~ZZL=R4|(&FYyY@V5pV7NgCqeVlRH-qRqXxUW*0Q>h5?3B_C>T$ zByc|@RLAe$`9>OJQu6p|K%h-L7#05;)aqL0r7O=iqI8B2LqF1390(~|rT@&0G*>GA9EB6%GUUDK0q?Vadj`6cE!oHpwq5ND2cj4lHB@fe6N^8s(u1Un`Z z_$m8BXW5>j%~%*ndwf$AE#y+;{Jxn5x%G6wDUePLv4W6qZnS&F|;o8NIEJ z>ybgd&(nT}YGkGU$Jo@Q*I0cilJ5te?BUefkOQmZ#K za$-w>93az$VWmD|ashT(##TW=^akaX!Cj?o_gU3W3)qcc7H$8f4-MhrLu3u@3C4W{ z?yQx4?qMGMS(v!j_z5zl;B?vqj}WAUs@)S5bntBp9ofAsy+B2;X}|)dI;FmE_+*%L1Pt1Rwqe#B*F8Xe>F{<1oX8b)JApJo* z1HsW2zBkjKp-XC()QYhWQF-iapS?i+aqaN$*tub4zm+D@beVL>tJen!;Vio*ABPIo zFB|D#ML~<1a*SG10UDA4qRTpnvdnY@Qz8aq+k!O1H{X0eAeixkz2N5I(F?vXft`7! zyK6d~l~3gbZ(3x%TKucig-{9^;Og7u|L)s!KsX=rxC2#(3rz2ok(Zh$?$L9IKLq;S zmVyQb(FRAED#+1sq(Nx3_=ZwT*gn%0hR0K$oK6yd1em<=Ro=|DaAw}v2`qwpeFwL0 ziWolr=362Tr#YbCbhle%wzlf0wDQ~Y9_3$eqr+zm|HjigltMI#hJLDqex?VKUyv^f z;*qAIVG>#~*3PnFDM>ipU_ao;@nKHbg)hd9YXTiITe}+w#dVohy)MdanWiUJ&N{Hi z6*MA?_k(B<;Z^C$-csa?0(IL4IX}Os2cA<0YD>_*OTiWxe@AB3uf|AAdK&RabZ+SIVl!Utaj< z{GcYo<-}pK99y3T>qtVXV+l(5vzrw81V{y1N+y`?*4-BAFeaKAzs$ps9Io3&2`ZD~ zczIWA${aH+haqXrMAoYsVZvX7kNtcQ41Lggmc@%wOt^K&aE%netN52udFUfe@z3%Kfx)Rmz9`?EqgDG zL-3qKhaitg>Y&EBVz?Dg#vGIr*A5U4NvqX~;bPAknYw1@%BNXbXO{-_-1$*_FW9)wXhJ8Un`bD@`o z=2F)VxX)z}Rc>?STk*Hua3=2QPI&7!D8? zsz6pTo#>xDzo)0^;FuR&l#4f?IV9GZ9@(053Fw*)_PLX#kvsAAKWy^=;IGe#k5~7M z2ZXzg5zaSEr1fkYJZP}-VU@xlcWJW#D%JZdXkvz-dy=#EYqtpVjBn_0&YYeQMS-3A zJKr27AJx&r?;mm^C}Z0@&;^8FQWSR_Q?EnZuZ9|0>E<8$In5Er0RkLpt_+Ka-6evb zcvr-i24_j{MAd173i?l#I(TcUmb=-!k140jT$dLaP0ttUx;s-gK#LSX z!LOXdi$(?I;x!+i`%ts_b}6n+<(+ZqtEcX>TSXB!XO4I{2A;TE5;$*j9lDJO8l3ZK zn|)>EHYmKLzSN?iPr4pWvkL$Iowk-1j5qmo)*d#HX_Dn&C!u;Ri7&+a_~X!PX4;c2 z0odXni)-TXZj3E2^IVm2RBXn0I2Bv6Cp@@LPnBwhgrF16#y>;Mw|I-KYh=(6TRD(w zgHie?gNi_C9*fjOrxUVNr{A~?Wr9mZ65p=RhZDA>cseewmbU-;p*7t6J3rb_8>ZW* z!^KZM!t!zq=|P#)6whcJ!Q%e9Yl`YzG>d_6+&M4!es2wdsyVHt8V>M7OcL|W0W7F^a{^VM=cU2^mVIwecetebnCh%4>(s_nO`P~DW z=&ou!PWOWC^9~?ZeF&4RgdK#HbsipxJ-W9B#`IuMnWtZ6J_j~fK^%;j-80JU6{Nz0 zB6r&Hky(a*Ft6w2Cs?&@25?_~8le-y{-B?uD zhJ(Mc`qBft+M&6zH7R*D7SB7xZ z=n%DVfgQEmQ3Fn7ytB#EGuJF7z!2G!i9R}fWuu2nQ{~pdIze@no$6uv5gVnrEB+EY z-y^&hnb&#$9t&gJAg~Z3xNlh|d#40PUJK5!5nK`&8~a^-F>4!9}9+apWeOI z>FM@Ub|TWb2r^NK zaJ4FyXe7{djGBZBd`FY@POygJ!jZN3_3_@`2VV@rNNU5E3pUfhX(l091&p_TWR*xD z>VHn-5vnRbHVxSDS2*n;5iaX@>J%z(aQdcF95dTkO^i94c$Z(BooR)at_dFEy*TgNTdB~}&?JFMPgiZ=Q8#Mf?&7toru9BWEb~RP z?J{P6jq8r1!y|}AaliVCC;XDHt2C$v;f3GvABtQ4Flrrlv=%x7f?E!pIW}9Hm=50p zzk7S1mrm693fMbGv$$6c!QL^>zCvEp$UGc35K0aS#!@dRH#6ilMAwmK@}pB)%zQj$ zA34X)iS3lfUFlF%j6FD?hM;ZpzCS3?wf6UwdB~4rTksea{#fM1zD9gQ)D1Y=be@vZN5oR*JH(A;k<@tTRut z6;dQr(qi9+vXos5WzA%-L4>ls*F9RE=Q!Tu{rZ0SKOY=V9Y^l#zV7qM8(4?IrVJM>XSX=t7LGt&qk{ExCt))tpOEVs5$mg%n}HUs}v^e?X%C?LycmROfmhJX^Lw^7Xq6^ zr{juhLq^sSRf99wM88P%tU*}f`p{TPiZKoF)5dU0L5S%e$7lBy)&%-9ZeRTNO3?j$ z7js?kC{W^T0!83Tuoo!#nRsXA&`J{q!x$lD~828mqOHbyli@<;w~&k z^3}+de}u!v4Th{XGl^kh3+ldb*s-J<5({!HxH|(LZ9tmtV`oI2)ldwO-8zGl)U%tJL}KA4CdONkO*TWZuA zoikugd9=zrTs?YWH}!sq)}o9EmUhW^Aa6LUSq>3|B64kN{MpAI$Oeh6#6T2r8TuXT z;+kB84WK6?(5k(SYak`_h4Xe%1bLZ`y`cg7Hk;c#JWScPW zNz@(VT?D0+M;gq-p`#yjTub<&efGN+q5itmJy(`*uvp$vh}l2oSJNOCu9<|pE9G73 z1@&ZfM}<84rI0ni8m&2hQe@!yO|)(h^*Rd)OG0cRWS!d66f%2>pj6H(tmeOmTw-8I z{ZbxDl4j)-Ma7Ps{>RtY0@=m81j5PIM%^X75@)1*Mt9z)&cVUk13|Pn+=7^*uKCUd zkZT>8~@%@6;fZx zBDKcmyN6G_06QHNXBcr_(*&xCyD|jIT@rMJkw^1!9`CjRQ}bjEUCz2TzSewQw)SG% zQgYt|jhgca`sCyxxaoc^HY6fMu1+iI$f}iX!}4r@se?_!oU2h;;4k>dq&6Wpk;E#N z3K;GT2WoiBg`|3->{^eUY##S6-AvzD+1ki+{=D?5DI?I+Vr-G3pjrzLcmNBy{YowR zJiWO)W;J{7OkQi4+PC8wQ_cF?av3*;8aY|xa{E$ov4~^Ou<>R7gv%KEz9{s&(-tOO zMYsGKyN+p)&`|2v z(W)(c@)JncQ5tt-6Ud&?ozcDI#fGP+_fbHLvqZxS+9LmYJZ2`*%KpjlBSf^Kh%5=K zi6WuyM;=TwZ@5ld>CG3xAh0yGeWts+4~Qu9rlV#m+b=x_)1z=a`$fPA(BYTM zer;kyG!42@14s6hypesQSUxglebdW1*K4JGzb_o`hCGHU9v%D0_=}xbyqVeN%|WKL z)lrs2l0^a+=luS1A#Os)y&I|L^3Z*tWRx!1Qc3~^>MbN}Z&rwG|20-*uxX&4^Fwjs zp0!Y^G>dh((x$UN(J|%{C_X(Q#M18hxaQSx+&bt7PKwx79;_IOO~^OPdlg({QUEP( zCO@!xJnE;zjCF!t#vxx$R{zwc2sXSk!ui12aEVw%dWpZhT^nXKcd%g-L*1zUyOiJ-sbFz zy|k1n&_P_5A2p3p>5Tj5``KCbANBleIw}WK^u)0zu{9rhdvC4H-`5%;p5A=x%edu? z#GZSnfaBYc?DWupaGW4sp88SZ7;!nO;ri?2<*QwnZUI}l0Gf5i=3Z1ib8fu}FKk$` zt~l?ZN$z{r8D#%(O$;loi5?Q$`njThStFyd*mlFw0%DWC%Tdp9ce)8#4}KkBFHdzC zVDT}O_%o3x9QmMbt}&eGEin5z|K_k?kHH>;A%jH6!&!A-s3fKdP)hiEs<0K5RpuB7 z1@B_vM{(B=4$pNSAUy1^2pr4TA#jGyUFNelY`NjO!v?ya7$}IcIrAcnCGJ=J~joBWiF^suk)# zB#=N>K(MByQ0|t^_Jgv#k-{i?p8M`_u8cu9Cv|_1GhejJOS;}IJ!up&I*gmu9T=_h zyx%q`R$4D5k(J3lSzuT3EmG89rzlYh)2+%|WYLnL;xp_F1%Y`#QiZNSuSKDLGOHjf?}wTUeE|ECQBPRBN;0Mg zC8;W8iz8jUiLR~iWEYU49uY|PDJqf$kh1s!R1$cyLK8`?%l_RtZs+~i?;#6=@9v;i zp_f#Y#WaRN2pZBpH@vTAM(YoPw4yd6-Okr(*un})>SWg?Qg0ry1iQ7C>sd}>Pi{&y zjU*ZeVLmk)*1TTi6xK{j`9gfHSj;;pw>D}x^-X9Bkip$$M)EZ#e1{}^j~ys4`l!Df z-%YoCaO~dK-jXrdIt$-@+RQW+uwde}|Bkud612C;+|WR#S`vr{*1I_PT?%L*$u*FM zNWoB|{Lzq8e`&kukdpv-qf?df?LRcz&-uvJe@jZAyZp=D+he8(9MzlY-dn6-#dCB= zMxsGgpM0TvXyKV^ogO$$v$fN2^0}|CY$3t5_xz>GH;Xa16`mjy;tM&#w>lr~2hcVv zr73(FIw_r)7WoWiX~9AVjC$4D&nZ>^?e#e;t~yu(j^LUvofj&;Len-g{Vx6Ngx^;K zKF54SYHMo14;;M&y(GFKB};o|{ghLN-o8?|{woXyV0Ic2ftN#VfE@h@7I|gqf`wqc zTU`ubJ4aq=qcx%vB3=gGOpe^;KXp%%@?=tUX(*(%#>zIC_zy(MjBY@QkhRu=2J~k` zG=yJ0NbLP&|w5%|Z5q&xZby`t7@kvGb$izYJGw2tnU5Ea8gvH$CL$I@SE9T#2j` zGgSxP-;o4agXOT;tLG~`Srvyq+hd%|>|+!Np=yDt$$^8KOz&N6lK0W+ z;~$Mm42#YCLg&<0WD}$?W}Ug4vW=u_JlyPvQ9Ar?|9B_mGcHITM!xy^Ufqk` zmW%`XofI*X3%!&>`V_Q|BP&7C22M;KabQX*5E1WL)5j7SR^RR8^^)6!&MS0a;~T=t zQ30-Ppufp$<12exkL4^ccbXJ7Dil~ z2KM$jR#jJ53zlPUU<%!P3O^MrkMq$@i1|feC*p2keBa)cq{vgEblq#vn4wN9tbGeMmmM#+v(QJ;3s39&vB^UE)|}&|Gkw>n zll-JB$K+O$u!0Za;(dYSXekLkk=Rca{^HIJ?+$v!6=Y8IVK3>O=uNz0=^E*Eu1)8W z$Nl;uRj;|x*5;E#pq%ph3%qmHqZ1hZZjo*o(jMBH9`jJDqKoe}6QV(~L;m_*Bn9Ht zc|XRrsNW~dg>2Y2;PNUkW{au#V~^kWNJKsY5?JgxzK!8Kxk_UY%*|-UyUTkXvm#PW zRD|l~DFJB0lBoj3+J-h8Rx)yS5RbjXRV*(d(wj2>elWQ!Y|Y@1R{vQKD>QWblI*>& zVP`$m#UGk19+U6}^FI%tcln~;<|JNeq*AT!A&SJuC$m|U8}dNk99wk5Jqk_`#`)LD zveZ~s!-{SQV^pwOx@OZAy=WQw;$P5rL(~yt&6c&}2z#ZT%wak3Z~PZ(J)Xma>a67wdQT*dhK% z1Js4t`W#d~*_WD^8s(6?^~>9B2qA6ZgnnJ9iJz#Wqx0kV=*=ANL^p6ai-Y>ff$zy7 zB1wHLv)88jphEM zQ&@>H@2Xn;AEI4ODpBjP*3igCuIY<tz z2Fqq1%^vBAp?6X8PKV?wd}u7*ZGPeI?Af_1e#Q+1%#JmW zP;2GKM$XIkUMnE+l8A}5mLdVWEHUUMX%W3n-v;UPIWket*BtE&`}O?s{lE<{RnHP4 z!;hzCqqjz=6K(|UY(6#KI5#{s%sICjG!y!;-7g&9a4w6hN#6W{$3^Cp)Ku9XY1Y=1 zN2!yl<*$mJV%*4X6QG+<70PCp=jo8=6pfonkZej}PI+w}pBx}CeckP8*-vtJJ-r4s z$aS{yx(DB55BuKVUkt%4ag2)fK2^1<1fPqv9liBM#Xp?Nn!kZ&K+9i3?J5R@`j+`u5+gD(&Y`9wt9!p%`RuIY z562Vs47l_i2;b4W#BRpXw7C^UN@HqZbH9p}4|h)b;^*Kn*a}g``~yah5BDU2oEQ@b z-9~$I=}uKW`lySowGO}s0~C;3@)?})y5fl3UmvH>go>T zvtS67Q$g3=Wr>q<9xKeb(J>u~p0ySomSuWDjn`+_Ku<*CZs-oX{5#@{7ca_=lcT#% zK}~MqI5=4Ryoz3pZvF>JEiG`eIV1Ip4S|v57BNiw?bq0lN(LT!?8Rtf)ep<-1zPsL zL~pI~>MQ++=|j|ykVl10&mBTZTMr zkAUQm)u+Y*7rHfe`bjKHNQ>WZJ`p5ilmG?&AlSZ7YD3mMxv<2$CnV$WSLpeP9&xYh z^#zW*W+!I#*5}1JokHID5NC5$>g`O9)PYAqLitZ)fMwj~P#L6<4u=lRLjYX;c=t9I zp6$H*_RX7w7g|8Pxs3scQap6={36&XgBd=ffoFdr9X1Pdz-ZnatFPK{k%qP*>b4X1 zY(Kf#(lyZ|V(jS8>GeYsUzH6?V(jepuKtkQ>+wOY{CmTX-XH^I_x!KJxY)V&fq*$K z=&2UyGcFZ}PUCb2Txu$|gZ(h*1d^`u*b)i-{cabT`uN95i2u0kpCrh_Hee8^=f(yY z?&4crV#n2Jx#KA1M2&g3xM-@=ZvC2&xG1x(_%Z#O0hPxj@R7kLNc4!+;njKZQwK9$n6 zAG$lA)nqZA3I8xF08DIIh9`sl#*=)&Cy&a1E*Wrm=d2^2+@XoE)#)1t+y!T8z11Ib zPZ{}*^)Jv~{Ul;!@)fdK_PPb@#ovrk@2CN`8X}DT+rPou=Xzc95aVBn5U%%ga(Ciez!wvRL7XeipaPBYX1?c7m<( zT*xbdv$$C29Zo+yQLyF&zjpvQ38ilST%oV*s_C+DwtTgLp9uKIHl9~~MlkCo*0&!L zY_{>;`3JG9fG^fNb+N33p+!o(*ynrOY(vshgFSdesn!=i+2OX(zSFoM`-<~2-xn9h z06jtlE%-U0zi90CnM$p4hzBaLej?hAZYd>-F%uOQgh|574i{TPHW6t768#MBk8ztC z0Gub&c@_;f-fUe2AEaY-n6M*!FF}+bY~R}eT7zKF8x_7InIKvDbjR5BhU3g@s zm><|1W#+`qD+ydOZ_rF!_LThHX@{*C*Nomc;5}WW#pb215YOyP=w&!K8yr3?IRWi) zAseY6sthvH3`IbZVs|WlEg6((h#7imVR21R zxtn&O)j(MuCleSF=!j8i8_S3y6xfzsZyXgndkb{8HS?0{F%(%U7=vuevPGz6EOAmN z)CD-#$`$J%D1+WuC$l78At2xfz8*lKRA_kLuZt!S z6_8-<;0+zI@=YLrFYD1$D56==_LR#)sVo0R=-Rp1<+uEcgHJKF2jb#cZa+ENwAoR=EC24tYv895|A>508h>frnzl7fgV z0$TF0fA1oZfe0ThiCFn^Bo8VB6gxQwSl^4EIO>F}stP&!b76QqaH?A0yC_@gUR+g{ zL!jXM%GO`_6Nd!Pl*7D=~Ng%XLQs(w`%zc@OQag6YuEkfVKkqja&10!P}xiNUo? z?zd9Z#P4aXmmSr{IxTbpLNO6de|`1{SJO)|u$QjQl9+a)PCtLkkF?2}Q_PO2(8$oh z?M#%_XYAerF0VZP7}xaOW^tj)>0^^z8#ckL9Bh7ds_(pXBlFrpiJ!~MM(aD3mtr7N zAB=HQa;%@mb7C$%i1Xvq#x{eV@Eka+D~Y!^SmGlMf{n}H-0M-)*&z)fR5%ygwul!& z1C!O|)vydP;1BP@YR%PSGxjEnA=KmEi2N=vYdN*?Mg^E$^Kfg9bR#`P1>_xI>&I~a5(LoLS*5fz&P zKcD-a$$(omOCpu*Gr3LLn&Jt(g+^8!C~&_oM9O^XP1-S6NOiBTFanNsigAjV22faH567=W3 zPreIY_y8Cem{zpmweI?{%T@!pPn=9o?2FxPGBWRFw~n;}WiI984XZz+0L4xx{aE(= z9a#YaJOe}n-*MAIMGS1Z#ol2O0OH>=_Pq(mFM?`=W6Vk3reUCU=q<>iEztXK=^RUg zd;4>%r61@O@-?GZPrX{kk=vVp+LipZ%9%v%%x;E|5|JKzsHFiGsD0;m+Swcijs2Rd z^X3nbCveFYOhTF$RX8?p{#;PLl=M!fG`L%Ie8;&DD|QZlSTA7UuK-0Bi1yUkut*0n z)}SHaBcK9*u1<&D&Sk@ao;rG)8f!%tm)0xe+7_yD=1p`B`%k2IR^X7DlP%AeGZynY&i@|%K;+1 z?d?@BQy+Q!?~~mHCyPq)JhXALsBM{&-pdPLqf#SlmgyCp_X3q6XiMWkU2Ti!fZK$} zme_V4=v?k2s{8(<6D6Y`Tv&k51W2(i;JAhCY}!Sa;fd@JPjP(>+vXXvY%NXcz|bzCe68PU zRdA7hF#qZR2{8}^zs%kOs?#;V*!gondnZ7nBRmxf@E#zD`M5sR@B_`yPU)Bj%_oO| z=(fYZp1*ysc~0!yH8~beWd_cem#v53ao&Gn3qW=IJ5m^I#f(zmsIM;zd!sA@Qh~(GL&|n0q z66hV0g?GtLfT7$?JW>8sk)%*`EPIo2GhD8~^H5%0o`xJg`xjEoGI_vfSD$@&!I+_| zMO#zAf~yNay1n8(y1ToR*T6=HT}l3=qE!iaP}O}f!*8r`9_+K30PA9m;iX;b=f4=X z@Vo!Ney3w`FKAere=YSm8~ zaVg=N8mKovZm%x%{Lj1NHGr=-<&&Xthyfxu(ekzR`IZlS`I~|Qf%`ZCT7khgqEhi+ zUr>cZmqZTd5bo{bB?UdQuTra;sbil2K2yOf%i)uM5?*X?Er&?uLZyuxqJY28*3k;9 zc+~3>P!=l2!O58ePwbW41eb{0DmBfOE8(hFzBSvj2F(oB&QqDTWCM_AbSMuD43zr> zZNzenn^Xe4$HPwt7by%Aujb$gX6XeF$AIeYy<{F0kW_~eqk_E#7$a3k4E?~U;{4a+ zYG6V$n2cJ|^^ceVx1!9OOFuzhpXrGb&sLFhcG5W0Kf*=&$=27+kL&AvF%`1*3spJ! zN@3#$Dp=q2dex-6aOX&U0i&Paz?n|aDxC}nzxSQU6|XkXW&n@;_C>?ypl@f2C)EO} z`TecA>eer3QCmzIO0}U`4lpKc-M|E3M8gX~D(`;a;3eh5+kN3>AP=vE%y$~R0e+s^ z(IGI9KI%{taADvrLZf3X=t=+%^TRQ6qc-7ro1gy;2KSip0eve^J4ZH$ACvhW+5Om| zJ>iPHIo-ao|3nwg+5SG(r?LgT(rF+JCb|CDHGA_34~(-qc%&Kf z$ln;3hz#HccmC$Xf;HpLU1g}TWnitK-UTq@d@fr6^dPs|MSw@$&IqBG;d*BP1b$Ql z%K~OV$L1>h8Z|0)IEEP zWvsNo7_ypl&gM&SkRKu-x7>K9q}&XRtTj4|gnxJ6nI$mjKKKhBgxt6tAfuR zWO|K-@;(Xdw4w;wk{B5lc@xYXfAF@tLh)aZFiwK&#$GM|O2}B~5^XNS1AA;m47)#q za85$^FadCI~CM_^5>RfF& z^EuFmTL!W)M1PLIX27K#6z`_QQ_J0}YKR>!JfyXr1O+kpbh6Sllhp z-_iuG0^6lQSL85QYOJ;`^%j9Q6|fL35S$zwBh>4wPGLf!OP_SlzEZjR<<>^*{OkL8 z0&czX-3zOY2faN`vnVAbO<<8+Py-m9p+=P}kzrp>_&B6|K3yNpI{`%8q$Si?FY`ao)dv)Y z;D`Fa4f-G#WVQwEyz@ABVc`CyyQe6`cu9@p7Q+fc!ZbAZ>&^f;3R9Kewe_?5;$T=< zCJ>e%ndZ(Ev!FW?lj(K-v@?QQlFY1Zc{4Ocf_C}p(NBDKBvto(BIDag8Sd*4NrOx4AF@>KaXE(3$iSS@ZnnJi$_I*z?` JEg(3C{~tFIcrgF~ literal 0 HcmV?d00001 diff --git a/docs/fidesops/docs/img/erasure_graph.png b/docs/fidesops/docs/img/erasure_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..4f6775e72ab41a696f2763b5be076d93c6eefa4b GIT binary patch literal 71915 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ðg?n=g;`bvvIv7Dfm{^xIvX*Ns@}~+$&I^f4S7H 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 literal 0 HcmV?d00001 diff --git a/docs/fidesops/mkdocs.yml b/docs/fidesops/mkdocs.yml index 8a2410d2aa..6aea2e384a 100644 --- a/docs/fidesops/mkdocs.yml +++ b/docs/fidesops/mkdocs.yml @@ -29,6 +29,7 @@ nav: - Data Rights Protocol: guides/data_rights_protocol.md - Configure Automatic Emails: guides/email_communications.md - Configure Manual Webhooks: guides/manual_webhooks.md + - Understanding Privacy Request Execution: guides/fidesops_workflow.md - SaaS Connectors: - Connect to SaaS Applications: saas_connectors/saas_connectors.md - SaaS Configuration: saas_connectors/saas_config.md From 13fabfedec15a14b91eaaa5ab42ea79573cd802c Mon Sep 17 00:00:00 2001 From: shawnplusplus <46225246+shawnplusplus@users.noreply.github.com> Date: Fri, 30 Sep 2022 10:30:34 -0500 Subject: [PATCH 11/30] Update docker command on privacy center step 4 (#1410) removing the typo ` . at the end of the command so that the command works. --- docs/fidesops/docs/deployment.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fidesops/docs/deployment.md b/docs/fidesops/docs/deployment.md index e490bda3e7..b283dd2b83 100644 --- a/docs/fidesops/docs/deployment.md +++ b/docs/fidesops/docs/deployment.md @@ -172,5 +172,5 @@ After the configuration is updated the docker image can be run using your custom directory name to match the name you used) start the docker container: ``` -docker run --rm -v $(pwd)/config:/app/config -p 3000:3000 ethyca/fides-privacy-center:latest`. +docker run --rm -v $(pwd)/config:/app/config -p 3000:3000 ethyca/fides-privacy-center:latest ``` From ac8e9de61bc6b2c823cc283930c5c26b324dd9e9 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 30 Sep 2022 14:33:02 -0400 Subject: [PATCH 12/30] 1319 consent UI api integration (#1407) * Add consent UI back in * Finish initial integration with consent api * WIP consent page * Get initial consent updating working * Improve button look and feel * Add untracked files * Format VerificationForm.tsx * Remove comments * Rename Privacy modal variables * Rename variable --- .../ops/privacy-center/common/hooks/index.ts | 2 + .../common/hooks/useLocalStorage.ts | 47 ++++ .../privacy-center/components/ConsentCard.tsx | 17 ++ .../components/ConsentItemCard.tsx | 104 ++++++++ .../components/modals/VerificationForm.tsx | 8 +- .../ConsentRequestForm.tsx | 205 +++++++++++++++ .../ConsentRequestModal.tsx | 109 ++++++++ .../privacy-center/components/modals/types.ts | 2 + clients/ops/privacy-center/config/config.json | 22 +- clients/ops/privacy-center/pages/consent.tsx | 234 ++++++++++++++++++ clients/ops/privacy-center/pages/index.tsx | 56 ++++- clients/ops/privacy-center/public/consent.svg | 3 + clients/ops/privacy-center/types/index.ts | 11 + src/fidesops/ops/schemas/privacy_request.py | 2 +- 14 files changed, 807 insertions(+), 15 deletions(-) create mode 100644 clients/ops/privacy-center/common/hooks/index.ts create mode 100644 clients/ops/privacy-center/common/hooks/useLocalStorage.ts create mode 100644 clients/ops/privacy-center/components/ConsentCard.tsx create mode 100644 clients/ops/privacy-center/components/ConsentItemCard.tsx create mode 100644 clients/ops/privacy-center/components/modals/consent-request-modal/ConsentRequestForm.tsx create mode 100644 clients/ops/privacy-center/components/modals/consent-request-modal/ConsentRequestModal.tsx create mode 100644 clients/ops/privacy-center/pages/consent.tsx create mode 100644 clients/ops/privacy-center/public/consent.svg diff --git a/clients/ops/privacy-center/common/hooks/index.ts b/clients/ops/privacy-center/common/hooks/index.ts new file mode 100644 index 0000000000..7698472dc4 --- /dev/null +++ b/clients/ops/privacy-center/common/hooks/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { useLocalStorage} from "./useLocalStorage" \ No newline at end of file diff --git a/clients/ops/privacy-center/common/hooks/useLocalStorage.ts b/clients/ops/privacy-center/common/hooks/useLocalStorage.ts new file mode 100644 index 0000000000..c5ce125ffa --- /dev/null +++ b/clients/ops/privacy-center/common/hooks/useLocalStorage.ts @@ -0,0 +1,47 @@ +import { useState } from "react"; + +/* +Design taken from: https://usehooks.com/useLocalStorage/ +*/ + +// eslint-disable-next-line import/prefer-default-export +export function useLocalStorage(key: string, initialValue: string) { + // State to store our value + // Pass initial state function to useState so logic is only executed once + const [storedValue, setStoredValue] = useState(() => { + if (typeof window === "undefined") { + return initialValue; + } + try { + // Get from local storage by key + const item = window.localStorage.getItem(key); + // Parse stored json or if none return initialValue + return item ? JSON.parse(item) : initialValue; + } catch (error) { + // If error also return initialValue + // eslint-disable-next-line no-console + console.error(error); + return initialValue; + } + }); + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue = (value: string | ((x: string)=> void)) => { + try { + // Allow value to be a function so we have same API as useState + const valueToStore = + value instanceof Function ? value(storedValue) : value; + // Save state + setStoredValue(valueToStore); + // Save to local storage + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + } catch (error) { + // A more advanced implementation would handle the error case + // eslint-disable-next-line no-console + console.error(error); + } + }; + return [storedValue, setValue]; +} \ No newline at end of file diff --git a/clients/ops/privacy-center/components/ConsentCard.tsx b/clients/ops/privacy-center/components/ConsentCard.tsx new file mode 100644 index 0000000000..21dd2f5c9d --- /dev/null +++ b/clients/ops/privacy-center/components/ConsentCard.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import Card from "./Card"; + +type ConsentCardProps = { + onOpen: () => void; +}; + +const ConsentCard: React.FC = ({ onOpen }) => ( + +); + +export default ConsentCard; diff --git a/clients/ops/privacy-center/components/ConsentItemCard.tsx b/clients/ops/privacy-center/components/ConsentItemCard.tsx new file mode 100644 index 0000000000..5282ea6db0 --- /dev/null +++ b/clients/ops/privacy-center/components/ConsentItemCard.tsx @@ -0,0 +1,104 @@ +import React, { useState, useEffect } from "react"; +import { + Flex, + Box, + Text, + Link, + Radio, + RadioGroup, + Stack, + HStack, +} from "@fidesui/react"; +import { ExternalLinkIcon } from "@chakra-ui/icons"; +import { ConsentItem } from "../types"; + +type SetConsentValueProp = { + setConsentValue: (x: boolean) => void; +}; + +type ConsentItemProps = ConsentItem & SetConsentValueProp; + +const ConsentItemCard: React.FC = ({ + name, + description, + highlight, + defaultValue, + consentValue, + url, + setConsentValue, +}) => { + const [value, setValue] = useState("false"); + const backgroundColor = highlight ? "gray.100" : ""; + useEffect(() => { + if (consentValue !== undefined) { + setValue(consentValue ? "true" : "false"); + } else { + setValue(defaultValue ? "true" : "false"); + setConsentValue(defaultValue); + } + }, [consentValue, defaultValue, setValue, setConsentValue]); + + return ( + + + + + {name} + + + {description} + + + + + {" "} + Find out more about this consent{" "} + + + + + + { + setValue(e); + setConsentValue(e === "true"); + }} + value={value} + > + + + Yes + + + No + + + + + + ); +}; + +export default ConsentItemCard; diff --git a/clients/ops/privacy-center/components/modals/VerificationForm.tsx b/clients/ops/privacy-center/components/modals/VerificationForm.tsx index d90213049a..d5e8481a0c 100644 --- a/clients/ops/privacy-center/components/modals/VerificationForm.tsx +++ b/clients/ops/privacy-center/components/modals/VerificationForm.tsx @@ -20,6 +20,7 @@ import { Headers } from "headers-polyfill"; import type { AlertState } from "../../types/AlertState"; import { ModalViews, VerificationType } from "./types"; import { addCommonHeaders } from "../../common/CommonHeaders"; +import { useLocalStorage } from "../../common/hooks"; import { hostUrl } from "../../constants"; @@ -41,6 +42,11 @@ const useVerificationForm = ({ successHandler: () => void; }) => { const [isLoading, setIsLoading] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [verificationCode, setVerificationCode] = useLocalStorage( + "verificationCode", + "" + ); const resetVerificationProcess = useCallback(() => { setCurrentView(resetView); }, [setCurrentView, resetView]); @@ -83,7 +89,7 @@ const useVerificationForm = ({ handleError(data?.detail); return; } - + setVerificationCode(values.code); successHandler(); } catch (error) { handleError(""); diff --git a/clients/ops/privacy-center/components/modals/consent-request-modal/ConsentRequestForm.tsx b/clients/ops/privacy-center/components/modals/consent-request-modal/ConsentRequestForm.tsx new file mode 100644 index 0000000000..f0bd76d4df --- /dev/null +++ b/clients/ops/privacy-center/components/modals/consent-request-modal/ConsentRequestForm.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from "react"; +import { + Button, + chakra, + FormControl, + FormErrorMessage, + Input, + ModalBody, + ModalFooter, + ModalHeader, + Stack, + Text, +} from "@fidesui/react"; + +import { useFormik } from "formik"; + +import { Headers } from "headers-polyfill"; +import type { AlertState } from "../../../types/AlertState"; +import { addCommonHeaders } from "../../../common/CommonHeaders"; + +import { ModalViews, VerificationType } from "../types"; +import { hostUrl } from "../../../constants"; + +const useConsentRequestForm = ({ + onClose, + setAlert, + setCurrentView, + setConsentRequestId, + isVerificationRequired, +}: { + onClose: () => void; + setAlert: (state: AlertState) => void; + setCurrentView: (view: ModalViews) => void; + setConsentRequestId: (id: string) => void; + isVerificationRequired: boolean; +}) => { + const [isLoading, setIsLoading] = useState(false); + const formik = useFormik({ + initialValues: { + email: "", + }, + onSubmit: async (values) => { + setIsLoading(true); + + const body = { + email: values.email, + }; + const handleError = () => { + setAlert({ + status: "error", + description: "Your request has failed. Please try again.", + }); + onClose(); + }; + + try { + const headers: Headers = new Headers(); + addCommonHeaders(headers, null); + + const response = await fetch( + `${hostUrl}/${VerificationType.ConsentRequest}`, + { + method: "POST", + headers, + body: JSON.stringify(body), + } + ); + + if (!response.ok) { + handleError(); + return; + } + + const data = await response.json(); + + if (!isVerificationRequired && data.consent_request_id) { + setAlert({ + status: "success", + description: + "Your request was successful, please await further instructions.", + }); + } else if (isVerificationRequired && data.consent_request_id) { + setConsentRequestId(data.consent_request_id); + setCurrentView(ModalViews.IdentityVerification); + } else { + handleError(); + } + } catch (error) { + handleError(); + return; + } + + if (!isVerificationRequired) { + onClose(); + } + }, + validate: (values) => { + const errors: { + email?: string; + phone?: string; + } = {}; + + if (!values.email) { + errors.email = "Required"; + } + + return errors; + }, + }); + + return { ...formik, isLoading }; +}; + +type ConsentRequestFormProps = { + isOpen: boolean; + onClose: () => void; + setAlert: (state: AlertState) => void; + setCurrentView: (view: ModalViews) => void; + setConsentRequestId: (id: string) => void; + isVerificationRequired: boolean; +}; + +const ConsentRequestForm: React.FC = ({ + isOpen, + onClose, + setAlert, + setCurrentView, + setConsentRequestId, + isVerificationRequired, +}) => { + const { + errors, + handleBlur, + handleChange, + handleSubmit, + touched, + values, + isValid, + dirty, + resetForm, + } = useConsentRequestForm({ + onClose, + setAlert, + setCurrentView, + setConsentRequestId, + isVerificationRequired, + }); + + useEffect(() => resetForm(), [isOpen, resetForm]); + + return ( + <> + + Manage your consent + + + + + We will email you a report of the data from your account. + + + + + {errors.email} + + + + + + + + + + + ); +}; + +export default ConsentRequestForm; diff --git a/clients/ops/privacy-center/components/modals/consent-request-modal/ConsentRequestModal.tsx b/clients/ops/privacy-center/components/modals/consent-request-modal/ConsentRequestModal.tsx new file mode 100644 index 0000000000..ee4ad933df --- /dev/null +++ b/clients/ops/privacy-center/components/modals/consent-request-modal/ConsentRequestModal.tsx @@ -0,0 +1,109 @@ +import React, { useState, useCallback } from "react"; +import { useRouter } from "next/router"; +import RequestModal from "../RequestModal"; + +import type { AlertState } from "../../../types/AlertState"; + +import { ModalViews, VerificationType } from "../types"; +import ConsentRequestForm from "./ConsentRequestForm"; +import VerificationForm from "../VerificationForm"; +import { useLocalStorage } from "../../../common/hooks"; + +export const useConsentRequestModal = () => { + const [isOpen, setIsOpen] = useState(false); + const [currentView, setCurrentView] = useState( + ModalViews.ConsentRequest + ); + const router = useRouter(); + const [consentRequestId, setConsentRequestId] = useLocalStorage( + "consentRequestId", + "" + ); + + const successHandler = useCallback(() => { + setConsentRequestId(consentRequestId); + router.push("consent"); + }, [router, setConsentRequestId, consentRequestId]); + + const onOpen = () => { + setCurrentView(ModalViews.ConsentRequest); + setIsOpen(true); + }; + + const onClose = () => { + setIsOpen(false); + setCurrentView(ModalViews.ConsentRequest); + setConsentRequestId(""); + }; + + return { + isOpen, + onClose, + onOpen, + currentView, + setCurrentView, + consentRequestId, + setConsentRequestId, + successHandler, + }; +}; + +export type ConsentRequestModalProps = { + isOpen: boolean; + onClose: () => void; + setAlert: (state: AlertState) => void; + currentView: ModalViews; + setCurrentView: (view: ModalViews) => void; + consentRequestId: string; + setConsentRequestId: (id: string) => void; + isVerificationRequired: boolean; + successHandler: () => void; +}; + +export const ConsentRequestModal: React.FC = ({ + isOpen, + onClose, + setAlert, + currentView, + setCurrentView, + consentRequestId, + setConsentRequestId, + isVerificationRequired, + successHandler, +}) => { + let form = null; + + if (currentView === ModalViews.ConsentRequest) { + form = ( + + ); + } + + if (currentView === ModalViews.IdentityVerification) { + form = ( + + ); + } + + return ( + + {form} + + ); +}; diff --git a/clients/ops/privacy-center/components/modals/types.ts b/clients/ops/privacy-center/components/modals/types.ts index 0e4208c4f5..99f054c7fe 100644 --- a/clients/ops/privacy-center/components/modals/types.ts +++ b/clients/ops/privacy-center/components/modals/types.ts @@ -1,11 +1,13 @@ /* eslint-disable import/prefer-default-export */ export enum ModalViews { + ConsentRequest = "consentRequest", PrivacyRequest = "privacyRequest", IdentityVerification = "identityVerification", RequestSubmitted = "requestSubmitted", } export enum VerificationType { + ConsentRequest = "consent-request", PrivacyRequest = "privacy-request", } diff --git a/clients/ops/privacy-center/config/config.json b/clients/ops/privacy-center/config/config.json index f49faf59c9..0cef23f4a1 100644 --- a/clients/ops/privacy-center/config/config.json +++ b/clients/ops/privacy-center/config/config.json @@ -27,5 +27,25 @@ "phone": "optional" } } - ] + ], + "includeConsent": true, + "consent": { + "consentOptions": [ + { + "fidesDataUseKey": "third_party_sharing", + "name": "Do not sell my personal information", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vulputate libero et velit. Lorem ipsum dolor sit amet, consectetur.", + "highlight": true, + "url": "https://example.com/privacy#data-sales" + }, + { + "default": true, + "fidesDataUseKey": "provide.service", + "name": "Provide a service", + "url": "https://example.com/privacy#provide-service", + "highlight": false, + "description": "Manage how we use your data, including Do Not Sell My Personal Information." + } + ] + } } diff --git a/clients/ops/privacy-center/pages/consent.tsx b/clients/ops/privacy-center/pages/consent.tsx new file mode 100644 index 0000000000..bb15f29f6d --- /dev/null +++ b/clients/ops/privacy-center/pages/consent.tsx @@ -0,0 +1,234 @@ +import React, { useEffect, useState, useCallback } from "react"; +import type { NextPage } from "next"; +import Head from "next/head"; +import { Flex, Heading, Text, Stack, Image, Button } from "@fidesui/react"; +import { useRouter } from "next/router"; + +import { Headers } from "headers-polyfill"; +import ConsentItemCard from "../components/ConsentItemCard"; + +import config from "../config/config.json"; +import { hostUrl } from "../constants"; +import { addCommonHeaders } from "../common/CommonHeaders"; +import { VerificationType } from "../components/modals/types"; +import { useLocalStorage } from "../common/hooks"; +import { ConsentItem } from "../types"; + +type ApiUserConsent = { + data_use: string; + data_use_description?: string; + opt_in: boolean; +}; + +type ApiUserConcents = { + consent: ApiUserConsent[]; +}; + +const Consent: NextPage = () => { + const content: any = []; + const [consentRequestId] = useLocalStorage("consentRequestId", ""); + const [verificationCode] = useLocalStorage("verificationCode", ""); + const router = useRouter(); + + useEffect(() => { + if (!consentRequestId || !verificationCode) { + router.push("/"); + } + }, [consentRequestId, verificationCode, router]); + + const [consentItems, setConsentItems] = useState([]); + + useEffect(() => { + const getUserConsents = async () => { + const headers: Headers = new Headers(); + addCommonHeaders(headers, null); + + const response = await fetch( + `${hostUrl}/${VerificationType.ConsentRequest}/${consentRequestId}/verify`, + { + method: "POST", + headers, + body: JSON.stringify({ code: verificationCode }), + } + ); + const data = (await response.json()) as ApiUserConcents; + if (!response.ok) { + router.push("/"); + } + if (data.consent) { + const newConsentItems: ConsentItem[] = []; + const userConsentMap: { [key: string]: ApiUserConsent } = {}; + data.consent.forEach((option) => { + const key = option.data_use as string; + userConsentMap[key] = option; + }); + config.consent.consentOptions.forEach((d) => { + if (d.fidesDataUseKey in userConsentMap) { + const currentConsent = userConsentMap[d.fidesDataUseKey]; + + newConsentItems.push({ + consentValue: currentConsent.opt_in, + defaultValue: d.default ? d.default : false, + description: currentConsent.data_use_description + ? currentConsent.data_use_description + : "", + fidesDataUseKey: currentConsent.data_use, + highlight: d.highlight, + name: d.name, + url: d.url, + }); + } else { + newConsentItems.push({ + fidesDataUseKey: d.fidesDataUseKey, + name: d.name, + description: d.description, + highlight: d.highlight, + url: d.url, + defaultValue: d.default ? d.default : false, + }); + } + }); + + setConsentItems(newConsentItems); + } else { + const temp = config.consent.consentOptions.map((option) => ({ + fidesDataUseKey: option.fidesDataUseKey, + name: option.name, + description: option.description, + highlight: option.highlight, + url: option.url, + defaultValue: option.default ? option.default : false, + })); + setConsentItems(temp); + } + }; + getUserConsents(); + }, [router, consentRequestId, verificationCode]); + + consentItems.forEach((option) => { + content.push( + { + /* eslint-disable-next-line no-param-reassign */ + option.consentValue = value; + }} + /> + ); + }); + + const saveUserConsentOptions = useCallback(async () => { + const headers: Headers = new Headers(); + addCommonHeaders(headers, null); + + const body = { + code: verificationCode, + consent: consentItems.map((d) => ({ + data_use: d.fidesDataUseKey, + data_use_description: d.description, + opt_in: d.consentValue, + })), + }; + + const response = await fetch( + `${hostUrl}/${VerificationType.ConsentRequest}/${consentRequestId}/preferences`, + { + method: "PATCH", + headers, + body: JSON.stringify(body), + } + ); + (await response.json()) as ApiUserConcents; + // TODO: display alert on successful patch + // TODO: display error alert on failed patch + }, [consentItems, consentRequestId, verificationCode]); + + return ( +
+ + Privacy Center + + + + +
+ + Logo + +
+ +
+ + + + Manage your consent + + + When you use our services, you’re trusting us with your + information. We understand this is a big responsibility and work + hard to protect your information and put you in control. + + + + {content} + + + + + + +
+
+ ); +}; + +export default Consent; diff --git a/clients/ops/privacy-center/pages/index.tsx b/clients/ops/privacy-center/pages/index.tsx index 516dbba6e7..1129ef9ff2 100644 --- a/clients/ops/privacy-center/pages/index.tsx +++ b/clients/ops/privacy-center/pages/index.tsx @@ -17,7 +17,12 @@ import { usePrivactRequestModal, PrivacyRequestModal, } from "../components/modals/privacy-request-modal/PrivacyRequestModal"; +import { + useConsentRequestModal, + ConsentRequestModal, +} from "../components/modals/consent-request-modal/ConsentRequestModal"; import PrivacyCard from "../components/PrivacyCard"; +import ConsentCard from "../components/ConsentCard"; import type { AlertState } from "../types/AlertState"; import config from "../config/config.json"; @@ -28,17 +33,28 @@ const Home: NextPage = () => { const [isVerificationRequired, setIsVerificationRequired] = useState(false); const { - isOpen, - onClose, - onOpen, + isOpen: isPrivacyModalOpen, + onClose: onPrivacyModalClose, + onOpen: onPrivacyModalOpen, openAction, - currentView, - setCurrentView, + currentView: currentPrivacyModalView, + setCurrentView: setCurrentPrivacyModalView, privacyRequestId, setPrivacyRequestId, - successHandler, + successHandler: privacyModalSuccessHandler, } = usePrivactRequestModal(); + const { + isOpen: isConsentModalOpen, + onOpen: onConsentModalOpen, + onClose: onConsentModalClose, + currentView: currentConsentModalView, + setCurrentView: setCurrentConsentModalView, + consentRequestId, + setConsentRequestId, + successHandler: consentModalSuccessHandler, + } = useConsentRequestModal(); + useEffect(() => { if (alert?.status) { const closeAlertTimer = setTimeout(() => setAlert(null), 8000); @@ -70,11 +86,15 @@ const Home: NextPage = () => { policyKey={action.policy_key} iconPath={action.icon_path} description={action.description} - onOpen={onOpen} + onOpen={onPrivacyModalOpen} /> ); }); + if (config.includeConsent) { + content.push(); + } + return (
@@ -144,16 +164,28 @@ const Home: NextPage = () => { + +
diff --git a/clients/ops/privacy-center/public/consent.svg b/clients/ops/privacy-center/public/consent.svg new file mode 100644 index 0000000000..ad98555332 --- /dev/null +++ b/clients/ops/privacy-center/public/consent.svg @@ -0,0 +1,3 @@ + + + diff --git a/clients/ops/privacy-center/types/index.ts b/clients/ops/privacy-center/types/index.ts index 3e6e195106..526806656d 100644 --- a/clients/ops/privacy-center/types/index.ts +++ b/clients/ops/privacy-center/types/index.ts @@ -12,3 +12,14 @@ export enum PrivacyRequestStatus { IDENTITY_UNVERIFIED = "identity_unverified", REQUIRES_INPUT = "requires_input", } + +export type ConsentItem = { + fidesDataUseKey: string; + name: string; + description: string; + highlight: boolean; + url: string; + defaultValue: boolean; + // eslint-disable-next-line react/require-default-props + consentValue?: boolean; +}; diff --git a/src/fidesops/ops/schemas/privacy_request.py b/src/fidesops/ops/schemas/privacy_request.py index b112bb1cd5..206e6d9635 100644 --- a/src/fidesops/ops/schemas/privacy_request.py +++ b/src/fidesops/ops/schemas/privacy_request.py @@ -231,7 +231,7 @@ class ConsentPreferences(BaseSchema): class ConsentPreferencesWithVerificationCode(BaseSchema): - """scheam for consent preferences including the verification code.""" + """Schema for consent preferences including the verification code.""" code: str consent: List[Consent] From ddf0247322103b4bfc12f9355188f928d408e023 Mon Sep 17 00:00:00 2001 From: chriscalhoun1974 <68459950+chriscalhoun1974@users.noreply.github.com> Date: Sat, 1 Oct 2022 21:16:43 -0400 Subject: [PATCH 13/30] 1401 admin UI persist redux store to localstorage (#1409) * 1401 - Admin UI: Persist Redux store to localStorage * Resolved React memory leak when user attempts to logout via the Subject Requests landing page * Resolved UI unit test failure * Rollback previous change * Removed blacklist property from Redux store config * Refactored due to recommended code review feedback --- CHANGELOG.md | 1 + clients/ops/admin-ui/package-lock.json | 27 ++++- clients/ops/admin-ui/package.json | 1 + clients/ops/admin-ui/src/app/store.ts | 109 ++++++++++++++---- clients/ops/admin-ui/src/constants.ts | 5 +- .../auth/__tests__/auth.slice.test.ts | 42 ------- .../admin-ui/src/features/auth/auth.slice.ts | 29 +---- .../add-connection/AddConnection.tsx | 52 +++------ clients/ops/admin-ui/src/pages/_app.tsx | 11 +- 9 files changed, 138 insertions(+), 139 deletions(-) delete mode 100644 clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eeee73b80..6c595609c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ The types of changes are: * Add consent request api [#1387](https://github.com/ethyca/fidesops/pull/1387) * Add authenticated route to get consent preferences [#1402](https://github.com/ethyca/fidesops/pull/1402) * Access and erasure support for Braze [#1248](https://github.com/ethyca/fidesops/pull/1248) +* Admin UI: Persist Redux store to localStorage [#1401](https://github.com/ethyca/fidesops/pull/1409) ### Removed diff --git a/clients/ops/admin-ui/package-lock.json b/clients/ops/admin-ui/package-lock.json index 68b2a7af19..0acf039d07 100644 --- a/clients/ops/admin-ui/package-lock.json +++ b/clients/ops/admin-ui/package-lock.json @@ -29,6 +29,7 @@ "react-dom": "^17.0.2", "react-feature-flags": "^1.0.0", "react-redux": "^7.2.6", + "redux-persist": "^6.0.0", "whatwg-fetch": "^3.6.2", "yup": "^0.32.11" }, @@ -6078,9 +6079,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001406", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001406.tgz", - "integrity": "sha512-bWTlaXUy/rq0BBtYShc/jArYfBPjEV95euvZ8JVtO43oQExEN/WquoqpufFjNu4kSpi5cy5kMbNvzztWDfv1Jg==", + "version": "1.0.30001412", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", + "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==", "funding": [ { "type": "opencollective", @@ -11112,6 +11113,14 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "peerDependencies": { + "redux": ">4.0.0" + } + }, "node_modules/redux-thunk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", @@ -17197,9 +17206,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001406", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001406.tgz", - "integrity": "sha512-bWTlaXUy/rq0BBtYShc/jArYfBPjEV95euvZ8JVtO43oQExEN/WquoqpufFjNu4kSpi5cy5kMbNvzztWDfv1Jg==" + "version": "1.0.30001412", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", + "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==" }, "chalk": { "version": "4.1.2", @@ -20816,6 +20825,12 @@ "@babel/runtime": "^7.9.2" } }, + "redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "requires": {} + }, "redux-thunk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", diff --git a/clients/ops/admin-ui/package.json b/clients/ops/admin-ui/package.json index 6dbff223f4..0bc9bf0f47 100644 --- a/clients/ops/admin-ui/package.json +++ b/clients/ops/admin-ui/package.json @@ -43,6 +43,7 @@ "react-dom": "^17.0.2", "react-feature-flags": "^1.0.0", "react-redux": "^7.2.6", + "redux-persist": "^6.0.0", "whatwg-fetch": "^3.6.2", "yup": "^0.32.11" }, diff --git a/clients/ops/admin-ui/src/app/store.ts b/clients/ops/admin-ui/src/app/store.ts index 5a28cd7477..181e704ecf 100644 --- a/clients/ops/admin-ui/src/app/store.ts +++ b/clients/ops/admin-ui/src/app/store.ts @@ -1,13 +1,24 @@ -import { configureStore, StateFromReducersMapObject } from "@reduxjs/toolkit"; +import { + AnyAction, + combineReducers, + configureStore, + StateFromReducersMapObject, +} from "@reduxjs/toolkit"; import { setupListeners } from "@reduxjs/toolkit/query/react"; - -import { STORED_CREDENTIALS_KEY } from "../constants"; import { - authApi, - AuthState, - credentialStorage, - reducer as authReducer, -} from "../features/auth"; + FLUSH, + PAUSE, + PERSIST, + persistReducer, + persistStore, + PURGE, + REGISTER, + REHYDRATE, +} from "redux-persist"; +import createWebStorage from "redux-persist/lib/storage/createWebStorage"; + +import { STORAGE_ROOT_KEY } from "../constants"; +import { authApi, AuthState, reducer as authReducer } from "../features/auth"; import { connectionTypeApi, reducer as connectionTypeReducer, @@ -25,27 +36,83 @@ import { userApi, } from "../features/user-management"; -const reducer = { - [privacyRequestApi.reducerPath]: privacyRequestApi.reducer, - subjectRequests: privacyRequestsReducer, - [userApi.reducerPath]: userApi.reducer, +/** + * To prevent the "redux-perist failed to create sync storage. falling back to noop storage" + * console message within Next.js, the following snippet is required. + * {@https://mightycoders.xyz/redux-persist-failed-to-create-sync-storage-falling-back-to-noop-storage} + */ +const createNoopStorage = () => ({ + getItem() { + return Promise.resolve(null); + }, + setItem(_key: any, value: any) { + return Promise.resolve(value); + }, + removeItem() { + return Promise.resolve(); + }, +}); + +const storage = + typeof window !== "undefined" + ? createWebStorage("local") + : createNoopStorage(); + +const reducerMap = { [authApi.reducerPath]: authApi.reducer, - userManagement: userManagementReducer, - [datastoreConnectionApi.reducerPath]: datastoreConnectionApi.reducer, - datastoreConnections: datastoreConnectionReducer, auth: authReducer, [connectionTypeApi.reducerPath]: connectionTypeApi.reducer, connectionType: connectionTypeReducer, + [datastoreConnectionApi.reducerPath]: datastoreConnectionApi.reducer, + datastoreConnections: datastoreConnectionReducer, + [privacyRequestApi.reducerPath]: privacyRequestApi.reducer, + subjectRequests: privacyRequestsReducer, + [userApi.reducerPath]: userApi.reducer, + userManagement: userManagementReducer, }; -export type RootState = StateFromReducersMapObject; +const allReducers = combineReducers(reducerMap); + +const rootReducer = (state: any, action: AnyAction) => { + let newState = { ...state }; + if (action.type === "auth/logout") { + storage.removeItem(STORAGE_ROOT_KEY); + newState = undefined; + } + return allReducers(newState, action); +}; + +const persistConfig = { + key: "root", + storage, + /* + NOTE: It is also strongly recommended to blacklist any api(s) that you have configured with RTK Query. + If the api slice reducer is not blacklisted, the api cache will be automatically persisted + and restored which could leave you with phantom subscriptions from components that do not exist any more. + (https://redux-toolkit.js.org/usage/usage-guide#use-with-redux-persist) + */ + blacklist: [ + authApi.reducerPath, + connectionTypeApi.reducerPath, + datastoreConnectionApi.reducerPath, + privacyRequestApi.reducerPath, + userApi.reducerPath, + ], +}; + +const persistedReducer = persistReducer(persistConfig, rootReducer); + +export type RootState = StateFromReducersMapObject; export const makeStore = (preloadedState?: Partial) => configureStore({ - reducer, + reducer: persistedReducer, middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat( - credentialStorage.middleware, + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }).concat( privacyRequestApi.middleware, userApi.middleware, authApi.middleware, @@ -58,7 +125,7 @@ export const makeStore = (preloadedState?: Partial) => let storedAuthState: AuthState | undefined; if (typeof window !== "undefined" && "localStorage" in window) { - const storedAuthStateString = localStorage.getItem(STORED_CREDENTIALS_KEY); + const storedAuthStateString = localStorage.getItem(STORAGE_ROOT_KEY); if (storedAuthStateString) { try { storedAuthState = JSON.parse(storedAuthStateString); @@ -74,6 +141,8 @@ const store = makeStore({ auth: storedAuthState, }); +export const persistor = persistStore(store); + setupListeners(store.dispatch); export default store; diff --git a/clients/ops/admin-ui/src/constants.ts b/clients/ops/admin-ui/src/constants.ts index 1c2104598a..baff538852 100644 --- a/clients/ops/admin-ui/src/constants.ts +++ b/clients/ops/admin-ui/src/constants.ts @@ -6,7 +6,10 @@ const API_URL = process.env.NEXT_PUBLIC_FIDESOPS_API : ""; export const BASE_URL = API_URL + BASE_API_URN; -export const STORED_CREDENTIALS_KEY = "auth.fidesops-admin-ui"; +/** + * Redux-persist storage root key + */ +export const STORAGE_ROOT_KEY = "persist:root"; export const USER_PRIVILEGES: UserPrivileges[] = [ { diff --git a/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts b/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts deleted file mode 100644 index f90d25eac9..0000000000 --- a/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { makeStore } from "../../../app/store"; -import { STORED_CREDENTIALS_KEY } from "../../../constants"; -import { login, logout } from "../auth.slice"; - -describe("Auth", () => { - it("should persist auth state to localStorage on login", () => { - jest.spyOn(Object.getPrototypeOf(window.localStorage), "setItem"); - const store = makeStore(); - - store.dispatch( - login({ - user_data: { - username: "Test", - }, - token_data: { - access_token: "test-access-token", - }, - }) - ); - - expect(window.localStorage.setItem).toHaveBeenCalledWith( - STORED_CREDENTIALS_KEY, - JSON.stringify({ - token: "test-access-token", - user: { - username: "Test", - }, - }) - ); - }); - - it("should remove auth state from localStorage on logout", () => { - jest.spyOn(Object.getPrototypeOf(window.localStorage), "removeItem"); - const store = makeStore(); - - store.dispatch(logout()); - - expect(window.localStorage.removeItem).toHaveBeenCalledWith( - STORED_CREDENTIALS_KEY - ); - }); -}); diff --git a/clients/ops/admin-ui/src/features/auth/auth.slice.ts b/clients/ops/admin-ui/src/features/auth/auth.slice.ts index 06f968998f..aba06d2539 100644 --- a/clients/ops/admin-ui/src/features/auth/auth.slice.ts +++ b/clients/ops/admin-ui/src/features/auth/auth.slice.ts @@ -1,12 +1,8 @@ -import { - createListenerMiddleware, - createSlice, - PayloadAction, -} from "@reduxjs/toolkit"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; import type { RootState } from "../../app/store"; -import { BASE_URL, STORED_CREDENTIALS_KEY } from "../../constants"; +import { BASE_URL } from "../../constants"; import { addCommonHeaders } from "../common/CommonHeaders"; import { utf8ToB64 } from "../common/utils"; import { User } from "../user-management/types"; @@ -56,27 +52,6 @@ export const selectToken = (state: RootState) => selectAuth(state).token; export const { login, logout } = authSlice.actions; -export const credentialStorage = createListenerMiddleware(); -credentialStorage.startListening({ - actionCreator: login, - effect: (action, { getState }) => { - if (window && window.localStorage) { - localStorage.setItem( - STORED_CREDENTIALS_KEY, - JSON.stringify(selectAuth(getState() as RootState)) - ); - } - }, -}); -credentialStorage.startListening({ - actionCreator: logout, - effect: () => { - if (window && window.localStorage) { - localStorage.removeItem(STORED_CREDENTIALS_KEY); - } - }, -}); - // Auth API export const authApi = createApi({ reducerPath: "authApi", diff --git a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx index ee28a34b76..577d092d74 100644 --- a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx +++ b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx @@ -18,35 +18,11 @@ import { AddConnectionStep } from "./types"; const AddConnection: React.FC = () => { const dispatch = useDispatch(); const router = useRouter(); - const { connectorType, key, step: currentStep } = router.query; + const { connectorType, step: currentStep } = router.query; const { connectionOption, step } = useAppSelector(selectConnectionTypeState); - /** - * NOTE: If the user reloads the web page via F5, the react redux store state is lost. - * By default its persisted in internal memory. As a result, a runtime exception occurs - * which impedes the page rendering. - * - * @example - * The above error occurred in the component - * - * For now, a temporary solution is to redirect the user - * to the "Choose your connection" step. This allows a better overall user experience. - * A permanent solution will be to persist the react redux store state to either local storage - * or session storage. Once completed, this method can be deleted. - */ - const reload = useCallback(() => { - if ( - key && - currentStep && - (currentStep as unknown as number) !== step?.stepId - ) { - window.location.href = STEPS[1].href; - } - }, [currentStep, key, step?.stepId]); - useEffect(() => { - reload(); if (connectorType) { dispatch(setConnectionOption(JSON.parse(connectorType as string))); } @@ -55,19 +31,7 @@ const AddConnection: React.FC = () => { dispatch(setStep(item || STEPS[1])); } return () => {}; - }, [connectorType, currentStep, dispatch, reload, router.query.step]); - - const getComponent = useCallback(() => { - switch (step.stepId) { - case 1: - return ; - case 2: - case 3: - return ; - default: - return ; - } - }, [step.stepId]); + }, [connectorType, currentStep, dispatch, router.query.step]); const getLabel = useCallback( (s: AddConnectionStep): string => { @@ -108,7 +72,17 @@ const AddConnection: React.FC = () => { {!connectionOption && {getLabel(step)}}
- {getComponent()} + {(() => { + switch (step.stepId) { + case 1: + return ; + case 2: + case 3: + return ; + default: + return ; + } + })()} ); }; diff --git a/clients/ops/admin-ui/src/pages/_app.tsx b/clients/ops/admin-ui/src/pages/_app.tsx index 188390a36d..4fe31232f9 100644 --- a/clients/ops/admin-ui/src/pages/_app.tsx +++ b/clients/ops/admin-ui/src/pages/_app.tsx @@ -9,8 +9,9 @@ import React from "react"; // @ts-ignore import { FlagsProvider } from "react-feature-flags"; import { Provider } from "react-redux"; +import { PersistGate } from "redux-persist/integration/react"; -import store from "../app/store"; +import store, { persistor } from "../app/store"; import flags from "../flags.json"; import theme from "../theme"; @@ -29,9 +30,11 @@ const MyApp = ({ Component, pageProps }: AppProps) => ( - - - + + + + + From b966074d83ccc203758203f905949835d4eca947 Mon Sep 17 00:00:00 2001 From: Cole Isaac <82131455+conceptualshark@users.noreply.github.com> Date: Mon, 3 Oct 2022 10:33:21 -0400 Subject: [PATCH 14/30] update footer links (#1406) * update footer links * changelog * Update CHANGELOG.md Co-authored-by: Paul Sanders Co-authored-by: Sean Preston Co-authored-by: Paul Sanders --- CHANGELOG.md | 2 ++ docs/fidesops/overrides/partials/footer.html | 7 ------- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c595609c1..890b8d310a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ The types of changes are: * Refactor privacy center to be more modular [#1363](https://github.com/ethyca/fidesops/pull/1363) +### Docs +* Update docs footer links [#1406](https://github.com/ethyca/fidesops/pull/1406) ### Fixed * Distinguish whether webhook has been visited and no fields were found, versus never visited [#1339](https://github.com/ethyca/fidesops/pull/1339) diff --git a/docs/fidesops/overrides/partials/footer.html b/docs/fidesops/overrides/partials/footer.html index 47beec8b79..f7bc7cbdc9 100644 --- a/docs/fidesops/overrides/partials/footer.html +++ b/docs/fidesops/overrides/partials/footer.html @@ -123,13 +123,6 @@
  • Getting Started
  • -