4 Commits
v0.1.6 ... main

Author SHA1 Message Date
2511fbb5a8 intermittent commit 2026-07-14 13:22:12 +02:00
664eb38ab9 Release v0.1.8 2026-07-11 16:49:03 +02:00
97f05a083f Release v0.1.7 2026-07-11 02:34:58 +02:00
423720229b Add hierarchical policy simulation helpers 2026-07-11 00:46:09 +02:00
16 changed files with 1003 additions and 199 deletions

View File

@@ -1,11 +1,22 @@
# GovOPlaN Policy
`govoplan-policy` owns policy and retention API route contributions during the
GovOPlaN module split.
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
The current package delegates to the legacy access administration
implementation while route ownership is separated before model migration.
`govoplan-policy` owns policy and retention API route contributions and the
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
in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.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.

View File

@@ -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
does not import this implementation as a hidden fallback when policy is
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
`audit.retention` capability; it does not import audit module tables or
providers directly.
@@ -47,6 +54,18 @@ GET /api/v1/admin/privacy-retention/policies/{scope_type}/explain
The response contains `decision`, `effective_policy`, `parent_policy`,
`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.
## UI Expectations

32
package.json Normal file
View 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
}
}
}

View File

@@ -4,14 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-policy"
version = "0.1.6"
version = "0.1.8"
description = "GovOPlaN policy platform module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.6",
"govoplan-access>=0.1.6",
"govoplan-core>=0.1.8",
]
[tool.setuptools.packages.find]
@@ -22,4 +21,3 @@ govoplan_policy = ["py.typed"]
[project.entry-points."govoplan.modules"]
policy = "govoplan_policy.backend.manifest:get_manifest"

View File

@@ -14,9 +14,9 @@ from govoplan_core.core.configuration_control import (
)
from govoplan_core.core.policy import PolicyDecision, PolicySourceStep
from govoplan_core.db.session import get_session
from govoplan_core.privacy.schemas import RETENTION_POLICY_FIELD_KEYS
from govoplan_policy.backend.retention import (
PrivacyPolicyError,
RETENTION_POLICY_FIELD_KEYS,
apply_retention_policy,
effective_privacy_policy,
effective_privacy_policy_sources,
@@ -24,6 +24,7 @@ from govoplan_policy.backend.retention import (
parent_privacy_policy,
parent_privacy_policy_sources,
set_privacy_policy_for_scope,
simulate_privacy_policy_change,
)
from .schemas import (
@@ -31,6 +32,7 @@ from .schemas import (
PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyScopeRequest,
PrivacyRetentionPolicyScopeResponse,
PrivacyRetentionPolicySimulationResponse,
RetentionRunRequest,
RetentionRunResponse,
)
@@ -200,6 +202,32 @@ def explain_privacy_retention_policy(
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]:
if parent is None:
return []

View File

@@ -1,43 +1,10 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
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
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
class PolicySourceStepItem(BaseModel):
@@ -49,42 +16,6 @@ class PolicySourceStepItem(BaseModel):
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):
model_config = ConfigDict(extra="forbid")
@@ -121,6 +52,12 @@ class PrivacyRetentionPolicyExplainResponse(BaseModel):
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):
model_config = ConfigDict(extra="forbid")

View 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,
)

View File

@@ -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.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):
@@ -22,9 +22,13 @@ def _privacy_retention_service(context: ModuleContext) -> object:
manifest = ModuleManifest(
id="policy",
name="Policy",
version="0.1.6",
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
route_factory=_route_factory,
frontend=FrontendModule(
module_id="policy",
package_name="@govoplan/policy-webui",
),
capability_factories={
CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service,
},

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
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 govoplan_core.admin.settings import get_system_settings
@@ -23,45 +23,28 @@ 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.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.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 = (
"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}
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 = {
"attachments",
"bcc",
@@ -86,18 +69,7 @@ AUDIT_DETAIL_REDACTION_KEYS = {
}
class PrivacyRetentionPolicy(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)
class PrivacyRetentionPolicy(PrivacyRetentionPolicyItem):
@field_validator(
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
@@ -115,21 +87,10 @@ class PrivacyRetentionPolicy(BaseModel):
@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)
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatch(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
class PrivacyRetentionPolicyPatch(PrivacyRetentionPolicyPatchItem):
@field_validator(*RETENTION_DAY_KEYS, mode="before")
@classmethod
def _blank_string_is_none(cls, value: Any) -> Any:
@@ -140,7 +101,7 @@ class PrivacyRetentionPolicyPatch(BaseModel):
@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)
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyPolicyError(RuntimeError):
@@ -277,26 +238,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]:
@@ -499,49 +480,175 @@ def set_privacy_policy_for_scope(
) -> dict[str, Any]:
clean_scope = scope_type.strip().casefold()
if clean_scope == "system":
item = get_system_settings(session)
validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {}))
session.add(item)
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":
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
if not scope_id:
raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id")
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")
if _set_user_settings(session, user_id=scope_id, tenant_id=tenant_id, settings_payload=_set_settings_privacy_policy(current_settings, patch)) is None:
raise PrivacyPolicyError("User privacy policy not found")
return patch
if clean_scope == "group":
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")
if _set_group_settings(session, group_id=scope_id, tenant_id=tenant_id, settings_payload=_set_settings_privacy_policy(current_settings, patch)) is None:
raise PrivacyPolicyError("Group privacy policy not found")
return patch
if clean_scope == "campaign":
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=scope_id)
settings_payload = _set_settings_privacy_policy(dict(campaign.settings or {}), patch)
try:
provider.set_campaign_settings(session, tenant_id=tenant_id, campaign_id=scope_id, settings=settings_payload)
except ValueError as exc:
raise PrivacyPolicyError("Campaign privacy policy not found") from exc
return patch
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)
validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {}))
session.add(item)
return validated.model_dump(mode="json")
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)
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(
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 +830,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)

View 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()

View 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
View 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
}
}
}

View 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)}`);
}

View 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
View 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
View 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;