354 lines
13 KiB
Python
354 lines
13 KiB
Python
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",
|
|
]
|