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", ]