diff --git a/src/govoplan_policy/backend/api/v1/routes.py b/src/govoplan_policy/backend/api/v1/routes.py index 7ed7d2f..0115172 100644 --- a/src/govoplan_policy/backend/api/v1/routes.py +++ b/src/govoplan_policy/backend/api/v1/routes.py @@ -34,6 +34,11 @@ from govoplan_policy.backend.definition_policy_service import ( save_definition_policy, ) from govoplan_policy.backend.policy_overrides import PolicyOverrideError +from govoplan_policy.backend.campaign_archive_encryption import ( + CampaignArchiveEncryptionPolicyError, + campaign_archive_encryption_policy_state, + save_campaign_archive_encryption_policy, +) from govoplan_policy.backend.view_policy_service import ( ViewPolicyError, remove_view_policy, @@ -43,6 +48,8 @@ from govoplan_policy.backend.view_policy_service import ( ) from .schemas import ( + CampaignArchiveEncryptionPolicyScopeRequest, + CampaignArchiveEncryptionPolicyScopeResponse, DefinitionPolicyScopeRequest, DefinitionPolicyScopeResponse, PrivacyRetentionPolicyExplainResponse, @@ -87,6 +94,150 @@ def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPExc ) +def _archive_encryption_policy_response( + *, + scope_type: str, + scope_id: str | None, + state, +) -> CampaignArchiveEncryptionPolicyScopeResponse: + row = state.row + return CampaignArchiveEncryptionPolicyScopeResponse( + scope_type=scope_type, + scope_id=scope_id, + id=row.id if row else None, + revision=row.revision if row else None, + policy=dict(row.policy) if row and isinstance(row.policy, dict) else {}, + effective_policy=state.effective.to_dict(), + parent_policy=state.parent.to_dict(), + ) + + +@router.get( + "/campaign-archive-encryption/policies/{scope_type}", + response_model=CampaignArchiveEncryptionPolicyScopeResponse, +) +def read_campaign_archive_encryption_policy( + scope_type: str, + scope_id: str | None = Query(default=None), + owner_type: str | None = Query(default=None), + owner_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + _require_permission(principal, "admin:policies:read") + clean_scope = scope_type.strip().casefold() + try: + state = campaign_archive_encryption_policy_state( + session, + tenant_id=principal.tenant_id, + scope_type=clean_scope, + scope_id=scope_id, + owner_type=owner_type, + owner_id=owner_id, + ) + return _archive_encryption_policy_response( + scope_type=clean_scope, + scope_id=scope_id, + state=state, + ) + except (CampaignArchiveEncryptionPolicyError, PolicyOverrideError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.put( + "/campaign-archive-encryption/policies/{scope_type}", + response_model=CampaignArchiveEncryptionPolicyScopeResponse, +) +def write_campaign_archive_encryption_policy( + scope_type: str, + payload: CampaignArchiveEncryptionPolicyScopeRequest, + scope_id: str | None = Query(default=None), + owner_type: str | None = Query(default=None), + owner_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + _require_permission(principal, "admin:policies:write") + clean_scope = scope_type.strip().casefold() + policy_value = payload.policy.model_dump(mode="json", exclude_none=True) + try: + before = campaign_archive_encryption_policy_state( + session, + tenant_id=principal.tenant_id, + scope_type=clean_scope, + scope_id=scope_id, + owner_type=owner_type, + owner_id=owner_id, + ) + if clean_scope == "system": + approval = ensure_configuration_change_allowed( + session, + key="campaign_archive_encryption_policy", + value=policy_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"scope_type": clean_scope}, + ) + else: + approval = None + state = save_campaign_archive_encryption_policy( + session, + tenant_id=principal.tenant_id, + scope_type=clean_scope, + scope_id=scope_id, + owner_type=owner_type, + owner_id=owner_id, + policy=policy_value, + actor_id=principal.user.id, + ) + if clean_scope == "system": + record_configuration_change_applied( + session, + key="campaign_archive_encryption_policy", + before_value=(dict(before.row.policy) if before.row else {}), + after_value=policy_value, + actor_user_id=principal.user.id, + approval=approval, + target={"scope_type": clean_scope}, + audit_event="campaign_archive_encryption_policy.updated", + ) + audit_from_principal( + session, + principal, + action="campaign_archive_encryption_policy.updated", + scope="system" if clean_scope == "system" else "tenant", + object_type="campaign_archive_encryption_policy", + object_id=f"{clean_scope}:{scope_id or ''}", + details={ + "scope_type": clean_scope, + "scope_id": scope_id, + "owner_type": owner_type, + "owner_id": owner_id, + "policy_hash": state.effective.policy_hash, + "fields": sorted(policy_value), + }, + ) + session.commit() + return _archive_encryption_policy_response( + scope_type=clean_scope, + scope_id=scope_id, + state=state, + ) + except ConfigurationControlError as exc: + session.rollback() + raise _configuration_control_http_error(exc) from exc + except (CampaignArchiveEncryptionPolicyError, PolicyOverrideError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + def _definition_policy_response( *, module_id: str, diff --git a/src/govoplan_policy/backend/api/v1/schemas.py b/src/govoplan_policy/backend/api/v1/schemas.py index bb8627a..f9a0e61 100644 --- a/src/govoplan_policy/backend/api/v1/schemas.py +++ b/src/govoplan_policy/backend/api/v1/schemas.py @@ -46,6 +46,36 @@ class PolicyDecisionItem(BaseModel): details: dict[str, Any] = Field(default_factory=dict) +class CampaignArchiveEncryptionPolicyItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + allowed_password_encryption_methods: list[ + Literal["aes", "zip_standard"] + ] | None = None + allowed_password_delivery_channels: list[ + Literal["separate_mail", "sms", "letter", "phone", "in_person"] + ] | None = None + + +class CampaignArchiveEncryptionPolicyScopeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + policy: CampaignArchiveEncryptionPolicyItem = Field( + default_factory=CampaignArchiveEncryptionPolicyItem + ) + change_request_id: str | None = None + + +class CampaignArchiveEncryptionPolicyScopeResponse(BaseModel): + scope_type: Literal["system", "tenant", "group", "user", "campaign"] + scope_id: str | None = None + id: str | None = None + revision: int | None = None + policy: dict[str, Any] = Field(default_factory=dict) + effective_policy: dict[str, Any] + parent_policy: dict[str, Any] + + class DefinitionPolicyItem(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/src/govoplan_policy/backend/campaign_archive_encryption.py b/src/govoplan_policy/backend/campaign_archive_encryption.py new file mode 100644 index 0000000..89b5a78 --- /dev/null +++ b/src/govoplan_policy/backend/campaign_archive_encryption.py @@ -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", +] diff --git a/src/govoplan_policy/backend/manifest.py b/src/govoplan_policy/backend/manifest.py index fc46a39..20a4d3b 100644 --- a/src/govoplan_policy/backend/manifest.py +++ b/src/govoplan_policy/backend/manifest.py @@ -15,6 +15,7 @@ from govoplan_core.core.module_guards import ( persistent_table_uninstall_guard, ) from govoplan_core.core.policy import ( + CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION, CAPABILITY_POLICY_DEFINITION_GOVERNANCE, CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE, CAPABILITY_POLICY_PRIVACY_RETENTION, @@ -115,6 +116,15 @@ def _access_explanation_subject_policy(context: ModuleContext) -> object: return AccessExplanationSubjectPolicyProvider() +def _campaign_archive_encryption_policy(context: ModuleContext) -> object: + del context + from govoplan_policy.backend.campaign_archive_encryption import ( + CampaignArchiveEncryptionPolicyProvider, + ) + + return CampaignArchiveEncryptionPolicyProvider() + + ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user" @@ -223,6 +233,24 @@ manifest = ModuleManifest( audience=("user", "tenant_admin", "policy_admin"), metadata={"kind": "reference"}, ), + DocumentationTopic( + id="policy.campaign-archive-encryption", + title="Govern Campaign archive encryption", + summary="Restrict password-protected Campaign ZIP formats and password-delivery channels through an explainable hierarchy.", + body=( + "The secure baseline permits AES only. An authorized policy administrator may explicitly permit legacy ZipCrypto at system scope, after which tenant, owner group or user, and campaign rules may only narrow the inherited methods. The same intersection controls the separate channel used to convey a password. Policy records the complete source path and a stable policy hash; malformed configuration fails closed. Policy changes never rewrite old build evidence, while Campaign rejects a queued or sent build whose effective policy is now more restrictive." + ), + documentation_types=("admin", "user"), + audience=("system_admin", "tenant_admin", "policy_admin", "campaign_manager"), + related_modules=("campaign", "audit", "access"), + metadata={ + "kind": "reference", + "help_contexts": [ + "policy.campaign-archive-encryption", + "campaign.archive-encryption", + ], + }, + ), DocumentationTopic( id="policy.hierarchy-overrides-and-retention", title="Administer policy hierarchy and overrides", @@ -389,6 +417,34 @@ manifest = ModuleManifest( label="User View policy", order=70, ), + ViewSurface( + id="policy.admin.system-campaign-archive-encryption", + module_id="policy", + kind="section", + label="System Campaign archive encryption", + order=75, + ), + ViewSurface( + id="policy.admin.tenant-campaign-archive-encryption", + module_id="policy", + kind="section", + label="Tenant Campaign archive encryption", + order=75, + ), + ViewSurface( + id="policy.admin.group-campaign-archive-encryption", + module_id="policy", + kind="section", + label="Group Campaign archive encryption", + order=75, + ), + ViewSurface( + id="policy.admin.user-campaign-archive-encryption", + module_id="policy", + kind="section", + label="User Campaign archive encryption", + order=75, + ), ViewSurface( id="policy.admin.system-retention", module_id="policy", @@ -432,6 +488,7 @@ manifest = ModuleManifest( ), CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service, CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: _scheduling_participant_privacy_policy, + CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION: _campaign_archive_encryption_policy, }, capability_documentation={ CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS: CapabilityDocumentation( @@ -454,6 +511,13 @@ manifest = ModuleManifest( documentation_types=("admin",), audience=("policy_admin", "privacy_officer", "system_admin"), ), + CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION: CapabilityDocumentation( + label="Campaign archive-encryption policy", + summary="Returns the restrictive format and password-delivery ceiling with complete provenance and a stable hash.", + contract_version="1.0", + documentation_types=("admin", "user"), + audience=("policy_admin", "campaign_manager"), + ), }, architecture=declared_module_architecture( layer="governance_accountability", diff --git a/src/govoplan_policy/backend/policy_overrides.py b/src/govoplan_policy/backend/policy_overrides.py index 4498e6e..3577ca9 100644 --- a/src/govoplan_policy/backend/policy_overrides.py +++ b/src/govoplan_policy/backend/policy_overrides.py @@ -9,8 +9,10 @@ from sqlalchemy.orm import Session from govoplan_policy.backend.db.models import PolicyOverride -POLICY_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user"}) -POLICY_FAMILIES = frozenset({"definition", "distribution_channels", "view"}) +POLICY_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"}) +POLICY_FAMILIES = frozenset( + {"campaign_archive_encryption", "definition", "distribution_channels", "view"} +) class PolicyOverrideError(ValueError): @@ -22,7 +24,7 @@ def normalize_policy_target(policy_family: str, target_key: str) -> tuple[str, s target = target_key.strip().casefold() if family not in POLICY_FAMILIES: raise PolicyOverrideError( - "Policy family must be definition, distribution_channels, or view" + "Policy family must be campaign_archive_encryption, definition, distribution_channels, or view" ) if not target or len(target) > 120: raise PolicyOverrideError("Policy target must contain 1 to 120 characters") @@ -40,7 +42,9 @@ def normalize_policy_scope( clean_scope = scope_type.strip().casefold() clean_id = str(scope_id or "").strip() or None if clean_scope not in POLICY_SCOPE_TYPES: - raise PolicyOverrideError("Policy scope must be system, tenant, group, or user") + raise PolicyOverrideError( + "Policy scope must be system, tenant, group, user, or campaign" + ) if clean_scope == "system": if clean_id is not None: raise PolicyOverrideError("System policy cannot declare a scope ID") @@ -142,6 +146,7 @@ def resolution_policy_overrides( tenant_id: str, group_ids: Iterable[str] = (), user_ids: Iterable[str] = (), + campaign_ids: Iterable[str] = (), ) -> tuple[PolicyOverride, ...]: family = policy_family.strip().casefold() targets = tuple( @@ -151,6 +156,9 @@ def resolution_policy_overrides( ) groups = tuple(sorted({str(value) for value in group_ids if str(value)})) users = tuple(sorted({str(value) for value in user_ids if str(value)})) + campaigns = tuple( + sorted({str(value) for value in campaign_ids if str(value)}) + ) scope_filters = [ PolicyOverride.scope_key == "system", PolicyOverride.scope_key == f"tenant:{tenant_id}", @@ -167,6 +175,15 @@ def resolution_policy_overrides( [f"user:{tenant_id}:{user_id}" for user_id in users] ) ) + if campaigns: + scope_filters.append( + PolicyOverride.scope_key.in_( + [ + f"campaign:{tenant_id}:{campaign_id}" + for campaign_id in campaigns + ] + ) + ) rows = ( session.query(PolicyOverride) .filter( @@ -177,7 +194,13 @@ def resolution_policy_overrides( .all() ) target_order = {target: index for index, target in enumerate(targets)} - scope_order = {"system": 0, "tenant": 1, "group": 2, "user": 3} + scope_order = { + "system": 0, + "tenant": 1, + "group": 2, + "user": 2, + "campaign": 3, + } return tuple( sorted( rows, diff --git a/tests/test_campaign_archive_encryption.py b/tests/test_campaign_archive_encryption.py new file mode 100644 index 0000000..4cbafa0 --- /dev/null +++ b/tests/test_campaign_archive_encryption.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_policy.backend.campaign_archive_encryption import ( + CampaignArchiveEncryptionPolicyError, + campaign_archive_encryption_policy_state, + resolve_campaign_archive_encryption_rows, + save_campaign_archive_encryption_policy, +) +from govoplan_policy.backend.db.models import PolicyOverride + + +class CampaignArchiveEncryptionPolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + PolicyOverride.__table__.create(self.engine) + + def test_secure_default_denies_legacy(self) -> None: + decision = resolve_campaign_archive_encryption_rows(()) + + self.assertEqual(frozenset({"aes"}), decision.allowed_password_encryption_methods) + self.assertNotIn("zip_standard", decision.allowed_password_encryption_methods) + self.assertEqual("system", decision.source_path[0].path) + self.assertTrue(decision.policy_hash) + + def test_child_scope_can_narrow_but_not_loosen_parent(self) -> None: + with Session(self.engine) as session: + save_campaign_archive_encryption_policy( + session, + tenant_id="tenant-1", + scope_type="system", + scope_id=None, + owner_type=None, + owner_id=None, + policy={"allowed_password_encryption_methods": ["aes", "zip_standard"]}, + actor_id="admin", + ) + tenant = save_campaign_archive_encryption_policy( + session, + tenant_id="tenant-1", + scope_type="tenant", + scope_id=None, + owner_type=None, + owner_id=None, + policy={"allowed_password_encryption_methods": ["aes"]}, + actor_id="admin", + ) + self.assertEqual( + frozenset({"aes"}), + tenant.effective.allowed_password_encryption_methods, + ) + + with self.assertRaises(CampaignArchiveEncryptionPolicyError): + save_campaign_archive_encryption_policy( + session, + tenant_id="tenant-1", + scope_type="user", + scope_id="owner-1", + owner_type=None, + owner_id=None, + policy={"allowed_password_encryption_methods": ["aes", "zip_standard"]}, + actor_id="admin", + ) + + def test_owner_and_campaign_sources_are_complete(self) -> None: + with Session(self.engine) as session: + for scope_type, scope_id, methods in ( + ("system", None, ["aes", "zip_standard"]), + ("tenant", None, ["aes", "zip_standard"]), + ("group", "owner-group", ["aes", "zip_standard"]), + ("campaign", "campaign-1", ["aes"]), + ): + save_campaign_archive_encryption_policy( + session, + tenant_id="tenant-1", + scope_type=scope_type, + scope_id=scope_id, + owner_type="group" if scope_type == "campaign" else None, + owner_id="owner-group" if scope_type == "campaign" else None, + policy={"allowed_password_encryption_methods": methods}, + actor_id="admin", + ) + state = campaign_archive_encryption_policy_state( + session, + tenant_id="tenant-1", + scope_type="campaign", + scope_id="campaign-1", + owner_type="group", + owner_id="owner-group", + ) + + self.assertEqual( + ["system", "tenant", "group", "campaign"], + [step.scope_type for step in state.effective.source_path], + ) + self.assertEqual( + frozenset({"aes"}), + state.effective.allowed_password_encryption_methods, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_policy_module_contract.py b/tests/test_policy_module_contract.py index e94fcf2..fbc40cd 100644 --- a/tests/test_policy_module_contract.py +++ b/tests/test_policy_module_contract.py @@ -5,6 +5,7 @@ import tomllib import unittest from govoplan_core.core.policy import ( + CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION, CAPABILITY_POLICY_DEFINITION_GOVERNANCE, CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE, CAPABILITY_POLICY_PRIVACY_RETENTION, @@ -56,6 +57,7 @@ class PolicyModuleContractTests(unittest.TestCase): CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY, CAPABILITY_POLICY_VIEW_GOVERNANCE, CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS, + CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION, }, set(manifest.capability_factories), ) diff --git a/webui/src/api/archiveEncryptionPolicies.ts b/webui/src/api/archiveEncryptionPolicies.ts new file mode 100644 index 0000000..98d5f1f --- /dev/null +++ b/webui/src/api/archiveEncryptionPolicies.ts @@ -0,0 +1,56 @@ +import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; + +export type ArchiveEncryptionPolicyScope = "system" | "tenant" | "group" | "user"; +export type ArchiveEncryptionMethod = "aes" | "zip_standard"; +export type PasswordDeliveryChannel = "separate_mail" | "sms" | "letter" | "phone" | "in_person"; + +export type ArchiveEncryptionPolicyItem = { + allowed_password_encryption_methods?: ArchiveEncryptionMethod[]; + allowed_password_delivery_channels?: PasswordDeliveryChannel[]; +}; + +export type EffectiveArchiveEncryptionPolicy = { + allowed_password_encryption_methods: ArchiveEncryptionMethod[]; + allowed_password_delivery_channels: PasswordDeliveryChannel[]; + policy_hash: string; + source_path: Array<{ path: string; label: string }>; + reason: string; + diagnostics: Array>; +}; + +export type ArchiveEncryptionPolicyResponse = { + scope_type: ArchiveEncryptionPolicyScope; + scope_id?: string | null; + id?: string | null; + revision?: number | null; + policy: ArchiveEncryptionPolicyItem; + effective_policy: EffectiveArchiveEncryptionPolicy; + parent_policy: EffectiveArchiveEncryptionPolicy; +}; + +function policyPath(scope: ArchiveEncryptionPolicyScope, scopeId?: string | null): string { + const params = new URLSearchParams(); + if (scopeId) params.set("scope_id", scopeId); + const suffix = params.toString(); + return `/api/v1/admin/campaign-archive-encryption/policies/${scope}${suffix ? `?${suffix}` : ""}`; +} + +export function fetchArchiveEncryptionPolicy( + settings: ApiSettings, + scope: ArchiveEncryptionPolicyScope, + scopeId?: string | null +): Promise { + return apiFetch(settings, policyPath(scope, scopeId)); +} + +export function updateArchiveEncryptionPolicy( + settings: ApiSettings, + scope: ArchiveEncryptionPolicyScope, + scopeId: string | null, + policy: ArchiveEncryptionPolicyItem +): Promise { + return apiFetch(settings, policyPath(scope, scopeId), { + method: "PUT", + body: JSON.stringify({ policy }) + }); +} diff --git a/webui/src/features/policy/ArchiveEncryptionPoliciesPanel.tsx b/webui/src/features/policy/ArchiveEncryptionPoliciesPanel.tsx new file mode 100644 index 0000000..e5c4b32 --- /dev/null +++ b/webui/src/features/policy/ArchiveEncryptionPoliciesPanel.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from "react"; +import { + AdminPageLayout, + adminErrorMessage, + Button, + Card, + DescriptionItem, + DescriptionList, + DismissibleAlert, + FormField, + SearchableSelect, + ToggleSwitch, + useUnsavedDraftGuard, + type ApiSettings, + type SearchableSelectOption +} from "@govoplan/core-webui"; +import { RefreshCw, Save, Undo2 } from "lucide-react"; +import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets"; +import { + fetchArchiveEncryptionPolicy, + updateArchiveEncryptionPolicy, + type ArchiveEncryptionMethod, + type ArchiveEncryptionPolicyItem, + type ArchiveEncryptionPolicyResponse, + type ArchiveEncryptionPolicyScope, + type PasswordDeliveryChannel +} from "../../api/archiveEncryptionPolicies"; + +type Props = { + settings: ApiSettings; + scopeType: ArchiveEncryptionPolicyScope; + canWrite: boolean; +}; + +type Draft = { + inheritMethods: boolean; + methods: ArchiveEncryptionMethod[]; + inheritChannels: boolean; + channels: PasswordDeliveryChannel[]; +}; + +const METHODS: Array<{ id: ArchiveEncryptionMethod; label: string; description: string }> = [ + { id: "aes", label: "AES (strong, default)", description: "Modern AES encryption for compatible ZIP clients." }, + { id: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption", description: "Requires a separate Campaign permission and reasoned acknowledgement." } +]; + +const CHANNELS: Array<{ id: PasswordDeliveryChannel; label: string }> = [ + { id: "separate_mail", label: "Separate email (never the campaign message)" }, + { id: "sms", label: "SMS" }, + { id: "letter", label: "Letter" }, + { id: "phone", label: "Telephone" }, + { id: "in_person", label: "In person" } +]; + +export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, canWrite }: Props) { + const [targets, setTargets] = useState([]); + const [targetId, setTargetId] = useState(""); + const [state, setState] = useState(null); + const [draft, setDraft] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const needsTarget = scopeType === "group" || scopeType === "user"; + const dirty = Boolean(state && draft && stable(buildPolicy(draft)) !== stable(state.policy)); + + useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard }); + + useEffect(() => { void initialize(); }, [scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + + async function initialize() { + setLoading(true); + setError(""); + try { + const loadedTargets = await loadTargets(settings, scopeType); + setTargets(loadedTargets); + const next = needsTarget ? loadedTargets[0]?.value ?? "" : ""; + setTargetId(next); + if (!needsTarget || next) await load(next, false); + } catch (cause) { + setError(adminErrorMessage(cause)); + } finally { + setLoading(false); + } + } + + async function load(nextTarget = targetId, manageLoading = true) { + if (needsTarget && !nextTarget) return; + if (manageLoading) setLoading(true); + setError(""); + setSuccess(""); + try { + const loaded = await fetchArchiveEncryptionPolicy(settings, scopeType, nextTarget || null); + setState(loaded); + setDraft(draftFromPolicy(loaded.policy, loaded.parent_policy)); + } catch (cause) { + setError(adminErrorMessage(cause)); + } finally { + if (manageLoading) setLoading(false); + } + } + + async function selectTarget(value: string) { + if (!value || value === targetId) return; + setTargetId(value); + await load(value); + } + + function discard() { + if (state) setDraft(draftFromPolicy(state.policy, state.parent_policy)); + setError(""); + setSuccess(""); + } + + async function save(): Promise { + if (!draft || !dirty) return true; + setBusy(true); + setError(""); + setSuccess(""); + try { + const loaded = await updateArchiveEncryptionPolicy(settings, scopeType, targetId || null, buildPolicy(draft)); + setState(loaded); + setDraft(draftFromPolicy(loaded.policy, loaded.parent_policy)); + setSuccess("Campaign archive-encryption policy saved."); + return true; + } catch (cause) { + setError(adminErrorMessage(cause)); + return false; + } finally { + setBusy(false); + } + } + + const scopeLabel = scopeType[0].toUpperCase() + scopeType.slice(1); + const parentMethods = state?.parent_policy.allowed_password_encryption_methods ?? ["aes"]; + const parentChannels = state?.parent_policy.allowed_password_delivery_channels ?? []; + + return + + + + }> + {needsTarget && + + void selectTarget(value)} disabled={busy} /> + + } + {draft && state && <> + + {state.effective_policy.reason} Legacy ZipCrypto remains a weak compatibility exception and is never an automatic fallback. + + + {scopeType !== "system" && setDraft({ ...draft, inheritMethods: checked, methods: checked ? [...parentMethods] : draft.methods })} />} + {METHODS.map((method) => setDraft({ ...draft, methods: toggle(draft.methods, method.id, checked) })} />)} + + + {scopeType !== "system" && setDraft({ ...draft, inheritChannels: checked, channels: checked ? [...parentChannels] : draft.channels })} />} + {CHANNELS.map((channel) => setDraft({ ...draft, channels: toggle(draft.channels, channel.id, checked) })} />)} + + + + {state.effective_policy.policy_hash} + {state.effective_policy.source_path.map((step) => step.label).join(" → ")} + + + } + ; +} + +function draftFromPolicy(policy: ArchiveEncryptionPolicyItem, parent: ArchiveEncryptionPolicyResponse["parent_policy"]): Draft { + return { + inheritMethods: policy.allowed_password_encryption_methods === undefined, + methods: [...(policy.allowed_password_encryption_methods ?? parent.allowed_password_encryption_methods)], + inheritChannels: policy.allowed_password_delivery_channels === undefined, + channels: [...(policy.allowed_password_delivery_channels ?? parent.allowed_password_delivery_channels)] + }; +} + +function buildPolicy(draft: Draft): ArchiveEncryptionPolicyItem { + return { + ...(draft.inheritMethods ? {} : { allowed_password_encryption_methods: draft.methods }), + ...(draft.inheritChannels ? {} : { allowed_password_delivery_channels: draft.channels }) + }; +} + +function stable(value: ArchiveEncryptionPolicyItem): string { + return JSON.stringify({ + methods: value.allowed_password_encryption_methods ? [...value.allowed_password_encryption_methods].sort() : null, + channels: value.allowed_password_delivery_channels ? [...value.allowed_password_delivery_channels].sort() : null + }); +} + +function toggle(values: T[], value: T, checked: boolean): T[] { + return checked ? Array.from(new Set([...values, value])) : values.filter((item) => item !== value); +} + +async function loadTargets(settings: ApiSettings, scope: ArchiveEncryptionPolicyScope): Promise { + if (scope === "group") { + const response = await fetchGroupsDelta(settings, { limit: 1000 }); + return response.groups.map((group) => ({ value: group.id, label: group.name, description: group.slug })); + } + if (scope === "user") { + const response = await fetchUsersDelta(settings, { limit: 1000 }); + return response.users.map((user) => ({ value: user.id, label: user.display_name || user.email, description: user.email })); + } + return []; +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 13e75df..7ebea83 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -3,4 +3,5 @@ export { default as ViewPoliciesPanel } from "./features/policy/ViewPoliciesPane export * from "./module"; export * from "./api/adminTargets"; export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel"; +export { default as ArchiveEncryptionPoliciesPanel } from "./features/policy/ArchiveEncryptionPoliciesPanel"; export type { PlatformWebModule } from "@govoplan/core-webui"; diff --git a/webui/src/module.ts b/webui/src/module.ts index f13a799..2df91c2 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -3,6 +3,7 @@ import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel")); const ViewPoliciesPanel = lazy(() => import("./features/policy/ViewPoliciesPanel")); +const ArchiveEncryptionPoliciesPanel = lazy(() => import("./features/policy/ArchiveEncryptionPoliciesPanel")); const policyAdminSections: AdminSectionsUiCapability = { sections: [ @@ -66,6 +67,66 @@ const policyAdminSections: AdminSectionsUiCapability = { canWrite: hasScope(auth, "admin:policies:write") }) }, + { + id: "system-campaign-archive-encryption", + moduleId: "policy", + kind: "settings", + surfaceId: "policy.admin.system-campaign-archive-encryption", + label: "Campaign archive encryption", + group: "SYSTEM", + order: 75, + allOf: ["admin:policies:read"], + render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, { + settings, + scopeType: "system", + canWrite: hasScope(auth, "admin:policies:write") + }) + }, + { + id: "tenant-campaign-archive-encryption", + moduleId: "policy", + kind: "settings", + surfaceId: "policy.admin.tenant-campaign-archive-encryption", + label: "Campaign archive encryption", + group: "TENANT", + order: 75, + allOf: ["admin:policies:read"], + render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, { + settings, + scopeType: "tenant", + canWrite: hasScope(auth, "admin:policies:write") + }) + }, + { + id: "group-campaign-archive-encryption", + moduleId: "policy", + kind: "settings", + surfaceId: "policy.admin.group-campaign-archive-encryption", + label: "Campaign archive encryption", + group: "GROUP", + order: 25, + allOf: ["admin:policies:read", "admin:groups:read"], + render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, { + settings, + scopeType: "group", + canWrite: hasScope(auth, "admin:policies:write") + }) + }, + { + id: "user-campaign-archive-encryption", + moduleId: "policy", + kind: "settings", + surfaceId: "policy.admin.user-campaign-archive-encryption", + label: "Campaign archive encryption", + group: "USER", + order: 25, + allOf: ["admin:policies:read", "admin:users:read"], + render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, { + settings, + scopeType: "user", + canWrite: hasScope(auth, "admin:policies:write") + }) + }, { id: "system-retention", moduleId: "policy", @@ -139,6 +200,10 @@ export const policyModule: PlatformWebModule = { { id: "policy.admin.tenant-view-policy", moduleId: "policy", kind: "section", label: "Tenant View policy", order: 70 }, { id: "policy.admin.group-view-policy", moduleId: "policy", kind: "section", label: "Group View policy", order: 70 }, { id: "policy.admin.user-view-policy", moduleId: "policy", kind: "section", label: "User View policy", order: 70 }, + { id: "policy.admin.system-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "System Campaign archive encryption", order: 75 }, + { id: "policy.admin.tenant-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "Tenant Campaign archive encryption", order: 75 }, + { id: "policy.admin.group-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "Group Campaign archive encryption", order: 75 }, + { id: "policy.admin.user-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "User Campaign archive encryption", order: 75 }, { id: "policy.admin.system-retention", moduleId: "policy", kind: "section", label: "System retention", order: 80 }, { id: "policy.admin.tenant-retention", moduleId: "policy", kind: "section", label: "Tenant retention", order: 80 }, { id: "policy.admin.group-retention", moduleId: "policy", kind: "section", label: "Group retention", order: 80 },