feat: govern legacy archive encryption

This commit is contained in:
2026-08-20 12:24:36 +02:00
parent d5b874c469
commit 75e8f864a7
19 changed files with 1102 additions and 24 deletions
@@ -9,6 +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.archive_encryption import (
CampaignArchiveEncryptionError,
assert_archive_encryption_allowed,
)
from govoplan_campaign.backend.campaign.models import (
DeliveryChannelPolicy,
DeliveryConfig,
@@ -22,8 +26,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 = "8"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION}
SNAPSHOT_VERSION = "9"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", "8", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError):
@@ -57,6 +61,7 @@ class ExecutionSnapshot(BaseModel):
queueable_job_count: int = 0
job_manifest_sha256: str | None = None
effective_policy_sha256: str | None = None
archive_encryption: dict[str, Any] | None = None
smtp_transport_revision: str | None = None
imap_transport_revision: str | None = None
uses_mail: bool = True
@@ -263,6 +268,7 @@ def create_execution_snapshot(
imap_credential_id: str | None = None,
jobs: Iterable[CampaignJob] = (),
build_summary: dict[str, Any] | None = None,
archive_encryption: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs)
@@ -311,6 +317,7 @@ def create_execution_snapshot(
delivery,
snapshot_version=SNAPSHOT_VERSION,
),
archive_encryption=archive_encryption,
smtp_transport_revision=smtp_transport_revision,
imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail,
@@ -355,6 +362,39 @@ def _assert_snapshot_matches_persisted_inputs(
"Revalidate and rebuild the campaign before delivery."
)
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Execution snapshot Campaign no longer exists")
try:
current_archive_policy = assert_archive_encryption_allowed(
session,
campaign,
raw_json,
)
except CampaignArchiveEncryptionError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
archive_snapshot = snapshot.archive_encryption
configured_archives = (
((raw_json.get("attachments") or {}).get("zip") or {}).get("archives")
if isinstance(raw_json.get("attachments"), dict)
else None
)
if configured_archives and not isinstance(archive_snapshot, dict):
raise ExecutionSnapshotError(
"Execution snapshot has no governed archive-encryption evidence; rebuild before delivery."
)
if isinstance(archive_snapshot, dict):
frozen_policy = archive_snapshot.get("policy")
frozen_hash = (
frozen_policy.get("policy_hash")
if isinstance(frozen_policy, dict)
else None
)
if frozen_hash != current_archive_policy.policy_hash:
raise ExecutionSnapshotError(
"The effective archive-encryption policy changed after build. Revalidate and rebuild 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")
@@ -494,6 +534,12 @@ def ensure_execution_snapshot(
delivery=config.delivery,
jobs=jobs,
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
archive_encryption=(
version.build_summary.get("archive_encryption")
if isinstance(version.build_summary, dict)
and isinstance(version.build_summary.get("archive_encryption"), dict)
else None
),
)
version.execution_snapshot = payload
version.execution_snapshot_hash = digest