diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fd4061 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 032dd5f..54e3741 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/POLICY_DECISION_PROVENANCE.md b/docs/POLICY_DECISION_PROVENANCE.md new file mode 100644 index 0000000..f8d7db6 --- /dev/null +++ b/docs/POLICY_DECISION_PROVENANCE.md @@ -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. diff --git a/src/govoplan_policy.egg-info/PKG-INFO b/src/govoplan_policy.egg-info/PKG-INFO deleted file mode 100644 index 1015a51..0000000 --- a/src/govoplan_policy.egg-info/PKG-INFO +++ /dev/null @@ -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. - diff --git a/src/govoplan_policy.egg-info/SOURCES.txt b/src/govoplan_policy.egg-info/SOURCES.txt deleted file mode 100644 index 9060033..0000000 --- a/src/govoplan_policy.egg-info/SOURCES.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/govoplan_policy.egg-info/dependency_links.txt b/src/govoplan_policy.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/govoplan_policy.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/govoplan_policy.egg-info/entry_points.txt b/src/govoplan_policy.egg-info/entry_points.txt deleted file mode 100644 index eee1d6e..0000000 --- a/src/govoplan_policy.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[govoplan.modules] -policy = govoplan_policy.backend.manifest:get_manifest diff --git a/src/govoplan_policy.egg-info/requires.txt b/src/govoplan_policy.egg-info/requires.txt deleted file mode 100644 index 58ec5d6..0000000 --- a/src/govoplan_policy.egg-info/requires.txt +++ /dev/null @@ -1,2 +0,0 @@ -govoplan-core>=0.1.4 -govoplan-access>=0.1.4 diff --git a/src/govoplan_policy.egg-info/top_level.txt b/src/govoplan_policy.egg-info/top_level.txt deleted file mode 100644 index 8e22451..0000000 --- a/src/govoplan_policy.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -govoplan_policy diff --git a/src/govoplan_policy/__pycache__/__init__.cpython-312.pyc b/src/govoplan_policy/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index b52f7e8..0000000 Binary files a/src/govoplan_policy/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_policy/__pycache__/__init__.cpython-313.pyc b/src/govoplan_policy/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 9e43ecc..0000000 Binary files a/src/govoplan_policy/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_policy/backend/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a8b607d..0000000 Binary files a/src/govoplan_policy/backend/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/__pycache__/__init__.cpython-313.pyc b/src/govoplan_policy/backend/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 8f346db..0000000 Binary files a/src/govoplan_policy/backend/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_policy/backend/__pycache__/manifest.cpython-312.pyc deleted file mode 100644 index 24642c7..0000000 Binary files a/src/govoplan_policy/backend/__pycache__/manifest.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_policy/backend/__pycache__/manifest.cpython-313.pyc deleted file mode 100644 index adb126a..0000000 Binary files a/src/govoplan_policy/backend/__pycache__/manifest.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 6cbbae6..0000000 Binary files a/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-313.pyc b/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 727ddff..0000000 Binary files a/src/govoplan_policy/backend/api/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-312.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 8e6b8e1..0000000 Binary files a/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-313.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index aa48d0e..0000000 Binary files a/src/govoplan_policy/backend/api/v1/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-312.pyc deleted file mode 100644 index 64bd49a..0000000 Binary files a/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-313.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-313.pyc deleted file mode 100644 index 68ae94d..0000000 Binary files a/src/govoplan_policy/backend/api/v1/__pycache__/routes.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-312.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-312.pyc deleted file mode 100644 index 619bf00..0000000 Binary files a/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-313.pyc b/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-313.pyc deleted file mode 100644 index 64b18c8..0000000 Binary files a/src/govoplan_policy/backend/api/v1/__pycache__/schemas.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_policy/backend/api/v1/routes.py b/src/govoplan_policy/backend/api/v1/routes.py index df4d67f..dfcb370 100644 --- a/src/govoplan_policy/backend/api/v1/routes.py +++ b/src/govoplan_policy/backend/api/v1/routes.py @@ -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): diff --git a/src/govoplan_policy/backend/api/v1/schemas.py b/src/govoplan_policy/backend/api/v1/schemas.py index 30ff092..039d5ea 100644 --- a/src/govoplan_policy/backend/api/v1/schemas.py +++ b/src/govoplan_policy/backend/api/v1/schemas.py @@ -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")