security: harden Campaign delivery effects and reconciliation

This commit is contained in:
2026-07-21 17:14:33 +02:00
parent 057e660b17
commit 09c63de813
25 changed files with 2079 additions and 708 deletions

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import dataclasses
import logging
from collections.abc import Callable
from typing import Any
@@ -99,7 +100,10 @@ from govoplan_campaign.backend.db.models import (
)
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.response_security import public_campaign_payload
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
)
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
@@ -108,9 +112,15 @@ from govoplan_campaign.backend.persistence.campaigns import (
load_campaign_config_from_json,
validate_campaign_version,
)
from govoplan_campaign.backend.integrations import files_integration
from govoplan_campaign.backend.integrations import (
MailProfileError,
SmtpConfigurationError,
SmtpSendError,
files_integration,
)
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
from govoplan_core.security.time import utc_now
from govoplan_campaign.backend.persistence.versions import (
@@ -130,6 +140,7 @@ from govoplan_campaign.backend.persistence.versions import (
update_campaign_review_state,
validate_campaign_partial,
)
from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
from govoplan_campaign.backend.sending.jobs import (
@@ -147,6 +158,7 @@ from govoplan_campaign.backend.sending.jobs import (
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
logger = logging.getLogger(__name__)
CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs"
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
@@ -341,9 +353,7 @@ def _recipient_sections_changed(current: dict[str, object] | None, proposed: dic
def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
value = server.get("mail_profile_id") if isinstance(server, dict) else None
return str(value) if value else None
return campaign_mail_profile_id(raw_json)
def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None:
@@ -419,12 +429,14 @@ def _update_campaign_version_detail_response(
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=autosave,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
),
audit_action=audit_action,
details=lambda version: {
"campaign_id": campaign_id,
"current_flow": version.current_flow,
"current_step": version.current_step,
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
},
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
)
@@ -1461,7 +1473,18 @@ def fork_version_for_edit(
"""
payload = payload or CampaignVersionUpdateRequest()
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
source_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if source_version.campaign_id != campaign_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
effective_json = (
payload.campaign_json
if isinstance(payload.campaign_json, dict)
else source_version.raw_json
)
_require_mail_profile_use_if_needed(
principal,
effective_json if isinstance(effective_json, dict) else {},
)
try:
version = fork_campaign_version_for_edit(
session,
@@ -1475,6 +1498,7 @@ def fork_version_for_edit(
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=True,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
audit_from_principal(
@@ -1483,7 +1507,12 @@ def fork_version_for_edit(
action="campaign.version_forked_for_edit",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number},
details={
"campaign_id": campaign_id,
"source_version_id": version_id,
"version_number": version.version_number,
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
},
commit=True,
)
return CampaignCreateResponse(
@@ -1882,7 +1911,11 @@ def _job_summary_payload(
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"attempt_count": job.attempt_count,
"last_error": job.last_error,
"last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
),
"queued_at": job.queued_at,
"claimed_at": job.claimed_at,
"smtp_started_at": job.smtp_started_at,
@@ -1925,14 +1958,14 @@ def _job_attempts_payload(
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"smtp_status_code": attempt.smtp_status_code,
"smtp_response": attempt.smtp_response,
"error_type": attempt.error_type,
"error_message": attempt.error_message,
"started_at": attempt.started_at,
"finished_at": attempt.finished_at,
}
if include_diagnostics:
payload["claim_token"] = attempt.claim_token
payload["smtp_response"] = attempt.smtp_response
payload["error_type"] = attempt.error_type
payload["error_message"] = attempt.error_message
smtp_payloads.append(payload)
imap_payloads: list[dict[str, object]] = []
@@ -1942,12 +1975,12 @@ def _job_attempts_payload(
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"folder": attempt.folder,
"error_message": attempt.error_message,
"created_at": attempt.created_at,
"updated_at": attempt.updated_at,
}
if include_diagnostics:
payload["claim_token"] = attempt.claim_token
payload["error_message"] = attempt.error_message
imap_payloads.append(payload)
return {"smtp": smtp_payloads, "imap": imap_payloads}
@@ -1972,6 +2005,7 @@ def _job_diagnostics_payload(
"claimed_at": job.claimed_at,
"smtp_started_at": job.smtp_started_at,
"outcome_unknown_at": job.outcome_unknown_at,
"last_error": job.last_error,
},
attempts=_job_attempts_payload(
send_attempts,
@@ -2587,6 +2621,7 @@ def campaign_summary(
campaign_id=campaign_id,
version_id=version_id,
include_jobs=include_jobs,
include_recent_failures=include_jobs,
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -2612,6 +2647,7 @@ def campaign_report(
campaign_id=campaign_id,
version_id=version_id,
include_jobs=include_jobs,
include_recent_failures=include_jobs,
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -2650,8 +2686,15 @@ def email_campaign_report(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")),
):
_get_campaign_for_principal(session, campaign_id, principal)
if payload.include_jobs:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
selected_version_id = payload.version_id or campaign.current_version_id
selected_version = session.get(CampaignVersion, selected_version_id) if selected_version_id else None
if selected_version is not None and selected_version.campaign_id == campaign.id:
_require_mail_profile_use_if_needed(
principal,
selected_version.raw_json if isinstance(selected_version.raw_json, dict) else {},
)
if payload.include_jobs or payload.attach_jobs_csv:
_require_permission(principal, "campaigns:recipient:export")
"""Generate a campaign report and send it to one or more email addresses."""
@@ -2679,8 +2722,14 @@ def email_campaign_report(
return ReportEmailResponse(result=result.as_dict())
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except (CampaignReportEmailError, Exception) as exc:
except (CampaignReportEmailError, MailProfileError, SmtpConfigurationError, SmtpSendError) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except Exception as exc:
logger.error("Campaign report email failed with an unexpected internal error")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Campaign report email could not be completed.",
) from exc
@@ -2984,6 +3033,7 @@ def resolve_campaign_job_outcome(
job_id=job_id,
decision=payload.decision,
note=payload.note,
commit=False,
)
audit_from_principal(
session,
@@ -2996,7 +3046,11 @@ def resolve_campaign_job_outcome(
)
return CampaignActionResponse(result=result)
except (QueueingError, ExecutionSnapshotError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except Exception:
session.rollback()
raise
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)