Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/7102.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copy this file and rename it (e.g., pr-number.yaml or feature-name.yaml)
# Fill in the required fields and delete this comment block

type: Added # One of: Added, Changed, Developer Experience, Deprecated, Docs, Fixed, Removed, Security
description: Consent DSR completion email template.
pr: 7102 # PR number
labels: [] # Optional: ["high-risk", "db-migration"]
2 changes: 1 addition & 1 deletion clients/admin-ui/cypress/e2e/messaging.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe("Messaging", () => {
});

it("should display message type selector after clicking on the add button", () => {
const customizableMessagesCount = 8;
const customizableMessagesCount = 9;

cy.visit("/notifications/templates");
cy.wait("@getEmailTemplatesSummary");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum CustomizableMessagingTemplatesEnum {
PRIVACY_REQUEST_RECEIPT = "privacy_request_receipt",
PRIVACY_REQUEST_COMPLETE_ACCESS = "privacy_request_complete_access",
PRIVACY_REQUEST_COMPLETE_DELETION = "privacy_request_complete_deletion",
PRIVACY_REQUEST_COMPLETE_CONSENT = "privacy_request_complete_consent",
SUBJECT_IDENTITY_VERIFICATION = "subject_identity_verification",
MANUAL_TASK_DIGEST = "manual_task_digest",
EXTERNAL_USER_WELCOME = "external_user_welcome",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const CustomizableMessagingTemplatesLabelEnum: Record<
"Access request completed",
[CustomizableMessagingTemplatesEnum.PRIVACY_REQUEST_COMPLETE_DELETION]:
"Erasure request completed",
[CustomizableMessagingTemplatesEnum.PRIVACY_REQUEST_COMPLETE_CONSENT]:
"Consent request completed",
[CustomizableMessagingTemplatesEnum.PRIVACY_REQUEST_RECEIPT]:
"Privacy request received",
[CustomizableMessagingTemplatesEnum.PRIVACY_REQUEST_REVIEW_APPROVE]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum MessagingActionType {
PRIVACY_REQUEST_RECEIPT = "privacy_request_receipt",
PRIVACY_REQUEST_COMPLETE_ACCESS = "privacy_request_complete_access",
PRIVACY_REQUEST_COMPLETE_DELETION = "privacy_request_complete_deletion",
PRIVACY_REQUEST_COMPLETE_CONSENT = "privacy_request_complete_consent",
PRIVACY_REQUEST_REVIEW_DENY = "privacy_request_review_deny",
PRIVACY_REQUEST_REVIEW_APPROVE = "privacy_request_review_approve",
USER_INVITE = "user_invite",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum MessagingActionType {
PRIVACY_REQUEST_RECEIPT = "privacy_request_receipt",
PRIVACY_REQUEST_COMPLETE_ACCESS = "privacy_request_complete_access",
PRIVACY_REQUEST_COMPLETE_DELETION = "privacy_request_complete_deletion",
PRIVACY_REQUEST_COMPLETE_CONSENT = "privacy_request_complete_consent",
PRIVACY_REQUEST_REVIEW_DENY = "privacy_request_review_deny",
PRIVACY_REQUEST_REVIEW_APPROVE = "privacy_request_review_approve",
USER_INVITE = "user_invite",
Expand Down
7 changes: 7 additions & 0 deletions src/fides/api/models/messaging_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@
"body": "Your erasure request has been completed.",
},
},
MessagingActionType.PRIVACY_REQUEST_COMPLETE_CONSENT.value: {
"label": "Consent request completed",
"content": {
"subject": "Your consent preferences have been saved",
"body": "Your consent request has been completed.",
},
},
MessagingActionType.MANUAL_TASK_DIGEST.value: {
"label": "Manual task digest",
"content": {
Expand Down
2 changes: 2 additions & 0 deletions src/fides/api/schemas/messaging/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class MessagingActionType(StrEnum):
PRIVACY_REQUEST_RECEIPT = "privacy_request_receipt"
PRIVACY_REQUEST_COMPLETE_ACCESS = "privacy_request_complete_access"
PRIVACY_REQUEST_COMPLETE_DELETION = "privacy_request_complete_deletion"
PRIVACY_REQUEST_COMPLETE_CONSENT = "privacy_request_complete_consent"
PRIVACY_REQUEST_REVIEW_DENY = "privacy_request_review_deny"
PRIVACY_REQUEST_REVIEW_APPROVE = "privacy_request_review_approve"
USER_INVITE = "user_invite"
Expand All @@ -103,6 +104,7 @@ class MessagingActionType(StrEnum):
MessagingActionType.PRIVACY_REQUEST_RECEIPT.value,
MessagingActionType.PRIVACY_REQUEST_COMPLETE_ACCESS.value,
MessagingActionType.PRIVACY_REQUEST_COMPLETE_DELETION.value,
MessagingActionType.PRIVACY_REQUEST_COMPLETE_CONSENT.value,
MessagingActionType.PRIVACY_REQUEST_REVIEW_DENY.value,
MessagingActionType.PRIVACY_REQUEST_REVIEW_APPROVE.value,
MessagingActionType.MANUAL_TASK_DIGEST.value,
Expand Down
7 changes: 7 additions & 0 deletions src/fides/api/service/messaging/message_dispatch_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ def _build_sms( # pylint: disable=too-many-return-statements
)
if action_type == MessagingActionType.PRIVACY_REQUEST_COMPLETE_DELETION:
return "Your privacy request for deletion has been completed."
if action_type == MessagingActionType.PRIVACY_REQUEST_COMPLETE_CONSENT:
return "Your consent request has been completed."
if action_type == MessagingActionType.PRIVACY_REQUEST_REVIEW_APPROVE:
return "Your privacy request has been approved and is currently processing."
if action_type == MessagingActionType.PRIVACY_REQUEST_REVIEW_DENY:
Expand Down Expand Up @@ -446,6 +448,11 @@ def _build_email( # pylint: disable=too-many-return-statements, too-many-branch
subject=_render(messaging_template.content["subject"]), # type: ignore
body=_render(messaging_template.content["body"]), # type: ignore
)
if action_type == MessagingActionType.PRIVACY_REQUEST_COMPLETE_CONSENT:
return EmailForActionType(
subject=_render(messaging_template.content["subject"]), # type: ignore
body=_render(messaging_template.content["body"]), # type: ignore
)
if action_type == MessagingActionType.PRIVACY_REQUEST_ERROR_NOTIFICATION:
# This action type does not use the default templates that are configurable in the Admin-UI.
# They are instead hard-coded in fides, which we retrieve using get_email_template(action_type)
Expand Down
216 changes: 141 additions & 75 deletions src/fides/api/service/privacy_request/request_runner_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,17 +656,6 @@ def run_privacy_request(
privacy_request_proceed=True, # Should always be True unless we're testing
)

# Finalize Consent CHECKPOINT
if can_run_checkpoint(
request_checkpoint=CurrentStep.finalize_consent,
from_checkpoint=resume_step,
):
# This checkpoint allows a Privacy Request to be re-queued
# after the Consent Step is complete for DSR 3.0
privacy_request.cache_failed_checkpoint_details(
CurrentStep.finalize_consent
)

except PrivacyRequestPaused as exc:
privacy_request.pause_processing(session)
_log_warning(exc, CONFIG.dev_mode)
Expand Down Expand Up @@ -738,6 +727,7 @@ def run_privacy_request(
if not proceed:
return

# pylint: disable=too-many-nested-blocks
# Request finalization CHECKPOINT
if can_run_checkpoint(
request_checkpoint=CurrentStep.finalization,
Expand All @@ -750,11 +740,12 @@ def run_privacy_request(
erasure_rules = policy.get_rules_for_action(
action_type=ActionType.erasure
)
if (
privacy_request.finalized_at is None
and erasure_rules
and CONFIG.execution.erasure_request_finalization_required
):
config_proxy = ConfigProxy(session)
requires_finalization = privacy_request.finalized_at is None and (
erasure_rules
and config_proxy.execution.erasure_request_finalization_required
)
if requires_finalization:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some cases where we wouldn't want to pause the request for finalization even if the setting is enabled. I know this is at least the case for requests with a source of consent_webhook. These requests are system-generated and should just continue processing. You might want to check with @mfbrown if there are other sources that we want to exclude from this finalization (like Fides.js or Janus SDK).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would the impact of manualizing those be? I can imagine that if a notice was used on a website it might be too many DSRs to manually approve. But is there a technical reason beyond human workload?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to maybe add a specific type etc to the env var that way a user would turn on the finalization for the types they wanted explicitly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have to add this, that would be better yeah. My concern being that source should be more expansive than it currently is and I'd like some future wiggle room to define more of them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think we should add a config to specify which sources require finalization. To the consent point, it would add a lot of manual work to a process that that could process many thousands of webhook responses, so I don't think we can put that work suddenly on customers for all requests.

I agree with @tvandort that we should add the ability to define more sources, more easily.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK - making an update here. I did a little work on this over the last week. I have several PRs lined up to make Consent Manual Tasks a thing. I will update this PR to handle the email updates and remove the other elements.

The PR chain starts here The first couple are marked ready for review :)

logger.info(
"Marking privacy request '{}' as requires manual finalization.",
privacy_request.id,
Expand Down Expand Up @@ -803,68 +794,143 @@ def run_privacy_request(
session
).notifications.send_request_completion_notification

action_type = (
MessagingActionType.PRIVACY_REQUEST_COMPLETE_ACCESS
if policy.get_rules_for_action(
action_type=ActionType.access
action_types = policy.get_all_action_types()

# Access/erasure completion emails take priority over consent
if (
ActionType.access in action_types
or ActionType.erasure in action_types
):
action_type = (
MessagingActionType.PRIVACY_REQUEST_COMPLETE_ACCESS
if ActionType.access in action_types
else MessagingActionType.PRIVACY_REQUEST_COMPLETE_DELETION
)
else MessagingActionType.PRIVACY_REQUEST_COMPLETE_DELETION
)

message_send_result = message_send_enabled(
session,
privacy_request.property_id,
action_type,
legacy_request_completion_enabled,
)
has_consent_rules = policy.get_rules_for_action(
action_type=ActionType.consent
)
message_send_result = message_send_enabled(
session,
privacy_request.property_id,
action_type,
legacy_request_completion_enabled,
)

if message_send_result:
if not access_result_urls:
# For DSR 3.0, if the request had both access and erasure rules, this needs to be fetched
# from the database because the Privacy Request would have exited
# processing and lost access to the access_result_urls in memory
access_result_urls = (
privacy_request.access_result_urls or {}
).get("access_result_urls", [])

try:
initiate_privacy_request_completion_email(
session,
policy,
access_result_urls,
identity_data,
privacy_request.property_id,
privacy_request.id,
)
# Add success log for completion email
privacy_request.add_success_execution_log(
session,
connection_key=None,
dataset_name="Privacy request completion email",
collection_name=None,
message="Privacy request completion email sent successfully.",
action_type=privacy_request.policy.get_action_type(), # type: ignore
)
except (
IdentityNotFoundException,
MessageDispatchException,
) as e:
# Add error log for completion email failure
privacy_request.add_error_execution_log(
session,
connection_key=None,
dataset_name="Privacy request completion email",
collection_name=None,
message=f"Privacy request completion email failed: {str(e)}",
action_type=privacy_request.policy.get_action_type(), # type: ignore
)
privacy_request.error_processing(db=session)
# If dev mode, log traceback
_log_exception(e, CONFIG.dev_mode)
return

# Send consent completion email only for consent-only requests
elif ActionType.consent in action_types:
consent_message_enabled = message_send_enabled(
session,
privacy_request.property_id,
MessagingActionType.PRIVACY_REQUEST_COMPLETE_CONSENT,
legacy_request_completion_enabled,
)
if consent_message_enabled:
try:
initiate_consent_request_completion_email(
session,
identity_data,
privacy_request.property_id,
)
privacy_request.add_success_execution_log(
session,
connection_key=None,
dataset_name="Consent request completion email",
collection_name=None,
message="Consent request completion email sent successfully.",
action_type=ActionType.consent,
)
except (
IdentityNotFoundException,
MessageDispatchException,
) as e:
privacy_request.add_error_execution_log(
session,
connection_key=None,
dataset_name="Consent request completion email",
collection_name=None,
message=f"Consent request completion email failed: {str(e)}",
action_type=ActionType.consent,
)
privacy_request.error_processing(db=session)
_log_exception(e, CONFIG.dev_mode)
return


def initiate_consent_request_completion_email(
session: Session,
identity_data: dict[str, Any],
property_id: Optional[str],
) -> None:
"""
Send consent request completion email to the user.

if message_send_result and not has_consent_rules:
if not access_result_urls:
# For DSR 3.0, if the request had both access and erasure rules, this needs to be fetched
# from the database because the Privacy Request would have exited
# processing and lost access to the access_result_urls in memory
access_result_urls = (
privacy_request.access_result_urls or {}
).get("access_result_urls", [])

try:
initiate_privacy_request_completion_email(
session,
policy,
access_result_urls,
identity_data,
privacy_request.property_id,
privacy_request.id,
)
# Add success log for completion email
privacy_request.add_success_execution_log(
session,
connection_key=None,
dataset_name="Privacy request completion email",
collection_name=None,
message="Privacy request completion email sent successfully.",
action_type=privacy_request.policy.get_action_type(), # type: ignore
)
except (
IdentityNotFoundException,
MessageDispatchException,
) as e:
# Add error log for completion email failure
privacy_request.add_error_execution_log(
session,
connection_key=None,
dataset_name="Privacy request completion email",
collection_name=None,
message=f"Privacy request completion email failed: {str(e)}",
action_type=privacy_request.policy.get_action_type(), # type: ignore
)
privacy_request.error_processing(db=session)
# If dev mode, log traceback
_log_exception(e, CONFIG.dev_mode)
return
:param session: SQLAlchemy Session
:param identity_data: Dict of identity data
:param property_id: Property id associated with the privacy request
"""
config_proxy = ConfigProxy(session)
if not (
identity_data.get(ProvidedIdentityType.email.value)
or identity_data.get(ProvidedIdentityType.phone_number.value)
):
raise IdentityNotFoundException(
"Identity email or phone number was not found, so consent completion message could not be sent."
)
to_identity: Identity = Identity(
email=identity_data.get(ProvidedIdentityType.email.value),
phone_number=identity_data.get(ProvidedIdentityType.phone_number.value),
)
dispatch_message(
db=session,
action_type=MessagingActionType.PRIVACY_REQUEST_COMPLETE_CONSENT,
to_identity=to_identity,
service_type=config_proxy.notifications.notification_service_type,
message_body_params=None,
property_id=property_id,
)


def initiate_privacy_request_completion_email(
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/application_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -4304,14 +4304,18 @@ def enable_erasure_request_finalization_required(db):
"""Enable erasure finalization via config"""
original_value = CONFIG.execution.erasure_request_finalization_required
CONFIG.execution.erasure_request_finalization_required = True
ApplicationConfig.update_config_set(db, CONFIG)
yield
CONFIG.execution.erasure_request_finalization_required = original_value
ApplicationConfig.update_config_set(db, CONFIG)


@pytest.fixture(scope="function")
def disable_erasure_request_finalization_required(db):
"""Disable erasure finalization via config"""
original_value = CONFIG.execution.erasure_request_finalization_required
CONFIG.execution.erasure_request_finalization_required = False
ApplicationConfig.update_config_set(db, CONFIG)
yield
CONFIG.execution.erasure_request_finalization_required = original_value
ApplicationConfig.update_config_set(db, CONFIG)
Loading
Loading