From f095a3e2c75e3a99206f30f160a45651c9f75edb Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 09:21:44 +0200 Subject: [PATCH] fix(campaign): sanitize synchronous send results --- .../backend/response_security.py | 98 +++++++++++++ src/govoplan_campaign/backend/router.py | 15 +- tests/test_send_now_response_security.py | 133 ++++++++++++++++++ 3 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 tests/test_send_now_response_security.py diff --git a/src/govoplan_campaign/backend/response_security.py b/src/govoplan_campaign/backend/response_security.py index 9383339..a11ac33 100644 --- a/src/govoplan_campaign/backend/response_security.py +++ b/src/govoplan_campaign/backend/response_security.py @@ -40,6 +40,51 @@ CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS = frozenset( } ) +_SEND_NOW_RESULT_KEYS = ( + "campaign_id", + "version_id", + "attempted_count", + "sent_count", + "failed_count", + "outcome_unknown_count", + "skipped_count", + "preflight_count", + "delivery_mode", + "dry_run", +) +_SEND_NOW_JOB_RESULT_KEYS = ( + "campaign_id", + "version_id", + "job_id", + "status", + "attempt_number", + "dry_run", + "queued_count", + "skipped_count", + "blocked_count", + "enqueued_count", + "delivery_mode", + "worker_queue_available", +) +_SYNCHRONOUS_POLICY_KEYS = ( + "max_recipient_jobs", + "source", + "deployment_max_recipient_jobs", + "tenant_max_recipient_jobs", +) +_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count") +_BUILD_SUMMARY_KEYS = ( + "built_count", + "build_failed_count", + "ready_count", + "warning_count", + "needs_review_count", + "blocked_count", + "excluded_count", + "inactive_count", + "queueable_count", +) + def public_campaign_payload(value: Any, *, include_diagnostics: bool = False) -> Any: """Return a detached payload without infrastructure-only locators.""" @@ -83,6 +128,59 @@ def public_delivery_result_message( return "Delivery recorded a warning; an operator can inspect restricted diagnostics." +def public_send_campaign_now_result( + value: dict[str, Any], + *, + validation_summary: dict[str, Any], + build_summary: dict[str, Any], +) -> dict[str, Any]: + """Project synchronous delivery into its recipient-authorized public contract. + + Per-job provider messages are deliberately omitted. They can contain SMTP + diagnostics or refused envelope addresses and belong only in restricted + diagnostics backed by persisted job state. + """ + + result = _selected_payload(value, _SEND_NOW_RESULT_KEYS) + policy = value.get("synchronous_send_policy") + result["synchronous_send_policy"] = _selected_payload( + policy if isinstance(policy, dict) else {}, + _SYNCHRONOUS_POLICY_KEYS, + ) + rows = value.get("results") + if isinstance(rows, list): + result["results"] = [ + _selected_payload(row, _SEND_NOW_JOB_RESULT_KEYS) + for row in rows + if isinstance(row, dict) + ] + else: + result["results"] = [] + result["validation"] = _selected_payload(validation_summary, _VALIDATION_SUMMARY_KEYS) + result["build"] = _selected_payload(build_summary, _BUILD_SUMMARY_KEYS) + return result + + +def send_campaign_now_audit_details(value: dict[str, Any]) -> dict[str, Any]: + """Return aggregate-only evidence for a synchronous Campaign send audit.""" + + details = _selected_payload(value, _SEND_NOW_RESULT_KEYS) + policy = value.get("synchronous_send_policy") + details["synchronous_send_policy"] = _selected_payload( + policy if isinstance(policy, dict) else {}, + _SYNCHRONOUS_POLICY_KEYS, + ) + return details + + +def _selected_payload(value: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]: + return { + key: copy.deepcopy(value[key]) + for key in keys + if key in value + } + + def public_campaign_configuration(value: Any) -> Any: """Return campaign JSON without infrastructure locators or mail secrets. diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index a9f7a23..5a6c7cd 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -117,6 +117,8 @@ from govoplan_campaign.backend.reports.aggregate import ( from govoplan_campaign.backend.response_security import ( public_campaign_payload, public_delivery_result_message, + public_send_campaign_now_result, + send_campaign_now_audit_details, ) from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email from govoplan_campaign.backend.persistence.campaigns import ( @@ -3580,7 +3582,7 @@ def send_campaign_now_endpoint( detail="Campaign version must be built before dry-run or sending.", ) - result = send_campaign_now( + delivery_result = send_campaign_now( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, @@ -3590,18 +3592,21 @@ def send_campaign_now_endpoint( use_rate_limit=payload.use_rate_limit, enqueue_imap_task=payload.enqueue_imap_task, ).as_dict() - result["validation"] = validation_result - result["build"] = build_result + response_result = public_send_campaign_now_result( + delivery_result, + validation_summary=validation_result, + build_summary=build_result, + ) audit_from_principal( session, principal, action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run", object_type="campaign", object_id=campaign_id, - details=result, + details=send_campaign_now_audit_details(delivery_result), commit=True, ) - return SendCampaignNowResponse(result=result) + return SendCampaignNowResponse(result=response_result) except SynchronousSendRejected as exc: audit_from_principal( session, diff --git a/tests/test_send_now_response_security.py b/tests/test_send_now_response_security.py new file mode 100644 index 0000000..b9a9967 --- /dev/null +++ b/tests/test_send_now_response_security.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from govoplan_campaign.backend import router +from govoplan_campaign.backend.schemas import SendCampaignNowRequest +from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult + + +def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() -> None: + campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1") + version = SimpleNamespace( + id="version-1", + raw_json={}, + locked_at=datetime(2026, 7, 22, tzinfo=UTC), + validation_summary={ + "ok": True, + "error_count": 0, + "warning_count": 1, + "issues": [{"message": "provider-secret validation detail"}], + }, + build_summary={ + "built_count": 2, + "build_failed_count": 0, + "ready_count": 2, + "warning_count": 1, + "messages": [{"recipient": "refused-recipient@example.test"}], + "build_token": "provider-secret-build-token", + }, + ) + delivery = SendCampaignNowResult( + campaign_id="campaign-1", + version_id="version-1", + attempted_count=2, + sent_count=1, + failed_count=1, + outcome_unknown_count=0, + skipped_count=0, + preflight_count=2, + synchronous_send_policy={ + "max_recipient_jobs": 25, + "source": "deployment_default", + "deployment_max_recipient_jobs": 25, + "tenant_max_recipient_jobs": None, + "provider_diagnostic": "provider-secret-policy", + }, + results=[ + { + "job_id": "job-failed", + "status": "failed", + "attempt_number": 1, + "message": "provider-secret smtp.internal.example", + "recipient_email": "private@example.test", + }, + { + "job_id": "job-refused", + "status": "smtp_accepted", + "attempt_number": 1, + "message": ( + "SMTP accepted 1/2 envelope recipient(s); refused recipients: " + '{"refused-recipient@example.test": [550, "not allowed"]}' + ), + }, + ], + ) + principal = SimpleNamespace(tenant_id="tenant-1", user=SimpleNamespace(id="user-1"), api_key=None) + audit = Mock() + + with ( + patch.object(router, "_get_campaign_for_principal", return_value=campaign), + patch.object(router, "_require_permission"), + patch.object(router, "_get_campaign_for_tenant", return_value=campaign), + patch.object(router, "_get_version_for_tenant", return_value=version), + patch.object(router, "_require_mail_profile_use_if_needed"), + patch.object(router, "is_user_locked_version", return_value=False), + patch.object(router, "send_campaign_now", return_value=delivery), + patch.object(router, "audit_from_principal", audit), + ): + response = router.send_campaign_now_endpoint( + "campaign-1", + SendCampaignNowRequest(), + session=Mock(), + principal=principal, # type: ignore[arg-type] + ) + + payload = response.model_dump(mode="json")["result"] + assert payload["results"] == [ + {"job_id": "job-failed", "status": "failed", "attempt_number": 1}, + {"job_id": "job-refused", "status": "smtp_accepted", "attempt_number": 1}, + ] + assert payload["validation"] == {"ok": True, "error_count": 0, "warning_count": 1} + assert payload["build"] == { + "built_count": 2, + "build_failed_count": 0, + "ready_count": 2, + "warning_count": 1, + } + assert payload["synchronous_send_policy"] == { + "max_recipient_jobs": 25, + "source": "deployment_default", + "deployment_max_recipient_jobs": 25, + "tenant_max_recipient_jobs": None, + } + + audit_details = audit.call_args.kwargs["details"] + assert set(audit_details) == { + "campaign_id", + "version_id", + "attempted_count", + "sent_count", + "failed_count", + "outcome_unknown_count", + "skipped_count", + "preflight_count", + "delivery_mode", + "dry_run", + "synchronous_send_policy", + } + assert "results" not in audit_details + assert "validation" not in audit_details + assert "build" not in audit_details + + serialized = repr({"response": payload, "audit": audit_details}) + for forbidden in ( + "provider-secret", + "smtp.internal.example", + "private@example.test", + "refused-recipient@example.test", + "not allowed", + ): + assert forbidden not in serialized