feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -9,7 +9,10 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
DeliveryChannelPolicy,
|
||||
DeliveryConfig,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CampaignMailProfileBoundaryError,
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
@@ -19,7 +22,8 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
||||
|
||||
SNAPSHOT_VERSION = "6"
|
||||
SNAPSHOT_VERSION = "7"
|
||||
SUPPORTED_SNAPSHOT_VERSIONS = {"6", SNAPSHOT_VERSION}
|
||||
|
||||
|
||||
class ExecutionSnapshotError(RuntimeError):
|
||||
@@ -41,7 +45,7 @@ class ExecutionSnapshot(BaseModel):
|
||||
snapshot_version: str = SNAPSHOT_VERSION
|
||||
campaign_version_id: str
|
||||
campaign_json_sha256: str
|
||||
mail_profile_id: str
|
||||
mail_profile_id: str | None = None
|
||||
smtp_server_id: str | None = None
|
||||
smtp_credential_id: str | None = None
|
||||
imap_server_id: str | None = None
|
||||
@@ -55,6 +59,8 @@ class ExecutionSnapshot(BaseModel):
|
||||
effective_policy_sha256: str | None = None
|
||||
smtp_transport_revision: str | None = None
|
||||
imap_transport_revision: str | None = None
|
||||
uses_mail: bool = True
|
||||
uses_postbox: bool = False
|
||||
delivery: DeliveryConfig
|
||||
|
||||
|
||||
@@ -72,7 +78,7 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
|
||||
|
||||
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
_assert_version_mail_profile_boundary(raw_json, require_profile=True)
|
||||
mail = mail_integration()
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
|
||||
@@ -106,7 +112,12 @@ def profile_transport_revisions(session: Session, version: CampaignVersion) -> d
|
||||
|
||||
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
_assert_version_mail_profile_boundary(
|
||||
raw_json,
|
||||
require_profile=snapshot.uses_mail,
|
||||
)
|
||||
if not snapshot.uses_mail:
|
||||
return
|
||||
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
|
||||
raise ExecutionSnapshotError(
|
||||
"The campaign's Mail profile reference differs from the built execution snapshot. "
|
||||
@@ -127,19 +138,35 @@ def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot:
|
||||
)
|
||||
|
||||
|
||||
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
|
||||
def _assert_version_mail_profile_boundary(
|
||||
raw_json: dict[str, Any],
|
||||
*,
|
||||
require_profile: bool,
|
||||
) -> None:
|
||||
try:
|
||||
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True)
|
||||
assert_campaign_uses_mail_profile_reference(
|
||||
raw_json,
|
||||
require_profile=require_profile,
|
||||
)
|
||||
except CampaignMailProfileBoundaryError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
|
||||
|
||||
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
|
||||
def _policy_fingerprint(
|
||||
raw_json: dict[str, Any],
|
||||
delivery: DeliveryConfig,
|
||||
*,
|
||||
snapshot_version: str = SNAPSHOT_VERSION,
|
||||
) -> str:
|
||||
delivery_payload = delivery.model_dump(mode="json")
|
||||
if snapshot_version == "6":
|
||||
delivery_payload.pop("channel_policy", None)
|
||||
delivery_payload.pop("postbox", None)
|
||||
return _sha256(
|
||||
{
|
||||
"validation_policy": raw_json.get("validation_policy"),
|
||||
"policy": raw_json.get("policy"),
|
||||
"delivery": delivery.model_dump(mode="json"),
|
||||
"delivery": delivery_payload,
|
||||
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
|
||||
if isinstance(raw_json.get("attachments"), dict)
|
||||
else None,
|
||||
@@ -147,8 +174,12 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
|
||||
)
|
||||
|
||||
|
||||
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
|
||||
return {
|
||||
def _job_execution_input_payload(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
snapshot_version: str = SNAPSHOT_VERSION,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
@@ -163,17 +194,47 @@ def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
|
||||
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
|
||||
"issues_sha256": _sha256(job.issues_snapshot or []),
|
||||
}
|
||||
if snapshot_version != "6":
|
||||
payload.update(
|
||||
{
|
||||
"delivery_channel_policy": getattr(
|
||||
job,
|
||||
"delivery_channel_policy",
|
||||
DeliveryChannelPolicy.MAIL.value,
|
||||
),
|
||||
"resolved_postbox_targets_sha256": _sha256(
|
||||
getattr(job, "resolved_postbox_targets", None) or []
|
||||
),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def job_execution_input_hash(job: CampaignJob) -> str:
|
||||
return _sha256(_job_execution_input_payload(job))
|
||||
def job_execution_input_hash(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
snapshot_version: str = SNAPSHOT_VERSION,
|
||||
) -> str:
|
||||
return _sha256(
|
||||
_job_execution_input_payload(
|
||||
job,
|
||||
snapshot_version=snapshot_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
||||
def job_manifest_hash(
|
||||
jobs: Iterable[CampaignJob],
|
||||
*,
|
||||
snapshot_version: str = SNAPSHOT_VERSION,
|
||||
) -> str:
|
||||
"""Hash the immutable per-message execution records in stable order."""
|
||||
|
||||
payload = [
|
||||
_job_execution_input_payload(job)
|
||||
_job_execution_input_payload(
|
||||
job,
|
||||
snapshot_version=snapshot_version,
|
||||
)
|
||||
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
|
||||
]
|
||||
return _sha256(payload)
|
||||
@@ -182,8 +243,8 @@ def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
||||
def create_execution_snapshot(
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
mail_profile_id: str,
|
||||
smtp_transport_revision: str,
|
||||
mail_profile_id: str | None,
|
||||
smtp_transport_revision: str | None,
|
||||
imap_transport_revision: str | None,
|
||||
delivery: DeliveryConfig,
|
||||
smtp_server_id: str | None = None,
|
||||
@@ -195,8 +256,23 @@ 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)
|
||||
channel_policies = {
|
||||
DeliveryChannelPolicy(
|
||||
getattr(
|
||||
job,
|
||||
"delivery_channel_policy",
|
||||
DeliveryChannelPolicy.MAIL.value,
|
||||
)
|
||||
)
|
||||
for job in job_list
|
||||
}
|
||||
uses_mail = any(policy.uses_mail for policy in channel_policies)
|
||||
uses_postbox = any(policy.uses_postbox for policy in channel_policies)
|
||||
for job in job_list:
|
||||
job.execution_input_sha256 = job_execution_input_hash(job)
|
||||
job.execution_input_sha256 = job_execution_input_hash(
|
||||
job,
|
||||
snapshot_version=SNAPSHOT_VERSION,
|
||||
)
|
||||
summary = build_summary if isinstance(build_summary, dict) else {}
|
||||
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
||||
payload = ExecutionSnapshot(
|
||||
@@ -211,10 +287,23 @@ def create_execution_snapshot(
|
||||
built_at=str(summary.get("built_at") or "") or None,
|
||||
job_count=len(job_list),
|
||||
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
|
||||
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
|
||||
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
|
||||
job_manifest_sha256=(
|
||||
job_manifest_hash(
|
||||
job_list,
|
||||
snapshot_version=SNAPSHOT_VERSION,
|
||||
)
|
||||
if job_list
|
||||
else None
|
||||
),
|
||||
effective_policy_sha256=_policy_fingerprint(
|
||||
raw_json,
|
||||
delivery,
|
||||
snapshot_version=SNAPSHOT_VERSION,
|
||||
),
|
||||
smtp_transport_revision=smtp_transport_revision,
|
||||
imap_transport_revision=imap_transport_revision,
|
||||
uses_mail=uses_mail,
|
||||
uses_postbox=uses_postbox,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
delivery=delivery,
|
||||
).model_dump(mode="json")
|
||||
@@ -238,13 +327,17 @@ def _assert_snapshot_matches_persisted_inputs(
|
||||
"Campaign inputs changed after this execution snapshot was built. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
if not snapshot.smtp_transport_revision:
|
||||
if snapshot.uses_mail and 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):
|
||||
if snapshot.effective_policy_sha256 != _policy_fingerprint(
|
||||
raw_json,
|
||||
snapshot.delivery,
|
||||
snapshot_version=snapshot.snapshot_version,
|
||||
):
|
||||
raise ExecutionSnapshotError(
|
||||
"Campaign delivery policy changed after the execution snapshot was created. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
@@ -255,7 +348,10 @@ def _assert_snapshot_matches_persisted_inputs(
|
||||
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):
|
||||
if effect_job.execution_input_sha256 != job_execution_input_hash(
|
||||
effect_job,
|
||||
snapshot_version=snapshot.snapshot_version,
|
||||
):
|
||||
raise ExecutionSnapshotError(
|
||||
"Built campaign job inputs changed after the execution snapshot was created. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
@@ -277,8 +373,19 @@ def _assert_snapshot_matches_persisted_inputs(
|
||||
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)
|
||||
or snapshot.job_manifest_sha256
|
||||
!= job_manifest_hash(
|
||||
jobs,
|
||||
snapshot_version=snapshot.snapshot_version,
|
||||
)
|
||||
or any(
|
||||
getattr(job, "execution_input_sha256", None)
|
||||
!= job_execution_input_hash(
|
||||
job,
|
||||
snapshot_version=snapshot.snapshot_version,
|
||||
)
|
||||
for job in jobs
|
||||
)
|
||||
):
|
||||
raise ExecutionSnapshotError(
|
||||
"Built campaign job inputs changed after the execution snapshot was created. "
|
||||
@@ -307,17 +414,20 @@ def ensure_execution_snapshot(
|
||||
)
|
||||
except CampaignPathSecurityError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
_assert_version_mail_profile_boundary(raw_json, require_profile=False)
|
||||
|
||||
if isinstance(version.execution_snapshot, dict):
|
||||
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION:
|
||||
stored_version = str(
|
||||
version.execution_snapshot.get("snapshot_version") or ""
|
||||
)
|
||||
if stored_version not in SUPPORTED_SNAPSHOT_VERSIONS:
|
||||
raise ExecutionSnapshotError(
|
||||
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
|
||||
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
|
||||
"and rebuild a new campaign version."
|
||||
)
|
||||
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
||||
expected = snapshot_hash(snapshot.model_dump(mode="json"))
|
||||
expected = snapshot_hash(version.execution_snapshot)
|
||||
if not version.execution_snapshot_hash:
|
||||
raise ExecutionSnapshotError("Execution snapshot checksum is missing")
|
||||
if version.execution_snapshot_hash != expected:
|
||||
@@ -334,11 +444,6 @@ def ensure_execution_snapshot(
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||
|
||||
_, _, config = load_version_config(session, version.id)
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise ExecutionSnapshotError("The selected Mail profile has no SMTP configuration")
|
||||
if profile_id is None:
|
||||
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
@@ -347,9 +452,24 @@ def ensure_execution_snapshot(
|
||||
)
|
||||
if not jobs:
|
||||
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
||||
summary = profile_delivery_summary(session, version)
|
||||
if not summary.get("smtp_transport_revision"):
|
||||
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
|
||||
uses_mail = any(
|
||||
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
|
||||
for job in jobs
|
||||
)
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
summary: dict[str, Any] = {}
|
||||
if uses_mail:
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise ExecutionSnapshotError(
|
||||
"The selected Mail profile has no SMTP configuration"
|
||||
)
|
||||
if profile_id is None:
|
||||
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
|
||||
summary = profile_delivery_summary(session, version)
|
||||
if not summary.get("smtp_transport_revision"):
|
||||
raise ExecutionSnapshotError(
|
||||
"The selected Mail profile has no SMTP transport revision"
|
||||
)
|
||||
payload, digest = create_execution_snapshot(
|
||||
version,
|
||||
mail_profile_id=profile_id,
|
||||
@@ -357,7 +477,7 @@ def ensure_execution_snapshot(
|
||||
smtp_credential_id=summary.get("smtp_credential_id"),
|
||||
imap_server_id=summary.get("imap_server_id"),
|
||||
imap_credential_id=summary.get("imap_credential_id"),
|
||||
smtp_transport_revision=summary["smtp_transport_revision"],
|
||||
smtp_transport_revision=summary.get("smtp_transport_revision"),
|
||||
imap_transport_revision=summary.get("imap_transport_revision"),
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
|
||||
Reference in New Issue
Block a user