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"
|
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
|
||||||
|
|||||||
@@ -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"] = {
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user