intermittent commit

This commit is contained in:
2026-07-14 13:22:12 +02:00
parent 664eb38ab9
commit 2511fbb5a8
5 changed files with 177 additions and 171 deletions

View File

@@ -14,9 +14,9 @@ from govoplan_core.core.configuration_control import (
) )
from govoplan_core.core.policy import PolicyDecision, PolicySourceStep from govoplan_core.core.policy import PolicyDecision, PolicySourceStep
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_core.privacy.schemas import RETENTION_POLICY_FIELD_KEYS
from govoplan_policy.backend.retention import ( from govoplan_policy.backend.retention import (
PrivacyPolicyError, PrivacyPolicyError,
RETENTION_POLICY_FIELD_KEYS,
apply_retention_policy, apply_retention_policy,
effective_privacy_policy, effective_privacy_policy,
effective_privacy_policy_sources, effective_privacy_policy_sources,

View File

@@ -1,43 +1,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PolicySourceStepItem(BaseModel): class PolicySourceStepItem(BaseModel):
@@ -49,42 +16,6 @@ class PolicySourceStepItem(BaseModel):
policy: dict[str, Any] = Field(default_factory=dict) policy: dict[str, Any] = Field(default_factory=dict)
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyRetentionPolicyScopeRequest(BaseModel): class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
import json import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import field_validator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.admin.settings import get_system_settings from govoplan_core.admin.settings import get_system_settings
@@ -26,6 +26,14 @@ from govoplan_core.core.access import (
from govoplan_core.core.policy import PolicySourceStep, policy_source_step from govoplan_core.core.policy import PolicySourceStep, policy_source_step
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.privacy.schemas import (
RETENTION_DAY_KEYS,
RETENTION_POLICY_FIELD_KEYS,
PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyPatchItem,
default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_core.tenancy.scope import Tenant from govoplan_core.tenancy.scope import Tenant
from govoplan_policy.backend.hierarchy import ( from govoplan_policy.backend.hierarchy import (
@@ -35,38 +43,8 @@ from govoplan_policy.backend.hierarchy import (
) )
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy" PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
AUDIT_DETAIL_LEVEL_ORDER = {"full": 0, "redacted": 1, "minimal": 2} AUDIT_DETAIL_LEVEL_ORDER = {"full": 0, "redacted": 1, "minimal": 2}
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
AUDIT_DETAIL_REDACTION_KEYS = { AUDIT_DETAIL_REDACTION_KEYS = {
"attachments", "attachments",
"bcc", "bcc",
@@ -91,18 +69,7 @@ AUDIT_DETAIL_REDACTION_KEYS = {
} }
class PrivacyRetentionPolicy(BaseModel): class PrivacyRetentionPolicy(PrivacyRetentionPolicyItem):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator( @field_validator(
"raw_campaign_json_retention_days", "raw_campaign_json_retention_days",
"generated_eml_retention_days", "generated_eml_retention_days",
@@ -120,21 +87,10 @@ class PrivacyRetentionPolicy(BaseModel):
@field_validator("allow_lower_level_limits", mode="before") @field_validator("allow_lower_level_limits", mode="before")
@classmethod @classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any: def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return _normalize_allow_lower_level_limits(value, fill_defaults=True) return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatch(BaseModel): class PrivacyRetentionPolicyPatch(PrivacyRetentionPolicyPatchItem):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator(*RETENTION_DAY_KEYS, mode="before") @field_validator(*RETENTION_DAY_KEYS, mode="before")
@classmethod @classmethod
def _blank_string_is_none(cls, value: Any) -> Any: def _blank_string_is_none(cls, value: Any) -> Any:
@@ -145,7 +101,7 @@ class PrivacyRetentionPolicyPatch(BaseModel):
@field_validator("allow_lower_level_limits", mode="before") @field_validator("allow_lower_level_limits", mode="before")
@classmethod @classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any: def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return _normalize_allow_lower_level_limits(value, fill_defaults=False) return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyPolicyError(RuntimeError): class PrivacyPolicyError(RuntimeError):
@@ -524,49 +480,130 @@ def set_privacy_policy_for_scope(
) -> dict[str, Any]: ) -> dict[str, Any]:
clean_scope = scope_type.strip().casefold() clean_scope = scope_type.strip().casefold()
if clean_scope == "system": if clean_scope == "system":
item = get_system_settings(session) return _set_system_privacy_policy(session, policy)
validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {})) patch = _privacy_policy_patch_payload(policy)
session.add(item) _validate_privacy_policy_patch_for_scope(
return validated.model_dump(mode="json") session,
patch = PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True) tenant_id=tenant_id,
_validate_privacy_patch_against_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)), patch) scope_type=clean_scope,
if clean_scope == "tenant": scope_id=scope_id,
tenant = session.get(Tenant, scope_id or tenant_id) patch=patch,
if tenant is None or tenant.id != tenant_id: )
raise PrivacyPolicyError("Tenant privacy policy not found") return _set_scoped_privacy_policy(
tenant.settings = _set_settings_privacy_policy(tenant.settings, patch) session,
session.add(tenant) tenant_id=tenant_id,
return patch scope_type=clean_scope,
if not scope_id: scope_id=scope_id,
raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id") patch=patch,
if clean_scope == "user": )
current_settings = _user_settings(session, user_id=scope_id, tenant_id=tenant_id)
if current_settings is None:
raise PrivacyPolicyError("User privacy policy not found") def _set_system_privacy_policy(session: Session, policy: dict[str, Any] | None) -> dict[str, Any]:
if _set_user_settings(session, user_id=scope_id, tenant_id=tenant_id, settings_payload=_set_settings_privacy_policy(current_settings, patch)) is None: item = get_system_settings(session)
raise PrivacyPolicyError("User privacy policy not found") validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {}))
return patch session.add(item)
if clean_scope == "group": return validated.model_dump(mode="json")
current_settings = _group_settings(session, group_id=scope_id, tenant_id=tenant_id)
if current_settings is None:
raise PrivacyPolicyError("Group privacy policy not found") def _privacy_policy_patch_payload(policy: dict[str, Any] | None) -> dict[str, Any]:
if _set_group_settings(session, group_id=scope_id, tenant_id=tenant_id, settings_payload=_set_settings_privacy_policy(current_settings, patch)) is None: return PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True)
raise PrivacyPolicyError("Group privacy policy not found")
return patch
if clean_scope == "campaign": def _validate_privacy_policy_patch_for_scope(
provider = _campaign_policy_provider() session: Session,
if provider is None: *,
raise PrivacyPolicyError("Campaign module is not installed") tenant_id: str,
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) scope_type: str,
settings_payload = _set_settings_privacy_policy(dict(campaign.settings or {}), patch) scope_id: str | None,
try: patch: dict[str, Any],
provider.set_campaign_settings(session, tenant_id=tenant_id, campaign_id=scope_id, settings=settings_payload) ) -> None:
except ValueError as exc: parent_scope_id = _parent_privacy_policy_scope_id(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
raise PrivacyPolicyError("Campaign privacy policy not found") from exc parent = parent_privacy_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=parent_scope_id)
return patch _validate_privacy_patch_against_parent(parent, patch)
def _parent_privacy_policy_scope_id(*, tenant_id: str, scope_type: str, scope_id: str | None) -> str | None:
if scope_id:
return scope_id
if scope_type == "tenant":
return tenant_id
return None
def _set_scoped_privacy_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
patch: dict[str, Any],
) -> dict[str, Any]:
if scope_type == "tenant":
return _set_tenant_privacy_policy(session, tenant_id=tenant_id, scope_id=scope_id, patch=patch)
clean_scope_id = _required_privacy_policy_scope_id(scope_type, scope_id)
if scope_type == "user":
return _set_user_privacy_policy(session, tenant_id=tenant_id, user_id=clean_scope_id, patch=patch)
if scope_type == "group":
return _set_group_privacy_policy(session, tenant_id=tenant_id, group_id=clean_scope_id, patch=patch)
if scope_type == "campaign":
return _set_campaign_privacy_policy(session, tenant_id=tenant_id, campaign_id=clean_scope_id, patch=patch)
raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign") raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign")
def _required_privacy_policy_scope_id(scope_type: str, scope_id: str | None) -> str:
if scope_id:
return scope_id
raise PrivacyPolicyError(f"{scope_type.capitalize()} privacy policy requires scope_id")
def _set_tenant_privacy_policy(
session: Session,
*,
tenant_id: str,
scope_id: str | None,
patch: dict[str, Any],
) -> dict[str, Any]:
tenant = session.get(Tenant, scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id:
raise PrivacyPolicyError("Tenant privacy policy not found")
tenant.settings = _set_settings_privacy_policy(tenant.settings, patch)
session.add(tenant)
return patch
def _set_user_privacy_policy(session: Session, *, tenant_id: str, user_id: str, patch: dict[str, Any]) -> dict[str, Any]:
current_settings = _user_settings(session, user_id=user_id, tenant_id=tenant_id)
if current_settings is None:
raise PrivacyPolicyError("User privacy policy not found")
settings_payload = _set_settings_privacy_policy(current_settings, patch)
if _set_user_settings(session, user_id=user_id, tenant_id=tenant_id, settings_payload=settings_payload) is None:
raise PrivacyPolicyError("User privacy policy not found")
return patch
def _set_group_privacy_policy(session: Session, *, tenant_id: str, group_id: str, patch: dict[str, Any]) -> dict[str, Any]:
current_settings = _group_settings(session, group_id=group_id, tenant_id=tenant_id)
if current_settings is None:
raise PrivacyPolicyError("Group privacy policy not found")
settings_payload = _set_settings_privacy_policy(current_settings, patch)
if _set_group_settings(session, group_id=group_id, tenant_id=tenant_id, settings_payload=settings_payload) is None:
raise PrivacyPolicyError("Group privacy policy not found")
return patch
def _set_campaign_privacy_policy(session: Session, *, tenant_id: str, campaign_id: str, patch: dict[str, Any]) -> dict[str, Any]:
provider = _campaign_policy_provider()
if provider is None:
raise PrivacyPolicyError("Campaign module is not installed")
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
settings_payload = _set_settings_privacy_policy(dict(campaign.settings or {}), patch)
try:
provider.set_campaign_settings(session, tenant_id=tenant_id, campaign_id=campaign_id, settings=settings_payload)
except ValueError as exc:
raise PrivacyPolicyError("Campaign privacy policy not found") from exc
return patch
def simulate_privacy_policy_change( def simulate_privacy_policy_change(
session: Session, session: Session,
*, *,

View File

@@ -2,6 +2,14 @@ from __future__ import annotations
import unittest import unittest
from govoplan_policy.backend.retention import (
PrivacyRetentionPolicy,
PrivacyRetentionPolicyPatch,
PrivacyPolicyError,
_parent_privacy_policy_scope_id,
_privacy_policy_patch_payload,
_required_privacy_policy_scope_id,
)
from govoplan_policy.backend.hierarchy import ( from govoplan_policy.backend.hierarchy import (
PolicyRestrictionRule, PolicyRestrictionRule,
simulate_hierarchical_policy_change, simulate_hierarchical_policy_change,
@@ -10,6 +18,36 @@ from govoplan_policy.backend.hierarchy import (
class PolicyHierarchyTests(unittest.TestCase): class PolicyHierarchyTests(unittest.TestCase):
def test_privacy_policy_parent_scope_ids_follow_scope_precedence(self) -> None:
self.assertEqual(
"tenant-1",
_parent_privacy_policy_scope_id(tenant_id="tenant-1", scope_type="tenant", scope_id=None),
)
self.assertIsNone(_parent_privacy_policy_scope_id(tenant_id="tenant-1", scope_type="user", scope_id=None))
self.assertIsNone(_parent_privacy_policy_scope_id(tenant_id="tenant-1", scope_type="group", scope_id=None))
self.assertEqual(
"campaign-1",
_parent_privacy_policy_scope_id(tenant_id="tenant-1", scope_type="campaign", scope_id="campaign-1"),
)
def test_privacy_policy_patch_payload_validates_and_removes_unset_fields(self) -> None:
payload = _privacy_policy_patch_payload({"audit_detail_level": "minimal", "generated_eml_retention_days": None})
self.assertEqual({"audit_detail_level": "minimal"}, payload)
def test_privacy_policy_models_extend_shared_schema_contract(self) -> None:
policy = PrivacyRetentionPolicy.model_validate({"generated_eml_retention_days": ""})
patch = PrivacyRetentionPolicyPatch.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertIsNone(policy.generated_eml_retention_days)
self.assertEqual({"audit_detail_level": False}, patch.allow_lower_level_limits)
def test_required_privacy_policy_scope_id_reports_missing_scope(self) -> None:
with self.assertRaises(PrivacyPolicyError) as captured:
_required_privacy_policy_scope_id("group", None)
self.assertEqual("Group privacy policy requires scope_id", str(captured.exception))
def test_parent_locks_block_lower_level_field_changes_and_limit_reenable(self) -> None: def test_parent_locks_block_lower_level_field_changes_and_limit_reenable(self) -> None:
issues = validate_hierarchical_policy_patch( issues = validate_hierarchical_policy_patch(
parent_policy={"retention_days": 30}, parent_policy={"retention_days": 30},

View File

@@ -13,7 +13,7 @@ class PolicyModuleContractTests(unittest.TestCase):
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.6", dependencies) self.assertTrue(any(item.startswith("govoplan-core>=") for item in dependencies))
self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies)) self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies))
def test_policy_source_does_not_import_access_implementation(self) -> None: def test_policy_source_does_not_import_access_implementation(self) -> None: