Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2511fbb5a8 | |||
| 664eb38ab9 | |||
| 97f05a083f | |||
| 423720229b |
19
README.md
19
README.md
@@ -1,11 +1,22 @@
|
|||||||
# GovOPlaN Policy
|
# GovOPlaN Policy
|
||||||
|
|
||||||
`govoplan-policy` owns policy and retention API route contributions during the
|
<!-- govoplan-repository-type:start -->
|
||||||
GovOPlaN module split.
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
The current package delegates to the legacy access administration
|
`govoplan-policy` owns policy and retention API route contributions and the
|
||||||
implementation while route ownership is separated before model migration.
|
retention administration WebUI sections during the GovOPlaN module split.
|
||||||
|
|
||||||
|
The `@govoplan/policy-webui` package contributes system, tenant, group, and
|
||||||
|
user retention sections through the shared `admin.sections` UI capability. The
|
||||||
|
admin shell does not render retention policy panels unless this module is
|
||||||
|
installed and enabled.
|
||||||
|
|
||||||
Policy decision and provenance payloads use the shared kernel DTOs documented
|
Policy decision and provenance payloads use the shared kernel DTOs documented
|
||||||
in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md)
|
in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md)
|
||||||
and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`.
|
and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`.
|
||||||
|
|
||||||
|
Hierarchical policy evaluation, delegation ceilings, and write simulations are
|
||||||
|
implemented in `govoplan_policy.backend.hierarchy`. Privacy retention uses that
|
||||||
|
shared helper and exposes `/api/v1/admin/privacy-retention/policies/{scope}/simulate`
|
||||||
|
for preflight checks before saving lower-level policy changes.
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ Privacy retention implementation lives in this module at
|
|||||||
dispatch to the active policy module without owning policy logic in core. Core
|
dispatch to the active policy module without owning policy logic in core. Core
|
||||||
does not import this implementation as a hidden fallback when policy is
|
does not import this implementation as a hidden fallback when policy is
|
||||||
disabled.
|
disabled.
|
||||||
|
|
||||||
|
Reusable hierarchical policy validation lives in
|
||||||
|
`govoplan_policy.backend.hierarchy`. Policy families should use that helper for
|
||||||
|
parent locks, lower-level override ceilings, "more restrictive only" checks,
|
||||||
|
and read-only simulations before destructive or limiting changes are saved.
|
||||||
|
Domain modules keep their own policy fields and restriction rules, but the
|
||||||
|
decision shape and simulation payload stay consistent.
|
||||||
When retention needs audit-log storage behavior, it requests the
|
When retention needs audit-log storage behavior, it requests the
|
||||||
`audit.retention` capability; it does not import audit module tables or
|
`audit.retention` capability; it does not import audit module tables or
|
||||||
providers directly.
|
providers directly.
|
||||||
@@ -47,6 +54,18 @@ GET /api/v1/admin/privacy-retention/policies/{scope_type}/explain
|
|||||||
|
|
||||||
The response contains `decision`, `effective_policy`, `parent_policy`,
|
The response contains `decision`, `effective_policy`, `parent_policy`,
|
||||||
`effective_policy_sources`, `parent_policy_sources`, and `blocked_fields`.
|
`effective_policy_sources`, `parent_policy_sources`, and `blocked_fields`.
|
||||||
|
|
||||||
|
Retention policy also exposes a write-preflight endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/admin/privacy-retention/policies/{scope}/simulate
|
||||||
|
```
|
||||||
|
|
||||||
|
The request body is the same as the write endpoint. The response contains a
|
||||||
|
`simulation` object with `allowed`, `changed_fields`, `issues`,
|
||||||
|
`before_policy`, `requested_policy`, and a shared `PolicyDecision` payload.
|
||||||
|
The endpoint never writes policy state and is intended for UI validation before
|
||||||
|
operators attempt destructive or limiting changes.
|
||||||
Clients can use `blocked_fields` to disable controls before a save attempt.
|
Clients can use `blocked_fields` to disable controls before a save attempt.
|
||||||
|
|
||||||
## UI Expectations
|
## UI Expectations
|
||||||
|
|||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/policy-webui",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-policy"
|
name = "govoplan-policy"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
description = "GovOPlaN policy platform module."
|
description = "GovOPlaN policy platform module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.6",
|
"govoplan-core>=0.1.8",
|
||||||
"govoplan-access>=0.1.6",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
@@ -22,4 +21,3 @@ govoplan_policy = ["py.typed"]
|
|||||||
|
|
||||||
[project.entry-points."govoplan.modules"]
|
[project.entry-points."govoplan.modules"]
|
||||||
policy = "govoplan_policy.backend.manifest:get_manifest"
|
policy = "govoplan_policy.backend.manifest:get_manifest"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -24,6 +24,7 @@ from govoplan_policy.backend.retention import (
|
|||||||
parent_privacy_policy,
|
parent_privacy_policy,
|
||||||
parent_privacy_policy_sources,
|
parent_privacy_policy_sources,
|
||||||
set_privacy_policy_for_scope,
|
set_privacy_policy_for_scope,
|
||||||
|
simulate_privacy_policy_change,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
@@ -31,6 +32,7 @@ from .schemas import (
|
|||||||
PrivacyRetentionPolicyItem,
|
PrivacyRetentionPolicyItem,
|
||||||
PrivacyRetentionPolicyScopeRequest,
|
PrivacyRetentionPolicyScopeRequest,
|
||||||
PrivacyRetentionPolicyScopeResponse,
|
PrivacyRetentionPolicyScopeResponse,
|
||||||
|
PrivacyRetentionPolicySimulationResponse,
|
||||||
RetentionRunRequest,
|
RetentionRunRequest,
|
||||||
RetentionRunResponse,
|
RetentionRunResponse,
|
||||||
)
|
)
|
||||||
@@ -200,6 +202,32 @@ def explain_privacy_retention_policy(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/privacy-retention/policies/{scope_type}/simulate", response_model=PrivacyRetentionPolicySimulationResponse)
|
||||||
|
def simulate_privacy_retention_policy(
|
||||||
|
scope_type: str,
|
||||||
|
payload: PrivacyRetentionPolicyScopeRequest,
|
||||||
|
scope_id: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
):
|
||||||
|
clean_scope = scope_type.strip().casefold()
|
||||||
|
_require_privacy_policy_write(principal, clean_scope)
|
||||||
|
try:
|
||||||
|
return PrivacyRetentionPolicySimulationResponse(
|
||||||
|
scope_type=clean_scope,
|
||||||
|
scope_id=scope_id,
|
||||||
|
simulation=simulate_privacy_policy_change(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=clean_scope,
|
||||||
|
scope_id=scope_id,
|
||||||
|
policy=payload.policy.model_dump(mode="json", exclude_none=True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except PrivacyPolicyError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def _blocked_privacy_retention_fields(parent) -> list[str]:
|
def _blocked_privacy_retention_fields(parent) -> list[str]:
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
@@ -121,6 +52,12 @@ class PrivacyRetentionPolicyExplainResponse(BaseModel):
|
|||||||
blocked_fields: list[str] = Field(default_factory=list)
|
blocked_fields: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyRetentionPolicySimulationResponse(BaseModel):
|
||||||
|
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
|
||||||
|
scope_id: str | None = None
|
||||||
|
simulation: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class RetentionRunRequest(BaseModel):
|
class RetentionRunRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
147
src/govoplan_policy/backend/hierarchy.py
Normal file
147
src/govoplan_policy/backend/hierarchy.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from govoplan_core.core.policy import PolicyDecision, PolicySourceStep
|
||||||
|
|
||||||
|
PolicyIssueSeverity = Literal["blocker", "warning", "info"]
|
||||||
|
RestrictionCheck = Callable[[Any, Any], bool]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PolicyValidationIssue:
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
field: str | None = None
|
||||||
|
severity: PolicyIssueSeverity = "blocker"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"severity": self.severity,
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
if self.field is not None:
|
||||||
|
payload["field"] = self.field
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PolicyRestrictionRule:
|
||||||
|
field: str
|
||||||
|
is_more_restrictive_or_equal: RestrictionCheck
|
||||||
|
less_restrictive_message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PolicySimulationResult:
|
||||||
|
allowed: bool
|
||||||
|
changed_fields: tuple[str, ...]
|
||||||
|
issues: tuple[PolicyValidationIssue, ...] = ()
|
||||||
|
before_policy: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
requested_policy: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
decision: PolicyDecision | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"allowed": self.allowed,
|
||||||
|
"changed_fields": list(self.changed_fields),
|
||||||
|
"issues": [issue.to_dict() for issue in self.issues],
|
||||||
|
"before_policy": dict(self.before_policy),
|
||||||
|
"requested_policy": dict(self.requested_policy),
|
||||||
|
"decision": self.decision.to_dict() if self.decision is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_hierarchical_policy_patch(
|
||||||
|
*,
|
||||||
|
parent_policy: Mapping[str, Any],
|
||||||
|
patch: Mapping[str, Any],
|
||||||
|
field_keys: Sequence[str],
|
||||||
|
parent_allow_lower_level_limits: Mapping[str, bool],
|
||||||
|
restriction_rules: Sequence[PolicyRestrictionRule] = (),
|
||||||
|
locked_field_message: Callable[[str], str] | None = None,
|
||||||
|
relock_message: Callable[[str], str] | None = None,
|
||||||
|
) -> tuple[PolicyValidationIssue, ...]:
|
||||||
|
issues: list[PolicyValidationIssue] = []
|
||||||
|
lock_message = locked_field_message or (lambda field: f"{field} is locked by the parent policy.")
|
||||||
|
reenable_message = relock_message or (lambda field: f"{field} limiting cannot be re-enabled below a parent policy lock.")
|
||||||
|
|
||||||
|
for field in field_keys:
|
||||||
|
if field in patch and patch.get(field) is not None and not parent_allow_lower_level_limits.get(field, True):
|
||||||
|
issues.append(PolicyValidationIssue(
|
||||||
|
code="field_locked_by_parent",
|
||||||
|
field=field,
|
||||||
|
message=lock_message(field),
|
||||||
|
))
|
||||||
|
|
||||||
|
patch_allow = patch.get("allow_lower_level_limits")
|
||||||
|
if isinstance(patch_allow, Mapping):
|
||||||
|
for field, allowed in patch_allow.items():
|
||||||
|
clean_field = str(field)
|
||||||
|
if bool(allowed) and not parent_allow_lower_level_limits.get(clean_field, True):
|
||||||
|
issues.append(PolicyValidationIssue(
|
||||||
|
code="lower_level_limit_locked_by_parent",
|
||||||
|
field=clean_field,
|
||||||
|
message=reenable_message(clean_field),
|
||||||
|
))
|
||||||
|
|
||||||
|
for rule in restriction_rules:
|
||||||
|
if rule.field not in patch or patch.get(rule.field) is None:
|
||||||
|
continue
|
||||||
|
parent_value = parent_policy.get(rule.field)
|
||||||
|
requested_value = patch.get(rule.field)
|
||||||
|
if not rule.is_more_restrictive_or_equal(parent_value, requested_value):
|
||||||
|
issues.append(PolicyValidationIssue(
|
||||||
|
code="less_restrictive_than_parent",
|
||||||
|
field=rule.field,
|
||||||
|
message=rule.less_restrictive_message,
|
||||||
|
))
|
||||||
|
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_hierarchical_policy_change(
|
||||||
|
*,
|
||||||
|
parent_policy: Mapping[str, Any],
|
||||||
|
current_policy: Mapping[str, Any],
|
||||||
|
patch: Mapping[str, Any],
|
||||||
|
field_keys: Sequence[str],
|
||||||
|
parent_allow_lower_level_limits: Mapping[str, bool],
|
||||||
|
restriction_rules: Sequence[PolicyRestrictionRule] = (),
|
||||||
|
source_path: Sequence[PolicySourceStep] = (),
|
||||||
|
locked_field_message: Callable[[str], str] | None = None,
|
||||||
|
relock_message: Callable[[str], str] | None = None,
|
||||||
|
) -> PolicySimulationResult:
|
||||||
|
issues = validate_hierarchical_policy_patch(
|
||||||
|
parent_policy=parent_policy,
|
||||||
|
patch=patch,
|
||||||
|
field_keys=field_keys,
|
||||||
|
parent_allow_lower_level_limits=parent_allow_lower_level_limits,
|
||||||
|
restriction_rules=restriction_rules,
|
||||||
|
locked_field_message=locked_field_message,
|
||||||
|
relock_message=relock_message,
|
||||||
|
)
|
||||||
|
changed_fields = tuple(
|
||||||
|
field
|
||||||
|
for field in (*field_keys, "allow_lower_level_limits")
|
||||||
|
if field in patch and current_policy.get(field) != patch.get(field)
|
||||||
|
)
|
||||||
|
allowed = not any(issue.severity == "blocker" for issue in issues)
|
||||||
|
decision = PolicyDecision(
|
||||||
|
allowed=allowed,
|
||||||
|
reason=None if allowed else "Requested policy change is blocked by parent policy constraints.",
|
||||||
|
source_path=tuple(source_path),
|
||||||
|
requirements=tuple(issue.field or issue.code for issue in issues if issue.severity == "blocker"),
|
||||||
|
details={"issues": [issue.to_dict() for issue in issues], "changed_fields": list(changed_fields)},
|
||||||
|
)
|
||||||
|
return PolicySimulationResult(
|
||||||
|
allowed=allowed,
|
||||||
|
changed_fields=changed_fields,
|
||||||
|
issues=issues,
|
||||||
|
before_policy=dict(current_policy),
|
||||||
|
requested_policy=dict(patch),
|
||||||
|
decision=decision,
|
||||||
|
)
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||||
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION
|
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION
|
||||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import FrontendModule, ModuleContext, ModuleManifest
|
||||||
|
|
||||||
|
|
||||||
def _route_factory(context: ModuleContext):
|
def _route_factory(context: ModuleContext):
|
||||||
@@ -22,9 +22,13 @@ def _privacy_retention_service(context: ModuleContext) -> object:
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="policy",
|
id="policy",
|
||||||
name="Policy",
|
name="Policy",
|
||||||
version="0.1.6",
|
version="0.1.8",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="policy",
|
||||||
|
package_name="@govoplan/policy-webui",
|
||||||
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service,
|
CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,45 +23,28 @@ from govoplan_core.core.access import (
|
|||||||
AuditRecordRef,
|
AuditRecordRef,
|
||||||
AuditRetentionProvider,
|
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.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 (
|
||||||
|
PolicyRestrictionRule,
|
||||||
|
simulate_hierarchical_policy_change,
|
||||||
|
validate_hierarchical_policy_patch,
|
||||||
|
)
|
||||||
|
|
||||||
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",
|
||||||
@@ -86,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",
|
||||||
@@ -115,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:
|
||||||
@@ -140,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):
|
||||||
@@ -277,26 +238,46 @@ def _merge_privacy_policy(parent: PrivacyRetentionPolicy, patch: dict[str, Any])
|
|||||||
return PrivacyRetentionPolicy.model_validate(payload)
|
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:
|
def _validate_privacy_patch_against_parent(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> None:
|
||||||
parent_payload = parent.model_dump(mode="json")
|
parent_payload = parent.model_dump(mode="json")
|
||||||
parent_allow = {**default_allow_lower_level_limits(), **(parent_payload.get("allow_lower_level_limits") or {})}
|
issues = validate_hierarchical_policy_patch(
|
||||||
for key in RETENTION_POLICY_FIELD_KEYS:
|
parent_policy=parent_payload,
|
||||||
if key in patch and patch.get(key) is not None and not parent_allow[key]:
|
patch=patch,
|
||||||
raise PrivacyPolicyError(f"{key} is locked by the parent retention policy.")
|
field_keys=RETENTION_POLICY_FIELD_KEYS,
|
||||||
patch_allow = patch.get("allow_lower_level_limits") or {}
|
parent_allow_lower_level_limits=_parent_allow_lower_level_limits(parent_payload),
|
||||||
for key, allowed in patch_allow.items():
|
restriction_rules=_privacy_restriction_rules(),
|
||||||
if allowed and not parent_allow[key]:
|
locked_field_message=lambda key: f"{key} is locked by the parent retention policy.",
|
||||||
raise PrivacyPolicyError(f"{key} limiting cannot be re-enabled below a parent retention policy lock.")
|
relock_message=lambda key: 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.")
|
if issues:
|
||||||
for key in RETENTION_DAY_KEYS:
|
raise PrivacyPolicyError(issues[0].message)
|
||||||
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.")
|
|
||||||
|
|
||||||
|
|
||||||
def _set_settings_privacy_policy(settings_payload: dict[str, Any] | None, policy: dict[str, Any]) -> dict[str, Any]:
|
def _set_settings_privacy_policy(settings_payload: dict[str, Any] | None, policy: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -499,47 +480,173 @@ 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":
|
||||||
|
return _set_system_privacy_policy(session, policy)
|
||||||
|
patch = _privacy_policy_patch_payload(policy)
|
||||||
|
_validate_privacy_policy_patch_for_scope(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type=clean_scope,
|
||||||
|
scope_id=scope_id,
|
||||||
|
patch=patch,
|
||||||
|
)
|
||||||
|
return _set_scoped_privacy_policy(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type=clean_scope,
|
||||||
|
scope_id=scope_id,
|
||||||
|
patch=patch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_system_privacy_policy(session: Session, policy: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
item = get_system_settings(session)
|
item = get_system_settings(session)
|
||||||
validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {}))
|
validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {}))
|
||||||
session.add(item)
|
session.add(item)
|
||||||
return validated.model_dump(mode="json")
|
return validated.model_dump(mode="json")
|
||||||
patch = PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True)
|
|
||||||
_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)
|
|
||||||
if clean_scope == "tenant":
|
def _privacy_policy_patch_payload(policy: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_privacy_policy_patch_for_scope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
patch: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
parent_scope_id = _parent_privacy_policy_scope_id(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
parent = parent_privacy_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=parent_scope_id)
|
||||||
|
_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")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
tenant = session.get(Tenant, scope_id or tenant_id)
|
||||||
if tenant is None or tenant.id != tenant_id:
|
if tenant is None or tenant.id != tenant_id:
|
||||||
raise PrivacyPolicyError("Tenant privacy policy not found")
|
raise PrivacyPolicyError("Tenant privacy policy not found")
|
||||||
tenant.settings = _set_settings_privacy_policy(tenant.settings, patch)
|
tenant.settings = _set_settings_privacy_policy(tenant.settings, patch)
|
||||||
session.add(tenant)
|
session.add(tenant)
|
||||||
return patch
|
return patch
|
||||||
if not scope_id:
|
|
||||||
raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id")
|
|
||||||
if clean_scope == "user":
|
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=scope_id, tenant_id=tenant_id)
|
current_settings = _user_settings(session, user_id=user_id, tenant_id=tenant_id)
|
||||||
if current_settings is None:
|
if current_settings is None:
|
||||||
raise PrivacyPolicyError("User privacy policy not found")
|
raise PrivacyPolicyError("User privacy policy not found")
|
||||||
if _set_user_settings(session, user_id=scope_id, tenant_id=tenant_id, settings_payload=_set_settings_privacy_policy(current_settings, patch)) is None:
|
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")
|
raise PrivacyPolicyError("User privacy policy not found")
|
||||||
return patch
|
return patch
|
||||||
if clean_scope == "group":
|
|
||||||
current_settings = _group_settings(session, group_id=scope_id, tenant_id=tenant_id)
|
|
||||||
|
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:
|
if current_settings is None:
|
||||||
raise PrivacyPolicyError("Group privacy policy not found")
|
raise PrivacyPolicyError("Group privacy policy not found")
|
||||||
if _set_group_settings(session, group_id=scope_id, tenant_id=tenant_id, settings_payload=_set_settings_privacy_policy(current_settings, patch)) is None:
|
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")
|
raise PrivacyPolicyError("Group privacy policy not found")
|
||||||
return patch
|
return patch
|
||||||
if clean_scope == "campaign":
|
|
||||||
|
|
||||||
|
def _set_campaign_privacy_policy(session: Session, *, tenant_id: str, campaign_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
provider = _campaign_policy_provider()
|
provider = _campaign_policy_provider()
|
||||||
if provider is None:
|
if provider is None:
|
||||||
raise PrivacyPolicyError("Campaign module is not installed")
|
raise PrivacyPolicyError("Campaign module is not installed")
|
||||||
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
|
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||||
settings_payload = _set_settings_privacy_policy(dict(campaign.settings or {}), patch)
|
settings_payload = _set_settings_privacy_policy(dict(campaign.settings or {}), patch)
|
||||||
try:
|
try:
|
||||||
provider.set_campaign_settings(session, tenant_id=tenant_id, campaign_id=scope_id, settings=settings_payload)
|
provider.set_campaign_settings(session, tenant_id=tenant_id, campaign_id=campaign_id, settings=settings_payload)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise PrivacyPolicyError("Campaign privacy policy not found") from exc
|
raise PrivacyPolicyError("Campaign privacy policy not found") from exc
|
||||||
return patch
|
return patch
|
||||||
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]:
|
def sanitize_audit_details_for_policy(session: Session, details: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -723,6 +830,9 @@ class SqlPrivacyRetentionService:
|
|||||||
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
|
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
return set_privacy_policy_for_scope(*args, **kwargs)
|
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:
|
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
return sanitize_audit_details_for_policy(*args, **kwargs)
|
return sanitize_audit_details_for_policy(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
105
tests/test_policy_hierarchy.py
Normal file
105
tests/test_policy_hierarchy.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 (
|
||||||
|
PolicyRestrictionRule,
|
||||||
|
simulate_hierarchical_policy_change,
|
||||||
|
validate_hierarchical_policy_patch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
issues = validate_hierarchical_policy_patch(
|
||||||
|
parent_policy={"retention_days": 30},
|
||||||
|
patch={"retention_days": 20, "allow_lower_level_limits": {"retention_days": True}},
|
||||||
|
field_keys=("retention_days",),
|
||||||
|
parent_allow_lower_level_limits={"retention_days": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["field_locked_by_parent", "lower_level_limit_locked_by_parent"], [issue.code for issue in issues])
|
||||||
|
self.assertEqual(["retention_days", "retention_days"], [issue.field for issue in issues])
|
||||||
|
|
||||||
|
def test_restriction_rules_block_less_restrictive_patch(self) -> None:
|
||||||
|
issues = validate_hierarchical_policy_patch(
|
||||||
|
parent_policy={"retention_days": 30},
|
||||||
|
patch={"retention_days": 45},
|
||||||
|
field_keys=("retention_days",),
|
||||||
|
parent_allow_lower_level_limits={"retention_days": True},
|
||||||
|
restriction_rules=(
|
||||||
|
PolicyRestrictionRule(
|
||||||
|
field="retention_days",
|
||||||
|
is_more_restrictive_or_equal=lambda parent, requested: int(requested) <= int(parent),
|
||||||
|
less_restrictive_message="retention_days cannot be widened",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(issues))
|
||||||
|
self.assertEqual("less_restrictive_than_parent", issues[0].code)
|
||||||
|
self.assertEqual("retention_days cannot be widened", issues[0].message)
|
||||||
|
|
||||||
|
def test_policy_simulation_returns_decision_payload(self) -> None:
|
||||||
|
simulation = simulate_hierarchical_policy_change(
|
||||||
|
parent_policy={"retention_days": 30},
|
||||||
|
current_policy={"retention_days": 30},
|
||||||
|
patch={"retention_days": 45},
|
||||||
|
field_keys=("retention_days",),
|
||||||
|
parent_allow_lower_level_limits={"retention_days": True},
|
||||||
|
restriction_rules=(
|
||||||
|
PolicyRestrictionRule(
|
||||||
|
field="retention_days",
|
||||||
|
is_more_restrictive_or_equal=lambda parent, requested: int(requested) <= int(parent),
|
||||||
|
less_restrictive_message="retention_days cannot be widened",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(simulation.allowed)
|
||||||
|
self.assertEqual(("retention_days",), simulation.changed_fields)
|
||||||
|
self.assertIsNotNone(simulation.decision)
|
||||||
|
self.assertEqual(("retention_days",), simulation.decision.requirements)
|
||||||
|
self.assertEqual("Requested policy change is blocked by parent policy constraints.", simulation.decision.reason)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
30
tests/test_policy_module_contract.py
Normal file
30
tests/test_policy_module_contract.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyModuleContractTests(unittest.TestCase):
|
||||||
|
def test_policy_package_does_not_hard_require_access(self) -> None:
|
||||||
|
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
dependencies = tuple(project["dependencies"])
|
||||||
|
|
||||||
|
self.assertTrue(any(item.startswith("govoplan-core>=") 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:
|
||||||
|
offenders: list[str] = []
|
||||||
|
for path in (ROOT / "src" / "govoplan_policy").rglob("*.py"):
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
if "govoplan_access" in source:
|
||||||
|
offenders.append(str(path.relative_to(ROOT)))
|
||||||
|
|
||||||
|
self.assertEqual([], offenders)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
27
webui/package.json
Normal file
27
webui/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/policy-webui",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
webui/src/api/adminTargets.ts
Normal file
56
webui/src/api/adminTargets.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { apiFetch, type ApiSettings, type DeltaDeletedItem } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type RoleSummary = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupSummary = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
member_count: number;
|
||||||
|
member_ids: string[];
|
||||||
|
roles: RoleSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserAdminItem = {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
groups: GroupSummary[];
|
||||||
|
roles: RoleSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeltaResponseFields = {
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserListDeltaResponse = { users: UserAdminItem[] } & DeltaResponseFields;
|
||||||
|
export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseFields;
|
||||||
|
|
||||||
|
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.since) params.set("since", options.since);
|
||||||
|
if (options.limit) params.set("limit", String(options.limit));
|
||||||
|
const suffix = params.toString();
|
||||||
|
return suffix ? `?${suffix}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUsersDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<UserListDeltaResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/users/delta${deltaSuffix(options)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/groups/delta${deltaSuffix(options)}`);
|
||||||
|
}
|
||||||
226
webui/src/features/policy/RetentionPoliciesPanel.tsx
Normal file
226
webui/src/features/policy/RetentionPoliciesPanel.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
AdminPageLayout,
|
||||||
|
adminErrorMessage,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
mergeDeltaRows,
|
||||||
|
RetentionPolicyScopeManager,
|
||||||
|
runRetentionPolicy,
|
||||||
|
useDeltaWatermarks,
|
||||||
|
type ApiSettings,
|
||||||
|
type DeltaDeletedItem,
|
||||||
|
type PrivacyRetentionPolicyScope,
|
||||||
|
type RetentionPolicyTargetOption,
|
||||||
|
type RetentionRunResponse
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
scopeType: Extract<PrivacyRetentionPolicyScope, "system" | "tenant" | "user" | "group">;
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeltaResponse = {
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
|
||||||
|
system: {
|
||||||
|
title: "System retention",
|
||||||
|
description: "Instance-wide privacy retention policy and lower-level override limits.",
|
||||||
|
policyTitle: "System retention policy",
|
||||||
|
policyDescription: "Set concrete system retention values. Override switches define whether lower levels may narrow each value."
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
title: "Tenant retention",
|
||||||
|
description: "Tenant-level privacy and retention limits for the active tenant.",
|
||||||
|
policyTitle: "Tenant retention policy",
|
||||||
|
policyDescription: "Tenant limits may only narrow the system policy where the parent policy allows overrides."
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
title: "User retention",
|
||||||
|
description: "User-scoped retention limits for campaigns owned by a user.",
|
||||||
|
targetLabel: "User",
|
||||||
|
policyTitle: "User retention policy",
|
||||||
|
policyDescription: "User limits may only narrow inherited system and tenant policy."
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
title: "Group retention",
|
||||||
|
description: "Group-scoped retention limits for campaigns owned by a group.",
|
||||||
|
targetLabel: "Group",
|
||||||
|
policyTitle: "Group retention policy",
|
||||||
|
policyDescription: "Group limits may only narrow inherited system and tenant policy."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
|
||||||
|
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
|
||||||
|
const usersRef = useRef<UserAdminItem[]>([]);
|
||||||
|
const groupsRef = useRef<GroupSummary[]>([]);
|
||||||
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
|
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
|
||||||
|
const [targetError, setTargetError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const [runError, setRunError] = useState("");
|
||||||
|
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
|
||||||
|
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
usersRef.current = [];
|
||||||
|
groupsRef.current = [];
|
||||||
|
resetDeltaWatermark();
|
||||||
|
void loadTargets();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, resetDeltaWatermark]);
|
||||||
|
|
||||||
|
async function loadTargets() {
|
||||||
|
if (scopeType !== "user" && scopeType !== "group") {
|
||||||
|
setTargets([]);
|
||||||
|
setLoadingTargets(false);
|
||||||
|
setTargetError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingTargets(true);
|
||||||
|
setTargetError("");
|
||||||
|
try {
|
||||||
|
if (scopeType === "user") {
|
||||||
|
const users = await loadDeltaRows<UserAdminItem, UserListDeltaResponse>(
|
||||||
|
usersRef.current,
|
||||||
|
"policy:retention-users",
|
||||||
|
getDeltaWatermark,
|
||||||
|
setDeltaWatermark,
|
||||||
|
(since) => fetchUsersDelta(settings, { since }),
|
||||||
|
(response) => response.users,
|
||||||
|
(user) => user.id,
|
||||||
|
"access_user",
|
||||||
|
sortUsers
|
||||||
|
);
|
||||||
|
usersRef.current = users;
|
||||||
|
setTargets(users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
label: user.display_name || user.email,
|
||||||
|
secondary: user.display_name ? user.email : null
|
||||||
|
})));
|
||||||
|
} else {
|
||||||
|
const groups = await loadDeltaRows<GroupSummary, GroupListDeltaResponse>(
|
||||||
|
groupsRef.current,
|
||||||
|
"policy:retention-groups",
|
||||||
|
getDeltaWatermark,
|
||||||
|
setDeltaWatermark,
|
||||||
|
(since) => fetchGroupsDelta(settings, { since }),
|
||||||
|
(response) => response.groups,
|
||||||
|
(group) => group.id,
|
||||||
|
"access_group",
|
||||||
|
sortGroups
|
||||||
|
);
|
||||||
|
groupsRef.current = groups;
|
||||||
|
setTargets(groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
label: group.name,
|
||||||
|
secondary: group.slug
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setTargets([]);
|
||||||
|
setTargetError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoadingTargets(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRetention(dryRun: boolean) {
|
||||||
|
setBusy(true);
|
||||||
|
setRunError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const response = await runRetentionPolicy(settings, dryRun);
|
||||||
|
setRetentionResult(response);
|
||||||
|
setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied.");
|
||||||
|
setConfirmRetentionRun(false);
|
||||||
|
} catch (err) {
|
||||||
|
setRunError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = copy[scopeType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
|
||||||
|
<RetentionPolicyScopeManager
|
||||||
|
settings={settings}
|
||||||
|
scopeType={scopeType}
|
||||||
|
targetOptions={targets}
|
||||||
|
targetLabel={labels.targetLabel}
|
||||||
|
title={labels.policyTitle}
|
||||||
|
description={labels.policyDescription}
|
||||||
|
canWrite={canWrite}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{scopeType === "system" && (
|
||||||
|
<div className="retention-run-card">
|
||||||
|
<Card title="Retention execution">
|
||||||
|
<p className="muted small-note">Run the saved effective retention policy against retained platform data.</p>
|
||||||
|
<div className="button-row compact-actions subsection-bottom-actions">
|
||||||
|
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
|
||||||
|
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
|
||||||
|
</div>
|
||||||
|
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AdminPageLayout>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmRetentionRun}
|
||||||
|
title="Apply retention policy"
|
||||||
|
message="This will redact or delete eligible retained data according to the saved policy."
|
||||||
|
confirmLabel="Apply retention"
|
||||||
|
tone="danger"
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setConfirmRetentionRun(false)}
|
||||||
|
onConfirm={() => void runRetention(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeltaRows<TItem, TResponse extends DeltaResponse>(
|
||||||
|
current: TItem[],
|
||||||
|
key: string,
|
||||||
|
getDeltaWatermark: (key: string) => string | null,
|
||||||
|
setDeltaWatermark: (key: string, watermark: string | null | undefined) => void,
|
||||||
|
fetchDelta: (since: string | null) => Promise<TResponse>,
|
||||||
|
rowsFromResponse: (response: TResponse) => TItem[],
|
||||||
|
getKey: (item: TItem) => string,
|
||||||
|
deletedResourceType: string,
|
||||||
|
sort?: (left: TItem, right: TItem) => number
|
||||||
|
): Promise<TItem[]> {
|
||||||
|
let nextWatermark = getDeltaWatermark(key);
|
||||||
|
let merged = current;
|
||||||
|
let hasMore = false;
|
||||||
|
do {
|
||||||
|
const response = await fetchDelta(nextWatermark);
|
||||||
|
const rows = rowsFromResponse(response);
|
||||||
|
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
||||||
|
nextWatermark = response.watermark ?? null;
|
||||||
|
hasMore = response.has_more;
|
||||||
|
} while (hasMore);
|
||||||
|
setDeltaWatermark(key, nextWatermark);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||||
|
return left.email.localeCompare(right.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||||
|
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||||
|
}
|
||||||
5
webui/src/index.ts
Normal file
5
webui/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default } from "./module";
|
||||||
|
export * from "./module";
|
||||||
|
export * from "./api/adminTargets";
|
||||||
|
export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel";
|
||||||
|
export type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
69
webui/src/module.ts
Normal file
69
webui/src/module.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel"));
|
||||||
|
|
||||||
|
const policyAdminSections: AdminSectionsUiCapability = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "system-retention",
|
||||||
|
label: "Retention",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 80,
|
||||||
|
allOf: ["system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(RetentionPoliciesPanel, {
|
||||||
|
settings,
|
||||||
|
scopeType: "system",
|
||||||
|
canWrite: hasScope(auth, "system:settings:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-retention",
|
||||||
|
label: "Retention",
|
||||||
|
group: "TENANT",
|
||||||
|
order: 80,
|
||||||
|
allOf: ["admin:policies:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(RetentionPoliciesPanel, {
|
||||||
|
settings,
|
||||||
|
scopeType: "tenant",
|
||||||
|
canWrite: hasScope(auth, "admin:policies:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-group-retention",
|
||||||
|
label: "Retention",
|
||||||
|
group: "GROUP",
|
||||||
|
order: 30,
|
||||||
|
allOf: ["admin:policies:read", "admin:groups:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(RetentionPoliciesPanel, {
|
||||||
|
settings,
|
||||||
|
scopeType: "group",
|
||||||
|
canWrite: hasScope(auth, "admin:policies:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-user-retention",
|
||||||
|
label: "Retention",
|
||||||
|
group: "USER",
|
||||||
|
order: 30,
|
||||||
|
allOf: ["admin:policies:read", "admin:users:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(RetentionPoliciesPanel, {
|
||||||
|
settings,
|
||||||
|
scopeType: "user",
|
||||||
|
canWrite: hasScope(auth, "admin:policies:write")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const policyModule: PlatformWebModule = {
|
||||||
|
id: "policy",
|
||||||
|
label: "Policy",
|
||||||
|
version: "0.1.6",
|
||||||
|
dependencies: ["access", "admin"],
|
||||||
|
uiCapabilities: {
|
||||||
|
"admin.sections": policyAdminSections
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default policyModule;
|
||||||
Reference in New Issue
Block a user