security: seal Campaign delivery inputs

This commit is contained in:
2026-07-21 17:53:43 +02:00
parent 9f4eab07f6
commit 50c509d161
8 changed files with 350 additions and 44 deletions

View File

@@ -124,28 +124,35 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
)
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
return {
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"subject": job.subject,
"message_id_header": job.message_id_header,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"build_status": job.build_status,
"validation_status": job.validation_status,
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []),
}
def job_execution_input_hash(job: CampaignJob) -> str:
return _sha256(_job_execution_input_payload(job))
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
"""Hash the immutable per-message execution records in stable order."""
payload: list[dict[str, Any]] = []
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)):
payload.append(
{
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"subject": job.subject,
"message_id_header": job.message_id_header,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"build_status": job.build_status,
"validation_status": job.validation_status,
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []),
}
)
payload = [
_job_execution_input_payload(job)
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
]
return _sha256(payload)
@@ -161,6 +168,8 @@ def create_execution_snapshot(
) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs)
for job in job_list:
job.execution_input_sha256 = job_execution_input_hash(job)
summary = build_summary if isinstance(build_summary, dict) else {}
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
payload = ExecutionSnapshot(
@@ -181,7 +190,77 @@ def create_execution_snapshot(
return payload, snapshot_hash(payload)
def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> ExecutionSnapshot:
def _assert_snapshot_matches_persisted_inputs(
session: Session,
version: CampaignVersion,
snapshot: ExecutionSnapshot,
*,
effect_job: CampaignJob | None = None,
) -> None:
"""Fail closed when any build-bound input drifted after snapshot creation."""
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
if snapshot.campaign_version_id != version.id:
raise ExecutionSnapshotError("Execution snapshot campaign version mismatch")
if snapshot.campaign_json_sha256 != _sha256(raw_json):
raise ExecutionSnapshotError(
"Campaign inputs changed after this execution snapshot was built. "
"Revalidate and rebuild the campaign before delivery."
)
if not snapshot.smtp_transport_revision:
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
if not snapshot.job_manifest_sha256:
raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum")
if not snapshot.effective_policy_sha256:
raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum")
if snapshot.effective_policy_sha256 != _policy_fingerprint(raw_json, snapshot.delivery):
raise ExecutionSnapshotError(
"Campaign delivery policy changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
if effect_job is not None:
if effect_job.campaign_version_id != version.id:
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
if not getattr(effect_job, "execution_input_sha256", None):
raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery")
if effect_job.execution_input_sha256 != job_execution_input_hash(effect_job):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
return
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
.all()
)
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
if snapshot.job_count != len(jobs):
raise ExecutionSnapshotError(
"Built campaign jobs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses)
if (
snapshot.queueable_job_count != queueable_count
or snapshot.job_manifest_sha256 != job_manifest_hash(jobs)
or any(getattr(job, "execution_input_sha256", None) != job_execution_input_hash(job) for job in jobs)
):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
)
def ensure_execution_snapshot(
session: Session,
version: CampaignVersion,
*,
effect_job: CampaignJob | None = None,
) -> ExecutionSnapshot:
"""Return a validated snapshot, creating one for pre-migration builds.
New builds create the snapshot after persisting their jobs. The fallback is
@@ -208,9 +287,17 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
)
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json"))
if version.execution_snapshot_hash and version.execution_snapshot_hash != expected:
if not version.execution_snapshot_hash:
raise ExecutionSnapshotError("Execution snapshot checksum is missing")
if version.execution_snapshot_hash != expected:
raise ExecutionSnapshotError("Execution snapshot checksum mismatch")
_assert_snapshot_profile_matches_version(version, snapshot)
_assert_snapshot_matches_persisted_inputs(
session,
version,
snapshot,
effect_job=effect_job,
)
return snapshot
from govoplan_campaign.backend.persistence.campaigns import load_version_config

View File

@@ -1063,11 +1063,10 @@ def reconcile_job_outcome(
note=note,
commit=commit,
)
if job.send_status not in {
JobSendStatus.OUTCOME_UNKNOWN.value,
JobSendStatus.SENDING.value,
JobSendStatus.CLAIMED.value,
}:
evidence_note = (note or "").strip()
if not evidence_note:
raise QueueingError("SMTP reconciliation requires an evidence note")
if job.send_status != JobSendStatus.OUTCOME_UNKNOWN.value:
raise QueueingError(f"Job status {job.send_status} does not require reconciliation")
version = _get_version_for_campaign(session, campaign, version_id=job.campaign_version_id)
snapshot = ensure_execution_snapshot(session, version)
@@ -1079,7 +1078,7 @@ def reconcile_job_outcome(
job.sent_at = job.sent_at or now
job.outcome_unknown_at = None
job.claim_token = None
job.last_error = note or "Operator confirmed SMTP acceptance after an uncertain outcome."
job.last_error = evidence_note
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
files_integration().mark_job_attachment_uses_sent(session, job)
attempt_status = "reconciled_smtp_accepted"
@@ -1088,7 +1087,7 @@ def reconcile_job_outcome(
job.queue_status = JobQueueStatus.DRAFT.value
job.outcome_unknown_at = None
job.claim_token = None
job.last_error = note or "Operator confirmed that SMTP did not accept this message."
job.last_error = evidence_note
attempt_status = "reconciled_not_sent"
else:
raise QueueingError("decision must be 'smtp_accepted' or 'not_sent'")
@@ -1131,10 +1130,7 @@ def _reconcile_imap_append_outcome(
raise QueueingError("IMAP reconciliation requires an evidence note")
if job.send_status not in SMTP_ACCEPTED_STATUSES:
raise QueueingError("Only an SMTP-accepted job can reconcile a Sent-folder append")
if job.imap_status not in {
JobImapStatus.OUTCOME_UNKNOWN.value,
JobImapStatus.APPENDING.value,
}:
if job.imap_status != JobImapStatus.OUTCOME_UNKNOWN.value:
raise QueueingError(f"IMAP status {job.imap_status} does not require reconciliation")
attempt = (
@@ -1516,7 +1512,7 @@ def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDe
if not version:
raise SendJobError("Campaign version not found")
try:
snapshot = ensure_execution_snapshot(session, version)
snapshot = ensure_execution_snapshot(session, version, effect_job=job)
except ExecutionSnapshotError as exc:
raise SendJobError(str(exc)) from exc
@@ -2044,7 +2040,7 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
if not version:
raise SendJobError("Campaign version not found")
try:
snapshot = ensure_execution_snapshot(session, version)
snapshot = ensure_execution_snapshot(session, version, effect_job=job)
except ExecutionSnapshotError as exc:
raise SendJobError(str(exc)) from exc
if not snapshot.delivery.imap_append_sent.enabled: