Add hierarchical policy simulation helpers

This commit is contained in:
2026-07-11 00:46:09 +02:00
parent bebb8a6e73
commit 423720229b
9 changed files with 394 additions and 21 deletions

View File

@@ -23,11 +23,16 @@ from govoplan_core.core.access import (
AuditRecordRef,
AuditRetentionProvider,
)
from govoplan_core.core.policy import policy_source_step
from govoplan_core.core.policy import PolicySourceStep, policy_source_step
from govoplan_core.core.runtime import get_registry
from govoplan_core.admin.models import SystemSettings
from govoplan_core.settings import settings
from govoplan_core.tenancy.scope import Tenant
from govoplan_policy.backend.hierarchy import (
PolicyRestrictionRule,
simulate_hierarchical_policy_change,
validate_hierarchical_policy_patch,
)
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
@@ -277,26 +282,46 @@ def _merge_privacy_policy(parent: PrivacyRetentionPolicy, patch: dict[str, Any])
return PrivacyRetentionPolicy.model_validate(payload)
def _parent_allow_lower_level_limits(parent_payload: dict[str, Any]) -> dict[str, bool]:
return {**default_allow_lower_level_limits(), **(parent_payload.get("allow_lower_level_limits") or {})}
def _privacy_restriction_rules() -> tuple[PolicyRestrictionRule, ...]:
return (
PolicyRestrictionRule(
field="store_raw_campaign_json",
is_more_restrictive_or_equal=lambda parent_value, requested_value: not (requested_value is True and parent_value is False),
less_restrictive_message="Raw campaign JSON storage cannot be re-enabled below a parent policy that disables it.",
),
*(
PolicyRestrictionRule(
field=key,
is_more_restrictive_or_equal=lambda parent_value, requested_value: parent_value is None or int(requested_value) <= int(parent_value),
less_restrictive_message=f"{key} cannot be less restrictive than the parent retention policy.",
)
for key in RETENTION_DAY_KEYS
),
PolicyRestrictionRule(
field="audit_detail_level",
is_more_restrictive_or_equal=lambda parent_value, requested_value: AUDIT_DETAIL_LEVEL_ORDER[str(requested_value)] >= AUDIT_DETAIL_LEVEL_ORDER[str(parent_value or "full")],
less_restrictive_message="Audit detail level cannot be less restrictive than the parent retention policy.",
),
)
def _validate_privacy_patch_against_parent(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> None:
parent_payload = parent.model_dump(mode="json")
parent_allow = {**default_allow_lower_level_limits(), **(parent_payload.get("allow_lower_level_limits") or {})}
for key in RETENTION_POLICY_FIELD_KEYS:
if key in patch and patch.get(key) is not None and not parent_allow[key]:
raise PrivacyPolicyError(f"{key} is locked by the parent retention policy.")
patch_allow = patch.get("allow_lower_level_limits") or {}
for key, allowed in patch_allow.items():
if allowed and not parent_allow[key]:
raise PrivacyPolicyError(f"{key} limiting cannot be re-enabled below a parent retention policy lock.")
if patch.get("store_raw_campaign_json") is True and not parent.store_raw_campaign_json:
raise PrivacyPolicyError("Raw campaign JSON storage cannot be re-enabled below a parent policy that disables it.")
for key in RETENTION_DAY_KEYS:
value = patch.get(key)
parent_value = parent_payload.get(key)
if value is not None and parent_value is not None and int(value) > int(parent_value):
raise PrivacyPolicyError(f"{key} cannot be less restrictive than the parent retention policy.")
detail_level = patch.get("audit_detail_level")
if detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] < AUDIT_DETAIL_LEVEL_ORDER[parent.audit_detail_level]:
raise PrivacyPolicyError("Audit detail level cannot be less restrictive than the parent retention policy.")
issues = validate_hierarchical_policy_patch(
parent_policy=parent_payload,
patch=patch,
field_keys=RETENTION_POLICY_FIELD_KEYS,
parent_allow_lower_level_limits=_parent_allow_lower_level_limits(parent_payload),
restriction_rules=_privacy_restriction_rules(),
locked_field_message=lambda key: f"{key} is locked by the parent retention policy.",
relock_message=lambda key: f"{key} limiting cannot be re-enabled below a parent retention policy lock.",
)
if issues:
raise PrivacyPolicyError(issues[0].message)
def _set_settings_privacy_policy(settings_payload: dict[str, Any] | None, policy: dict[str, Any]) -> dict[str, Any]:
@@ -542,6 +567,51 @@ def set_privacy_policy_for_scope(
raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign")
def simulate_privacy_policy_change(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
policy: dict[str, Any] | None = None,
) -> dict[str, Any]:
clean_scope = scope_type.strip().casefold()
patch = PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True)
current = get_privacy_policy_for_scope(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id)
if clean_scope == "system":
parent_payload = PrivacyRetentionPolicy.model_validate(current).model_dump(mode="json")
parent_allow = default_allow_lower_level_limits()
parent_sources = effective_privacy_policy_sources(session)
else:
parent = parent_privacy_policy(
session,
tenant_id=tenant_id,
scope_type=clean_scope,
scope_id=scope_id or (tenant_id if clean_scope == "tenant" else None),
)
parent_payload = parent.model_dump(mode="json")
parent_allow = _parent_allow_lower_level_limits(parent_payload)
parent_sources = parent_privacy_policy_sources(
session,
tenant_id=tenant_id,
scope_type=clean_scope,
scope_id=scope_id or (tenant_id if clean_scope == "tenant" else None),
)
simulation = simulate_hierarchical_policy_change(
parent_policy=parent_payload,
current_policy=current,
patch=patch,
field_keys=RETENTION_POLICY_FIELD_KEYS,
parent_allow_lower_level_limits=parent_allow,
restriction_rules=() if clean_scope == "system" else _privacy_restriction_rules(),
source_path=[PolicySourceStep.from_mapping(source) for source in parent_sources],
locked_field_message=lambda key: f"{key} is locked by the parent retention policy.",
relock_message=lambda key: f"{key} limiting cannot be re-enabled below a parent retention policy lock.",
)
return simulation.to_dict()
def sanitize_audit_details_for_policy(session: Session, details: dict[str, Any]) -> dict[str, Any]:
policy = privacy_policy_from_session(session)
if policy.audit_detail_level == "full":
@@ -723,6 +793,9 @@ class SqlPrivacyRetentionService:
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
return set_privacy_policy_for_scope(*args, **kwargs)
def simulate_privacy_policy_change(self, *args: Any, **kwargs: Any) -> Any:
return simulate_privacy_policy_change(*args, **kwargs)
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any:
return sanitize_audit_details_for_policy(*args, **kwargs)