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
@@ -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" GLOBAL = "global"
class ZipPasswordDeliveryChannel(StrEnum):
SEPARATE_MAIL = "separate_mail"
SMS = "sms"
LETTER = "letter"
PHONE = "phone"
IN_PERSON = "in_person"
class ZipPasswordMode(StrEnum): class ZipPasswordMode(StrEnum):
NONE = "none" NONE = "none"
DIRECT = "direct" DIRECT = "direct"
@@ -349,6 +357,13 @@ class ZipArchiveConfig(StrictModel):
password_field: str | None = None password_field: str | None = None
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
method: ZipMethod = ZipMethod.AES 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 # Compatibility fields for campaigns created by the first single-archive
# implementation. New WebUI campaigns use password_enabled/field/scope. # implementation. New WebUI campaigns use password_enabled/field/scope.
@@ -376,6 +391,20 @@ class ZipArchiveConfig(StrictModel):
normalized["password_scope"] = ZipPasswordScope.LOCAL.value normalized["password_scope"] = ZipPasswordScope.LOCAL.value
return normalized 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): class ZipCollectionConfig(StrictModel):
enabled: bool = False enabled: bool = False
+33
View File
@@ -159,6 +159,12 @@ PERMISSIONS = (
"Build exact messages and attachment evidence.", "Build exact messages and attachment evidence.",
"Campaigns", "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( _permission(
"campaigns:campaign:review", "campaigns:campaign:review",
"Complete 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( DocumentationTopic(
id="campaigns.workflow.complete-review", id="campaigns.workflow.complete-review",
title="Inspect built messages and complete review", title="Inspect built messages and complete review",
@@ -4,7 +4,7 @@ import mimetypes
import re import re
import tempfile import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import make_msgid, formatdate from email.utils import make_msgid, formatdate
from pathlib import Path from pathlib import Path
@@ -40,7 +40,10 @@ from govoplan_campaign.backend.campaign.models import (
effective_delivery_channel_policy, effective_delivery_channel_policy,
) )
from govoplan_campaign.backend.campaign.template_values import build_template_values 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 ( from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders as _find_unresolved_placeholders, find_unresolved_placeholders as _find_unresolved_placeholders,
render_template as _render_template, render_template as _render_template,
@@ -93,6 +96,7 @@ class _MimeBuildResult:
build_status: BuildStatus build_status: BuildStatus
validation_status: MessageValidationStatus validation_status: MessageValidationStatus
attachment_count: int attachment_count: int
archive_evidence: list[dict[str, object]] = field(default_factory=list)
@dataclass(slots=True) @dataclass(slots=True)
@@ -372,8 +376,9 @@ def _attach_files(
resolution: EntryAttachmentResolution, resolution: EntryAttachmentResolution,
values: dict[str, Any], values: dict[str, Any],
work_dir: Path, work_dir: Path,
) -> int: ) -> tuple[int, list[dict[str, object]]]:
attached_count = 0 attached_count = 0
evidence: list[dict[str, object]] = []
archive_members: dict[str, list[tuple[Path, str]]] = {} archive_members: dict[str, list[tuple[Path, str]]] = {}
archive_attachments: dict[str, list[ResolvedAttachment]] = {} archive_attachments: dict[str, list[ResolvedAttachment]] = {}
used_message_filenames: set[str] = set() used_message_filenames: set[str] = set()
@@ -429,13 +434,38 @@ def _attach_files(
password, password,
archive.method.value, 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) data, maintype, subtype = _attachment_bytes(archive_path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
attached_count += 1 attached_count += 1
for attachment in archive_attachments.get(archive.id, []): for attachment in archive_attachments.get(archive.id, []):
attachment.zip_filename = filename attachment.zip_filename = filename
return attached_count return attached_count, evidence
def _imap_initial_status( def _imap_initial_status(
config: CampaignConfig, config: CampaignConfig,
@@ -527,6 +557,7 @@ def _message_draft(
imap_status: ImapStatus | None = None, imap_status: ImapStatus | None = None,
subject: str | None = None, subject: str | None = None,
attachment_count: int = 0, attachment_count: int = 0,
archive_evidence: list[dict[str, object]] | None = None,
issues: list[MessageIssue] | None = None, issues: list[MessageIssue] | None = None,
eml_path: str | None = None, eml_path: str | None = None,
eml_size: int | None = None, eml_size: int | None = None,
@@ -566,6 +597,7 @@ def _message_draft(
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]), disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
attachment_count=attachment_count, attachment_count=attachment_count,
attachments=_attachment_summaries(context.resolution), attachments=_attachment_summaries(context.resolution),
archive_evidence=archive_evidence or [],
issues=issues if issues is not None else context.issues, issues=issues if issues is not None else context.issues,
eml_path=eml_path, eml_path=eml_path,
eml_size_bytes=eml_size, eml_size_bytes=eml_size,
@@ -763,7 +795,7 @@ def _build_mime_message(
_populate_message_body(message, rendered) _populate_message_body(message, rendered)
if work_dir is None: if work_dir is None:
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-")) work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
attachment_count = _attach_files( attachment_count, archive_evidence = _attach_files(
message=message, message=message,
config=config, config=config,
entry=entry, entry=entry,
@@ -789,6 +821,7 @@ def _build_mime_message(
build_status=BuildStatus.BUILT, build_status=BuildStatus.BUILT,
validation_status=context.validation_status, validation_status=context.validation_status,
attachment_count=attachment_count, attachment_count=attachment_count,
archive_evidence=archive_evidence,
) )
except ZipBuildError as exc: except ZipBuildError as exc:
context.issues.append( context.issues.append(
@@ -893,6 +926,7 @@ def build_entry_message(
validation_status=mime_result.validation_status, validation_status=mime_result.validation_status,
subject=rendered.subject, subject=rendered.subject,
attachment_count=mime_result.attachment_count, attachment_count=mime_result.attachment_count,
archive_evidence=mime_result.archive_evidence,
eml_path=eml_path, eml_path=eml_path,
eml_size=eml_size, eml_size=eml_size,
) )
@@ -1097,6 +1131,7 @@ def _build_residual_file_message(
validation_status=mime_result.validation_status, validation_status=mime_result.validation_status,
subject=rendered.subject, subject=rendered.subject,
attachment_count=mime_result.attachment_count, attachment_count=mime_result.attachment_count,
archive_evidence=mime_result.archive_evidence,
eml_path=eml_path, eml_path=eml_path,
eml_size=eml_size, eml_size=eml_size,
), ),
@@ -94,6 +94,7 @@ class MessageDraft(BaseModel):
attachment_count: int = 0 attachment_count: int = 0
attachments: list[MessageAttachmentSummary] = Field(default_factory=list) attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
archive_evidence: list[dict[str, object]] = Field(default_factory=list)
issues: list[MessageIssue] = Field(default_factory=list) issues: list[MessageIssue] = Field(default_factory=list)
eml_path: str | None = None eml_path: str | None = None
@@ -40,6 +40,10 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus, JobSendStatus,
JobValidationStatus, JobValidationStatus,
) )
from govoplan_campaign.backend.archive_encryption import (
CampaignArchiveEncryptionError,
assert_archive_encryption_allowed,
)
from govoplan_campaign.backend.campaign.loader import ( from govoplan_campaign.backend.campaign.loader import (
load_campaign_json, load_campaign_json,
validate_against_schema, validate_against_schema,
@@ -657,6 +661,15 @@ def validate_campaign_version(
raise CampaignPersistenceError( raise CampaignPersistenceError(
"Campaign version is not accessible for this tenant" "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") _ensure_current_campaign_version(campaign, version, action="validate")
if _version_is_user_locked(version) or version.workflow_state in { if _version_is_user_locked(version) or version.workflow_state in {
CampaignVersionWorkflowState.QUEUED.value, CampaignVersionWorkflowState.QUEUED.value,
@@ -734,6 +747,7 @@ def validate_campaign_version(
"warning_count": report.warning_count, "warning_count": report.warning_count,
"validated_at": datetime.now(UTC).isoformat(), "validated_at": datetime.now(UTC).isoformat(),
"validated_by_user_id": user_id, "validated_by_user_id": user_id,
"archive_encryption_policy": archive_policy.to_dict(),
} }
) )
version.validation_summary = report_json version.validation_summary = report_json
@@ -1430,6 +1444,11 @@ def _store_execution_snapshot(
delivery=config.delivery, delivery=config.delivery,
jobs=jobs, jobs=jobs,
build_summary=build_summary, 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 = snapshot
version.execution_snapshot_hash = snapshot_hash version.execution_snapshot_hash = snapshot_hash
@@ -1615,6 +1634,15 @@ def build_campaign_version(
raise CampaignPersistenceError( raise CampaignPersistenceError(
"Campaign version is not accessible for this tenant" "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") _ensure_current_campaign_version(campaign, version, action="build")
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value: if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt") 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 = _campaign_build_report(result, files)
report_json["built_by_user_id"] = user_id 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: if resolved_print_outputs_by_index:
first_output = next(iter(resolved_print_outputs_by_index.values())) first_output = next(iter(resolved_print_outputs_by_index.values()))
report_json["print_output"] = { report_json["print_output"] = {
+53 -2
View File
@@ -13,6 +13,10 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CAMPAIGN_MAIL_SERVER_KEYS, CAMPAIGN_MAIL_SERVER_KEYS,
campaign_mail_profile_id, campaign_mail_profile_id,
) )
from govoplan_campaign.backend.archive_encryption import (
CampaignArchiveEncryptionError,
stamp_legacy_zipcrypto_acknowledgements,
)
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignIssue, CampaignIssue,
@@ -388,7 +392,9 @@ def _update_campaign_version_detail_response(
autosave: bool, autosave: bool,
audit_action: str, audit_action: str,
) -> CampaignVersionDetailResponse: ) -> 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) current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if payload.base_revision is None: if payload.base_revision is None:
error = MissingPreconditionError( error = MissingPreconditionError(
@@ -421,9 +427,40 @@ def _update_campaign_version_detail_response(
) from exc ) from exc
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
_require_permission(principal, "campaigns:recipient:write") _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) _require_mail_profile_use_if_needed(principal, payload.campaign_json)
try: try:
return _campaign_version_detail_response( result = _campaign_version_detail_response(
session, session,
principal, principal,
campaign_id, campaign_id,
@@ -462,9 +499,23 @@ def _update_campaign_version_detail_response(
} }
), ),
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings, "legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
"legacy_zipcrypto_acknowledgements": acknowledgements,
}, },
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT, 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: except RevisionConflictError as exc:
session.rollback() session.rollback()
audit_from_principal( audit_from_principal(
@@ -130,9 +130,22 @@ from govoplan_campaign.backend.route_support import (
_write_current_version_snapshot_if_available, _write_current_version_snapshot_if_available,
bounded_query_rows as _bounded_query_rows, 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 = 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_LOOKUP = "addresses.lookup"
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
@@ -770,6 +770,14 @@ def validate_version(
except HTTPException: except HTTPException:
raise raise
except CampaignPersistenceError as exc: 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc ) from exc
@@ -883,6 +891,9 @@ def build_version(
"attachment_reuse": _attachment_reuse_audit_evidence( "attachment_reuse": _attachment_reuse_audit_evidence(
result.get("attachment_reuse") result.get("attachment_reuse")
), ),
"archive_encryption": _archive_encryption_audit_evidence(
result.get("archive_encryption")
),
}, },
commit=True, commit=True,
) )
@@ -891,6 +902,18 @@ def build_version(
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
) )
except CampaignPersistenceError as exc: 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc ) from exc
@@ -915,6 +938,38 @@ def build_version(
) from exc ) 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]: def _residual_file_audit_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict): if not isinstance(value, dict):
return {} 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( def _review_decision_audit_evidence(
version: CampaignVersion, version: CampaignVersion,
) -> dict[str, object]: ) -> dict[str, object]:
@@ -1625,6 +1625,44 @@
], ],
"default": "aes" "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": { "password_mode": {
"type": [ "type": [
"string", "string",
@@ -9,6 +9,10 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus 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 ( from govoplan_campaign.backend.campaign.models import (
DeliveryChannelPolicy, DeliveryChannelPolicy,
DeliveryConfig, 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.integrations import MailProfileError, files_integration, mail_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
SNAPSHOT_VERSION = "8" SNAPSHOT_VERSION = "9"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION} SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", "8", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError): class ExecutionSnapshotError(RuntimeError):
@@ -57,6 +61,7 @@ class ExecutionSnapshot(BaseModel):
queueable_job_count: int = 0 queueable_job_count: int = 0
job_manifest_sha256: str | None = None job_manifest_sha256: str | None = None
effective_policy_sha256: str | None = None effective_policy_sha256: str | None = None
archive_encryption: dict[str, Any] | None = None
smtp_transport_revision: str | None = None smtp_transport_revision: str | None = None
imap_transport_revision: str | None = None imap_transport_revision: str | None = None
uses_mail: bool = True uses_mail: bool = True
@@ -263,6 +268,7 @@ def create_execution_snapshot(
imap_credential_id: str | None = None, imap_credential_id: str | None = None,
jobs: Iterable[CampaignJob] = (), jobs: Iterable[CampaignJob] = (),
build_summary: dict[str, Any] | None = None, build_summary: dict[str, Any] | None = None,
archive_encryption: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]: ) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs) job_list = list(jobs)
@@ -311,6 +317,7 @@ def create_execution_snapshot(
delivery, delivery,
snapshot_version=SNAPSHOT_VERSION, snapshot_version=SNAPSHOT_VERSION,
), ),
archive_encryption=archive_encryption,
smtp_transport_revision=smtp_transport_revision, smtp_transport_revision=smtp_transport_revision,
imap_transport_revision=imap_transport_revision, imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail, uses_mail=uses_mail,
@@ -355,6 +362,39 @@ def _assert_snapshot_matches_persisted_inputs(
"Revalidate and rebuild the campaign before delivery." "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 is not None:
if effect_job.campaign_version_id != version.id: if effect_job.campaign_version_id != version.id:
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version") raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
@@ -494,6 +534,12 @@ def ensure_execution_snapshot(
delivery=config.delivery, delivery=config.delivery,
jobs=jobs, jobs=jobs,
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {}, 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 = payload
version.execution_snapshot_hash = digest version.execution_snapshot_hash = digest
@@ -2,6 +2,8 @@ from __future__ import annotations
import binascii import binascii
from datetime import datetime from datetime import datetime
import hashlib
from importlib import metadata
import secrets import secrets
import stat import stat
import struct import struct
@@ -49,6 +51,8 @@ def create_zip_archive(
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
members = _normalized_members(files) members = _normalized_members(files)
if password: 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: if method == ZIP_METHOD_STANDARD:
_create_zipcrypto_archive(output_path, members, password) _create_zipcrypto_archive(output_path, members, password)
return output_path return output_path
@@ -61,6 +65,51 @@ def create_zip_archive(
return output_path 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: 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.""" """Backward-compatible wrapper for the original per-rule ZIP helper."""
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_campaign.backend.archive_encryption import (
CampaignArchiveEncryptionError,
LEGACY_ZIPCRYPTO_SCOPE,
assert_archive_encryption_allowed,
effective_archive_encryption_policy,
stamp_legacy_zipcrypto_acknowledgements,
)
from govoplan_campaign.backend.db.models import Campaign
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import (
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
CampaignArchiveEncryptionDecision,
PolicySourceStep,
)
class _PolicyProvider:
def __init__(self, methods: set[str]) -> None:
self.methods = methods
def resolve_campaign_archive_encryption(self, session=None, *, request):
del session, request
return CampaignArchiveEncryptionDecision(
allowed_password_encryption_methods=frozenset(self.methods),
allowed_password_delivery_channels=frozenset(
{"separate_mail", "sms", "letter", "phone", "in_person"}
),
policy_hash="f" * 64,
source_path=(
PolicySourceStep(
scope_type="system",
label="System archive-encryption policy",
applied_fields=("allowed_password_encryption_methods",),
policy={"allowed_password_encryption_methods": sorted(self.methods)},
),
),
)
class _Registry:
def __init__(self, provider) -> None:
self.provider = provider
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION
def capability(self, name: str):
return self.provider if self.has_capability(name) else None
class CampaignArchiveEncryptionGovernanceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
self.session = Session(self.engine)
self.campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="example",
name="Example",
owner_user_id="user-1",
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_unavailable_policy_keeps_aes_and_fails_closed_for_legacy(self) -> None:
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None):
policy = effective_archive_encryption_policy(self.session, self.campaign)
self.assertFalse(policy.available)
self.assertEqual(frozenset({"aes"}), policy.allowed_password_encryption_methods)
assert_archive_encryption_allowed(
self.session,
self.campaign,
_raw_archive("aes"),
)
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "blocked"):
assert_archive_encryption_allowed(
self.session,
self.campaign,
_raw_archive("zip_standard", stamped=True),
)
def test_existing_password_archive_inherits_separate_mail_channel(self) -> None:
raw = _raw_archive("aes")
raw["attachments"]["zip"]["archives"][0].pop("password_delivery_channel")
with patch(
"govoplan_campaign.backend.archive_encryption.get_registry",
return_value=None,
):
decision = assert_archive_encryption_allowed(
self.session,
self.campaign,
raw,
)
self.assertIn(
"separate_mail",
decision.allowed_password_delivery_channels,
)
def test_legacy_selection_requires_permission_and_gets_server_stamp(self) -> None:
registry = _Registry(_PolicyProvider({"aes", "zip_standard"}))
candidate = _raw_archive("zip_standard")
candidate["attachments"]["zip"]["archives"][0].update(
{
"legacy_zipcrypto_acknowledged": True,
"legacy_zipcrypto_reason": "Recipient requires built-in Windows extraction",
}
)
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=registry):
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "Missing scope"):
stamp_legacy_zipcrypto_acknowledgements(
self.session,
self.campaign,
{},
candidate,
principal=_principal(set()),
)
stamped, evidence = stamp_legacy_zipcrypto_acknowledgements(
self.session,
self.campaign,
{},
candidate,
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
)
archive = stamped["attachments"]["zip"]["archives"][0]
self.assertEqual("user-1", archive["legacy_zipcrypto_acknowledged_by"])
self.assertTrue(archive["legacy_zipcrypto_acknowledged_at"])
self.assertEqual("f" * 64, evidence[0]["policy_hash"])
assert_archive_encryption_allowed(
self.session,
self.campaign,
stamped,
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
)
def _raw_archive(method: str, *, stamped: bool = False) -> dict:
archive = {
"id": "archive-1",
"method": method,
"password_enabled": True,
"password_delivery_channel": "separate_mail",
"legacy_zipcrypto_acknowledged": method == "zip_standard",
"legacy_zipcrypto_reason": "Windows recipient compatibility required"
if method == "zip_standard"
else None,
}
if stamped:
archive.update(
{
"legacy_zipcrypto_acknowledged_by": "user-1",
"legacy_zipcrypto_acknowledged_at": "2026-08-20T10:00:00+00:00",
}
)
return {"attachments": {"zip": {"enabled": True, "archives": [archive]}}}
def _principal(scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
if __name__ == "__main__":
unittest.main()
+30 -1
View File
@@ -10,7 +10,10 @@ try:
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
pyzipper = None pyzipper = None
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,
)
class ZipServiceTests(unittest.TestCase): class ZipServiceTests(unittest.TestCase):
@@ -28,6 +31,20 @@ class ZipServiceTests(unittest.TestCase):
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED) self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
self.assertTrue(info.flag_bits & 0x1) self.assertTrue(info.flag_bits & 0x1)
self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP") self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP")
with self.assertRaises(RuntimeError):
archive.read("message.txt", pwd=b"wrong-password")
evidence = zip_archive_evidence(
output,
[(source, "message.txt")],
password_protected=True,
method="zip_standard",
)
self.assertEqual("Legacy ZipCrypto", evidence["format"])
self.assertEqual("govoplan-campaign.zipcrypto", evidence["implementation"])
self.assertNotIn("password", evidence)
self.assertEqual(64, len(str(evidence["archive_sha256"])))
self.assertEqual(64, len(str(evidence["members"][0]["sha256"])))
@unittest.skipIf(pyzipper is None, "pyzipper is not installed") @unittest.skipIf(pyzipper is None, "pyzipper is not installed")
def test_aes_password_zip_keeps_aes_encryption(self) -> None: def test_aes_password_zip_keeps_aes_encryption(self) -> None:
@@ -63,6 +80,18 @@ class ZipServiceTests(unittest.TestCase):
self.assertFalse(info.flag_bits & 0x1) self.assertFalse(info.flag_bits & 0x1)
self.assertEqual(archive.read("message.txt"), b"Plain ZIP") self.assertEqual(archive.read("message.txt"), b"Plain ZIP")
def test_unknown_password_method_fails_without_downgrade_or_output(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = root / "source.txt"
source.write_text("No downgrade", encoding="utf-8")
output = root / "unknown.zip"
with self.assertRaisesRegex(ValueError, "Unsupported"):
create_zip_archive(output, [source], "secret", "unknown")
self.assertFalse(output.exists())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+28
View File
@@ -38,6 +38,24 @@ export type CampaignShare = {
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;}; export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];}; export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
export type CampaignArchiveEncryptionPolicy = {
available: boolean;
allowed_password_encryption_methods: Array<"aes" | "zip_standard">;
allowed_password_delivery_channels: Array<"separate_mail" | "sms" | "letter" | "phone" | "in_person">;
policy_hash: string;
source_path: Array<{
scope_type: string;
scope_id?: string | null;
path: string;
label: string;
applied_fields: string[];
policy: Record<string, unknown>;
}>;
reason: string;
diagnostics: Array<Record<string, unknown>>;
legacy_label: string;
};
export type CampaignUpdatePayload = { export type CampaignUpdatePayload = {
external_id?: string | null; external_id?: string | null;
name?: string | null; name?: string | null;
@@ -1141,6 +1159,16 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`); return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
} }
export async function getCampaignArchiveEncryptionPolicy(
settings: ApiSettings,
campaignId: string
): Promise<CampaignArchiveEncryptionPolicy> {
return apiFetch<CampaignArchiveEncryptionPolicy>(
settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/archive-encryption-policy`
);
}
export async function updateCampaignMetadata( export async function updateCampaignMetadata(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -2,7 +2,11 @@ import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui"; import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types"; import type { ApiSettings, AuthInfo } from "../../types";
import {
getCampaignArchiveEncryptionPolicy,
type CampaignArchiveEncryptionPolicy
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { PageActionBar, PageLayout } from "@govoplan/core-webui"; import { PageActionBar, PageLayout } from "@govoplan/core-webui";
@@ -22,14 +26,25 @@ import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay"; import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog"; import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments"; import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui"; import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions"; import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
type PathChooserState = {index: number;}; type PathChooserState = {index: number;};
type IndividualDisableState = {index: number;usageCount: number;}; type IndividualDisableState = {index: number;usageCount: number;};
export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = {
available: false,
allowed_password_encryption_methods: ["aes"],
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
policy_hash: "",
source_path: [],
reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.",
diagnostics: [],
legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption"
};
export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesModuleInstalled = usePlatformModuleInstalled("files");
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer"); const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
@@ -41,6 +56,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]); const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null); const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null); const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
const [archivePolicy, setArchivePolicy] = useState<CampaignArchiveEncryptionPolicy>(UNAVAILABLE_ARCHIVE_POLICY);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({ const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
@@ -70,7 +86,15 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION, () => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
[zipConfig.archives, zipConfig.enabled] [zipConfig.archives, zipConfig.enabled]
); );
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message; const canUseLegacyZipCrypto = hasScope(auth, "campaigns:archive:use_legacy_zipcrypto");
const legacyZipCryptoAllowed = archivePolicy.available && archivePolicy.allowed_password_encryption_methods.includes("zip_standard");
const legacyConfigurationInvalid = zipConfig.archives.some((archive) =>
archive.method === "zip_standard" && (
!legacyZipCryptoAllowed || !canUseLegacyZipCrypto ||
!archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10
)
);
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message && !legacyConfigurationInvalid;
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]); const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]); const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
const attachmentPreviewEntry = useMemo( const attachmentPreviewEntry = useMemo(
@@ -94,6 +118,22 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
return () => {cancelled = true;}; return () => {cancelled = true;};
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); }, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
let cancelled = false;
setArchivePolicy(UNAVAILABLE_ARCHIVE_POLICY);
void getCampaignArchiveEncryptionPolicy(settings, campaignId)
.then((policy) => { if (!cancelled) setArchivePolicy(policy); })
.catch((cause) => {
if (!cancelled) {
setArchivePolicy({
...UNAVAILABLE_ARCHIVE_POLICY,
reason: cause instanceof Error ? cause.message : String(cause)
});
}
});
return () => { cancelled = true; };
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function patchBasePaths(paths: AttachmentBasePath[]) { function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return; if (locked) return;
const normalized = ensureAttachmentBasePaths(paths); const normalized = ensureAttachmentBasePaths(paths);
@@ -385,6 +425,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
</Card> </Card>
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible> <Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
<DismissibleAlert tone={legacyZipCryptoAllowed ? "warning" : "info"} dismissible={false} compact>
<strong>{archivePolicy.legacy_label}</strong>: {archivePolicy.reason}
{archivePolicy.source_path.length > 0 && <> Source: {archivePolicy.source_path.map((step) => step.label).join(" → ")}.</>}
{!canUseLegacyZipCrypto && <> Your account does not have the dedicated legacy-encryption permission.</>}
</DismissibleAlert>
<div className="attachment-zip-master-toggle"> <div className="attachment-zip-master-toggle">
<ToggleSwitch <ToggleSwitch
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b" label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
@@ -403,6 +448,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes, invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
onEditName: setZipNameEditorIndex, onEditName: setZipNameEditorIndex,
passwordFields, passwordFields,
legacyAllowed: legacyZipCryptoAllowed,
canUseLegacy: canUseLegacyZipCrypto,
allowedDeliveryChannels: archivePolicy.allowed_password_delivery_channels,
patchArchive: patchZipArchive, patchArchive: patchZipArchive,
setStandard: setStandardZipArchive, setStandard: setStandardZipArchive,
addArchive: addZipArchive, addArchive: addZipArchive,
@@ -517,6 +565,9 @@ type ZipArchiveColumnContext = {
invalidNameIndexes: ReadonlySet<number>; invalidNameIndexes: ReadonlySet<number>;
onEditName: (index: number) => void; onEditName: (index: number) => void;
passwordFields: ReturnType<typeof getDraftFields>; passwordFields: ReturnType<typeof getDraftFields>;
legacyAllowed: boolean;
canUseLegacy: boolean;
allowedDeliveryChannels: CampaignArchiveEncryptionPolicy["allowed_password_delivery_channels"];
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void; patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
setStandard: (index: number) => void; setStandard: (index: number) => void;
addArchive: (afterIndex?: number) => void; addArchive: (afterIndex?: number) => void;
@@ -524,7 +575,7 @@ type ZipArchiveColumnContext = {
removeArchive: (index: number) => void; removeArchive: (index: number) => void;
}; };
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] { function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, legacyAllowed, canUseLegacy, allowedDeliveryChannels, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
return [ return [
{ {
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start", id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start",
@@ -557,18 +608,65 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
value: (archive) => archive.password_enabled ? "protected" : "none" value: (archive) => archive.password_enabled ? "protected" : "none"
}, },
{ {
id: "method", header: "ZIP mode", width: 280, sortable: true, filterable: true, id: "method", header: "Encryption", width: 360, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "aes", label: "AES" }, { value: "zip_standard", label: "Win-compatible" }] }, columnType: "from-list", list: { options: [{ value: "aes", label: "AES (strong, default)" }, { value: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption" }] },
render: (archive, index) => render: (archive, index) =>
<ToggleSwitch <select
label="Win-compatible" value={archive.method}
checked={archive.method === "zip_standard"}
disabled={disabled} disabled={disabled}
help="Win-compatible ZIP uses the legacy ZipCrypto format so password-protected archives can be opened with Windows Explorer. Use AES when recipients can use 7-Zip, NanaZip, WinRAR, or another AES-capable ZIP tool." aria-label="Archive password encryption"
onChange={(checked) => patchArchive(index, { method: checked ? "zip_standard" : "aes" })} />, onChange={(event) => {
const method = event.target.value === "zip_standard" ? "zip_standard" : "aes";
patchArchive(index, method === "zip_standard" ? {
method,
legacy_zipcrypto_acknowledged: false,
legacy_zipcrypto_reason: ""
} : {
method,
legacy_zipcrypto_acknowledged: false,
legacy_zipcrypto_reason: ""
});
}}>
<option value="aes">AES (strong, default)</option>
<option value="zip_standard" disabled={!legacyAllowed || !canUseLegacy}>Legacy ZipCrypto Windows-compatible, weak encryption</option>
</select>,
value: (archive) => archive.method value: (archive) => archive.method
}, },
{
id: "password_delivery_channel", header: "Password delivery", width: 230, sortable: true, filterable: true,
render: (archive, index) =>
<select
value={archive.password_delivery_channel}
disabled={disabled || !archive.password_enabled}
aria-label="Separate password-delivery channel"
onChange={(event) => patchArchive(index, { password_delivery_channel: event.target.value as AttachmentZipArchive["password_delivery_channel"] })}>
{(["separate_mail", "sms", "letter", "phone", "in_person"] as const).map((channel) =>
<option key={channel} value={channel} disabled={!allowedDeliveryChannels.includes(channel)}>{passwordDeliveryChannelLabel(channel)}</option>
)}
</select>,
value: (archive) => archive.password_delivery_channel
},
{
id: "legacy_acknowledgement", header: "Legacy acknowledgement", width: 380,
render: (archive, index) => archive.method === "zip_standard" ?
<div className="campaign-legacy-zipcrypto-acknowledgement">
<ToggleSwitch
label="I acknowledge that ZipCrypto encryption is weak"
checked={archive.legacy_zipcrypto_acknowledged}
disabled={disabled || !legacyAllowed || !canUseLegacy}
onChange={(checked) => patchArchive(index, { legacy_zipcrypto_acknowledged: checked })} />
<input
value={archive.legacy_zipcrypto_reason}
disabled={disabled || !archive.legacy_zipcrypto_acknowledged}
minLength={10}
maxLength={1000}
placeholder="Operational reason (at least 10 characters)"
aria-label="Reason for weak legacy encryption"
onChange={(event) => patchArchive(index, { legacy_zipcrypto_reason: event.target.value })} />
</div> : <span>Not required for AES</span>,
value: (archive) => archive.legacy_zipcrypto_reason
},
{ {
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true, id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] }, columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
@@ -685,6 +783,16 @@ function uniqueStrings(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
} }
function passwordDeliveryChannelLabel(channel: AttachmentZipArchive["password_delivery_channel"]): string {
return {
separate_mail: "Separate email (never this campaign message)",
sms: "SMS",
letter: "Letter",
phone: "Telephone",
in_person: "In person"
}[channel];
}
type AttachmentSourceColumnContext = { type AttachmentSourceColumnContext = {
locked: boolean; locked: boolean;
basePaths: AttachmentBasePath[]; basePaths: AttachmentBasePath[];
@@ -100,7 +100,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipient-data" element={<Navigate to="../recipients" replace />} /> <Route path="recipient-data" element={<Navigate to="../recipients" replace />} />
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} /> <Route path="files" element={<AttachmentsDataPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="attachments" element={<Navigate to="../files" replace />} /> <Route path="attachments" element={<Navigate to="../files" replace />} />
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} /> <Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} /> <Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
@@ -13,6 +13,11 @@ export type AttachmentZipArchive = {
password_field: string; password_field: string;
password_scope: AttachmentZipPasswordScope; password_scope: AttachmentZipPasswordScope;
method: "aes" | "zip_standard"; method: "aes" | "zip_standard";
password_delivery_channel: "separate_mail" | "sms" | "letter" | "phone" | "in_person";
legacy_zipcrypto_acknowledged: boolean;
legacy_zipcrypto_reason: string;
legacy_zipcrypto_acknowledged_by?: string;
legacy_zipcrypto_acknowledged_at?: string;
// Read-only compatibility values retained when normalizing older campaigns. // Read-only compatibility values retained when normalizing older campaigns.
password_mode?: "none" | "direct" | "field" | "template"; password_mode?: "none" | "direct" | "field" | "template";
password?: string; password?: string;
@@ -32,7 +37,10 @@ export function createAttachmentZipArchive(name = "attachments.zip", standard =
password_enabled: false, password_enabled: false,
password_field: "", password_field: "",
password_scope: "local", password_scope: "local",
method: "aes" method: "aes",
password_delivery_channel: "separate_mail",
legacy_zipcrypto_acknowledged: false,
legacy_zipcrypto_reason: ""
}; };
} }
@@ -52,6 +60,11 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
password_field: getText(archive, "password_field"), password_field: getText(archive, "password_field"),
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local", password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes", method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
password_delivery_channel: normalizePasswordDeliveryChannel(getText(archive, "password_delivery_channel", "separate_mail")),
legacy_zipcrypto_acknowledged: getBool(archive, "legacy_zipcrypto_acknowledged"),
legacy_zipcrypto_reason: getText(archive, "legacy_zipcrypto_reason"),
...(getText(archive, "legacy_zipcrypto_acknowledged_by") ? { legacy_zipcrypto_acknowledged_by: getText(archive, "legacy_zipcrypto_acknowledged_by") } : {}),
...(getText(archive, "legacy_zipcrypto_acknowledged_at") ? { legacy_zipcrypto_acknowledged_at: getText(archive, "legacy_zipcrypto_acknowledged_at") } : {}),
...(legacyMode ? { password_mode: legacyMode } : {}), ...(legacyMode ? { password_mode: legacyMode } : {}),
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}), ...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {}) ...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
@@ -76,6 +89,9 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
password_field: getText(zip, "password_field"), password_field: getText(zip, "password_field"),
password_scope: "local", password_scope: "local",
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes", method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
password_delivery_channel: normalizePasswordDeliveryChannel(getText(zip, "password_delivery_channel", "separate_mail")),
legacy_zipcrypto_acknowledged: getBool(zip, "legacy_zipcrypto_acknowledged"),
legacy_zipcrypto_reason: getText(zip, "legacy_zipcrypto_reason"),
...(legacyMode ? { password_mode: legacyMode } : {}), ...(legacyMode ? { password_mode: legacyMode } : {}),
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}), ...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {}) ...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
@@ -83,6 +99,13 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
}; };
} }
function normalizePasswordDeliveryChannel(value: string): AttachmentZipArchive["password_delivery_channel"] {
if (["sms", "letter", "phone", "in_person"].includes(value)) {
return value as AttachmentZipArchive["password_delivery_channel"];
}
return "separate_mail";
}
export function attachmentRuleZipSelection(rule: AttachmentRule): string { export function attachmentRuleZipSelection(rule: AttachmentRule): string {
const zip = asRecord(rule.zip); const zip = asRecord(rule.zip);
const archiveId = getText(zip, "archive_id"); const archiveId = getText(zip, "archive_id");
+10
View File
@@ -2025,6 +2025,16 @@
padding-bottom: 7px; padding-bottom: 7px;
} }
.campaign-legacy-zipcrypto-acknowledgement {
display: grid;
gap: var(--space-2);
min-width: 20rem;
}
.campaign-legacy-zipcrypto-acknowledgement input {
width: 100%;
}
.attachment-zip-name-button { .attachment-zip-name-button {
width: 100%; width: 100%;
min-height: 36px; min-height: 36px;