chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:22 +02:00
parent 3a3676e289
commit b2903e6adf
25 changed files with 218 additions and 43 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
build/
dist/
node_modules/
webui/node_modules/
webui/dist/
*.tsbuildinfo
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/

View File

@@ -6,3 +6,6 @@ GovOPlaN module split.
The current package delegates to the legacy access administration
implementation while route ownership is separated before model migration.
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`.

View File

@@ -0,0 +1,68 @@
# Policy Decision And Provenance Contract
`govoplan-policy` owns policy and retention route contributions. Core keeps the
small shared DTOs that let policy decisions look the same across modules.
## Backend DTOs
Use `govoplan_core.core.policy.PolicyDecision` for explainable policy results:
- `allowed`: effective decision for the checked action.
- `reason`: compact operator-readable explanation.
- `source_path`: ordered policy sources that produced the decision.
- `requirements`: machine-readable blockers or prerequisites.
- `details`: domain-specific structured context, redacted when needed.
Use `PolicySourceStep` or `policy_source_step()` for each provenance step:
- `scope_type`: `system`, `tenant`, `user`, `group`, or `campaign`.
- `scope_id`: stable ID for non-system scopes.
- `path`: stable string path generated by `policy_source_path()`.
- `label`: concrete source label such as `System`, `Tenant`, `Owner user`,
`Group`, or `Campaign`.
- `applied_fields`: field names affected by that step.
- `policy`: local policy fragment that explains the applied fields.
Do not build or split provenance paths manually. Use
`policy_source_path()` and `parse_policy_source_path()` so IDs are URL-encoded
consistently.
## Retention Explain Endpoint
Retention policy exposes the shared shape through:
```text
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`.
Clients can use `blocked_fields` to disable controls before a save attempt.
## UI Expectations
Policy UIs should render provenance close to the effective column or field it
explains. The display path should use concrete source labels and local values,
for example:
```text
System: Allow
> Tenant: Deny without override
```
If all lower levels still inherit, continue the path until the effective local
decision:
```text
System: Allow
> Tenant: Inherit
> Group: Inherit
> Campaign: Deny
```
When a parent disallows lower-level limits or changes, the UI should disable
the affected controls and avoid sending those fields in the save payload.
The shared core WebUI helper `PolicySourcePath` renders the source path shape
for module UIs. Modules may use their own field layout, but the data contract
should remain this shape.

View File

@@ -1,18 +0,0 @@
Metadata-Version: 2.4
Name: govoplan-policy
Version: 0.1.4
Summary: GovOPlaN policy platform module.
Author: GovOPlaN
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: govoplan-core>=0.1.4
Requires-Dist: govoplan-access>=0.1.4
# GovOPlaN Policy
`govoplan-policy` owns policy and retention API route contributions during the
GovOPlaN module split.
The current package delegates to the legacy access administration
implementation while route ownership is separated before model migration.

View File

@@ -1,16 +0,0 @@
README.md
pyproject.toml
src/govoplan_policy/__init__.py
src/govoplan_policy/py.typed
src/govoplan_policy.egg-info/PKG-INFO
src/govoplan_policy.egg-info/SOURCES.txt
src/govoplan_policy.egg-info/dependency_links.txt
src/govoplan_policy.egg-info/entry_points.txt
src/govoplan_policy.egg-info/requires.txt
src/govoplan_policy.egg-info/top_level.txt
src/govoplan_policy/backend/__init__.py
src/govoplan_policy/backend/manifest.py
src/govoplan_policy/backend/api/__init__.py
src/govoplan_policy/backend/api/v1/__init__.py
src/govoplan_policy/backend/api/v1/routes.py
src/govoplan_policy/backend/api/v1/schemas.py

View File

@@ -1,2 +0,0 @@
[govoplan.modules]
policy = govoplan_policy.backend.manifest:get_manifest

View File

@@ -1,2 +0,0 @@
govoplan-core>=0.1.4
govoplan-access>=0.1.4

View File

@@ -1 +0,0 @@
govoplan_policy

View File

@@ -1,13 +1,22 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_scope
from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.configuration_control import (
ConfigurationControlError,
ensure_configuration_change_allowed,
record_configuration_change_applied,
)
from govoplan_core.core.policy import PolicyDecision, PolicySourceStep
from govoplan_core.db.session import get_session
from govoplan_core.privacy.retention import (
PrivacyPolicyError,
RETENTION_POLICY_FIELD_KEYS,
apply_retention_policy,
effective_privacy_policy,
effective_privacy_policy_sources,
@@ -18,6 +27,7 @@ from govoplan_core.privacy.retention import (
)
from .schemas import (
PrivacyRetentionPolicyExplainResponse,
PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyScopeRequest,
PrivacyRetentionPolicyScopeResponse,
@@ -47,6 +57,13 @@ def _require_privacy_policy_write(principal: ApiPrincipal, scope_type: str) -> N
_require_permission(principal, "admin:policies:write")
def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": exc.code, "message": str(exc), "plan": exc.plan},
)
@router.get("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse)
def read_privacy_retention_policy(
scope_type: str,
@@ -83,13 +100,51 @@ def write_privacy_retention_policy(
):
clean_scope = scope_type.strip().casefold()
_require_privacy_policy_write(principal, clean_scope)
policy_value = payload.policy.model_dump(mode="json", exclude_none=True)
before_value: dict[str, Any] | None = None
if clean_scope == "system":
before_value = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
try:
approval = ensure_configuration_change_allowed(
session,
key="privacy_retention_policy",
value=policy_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"scope_type": clean_scope, "scope_id": scope_id},
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
else:
approval = None
try:
policy = set_privacy_policy_for_scope(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
policy=payload.policy.model_dump(mode="json", exclude_none=True),
policy=policy_value,
)
if clean_scope == "system":
record_configuration_change_applied(
session,
key="privacy_retention_policy",
before_value=before_value,
after_value=policy,
actor_user_id=principal.user.id,
approval=approval,
target={"scope_type": clean_scope, "scope_id": scope_id},
audit_event="privacy_retention.policy_updated",
)
audit_from_principal(
session,
principal,
action="privacy_retention.policy_updated",
scope="system" if clean_scope == "system" else "tenant",
object_type="privacy_retention_policy",
object_id=clean_scope if scope_id is None else f"{clean_scope}:{scope_id}",
details={"scope_type": clean_scope, "scope_id": scope_id},
)
session.commit()
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
@@ -105,7 +160,52 @@ def write_privacy_retention_policy(
)
except PrivacyPolicyError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/privacy-retention/policies/{scope_type}/explain", response_model=PrivacyRetentionPolicyExplainResponse)
def explain_privacy_retention_policy(
scope_type: str,
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_read(principal, clean_scope)
try:
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
effective_sources = _effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
parent_sources = _parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
blocked_fields = _blocked_privacy_retention_fields(parent)
decision_sources = parent_sources or effective_sources
decision = PolicyDecision(
allowed=not blocked_fields,
reason="Parent retention policy locks lower-level changes." if blocked_fields else None,
source_path=tuple(PolicySourceStep.from_mapping(source) for source in decision_sources),
requirements=tuple(blocked_fields),
details={"blocked_fields": blocked_fields},
)
return PrivacyRetentionPolicyExplainResponse(
scope_type=clean_scope,
scope_id=scope_id,
decision=decision.to_dict(),
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None,
effective_policy_sources=effective_sources,
parent_policy_sources=parent_sources,
blocked_fields=blocked_fields,
)
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 []
payload = parent.model_dump(mode="json")
allow_lower_level_limits = payload.get("allow_lower_level_limits") or {}
return [key for key in RETENTION_POLICY_FIELD_KEYS if allow_lower_level_limits.get(key) is False]
def _parent_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):

View File

@@ -43,6 +43,7 @@ def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> di
class PolicySourceStepItem(BaseModel):
scope_type: str
scope_id: str | None = None
path: str
label: str
applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
@@ -88,6 +89,7 @@ class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem)
change_request_id: str | None = None
class PrivacyRetentionPolicyScopeResponse(BaseModel):
@@ -100,6 +102,25 @@ class PrivacyRetentionPolicyScopeResponse(BaseModel):
parent_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
class PolicyDecisionItem(BaseModel):
allowed: bool
reason: str | None = None
source_path: list[PolicySourceStepItem] = Field(default_factory=list)
requirements: list[str] = Field(default_factory=list)
details: dict[str, Any] = Field(default_factory=dict)
class PrivacyRetentionPolicyExplainResponse(BaseModel):
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
scope_id: str | None = None
decision: PolicyDecisionItem
effective_policy: PrivacyRetentionPolicyItem
parent_policy: PrivacyRetentionPolicyItem | None = None
effective_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
parent_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
blocked_fields: list[str] = Field(default_factory=list)
class RetentionRunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")