feat: govern campaign archive encryption

This commit is contained in:
2026-08-20 12:12:14 +02:00
parent 8fcc12dbb2
commit be5e3a7d72
11 changed files with 1070 additions and 5 deletions
@@ -0,0 +1,353 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
import hashlib
import json
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.policy import (
CampaignArchiveEncryptionDecision,
CampaignArchiveEncryptionRequest,
PolicySourceStep,
)
from govoplan_policy.backend.policy_overrides import (
PolicyOverride,
get_policy_override,
resolution_policy_overrides,
set_policy_override,
)
POLICY_FAMILY = "campaign_archive_encryption"
POLICY_TARGET = "*"
ENCRYPTION_METHODS = frozenset({"aes", "zip_standard"})
PASSWORD_DELIVERY_CHANNELS = frozenset(
{"separate_mail", "sms", "letter", "phone", "in_person"}
)
DEFAULT_METHODS = frozenset({"aes"})
DEFAULT_DELIVERY_CHANNELS = PASSWORD_DELIVERY_CHANNELS
class CampaignArchiveEncryptionPolicyError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class CampaignArchiveEncryptionPolicyState:
row: PolicyOverride | None
effective: CampaignArchiveEncryptionDecision
parent: CampaignArchiveEncryptionDecision
class CampaignArchiveEncryptionPolicyProvider:
def resolve_campaign_archive_encryption(
self,
session: object | None = None,
*,
request: CampaignArchiveEncryptionRequest,
) -> CampaignArchiveEncryptionDecision:
if not isinstance(session, Session):
return _decision((), reason="Policy storage is unavailable; only AES is safe by default.")
rows = resolution_policy_overrides(
session,
policy_family=POLICY_FAMILY,
target_keys=(POLICY_TARGET,),
tenant_id=request.tenant_id,
group_ids=(request.owner_id,)
if request.owner_type == "group" and request.owner_id
else (),
user_ids=(request.owner_id,)
if request.owner_type == "user" and request.owner_id
else (),
campaign_ids=(request.campaign_id,),
)
return resolve_campaign_archive_encryption_rows(rows)
def validate_campaign_archive_encryption_policy(
value: object,
) -> dict[str, tuple[str, ...]]:
if not isinstance(value, Mapping):
raise CampaignArchiveEncryptionPolicyError("Archive-encryption policy must be an object")
supported = {
"allowed_password_encryption_methods": ENCRYPTION_METHODS,
"allowed_password_delivery_channels": PASSWORD_DELIVERY_CHANNELS,
}
unknown = sorted(str(key) for key in value if str(key) not in supported)
if unknown:
raise CampaignArchiveEncryptionPolicyError(
f"Unsupported archive-encryption policy fields: {', '.join(unknown)}"
)
result: dict[str, tuple[str, ...]] = {}
for key, raw in value.items():
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
raise CampaignArchiveEncryptionPolicyError(f"{key} must be a list")
normalized = tuple(dict.fromkeys(str(item).strip() for item in raw))
invalid = sorted(set(normalized).difference(supported[str(key)]))
if invalid:
raise CampaignArchiveEncryptionPolicyError(
f"Unsupported values for {key}: {', '.join(invalid)}"
)
result[str(key)] = normalized
return result
def resolve_campaign_archive_encryption_rows(
rows: Sequence[PolicyOverride],
) -> CampaignArchiveEncryptionDecision:
methods = set(DEFAULT_METHODS)
channels = set(DEFAULT_DELIVERY_CHANNELS)
sources: list[PolicySourceStep] = [
PolicySourceStep(
scope_type="system",
label="Secure archive-encryption baseline",
applied_fields=(
"allowed_password_encryption_methods",
"allowed_password_delivery_channels",
),
policy={
"allowed_password_encryption_methods": sorted(DEFAULT_METHODS),
"allowed_password_delivery_channels": sorted(DEFAULT_DELIVERY_CHANNELS),
"implicit": True,
},
)
]
diagnostics: list[Mapping[str, Any]] = []
explicit_system = False
for row in rows:
try:
policy = validate_campaign_archive_encryption_policy(row.policy)
except CampaignArchiveEncryptionPolicyError as exc:
methods.clear()
channels.clear()
diagnostics.append(
{
"code": "campaign_archive_encryption.invalid_fail_closed",
"scope": row.scope_key,
"message": str(exc),
}
)
sources.append(
PolicySourceStep(
scope_type=row.scope_type, # type: ignore[arg-type]
scope_id=row.scope_id,
label=f"{row.scope_type.capitalize()} archive-encryption policy",
applied_fields=("configuration_status",),
policy={"configuration_status": "invalid_fail_closed"},
)
)
continue
configured_methods = policy.get("allowed_password_encryption_methods")
configured_channels = policy.get("allowed_password_delivery_channels")
if row.scope_type == "system" and not explicit_system:
explicit_system = True
if configured_methods is not None:
methods = set(configured_methods)
if configured_channels is not None:
channels = set(configured_channels)
sources[0] = PolicySourceStep(
scope_type="system",
label="System archive-encryption policy",
applied_fields=tuple(sorted(policy)),
policy={key: list(items) for key, items in policy.items()},
)
continue
if configured_methods is not None:
methods.intersection_update(configured_methods)
if configured_channels is not None:
channels.intersection_update(configured_channels)
sources.append(
PolicySourceStep(
scope_type=row.scope_type, # type: ignore[arg-type]
scope_id=row.scope_id,
label=f"{row.scope_type.capitalize()} archive-encryption policy",
applied_fields=tuple(sorted(policy)),
policy={key: list(items) for key, items in policy.items()},
)
)
return _decision(tuple(sources), methods=methods, channels=channels, diagnostics=diagnostics)
def campaign_archive_encryption_policy_state(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
owner_type: str | None = None,
owner_id: str | None = None,
) -> CampaignArchiveEncryptionPolicyState:
clean_scope = scope_type.strip().casefold()
if clean_scope not in {"system", "tenant", "group", "user", "campaign"}:
raise CampaignArchiveEncryptionPolicyError("Unsupported policy scope")
effective_rows = _rows_for_scope(
session,
tenant_id=tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
owner_type=owner_type,
owner_id=owner_id,
)
parent_rows = tuple(row for row in effective_rows if row.scope_type != clean_scope)
row = get_policy_override(
session,
policy_family=POLICY_FAMILY,
target_key=POLICY_TARGET,
tenant_id=tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
return CampaignArchiveEncryptionPolicyState(
row=row,
effective=resolve_campaign_archive_encryption_rows(effective_rows),
parent=resolve_campaign_archive_encryption_rows(parent_rows),
)
def save_campaign_archive_encryption_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
owner_type: str | None,
owner_id: str | None,
policy: object,
actor_id: str | None,
) -> CampaignArchiveEncryptionPolicyState:
clean_policy = validate_campaign_archive_encryption_policy(policy)
before = campaign_archive_encryption_policy_state(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
owner_type=owner_type,
owner_id=owner_id,
)
if scope_type.strip().casefold() != "system":
requested_methods = set(
clean_policy.get(
"allowed_password_encryption_methods",
tuple(before.parent.allowed_password_encryption_methods),
)
)
requested_channels = set(
clean_policy.get(
"allowed_password_delivery_channels",
tuple(before.parent.allowed_password_delivery_channels),
)
)
if not requested_methods.issubset(before.parent.allowed_password_encryption_methods):
raise CampaignArchiveEncryptionPolicyError(
"A child scope cannot enable an archive-encryption method blocked by its parent"
)
if not requested_channels.issubset(before.parent.allowed_password_delivery_channels):
raise CampaignArchiveEncryptionPolicyError(
"A child scope cannot enable a password-delivery channel blocked by its parent"
)
set_policy_override(
session,
policy_family=POLICY_FAMILY,
target_key=POLICY_TARGET,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
policy={key: list(items) for key, items in clean_policy.items()},
actor_id=actor_id,
)
return campaign_archive_encryption_policy_state(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
owner_type=owner_type,
owner_id=owner_id,
)
def _rows_for_scope(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
owner_type: str | None,
owner_id: str | None,
) -> tuple[PolicyOverride, ...]:
group_ids: tuple[str, ...] = ()
user_ids: tuple[str, ...] = ()
campaign_ids: tuple[str, ...] = ()
if scope_type == "group" and scope_id:
group_ids = (scope_id,)
elif scope_type == "user" and scope_id:
user_ids = (scope_id,)
elif scope_type == "campaign":
if not scope_id:
raise CampaignArchiveEncryptionPolicyError("Campaign scope requires scope_id")
campaign_ids = (scope_id,)
if owner_type == "group" and owner_id:
group_ids = (owner_id,)
elif owner_type == "user" and owner_id:
user_ids = (owner_id,)
elif owner_type or owner_id:
raise CampaignArchiveEncryptionPolicyError("Campaign owner context is invalid")
rows = resolution_policy_overrides(
session,
policy_family=POLICY_FAMILY,
target_keys=(POLICY_TARGET,),
tenant_id=tenant_id,
group_ids=group_ids,
user_ids=user_ids,
campaign_ids=campaign_ids,
)
maximum = {"system": 0, "tenant": 1, "group": 2, "user": 2, "campaign": 3}[scope_type]
rank = {"system": 0, "tenant": 1, "group": 2, "user": 2, "campaign": 3}
return tuple(row for row in rows if rank.get(row.scope_type, 99) <= maximum)
def _decision(
source_path: tuple[PolicySourceStep, ...],
*,
methods: set[str] | frozenset[str] = DEFAULT_METHODS,
channels: set[str] | frozenset[str] = DEFAULT_DELIVERY_CHANNELS,
reason: str | None = None,
diagnostics: Sequence[Mapping[str, Any]] = (),
) -> CampaignArchiveEncryptionDecision:
payload = {
"allowed_password_encryption_methods": sorted(methods),
"allowed_password_delivery_channels": sorted(channels),
"source_path": [step.to_dict() for step in source_path],
"diagnostics": [dict(item) for item in diagnostics],
}
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()
).hexdigest()
if reason is None:
reason = (
"Legacy ZipCrypto is permitted by the effective policy."
if "zip_standard" in methods
else "Legacy ZipCrypto is blocked by the effective archive-encryption policy."
)
return CampaignArchiveEncryptionDecision(
allowed_password_encryption_methods=frozenset(methods), # type: ignore[arg-type]
allowed_password_delivery_channels=frozenset(channels), # type: ignore[arg-type]
policy_hash=digest,
source_path=source_path,
reason=reason,
diagnostics=tuple(diagnostics),
)
__all__ = [
"CampaignArchiveEncryptionPolicyError",
"CampaignArchiveEncryptionPolicyProvider",
"CampaignArchiveEncryptionPolicyState",
"campaign_archive_encryption_policy_state",
"resolve_campaign_archive_encryption_rows",
"save_campaign_archive_encryption_policy",
"validate_campaign_archive_encryption_policy",
]