feat: govern legacy archive encryption
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.policy import (
|
||||
CampaignArchiveEncryptionDecision,
|
||||
CampaignArchiveEncryptionRequest,
|
||||
campaign_archive_encryption_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
|
||||
|
||||
LEGACY_ZIPCRYPTO_SCOPE = "campaigns:archive:use_legacy_zipcrypto"
|
||||
LEGACY_ZIPCRYPTO_LABEL = "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveArchiveEncryptionPolicy:
|
||||
available: bool
|
||||
allowed_password_encryption_methods: frozenset[str]
|
||||
allowed_password_delivery_channels: frozenset[str]
|
||||
policy_hash: str
|
||||
source_path: tuple[Mapping[str, Any], ...]
|
||||
reason: str
|
||||
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"available": self.available,
|
||||
"allowed_password_encryption_methods": sorted(
|
||||
self.allowed_password_encryption_methods
|
||||
),
|
||||
"allowed_password_delivery_channels": sorted(
|
||||
self.allowed_password_delivery_channels
|
||||
),
|
||||
"policy_hash": self.policy_hash,
|
||||
"source_path": [dict(item) for item in self.source_path],
|
||||
"reason": self.reason,
|
||||
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||
"legacy_label": LEGACY_ZIPCRYPTO_LABEL,
|
||||
}
|
||||
|
||||
|
||||
def effective_archive_encryption_policy(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
) -> EffectiveArchiveEncryptionPolicy:
|
||||
provider = campaign_archive_encryption_policy(get_registry())
|
||||
if provider is None:
|
||||
payload = {
|
||||
"available": False,
|
||||
"allowed_password_encryption_methods": ["aes"],
|
||||
"allowed_password_delivery_channels": [
|
||||
"in_person",
|
||||
"letter",
|
||||
"phone",
|
||||
"separate_mail",
|
||||
"sms",
|
||||
],
|
||||
"source_path": [
|
||||
{
|
||||
"scope_type": "system",
|
||||
"scope_id": None,
|
||||
"path": "system",
|
||||
"label": "Secure local fallback",
|
||||
"applied_fields": ["allowed_password_encryption_methods"],
|
||||
"policy": {
|
||||
"allowed_password_encryption_methods": ["aes"],
|
||||
"policy_provider": "unavailable",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
return EffectiveArchiveEncryptionPolicy(
|
||||
available=False,
|
||||
allowed_password_encryption_methods=frozenset({"aes"}),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||
),
|
||||
policy_hash=_hash(payload),
|
||||
source_path=tuple(payload["source_path"]),
|
||||
reason=(
|
||||
"Policy is unavailable. AES remains available through the secure "
|
||||
"local baseline; legacy ZipCrypto fails closed."
|
||||
),
|
||||
)
|
||||
owner_type: str | None = None
|
||||
owner_id: str | None = None
|
||||
if campaign.owner_group_id:
|
||||
owner_type, owner_id = "group", campaign.owner_group_id
|
||||
elif campaign.owner_user_id:
|
||||
owner_type, owner_id = "user", campaign.owner_user_id
|
||||
decision: CampaignArchiveEncryptionDecision = (
|
||||
provider.resolve_campaign_archive_encryption(
|
||||
session,
|
||||
request=CampaignArchiveEncryptionRequest(
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
owner_type=owner_type, # type: ignore[arg-type]
|
||||
owner_id=owner_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return EffectiveArchiveEncryptionPolicy(
|
||||
available=True,
|
||||
allowed_password_encryption_methods=frozenset(
|
||||
decision.allowed_password_encryption_methods
|
||||
),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
decision.allowed_password_delivery_channels
|
||||
),
|
||||
policy_hash=decision.policy_hash,
|
||||
source_path=tuple(step.to_dict() for step in decision.source_path),
|
||||
reason=decision.reason or "Effective archive-encryption policy resolved.",
|
||||
diagnostics=decision.diagnostics,
|
||||
)
|
||||
|
||||
|
||||
def assert_archive_encryption_allowed(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
raw_json: Mapping[str, Any],
|
||||
*,
|
||||
principal: ApiPrincipal | None = None,
|
||||
) -> EffectiveArchiveEncryptionPolicy:
|
||||
policy = effective_archive_encryption_policy(session, campaign)
|
||||
for archive in _archive_configs(raw_json):
|
||||
method = str(archive.get("method") or "aes")
|
||||
if method not in policy.allowed_password_encryption_methods:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{_method_label(method)} is blocked. {policy.reason}"
|
||||
)
|
||||
if method == "zip_standard":
|
||||
if not policy.available:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
"Legacy ZipCrypto cannot be used while Policy is unavailable."
|
||||
)
|
||||
if principal is not None and not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||
)
|
||||
if not archive.get("legacy_zipcrypto_acknowledged"):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires explicit acknowledgement."
|
||||
)
|
||||
if len(str(archive.get("legacy_zipcrypto_reason") or "").strip()) < 10:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires a reason of at least 10 characters."
|
||||
)
|
||||
if not archive.get("legacy_zipcrypto_acknowledged_by") or not archive.get(
|
||||
"legacy_zipcrypto_acknowledged_at"
|
||||
):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
"Legacy ZipCrypto acknowledgement has no server-recorded actor or time. Save the campaign again."
|
||||
)
|
||||
if archive.get("password_enabled"):
|
||||
# Existing campaign revisions predate the explicit field. Their
|
||||
# model default is the separate-mail channel; apply the same
|
||||
# normalization before policy enforcement so saved revisions do
|
||||
# not become unusable merely because the field was omitted.
|
||||
channel = str(
|
||||
archive.get("password_delivery_channel") or "separate_mail"
|
||||
)
|
||||
if channel not in policy.allowed_password_delivery_channels:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Password-delivery channel {channel!r} is blocked by the effective policy."
|
||||
)
|
||||
return policy
|
||||
|
||||
|
||||
def stamp_legacy_zipcrypto_acknowledgements(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
current_raw_json: Mapping[str, Any],
|
||||
candidate_raw_json: dict[str, Any] | None,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||
if candidate_raw_json is None:
|
||||
return None, []
|
||||
candidate = copy.deepcopy(candidate_raw_json)
|
||||
current_by_id = {
|
||||
str(item.get("id") or index): item
|
||||
for index, item in enumerate(_archive_configs(current_raw_json))
|
||||
}
|
||||
acknowledgements: list[dict[str, Any]] = []
|
||||
policy = effective_archive_encryption_policy(session, campaign)
|
||||
archives = _archive_configs(candidate)
|
||||
for index, archive in enumerate(archives):
|
||||
if str(archive.get("method") or "aes") != "zip_standard":
|
||||
archive.pop("legacy_zipcrypto_acknowledged_by", None)
|
||||
archive.pop("legacy_zipcrypto_acknowledged_at", None)
|
||||
continue
|
||||
if not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||
)
|
||||
if not policy.available or "zip_standard" not in policy.allowed_password_encryption_methods:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} is blocked. {policy.reason}"
|
||||
)
|
||||
reason = str(archive.get("legacy_zipcrypto_reason") or "").strip()
|
||||
if not archive.get("legacy_zipcrypto_acknowledged") or len(reason) < 10:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires acknowledgement and a reason of at least 10 characters."
|
||||
)
|
||||
key = str(archive.get("id") or index)
|
||||
previous = current_by_id.get(key, {})
|
||||
unchanged = (
|
||||
previous.get("method") == "zip_standard"
|
||||
and previous.get("legacy_zipcrypto_acknowledged") is True
|
||||
and str(previous.get("legacy_zipcrypto_reason") or "").strip() == reason
|
||||
and previous.get("legacy_zipcrypto_acknowledged_by")
|
||||
and previous.get("legacy_zipcrypto_acknowledged_at")
|
||||
)
|
||||
if unchanged:
|
||||
archive["legacy_zipcrypto_acknowledged_by"] = previous[
|
||||
"legacy_zipcrypto_acknowledged_by"
|
||||
]
|
||||
archive["legacy_zipcrypto_acknowledged_at"] = previous[
|
||||
"legacy_zipcrypto_acknowledged_at"
|
||||
]
|
||||
else:
|
||||
archive["legacy_zipcrypto_acknowledged_by"] = principal.user.id
|
||||
archive["legacy_zipcrypto_acknowledged_at"] = datetime.now(UTC).isoformat()
|
||||
acknowledgements.append(
|
||||
{
|
||||
"archive_id": key,
|
||||
"reason": reason,
|
||||
"policy_hash": policy.policy_hash,
|
||||
}
|
||||
)
|
||||
return candidate, acknowledgements
|
||||
|
||||
|
||||
def has_password_archives(raw_json: Mapping[str, Any]) -> bool:
|
||||
return any(bool(item.get("password_enabled")) for item in _archive_configs(raw_json))
|
||||
|
||||
|
||||
def _archive_configs(raw_json: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
attachments = raw_json.get("attachments")
|
||||
zip_config = attachments.get("zip") if isinstance(attachments, Mapping) else None
|
||||
archives = zip_config.get("archives") if isinstance(zip_config, Mapping) else None
|
||||
if isinstance(archives, list):
|
||||
return [item for item in archives if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _method_label(method: str) -> str:
|
||||
return LEGACY_ZIPCRYPTO_LABEL if method == "zip_standard" else method.upper()
|
||||
|
||||
|
||||
def _hash(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignArchiveEncryptionError",
|
||||
"EffectiveArchiveEncryptionPolicy",
|
||||
"LEGACY_ZIPCRYPTO_LABEL",
|
||||
"LEGACY_ZIPCRYPTO_SCOPE",
|
||||
"assert_archive_encryption_allowed",
|
||||
"effective_archive_encryption_policy",
|
||||
"has_password_archives",
|
||||
"stamp_legacy_zipcrypto_acknowledgements",
|
||||
]
|
||||
@@ -76,6 +76,14 @@ class ZipPasswordScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
|
||||
|
||||
class ZipPasswordDeliveryChannel(StrEnum):
|
||||
SEPARATE_MAIL = "separate_mail"
|
||||
SMS = "sms"
|
||||
LETTER = "letter"
|
||||
PHONE = "phone"
|
||||
IN_PERSON = "in_person"
|
||||
|
||||
|
||||
class ZipPasswordMode(StrEnum):
|
||||
NONE = "none"
|
||||
DIRECT = "direct"
|
||||
@@ -349,6 +357,13 @@ class ZipArchiveConfig(StrictModel):
|
||||
password_field: str | None = None
|
||||
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
|
||||
method: ZipMethod = ZipMethod.AES
|
||||
password_delivery_channel: ZipPasswordDeliveryChannel = (
|
||||
ZipPasswordDeliveryChannel.SEPARATE_MAIL
|
||||
)
|
||||
legacy_zipcrypto_acknowledged: bool = False
|
||||
legacy_zipcrypto_reason: str | None = Field(default=None, max_length=1000)
|
||||
legacy_zipcrypto_acknowledged_by: str | None = Field(default=None, max_length=255)
|
||||
legacy_zipcrypto_acknowledged_at: str | None = Field(default=None, max_length=80)
|
||||
|
||||
# Compatibility fields for campaigns created by the first single-archive
|
||||
# implementation. New WebUI campaigns use password_enabled/field/scope.
|
||||
@@ -376,6 +391,20 @@ class ZipArchiveConfig(StrictModel):
|
||||
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_legacy_zipcrypto_acknowledgement(self) -> "ZipArchiveConfig":
|
||||
if self.method != ZipMethod.ZIP_STANDARD:
|
||||
return self
|
||||
if not self.legacy_zipcrypto_acknowledged:
|
||||
raise ValueError(
|
||||
"Legacy ZipCrypto requires explicit acknowledgement of its weak encryption"
|
||||
)
|
||||
if len((self.legacy_zipcrypto_reason or "").strip()) < 10:
|
||||
raise ValueError(
|
||||
"Legacy ZipCrypto requires an acknowledgement reason of at least 10 characters"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ZipCollectionConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
|
||||
@@ -159,6 +159,12 @@ PERMISSIONS = (
|
||||
"Build exact messages and attachment evidence.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:archive:use_legacy_zipcrypto",
|
||||
"Use legacy ZipCrypto",
|
||||
"Explicitly select weak Windows-compatible ZipCrypto when the effective policy permits it.",
|
||||
"Campaign governance",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:review",
|
||||
"Complete campaign review",
|
||||
@@ -1025,6 +1031,33 @@ manifest = ModuleManifest(
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.archive-encryption-governance",
|
||||
title="Use governed password-protected ZIP attachments",
|
||||
summary="Use AES by default and select weak Windows-compatible ZipCrypto only with explicit policy, permission, acknowledgement, and evidence.",
|
||||
body=(
|
||||
"Campaign resolves archive encryption through Policy across system, tenant, owner user or group, and campaign scopes. Password-protected archives use AES unless the complete inherited policy permits Legacy ZipCrypto — Windows-compatible, weak encryption and the actor has campaigns:archive:use_legacy_zipcrypto. A legacy selection requires a reasoned acknowledgement. Passwords are never included in Campaign evidence or the campaign message and must be conveyed through the separately selected, policy-allowed channel. Each build freezes the archive and member hashes, implementation version, policy hash and source path, acknowledgement actor, reason and time, and build identity. A more restrictive later policy blocks queueing and sending until the campaign is rebuilt; Campaign never falls back from AES to ZipCrypto after an error. Temporary plaintext and archive material is confined to the bounded build directory and removed after success or failure."
|
||||
),
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("campaign_manager", "campaign_reviewer", "policy_admin"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns",),
|
||||
any_scopes=(
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:review",
|
||||
"admin:policies:read",
|
||||
),
|
||||
),
|
||||
),
|
||||
related_modules=("policy", "audit", "access"),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/campaigns/{campaign_id}/files",
|
||||
"screen": "Campaign attachments",
|
||||
"help_contexts": ["campaign.archive-encryption"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.workflow.complete-review",
|
||||
title="Inspect built messages and complete review",
|
||||
|
||||
@@ -4,7 +4,7 @@ import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage
|
||||
from email.utils import make_msgid, formatdate
|
||||
from pathlib import Path
|
||||
@@ -40,7 +40,10 @@ from govoplan_campaign.backend.campaign.models import (
|
||||
effective_delivery_channel_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||
from govoplan_campaign.backend.services.zip_service import (
|
||||
create_zip_archive,
|
||||
zip_archive_evidence,
|
||||
)
|
||||
from govoplan_campaign.backend.template_rendering import (
|
||||
find_unresolved_placeholders as _find_unresolved_placeholders,
|
||||
render_template as _render_template,
|
||||
@@ -93,6 +96,7 @@ class _MimeBuildResult:
|
||||
build_status: BuildStatus
|
||||
validation_status: MessageValidationStatus
|
||||
attachment_count: int
|
||||
archive_evidence: list[dict[str, object]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -372,8 +376,9 @@ def _attach_files(
|
||||
resolution: EntryAttachmentResolution,
|
||||
values: dict[str, Any],
|
||||
work_dir: Path,
|
||||
) -> int:
|
||||
) -> tuple[int, list[dict[str, object]]]:
|
||||
attached_count = 0
|
||||
evidence: list[dict[str, object]] = []
|
||||
archive_members: dict[str, list[tuple[Path, str]]] = {}
|
||||
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
||||
used_message_filenames: set[str] = set()
|
||||
@@ -429,13 +434,38 @@ def _attach_files(
|
||||
password,
|
||||
archive.method.value,
|
||||
)
|
||||
archive_record = zip_archive_evidence(
|
||||
archive_path,
|
||||
members,
|
||||
password_protected=bool(password),
|
||||
method=archive.method.value,
|
||||
)
|
||||
archive_record.update(
|
||||
{
|
||||
"archive_id": archive.id,
|
||||
"filename": filename,
|
||||
"password_delivery_channel": (
|
||||
archive.password_delivery_channel.value if password else None
|
||||
),
|
||||
"legacy_acknowledgement": (
|
||||
{
|
||||
"actor_id": archive.legacy_zipcrypto_acknowledged_by,
|
||||
"reason": archive.legacy_zipcrypto_reason,
|
||||
"recorded_at": archive.legacy_zipcrypto_acknowledged_at,
|
||||
}
|
||||
if archive.method.value == "zip_standard"
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
evidence.append(archive_record)
|
||||
data, maintype, subtype = _attachment_bytes(archive_path)
|
||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
attached_count += 1
|
||||
for attachment in archive_attachments.get(archive.id, []):
|
||||
attachment.zip_filename = filename
|
||||
|
||||
return attached_count
|
||||
return attached_count, evidence
|
||||
|
||||
def _imap_initial_status(
|
||||
config: CampaignConfig,
|
||||
@@ -527,6 +557,7 @@ def _message_draft(
|
||||
imap_status: ImapStatus | None = None,
|
||||
subject: str | None = None,
|
||||
attachment_count: int = 0,
|
||||
archive_evidence: list[dict[str, object]] | None = None,
|
||||
issues: list[MessageIssue] | None = None,
|
||||
eml_path: str | None = None,
|
||||
eml_size: int | None = None,
|
||||
@@ -566,6 +597,7 @@ def _message_draft(
|
||||
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
|
||||
attachment_count=attachment_count,
|
||||
attachments=_attachment_summaries(context.resolution),
|
||||
archive_evidence=archive_evidence or [],
|
||||
issues=issues if issues is not None else context.issues,
|
||||
eml_path=eml_path,
|
||||
eml_size_bytes=eml_size,
|
||||
@@ -763,7 +795,7 @@ def _build_mime_message(
|
||||
_populate_message_body(message, rendered)
|
||||
if work_dir is None:
|
||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
|
||||
attachment_count = _attach_files(
|
||||
attachment_count, archive_evidence = _attach_files(
|
||||
message=message,
|
||||
config=config,
|
||||
entry=entry,
|
||||
@@ -789,6 +821,7 @@ def _build_mime_message(
|
||||
build_status=BuildStatus.BUILT,
|
||||
validation_status=context.validation_status,
|
||||
attachment_count=attachment_count,
|
||||
archive_evidence=archive_evidence,
|
||||
)
|
||||
except ZipBuildError as exc:
|
||||
context.issues.append(
|
||||
@@ -893,6 +926,7 @@ def build_entry_message(
|
||||
validation_status=mime_result.validation_status,
|
||||
subject=rendered.subject,
|
||||
attachment_count=mime_result.attachment_count,
|
||||
archive_evidence=mime_result.archive_evidence,
|
||||
eml_path=eml_path,
|
||||
eml_size=eml_size,
|
||||
)
|
||||
@@ -1097,6 +1131,7 @@ def _build_residual_file_message(
|
||||
validation_status=mime_result.validation_status,
|
||||
subject=rendered.subject,
|
||||
attachment_count=mime_result.attachment_count,
|
||||
archive_evidence=mime_result.archive_evidence,
|
||||
eml_path=eml_path,
|
||||
eml_size=eml_size,
|
||||
),
|
||||
|
||||
@@ -94,6 +94,7 @@ class MessageDraft(BaseModel):
|
||||
|
||||
attachment_count: int = 0
|
||||
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
||||
archive_evidence: list[dict[str, object]] = Field(default_factory=list)
|
||||
issues: list[MessageIssue] = Field(default_factory=list)
|
||||
|
||||
eml_path: str | None = None
|
||||
|
||||
@@ -40,6 +40,10 @@ from govoplan_campaign.backend.db.models import (
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
assert_archive_encryption_allowed,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.loader import (
|
||||
load_campaign_json,
|
||||
validate_against_schema,
|
||||
@@ -657,6 +661,15 @@ def validate_campaign_version(
|
||||
raise CampaignPersistenceError(
|
||||
"Campaign version is not accessible for this tenant"
|
||||
)
|
||||
try:
|
||||
archive_policy = assert_archive_encryption_allowed(
|
||||
session,
|
||||
campaign,
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
principal=principal,
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
raise CampaignPersistenceError(str(exc)) from exc
|
||||
_ensure_current_campaign_version(campaign, version, action="validate")
|
||||
if _version_is_user_locked(version) or version.workflow_state in {
|
||||
CampaignVersionWorkflowState.QUEUED.value,
|
||||
@@ -734,6 +747,7 @@ def validate_campaign_version(
|
||||
"warning_count": report.warning_count,
|
||||
"validated_at": datetime.now(UTC).isoformat(),
|
||||
"validated_by_user_id": user_id,
|
||||
"archive_encryption_policy": archive_policy.to_dict(),
|
||||
}
|
||||
)
|
||||
version.validation_summary = report_json
|
||||
@@ -1430,6 +1444,11 @@ def _store_execution_snapshot(
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=build_summary,
|
||||
archive_encryption=(
|
||||
build_summary.get("archive_encryption")
|
||||
if isinstance(build_summary.get("archive_encryption"), dict)
|
||||
else None
|
||||
),
|
||||
)
|
||||
version.execution_snapshot = snapshot
|
||||
version.execution_snapshot_hash = snapshot_hash
|
||||
@@ -1615,6 +1634,15 @@ def build_campaign_version(
|
||||
raise CampaignPersistenceError(
|
||||
"Campaign version is not accessible for this tenant"
|
||||
)
|
||||
try:
|
||||
archive_policy = assert_archive_encryption_allowed(
|
||||
session,
|
||||
campaign,
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
principal=principal,
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
raise CampaignPersistenceError(str(exc)) from exc
|
||||
_ensure_current_campaign_version(campaign, version, action="build")
|
||||
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
||||
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
||||
@@ -1749,6 +1777,25 @@ def build_campaign_version(
|
||||
)
|
||||
report_json = _campaign_build_report(result, files)
|
||||
report_json["built_by_user_id"] = user_id
|
||||
archive_records = [
|
||||
{
|
||||
**archive,
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_id": version.id,
|
||||
"build_token": report_json["build_token"],
|
||||
"built_at": report_json["built_at"],
|
||||
"policy_hash": archive_policy.policy_hash,
|
||||
"policy_source_path": [
|
||||
dict(item) for item in archive_policy.source_path
|
||||
],
|
||||
}
|
||||
for message in result.report.messages
|
||||
for archive in message.archive_evidence
|
||||
]
|
||||
report_json["archive_encryption"] = {
|
||||
"policy": archive_policy.to_dict(),
|
||||
"archives": archive_records,
|
||||
}
|
||||
if resolved_print_outputs_by_index:
|
||||
first_output = next(iter(resolved_print_outputs_by_index.values()))
|
||||
report_json["print_output"] = {
|
||||
|
||||
@@ -13,6 +13,10 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CAMPAIGN_MAIL_SERVER_KEYS,
|
||||
campaign_mail_profile_id,
|
||||
)
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
stamp_legacy_zipcrypto_acknowledgements,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
@@ -388,7 +392,9 @@ def _update_campaign_version_detail_response(
|
||||
autosave: bool,
|
||||
audit_action: str,
|
||||
) -> CampaignVersionDetailResponse:
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
campaign = _get_campaign_for_principal(
|
||||
session, campaign_id, principal, write=True
|
||||
)
|
||||
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if payload.base_revision is None:
|
||||
error = MissingPreconditionError(
|
||||
@@ -421,9 +427,40 @@ def _update_campaign_version_detail_response(
|
||||
) from exc
|
||||
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
||||
_require_permission(principal, "campaigns:recipient:write")
|
||||
acknowledgements: list[dict[str, Any]] = []
|
||||
try:
|
||||
payload.campaign_json, acknowledgements = (
|
||||
stamp_legacy_zipcrypto_acknowledgements(
|
||||
session,
|
||||
campaign,
|
||||
current_version.raw_json
|
||||
if isinstance(current_version.raw_json, dict)
|
||||
else {},
|
||||
payload.campaign_json,
|
||||
principal=principal,
|
||||
)
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.archive_encryption_denied",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"campaign_id": campaign_id, "reason": str(exc)},
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=(
|
||||
status.HTTP_403_FORBIDDEN
|
||||
if "Missing scope:" in str(exc)
|
||||
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
),
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||
try:
|
||||
return _campaign_version_detail_response(
|
||||
result = _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
@@ -462,9 +499,23 @@ def _update_campaign_version_detail_response(
|
||||
}
|
||||
),
|
||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||
"legacy_zipcrypto_acknowledgements": acknowledgements,
|
||||
},
|
||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
)
|
||||
for acknowledgement in acknowledgements:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.legacy_zipcrypto_acknowledged",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"campaign_id": campaign_id, **acknowledgement},
|
||||
commit=False,
|
||||
)
|
||||
if acknowledgements:
|
||||
session.commit()
|
||||
return result
|
||||
except RevisionConflictError as exc:
|
||||
session.rollback()
|
||||
audit_from_principal(
|
||||
|
||||
@@ -130,9 +130,22 @@ from govoplan_campaign.backend.route_support import (
|
||||
_write_current_version_snapshot_if_available,
|
||||
bounded_query_rows as _bounded_query_rows,
|
||||
)
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
effective_archive_encryption_policy,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/archive-encryption-policy")
|
||||
def campaign_archive_encryption_policy(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
return effective_archive_encryption_policy(session, campaign).to_dict()
|
||||
|
||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
||||
|
||||
|
||||
@@ -770,6 +770,14 @@ def validate_version(
|
||||
except HTTPException:
|
||||
raise
|
||||
except CampaignPersistenceError as exc:
|
||||
if _is_archive_encryption_denial(exc):
|
||||
_audit_archive_encryption_denial(
|
||||
session, principal, version_id=version_id, error=exc
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
@@ -883,6 +891,9 @@ def build_version(
|
||||
"attachment_reuse": _attachment_reuse_audit_evidence(
|
||||
result.get("attachment_reuse")
|
||||
),
|
||||
"archive_encryption": _archive_encryption_audit_evidence(
|
||||
result.get("archive_encryption")
|
||||
),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
@@ -891,6 +902,18 @@ def build_version(
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignPersistenceError as exc:
|
||||
if _is_archive_encryption_denial(exc):
|
||||
_audit_archive_encryption_denial(
|
||||
session, principal, version_id=version_id, error=exc
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=(
|
||||
status.HTTP_403_FORBIDDEN
|
||||
if "Missing scope:" in str(exc)
|
||||
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
),
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
@@ -915,6 +938,38 @@ def build_version(
|
||||
) from exc
|
||||
|
||||
|
||||
def _is_archive_encryption_denial(error: Exception) -> bool:
|
||||
message = str(error).casefold()
|
||||
return any(
|
||||
marker in message
|
||||
for marker in (
|
||||
"archive-encryption",
|
||||
"archive encryption",
|
||||
"zipcrypto",
|
||||
"password-delivery channel",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _audit_archive_encryption_denial(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
version_id: str,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
session.rollback()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.archive_encryption_denied",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"reason": str(error)},
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
def _residual_file_audit_evidence(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
@@ -945,6 +1000,25 @@ def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _archive_encryption_audit_evidence(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
policy = value.get("policy")
|
||||
archives = [
|
||||
item for item in (value.get("archives") or []) if isinstance(item, dict)
|
||||
]
|
||||
return {
|
||||
"policy_hash": policy.get("policy_hash")
|
||||
if isinstance(policy, dict)
|
||||
else None,
|
||||
"archive_count": len(archives),
|
||||
"legacy_zipcrypto_count": sum(
|
||||
1 for item in archives if item.get("method") == "zip_standard"
|
||||
),
|
||||
"archive_sha256": [item.get("archive_sha256") for item in archives],
|
||||
}
|
||||
|
||||
|
||||
def _review_decision_audit_evidence(
|
||||
version: CampaignVersion,
|
||||
) -> dict[str, object]:
|
||||
|
||||
@@ -1625,6 +1625,44 @@
|
||||
],
|
||||
"default": "aes"
|
||||
},
|
||||
"password_delivery_channel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"separate_mail",
|
||||
"sms",
|
||||
"letter",
|
||||
"phone",
|
||||
"in_person"
|
||||
],
|
||||
"default": "separate_mail"
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"legacy_zipcrypto_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 1000
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged_by": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255,
|
||||
"readOnly": true
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 80,
|
||||
"readOnly": true
|
||||
},
|
||||
"password_mode": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
from importlib import metadata
|
||||
import secrets
|
||||
import stat
|
||||
import struct
|
||||
@@ -49,6 +51,8 @@ def create_zip_archive(
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
members = _normalized_members(files)
|
||||
if password:
|
||||
if method not in {ZIP_METHOD_AES, ZIP_METHOD_STANDARD}:
|
||||
raise ValueError(f"Unsupported password-encryption method: {method}")
|
||||
if method == ZIP_METHOD_STANDARD:
|
||||
_create_zipcrypto_archive(output_path, members, password)
|
||||
return output_path
|
||||
@@ -61,6 +65,51 @@ def create_zip_archive(
|
||||
return output_path
|
||||
|
||||
|
||||
def zip_archive_evidence(
|
||||
output_path: Path,
|
||||
members: Iterable[Path | ArchiveMember],
|
||||
*,
|
||||
password_protected: bool,
|
||||
method: str,
|
||||
) -> dict[str, object]:
|
||||
"""Return password-free, content-addressed evidence for one built archive."""
|
||||
|
||||
normalized = _normalized_members(members)
|
||||
archive_bytes = output_path.read_bytes()
|
||||
if password_protected and method == ZIP_METHOD_AES:
|
||||
try:
|
||||
implementation_version = metadata.version("pyzipper")
|
||||
except metadata.PackageNotFoundError: # pragma: no cover - guarded by writer
|
||||
implementation_version = "unknown"
|
||||
implementation = "pyzipper"
|
||||
archive_format = "WinZip AES"
|
||||
elif password_protected and method == ZIP_METHOD_STANDARD:
|
||||
implementation = "govoplan-campaign.zipcrypto"
|
||||
implementation_version = "1"
|
||||
archive_format = "Legacy ZipCrypto"
|
||||
else:
|
||||
implementation = "python.zipfile"
|
||||
implementation_version = "stdlib"
|
||||
archive_format = "ZIP (unencrypted)"
|
||||
return {
|
||||
"format": archive_format,
|
||||
"method": method if password_protected else "none",
|
||||
"password_protected": password_protected,
|
||||
"implementation": implementation,
|
||||
"implementation_version": implementation_version,
|
||||
"archive_sha256": hashlib.sha256(archive_bytes).hexdigest(),
|
||||
"archive_size_bytes": len(archive_bytes),
|
||||
"members": [
|
||||
{
|
||||
"name": archive_name,
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"size_bytes": path.stat().st_size,
|
||||
}
|
||||
for path, archive_name in normalized
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
|
||||
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user