feat: govern campaign archive encryption
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user