feat: govern campaign archive encryption
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
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<ArchiveEncryptionPolicyResponse> {
|
||||
return apiFetch(settings, policyPath(scope, scopeId));
|
||||
}
|
||||
|
||||
export function updateArchiveEncryptionPolicy(
|
||||
settings: ApiSettings,
|
||||
scope: ArchiveEncryptionPolicyScope,
|
||||
scopeId: string | null,
|
||||
policy: ArchiveEncryptionPolicyItem
|
||||
): Promise<ArchiveEncryptionPolicyResponse> {
|
||||
return apiFetch(settings, policyPath(scope, scopeId), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ policy })
|
||||
});
|
||||
}
|
||||
@@ -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<SearchableSelectOption[]>([]);
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [state, setState] = useState<ArchiveEncryptionPolicyResponse | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(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<boolean> {
|
||||
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 <AdminPageLayout
|
||||
title={`${scopeLabel} Campaign archive encryption`}
|
||||
description="Restrict password-protected ZIP methods and the separate channel used to convey passwords. Lower scopes can only narrow inherited choices."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<>
|
||||
<Button title="Reload saved archive policy" aria-label="Reload saved archive policy" onClick={() => void load()} disabled={loading || busy || (needsTarget && !targetId)}><RefreshCw size={16} /></Button>
|
||||
<Button onClick={discard} disabled={!dirty || busy}><Undo2 size={16} /> Discard</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}><Save size={16} /> {busy ? "Saving..." : "Save"}</Button>
|
||||
</>}>
|
||||
{needsTarget && <Card title={`${scopeLabel} target`}>
|
||||
<FormField label={`Select ${scopeType}`}>
|
||||
<SearchableSelect value={targetId} options={targets} onChange={(value) => void selectTarget(value)} disabled={busy} />
|
||||
</FormField>
|
||||
</Card>}
|
||||
{draft && state && <>
|
||||
<DismissibleAlert tone={state.effective_policy.allowed_password_encryption_methods.includes("zip_standard") ? "warning" : "info"} dismissible={false}>
|
||||
{state.effective_policy.reason} Legacy ZipCrypto remains a weak compatibility exception and is never an automatic fallback.
|
||||
</DismissibleAlert>
|
||||
<Card title="Allowed password-encryption methods">
|
||||
{scopeType !== "system" && <ToggleSwitch label="Inherit methods from the parent scope" checked={draft.inheritMethods} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritMethods: checked, methods: checked ? [...parentMethods] : draft.methods })} />}
|
||||
{METHODS.map((method) => <ToggleSwitch key={method.id} label={method.label} help={method.description} checked={draft.methods.includes(method.id)} disabled={!canWrite || busy || draft.inheritMethods || (scopeType !== "system" && !parentMethods.includes(method.id))} onChange={(checked) => setDraft({ ...draft, methods: toggle(draft.methods, method.id, checked) })} />)}
|
||||
</Card>
|
||||
<Card title="Allowed separate password-delivery channels">
|
||||
{scopeType !== "system" && <ToggleSwitch label="Inherit channels from the parent scope" checked={draft.inheritChannels} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritChannels: checked, channels: checked ? [...parentChannels] : draft.channels })} />}
|
||||
{CHANNELS.map((channel) => <ToggleSwitch key={channel.id} label={channel.label} checked={draft.channels.includes(channel.id)} disabled={!canWrite || busy || draft.inheritChannels || (scopeType !== "system" && !parentChannels.includes(channel.id))} onChange={(checked) => setDraft({ ...draft, channels: toggle(draft.channels, channel.id, checked) })} />)}
|
||||
</Card>
|
||||
<Card title="Effective policy evidence">
|
||||
<DescriptionList>
|
||||
<DescriptionItem term="Policy hash"><code>{state.effective_policy.policy_hash}</code></DescriptionItem>
|
||||
<DescriptionItem term="Source path">{state.effective_policy.source_path.map((step) => step.label).join(" → ")}</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</Card>
|
||||
</>}
|
||||
</AdminPageLayout>;
|
||||
}
|
||||
|
||||
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<T extends string>(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<SearchableSelectOption[]> {
|
||||
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 [];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user