chore: sync GovOPlaN module split state
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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/
|
||||
@@ -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`.
|
||||
|
||||
68
docs/POLICY_DECISION_PROVENANCE.md
Normal file
68
docs/POLICY_DECISION_PROVENANCE.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[govoplan.modules]
|
||||
policy = govoplan_policy.backend.manifest:get_manifest
|
||||
@@ -1,2 +0,0 @@
|
||||
govoplan-core>=0.1.4
|
||||
govoplan-access>=0.1.4
|
||||
@@ -1 +0,0 @@
|
||||
govoplan_policy
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user