24 Commits
Author SHA1 Message Date
zemion 65159dec5f Release v0.1.18
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 21:07:45 +02:00
zemion 0941268f6b Release v0.1.17
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 20:34:09 +02:00
zemion 107cc3b654 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:52:12 +02:00
zemion 44d72914ae Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:18:13 +02:00
zemion 1fa6d1dcfb Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion d34bdc2ac3 Harden module package publication 2026-08-04 14:02:41 +02:00
zemion fc6d333a64 Add hierarchical View policy administration 2026-08-04 08:21:49 +02:00
zemion 86c95f85bb Add protected package release workflow 2026-08-04 04:14:06 +02:00
zemion f964ed7dc0 Migrate Policy retention interfaces 2026-08-03 10:22:46 +02:00
zemion 344e15dea4 feat: govern reporting privacy and retention 2026-08-02 05:29:42 +02:00
zemion 3aaa842ee6 docs: declare institutional architecture boundary 2026-08-01 17:48:38 +02:00
zemion a89c39862a Govern distribution delivery channels 2026-07-31 20:59:55 +02:00
zemion e061f230f2 Add function assignment governance profiles 2026-07-31 19:40:22 +02:00
zemion 84acc34f08 Govern hierarchical View projections 2026-07-31 17:55:35 +02:00
zemion 798138ef7d Persist hierarchical definition policy overrides 2026-07-31 17:34:30 +02:00
zemion 4d8bcec1f0 Align WebUI runtime peer dependencies 2026-07-31 02:48:56 +02:00
zemion 9b0eeb162f feat: classify policy administration surfaces 2026-07-30 17:42:06 +02:00
zemion 546b2a6e9d chore(webui): require patched React Router 2026-07-29 14:32:54 +02:00
zemion 242d023474 Declare policy View surfaces 2026-07-28 21:04:54 +02:00
zemion d6e09fbbd1 Add definition governance policy 2026-07-28 15:04:03 +02:00
zemion 1063622d31 Clarify policy hierarchy traversal 2026-07-21 03:16:24 +02:00
zemion b68c3f0473 feat(policy): resolve scheduling participant privacy 2026-07-20 18:44:48 +02:00
zemion bab9402c29 chore(policy): bump contract version to 0.1.9 2026-07-20 18:40:53 +02:00
zemion 2511fbb5a8 intermittent commit 2026-07-14 13:22:12 +02:00
42 changed files with 6626 additions and 353 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Policy Codex Guide
## Scope
This repository owns hierarchical policy evaluation, explicit overrides, provenance, simulation, and typed governance capabilities for consuming modules.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Policy internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Policy returns explainable decisions; consuming modules enforce them and own business state.
- Lower scopes may narrow inherited ceilings but must not silently loosen them.
+36
View File
@@ -12,6 +12,13 @@ user retention sections through the shared `admin.sections` UI capability. The
admin shell does not render retention policy panels unless this module is admin shell does not render retention policy panels unless this module is
installed and enabled. installed and enabled.
The same administration contribution exposes hierarchical **View policy** at
system, tenant, group, and user scope. Administrators can inherit, allow, or
block View use, selection, assignment, editing, derivation, and workflow
activation. Optional View-ID and surface-ID ceilings are intersected across the
scope path, and the UI displays effective limits and provenance. Lower scopes
can narrow but never broaden an ancestor restriction.
Policy decision and provenance payloads use the shared kernel DTOs documented Policy decision and provenance payloads use the shared kernel DTOs documented
in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md) in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md)
and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`. and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`.
@@ -20,3 +27,32 @@ Hierarchical policy evaluation, delegation ceilings, and write simulations are
implemented in `govoplan_policy.backend.hierarchy`. Privacy retention uses that implemented in `govoplan_policy.backend.hierarchy`. Privacy retention uses that
shared helper and exposes `/api/v1/admin/privacy-retention/policies/{scope}/simulate` shared helper and exposes `/api/v1/admin/privacy-retention/policies/{scope}/simulate`
for preflight checks before saving lower-level policy changes. for preflight checks before saving lower-level policy changes.
The module also provides the optional
`policy.schedulingParticipantPrivacy` capability. Scheduling owns each
request's participant-visibility setting; Policy can only preserve or narrow
it. The resolver reads an optional `maximum_visibility` ceiling from the
`scheduling_participant_privacy_policy` object in system and tenant settings.
Missing policy is unrestricted, while malformed explicit policy fails closed
to aggregate-only visibility. This resolver slice intentionally has no policy
management endpoint or UI yet.
Policy also provides `policy.definitionGovernance` for Dataflow and Workflow
libraries. It evaluates view, edit, run/start, reuse, derive, and automation
actions across system, tenant, group, and user scopes. Templates cannot run or
be automated. Derived definitions retain ancestor ceilings, and every
decision includes the ordered Policy source path and effective limits so a UI
can explain why an action is available or blocked.
Cross-module reports use `policy.reporting_governance`. System policy defines
the export, retention, privacy-transform, and high re-identification-risk
ceiling; tenant policy may only tighten it, and malformed explicit policy fails
closed. The shared privacy-retention run calls the optional
`reporting.retention` capability to clear expired provider-report payloads
without importing Reporting models, while Reporting keeps hashes and bounded
provenance as audit evidence.
The retention administration interface follows the platform pattern language
documented in [docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
It uses Core's effective-policy editor and renders system retention runs as
typed outcome evidence rather than raw JSON.
+19
View File
@@ -0,0 +1,19 @@
# Policy Interface Pattern Migration
Policy contributes four retention-administration sections to the Access-owned
administration host. It does not own an independent route or shell.
| Surface | Archetype | Consequence and provenance | Evidence |
| --- | --- | --- | --- |
| System retention | Effective-policy editor plus destructive operation | Typed values show their effective source path. Applying retention can irreversibly redact or delete eligible content and therefore requires explicit confirmation; dry-run and applied outcomes are distinguished. | `RetentionPoliciesPanel.tsx`, Core `RetentionPolicyScopeManager`, interface-pattern structural test |
| Tenant retention | Effective-policy editor | A tenant may only narrow fields that system policy allows. Read-only authority and parent locks are explicit. | Core policy-source and blocker components |
| Group retention | Targeted effective-policy editor | Group selection is loaded through bounded delta requests; missing target, parent lock, and write authority remain distinct states. | Target loader plus Core retention editor |
| User retention | Targeted effective-policy editor | User labels expose only authorized account metadata; retained data itself is never returned by this administration surface. | Target loader plus Core retention editor |
The retention execution result is a typed, filterable outcome table. Raw JSON
is neither the primary policy editor nor the operator result view. The backend
remains authoritative for policy validation, narrowing rules, destructive
effects, redaction, and audit evidence.
Contextual help uses `policy.retention` and `privacy.retention`, which resolve
through the optional Docs module or the hosted documentation fallback.
+35
View File
@@ -20,6 +20,34 @@ When retention needs audit-log storage behavior, it requests the
`audit.retention` capability; it does not import audit module tables or `audit.retention` capability; it does not import audit module tables or
providers directly. providers directly.
## Scheduling Participant Privacy
Policy exposes `policy.schedulingParticipantPrivacy` as an optional restriction
hook. Scheduling supplies the request-level visibility choice and remains
responsible for its secure fallback when Policy is absent. The provider never
broadens that choice.
System and tenant settings may contain:
```json
{
"scheduling_participant_privacy_policy": {
"maximum_visibility": "aggregates_only"
}
}
```
`maximum_visibility` is either `aggregates_only` or `names_and_statuses`.
Omitted settings impose no additional restriction. Effective visibility is the
most restrictive of the Scheduling request, the system ceiling, and the tenant
ceiling. Explicit malformed policy fails closed to `aggregates_only` and is
reported in decision details without echoing the invalid stored value. Policy
sources include only explicitly configured or invalid system and tenant steps.
The current slice is resolver-only. A managed write API and administration UI
must add validation, audit, parent-ceiling enforcement, and configuration
safety registration before operators can edit this setting through GovOPlaN.
## Backend DTOs ## Backend DTOs
Use `govoplan_core.core.policy.PolicyDecision` for explainable policy results: Use `govoplan_core.core.policy.PolicyDecision` for explainable policy results:
@@ -92,6 +120,13 @@ System: Allow
When a parent disallows lower-level limits or changes, the UI should disable 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 affected controls and avoid sending those fields in the save payload.
System retention execution is a separate high-consequence operation. The UI
must distinguish dry-run evidence from an applied run, render bounded counts as
typed rows rather than raw JSON, explain missing write authority, and require a
destructive confirmation that names deletion/redaction and recovery
expectations. The backend remains authoritative and records the mode plus
bounded outcome counts in audit evidence.
The shared core WebUI helper `PolicySourcePath` renders the source path shape 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 for module UIs. Modules may use their own field layout, but the data contract
should remain this shape. should remain this shape.
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/policy-webui", "name": "@govoplan/policy-webui",
"version": "0.1.8", "version": "0.1.18",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,11 +18,11 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-policy" name = "govoplan-policy"
version = "0.1.8" version = "0.1.18"
description = "GovOPlaN policy platform module." description = "GovOPlaN policy platform module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.18",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+644 -44
View File
@@ -14,9 +14,9 @@ from govoplan_core.core.configuration_control import (
) )
from govoplan_core.core.policy import PolicyDecision, PolicySourceStep from govoplan_core.core.policy import PolicyDecision, PolicySourceStep
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_core.privacy.schemas import RETENTION_POLICY_FIELD_KEYS
from govoplan_policy.backend.retention import ( from govoplan_policy.backend.retention import (
PrivacyPolicyError, PrivacyPolicyError,
RETENTION_POLICY_FIELD_KEYS,
apply_retention_policy, apply_retention_policy,
effective_privacy_policy, effective_privacy_policy,
effective_privacy_policy_sources, effective_privacy_policy_sources,
@@ -26,8 +26,25 @@ from govoplan_policy.backend.retention import (
set_privacy_policy_for_scope, set_privacy_policy_for_scope,
simulate_privacy_policy_change, simulate_privacy_policy_change,
) )
from govoplan_policy.backend.definition_policy_service import (
DefinitionPolicyError,
definition_policy_response_payload,
definition_policy_state,
remove_definition_policy,
save_definition_policy,
)
from govoplan_policy.backend.policy_overrides import PolicyOverrideError
from govoplan_policy.backend.view_policy_service import (
ViewPolicyError,
remove_view_policy,
save_view_policy,
view_policy_response_payload,
view_policy_state,
)
from .schemas import ( from .schemas import (
DefinitionPolicyScopeRequest,
DefinitionPolicyScopeResponse,
PrivacyRetentionPolicyExplainResponse, PrivacyRetentionPolicyExplainResponse,
PrivacyRetentionPolicyItem, PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyScopeRequest, PrivacyRetentionPolicyScopeRequest,
@@ -35,6 +52,8 @@ from .schemas import (
PrivacyRetentionPolicySimulationResponse, PrivacyRetentionPolicySimulationResponse,
RetentionRunRequest, RetentionRunRequest,
RetentionRunResponse, RetentionRunResponse,
ViewPolicyScopeRequest,
ViewPolicyScopeResponse,
) )
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -42,7 +61,9 @@ router = APIRouter(prefix="/admin", tags=["admin"])
def _require_permission(principal: ApiPrincipal, scope: str) -> None: def _require_permission(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope): if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}"
)
def _require_privacy_policy_read(principal: ApiPrincipal, scope_type: str) -> None: def _require_privacy_policy_read(principal: ApiPrincipal, scope_type: str) -> None:
@@ -66,7 +87,445 @@ def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPExc
) )
@router.get("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse) def _definition_policy_response(
*,
module_id: str,
scope_type: str,
scope_id: str | None,
state,
) -> DefinitionPolicyScopeResponse:
return DefinitionPolicyScopeResponse(
module_id=module_id,
scope_type=scope_type,
scope_id=scope_id,
**definition_policy_response_payload(state),
)
@router.get(
"/definition-policies/{module_id}/{scope_type}",
response_model=DefinitionPolicyScopeResponse,
)
def read_definition_policy(
module_id: str,
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:
state = definition_policy_state(
session,
module_id=module_id,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
return _definition_policy_response(
module_id=module_id,
scope_type=clean_scope,
scope_id=scope_id,
state=state,
)
except (DefinitionPolicyError, PolicyOverrideError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.put(
"/definition-policies/{module_id}/{scope_type}",
response_model=DefinitionPolicyScopeResponse,
)
def write_definition_policy(
module_id: str,
scope_type: str,
payload: DefinitionPolicyScopeRequest,
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)
policy_value = payload.policy.model_dump(mode="json", exclude_none=True)
try:
before = definition_policy_state(
session,
module_id=module_id,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
if clean_scope == "system":
approval = ensure_configuration_change_allowed(
session,
key="definition_policy",
value=policy_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"module_id": module_id, "scope_type": clean_scope},
)
else:
approval = None
state = save_definition_policy(
session,
module_id=module_id,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
policy=policy_value,
actor_id=principal.user.id,
)
if clean_scope == "system":
record_configuration_change_applied(
session,
key="definition_policy",
before_value=definition_policy_response_payload(before)["policy"],
after_value=policy_value,
actor_user_id=principal.user.id,
approval=approval,
target={"module_id": module_id, "scope_type": clean_scope},
audit_event="definition_policy.updated",
)
audit_from_principal(
session,
principal,
action="definition_policy.updated",
scope="system" if clean_scope == "system" else "tenant",
object_type="definition_policy",
object_id=f"{module_id}:{clean_scope}:{scope_id or ''}",
details={
"module_id": module_id,
"scope_type": clean_scope,
"scope_id": scope_id,
"fields": sorted(policy_value),
},
)
session.commit()
return _definition_policy_response(
module_id=module_id,
scope_type=clean_scope,
scope_id=scope_id,
state=state,
)
except ConfigurationControlError as exc:
session.rollback()
raise _configuration_control_http_error(exc) from exc
except (DefinitionPolicyError, PolicyOverrideError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.delete(
"/definition-policies/{module_id}/{scope_type}",
response_model=DefinitionPolicyScopeResponse,
)
def delete_definition_policy_route(
module_id: str,
scope_type: str,
scope_id: str | None = Query(default=None),
change_request_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:
before = definition_policy_state(
session,
module_id=module_id,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
if clean_scope == "system":
approval = ensure_configuration_change_allowed(
session,
key="definition_policy",
value={},
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=change_request_id,
target={"module_id": module_id, "scope_type": clean_scope},
)
else:
approval = None
removed = remove_definition_policy(
session,
module_id=module_id,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
if removed:
if clean_scope == "system":
record_configuration_change_applied(
session,
key="definition_policy",
before_value=definition_policy_response_payload(before)["policy"],
after_value={},
actor_user_id=principal.user.id,
approval=approval,
target={"module_id": module_id, "scope_type": clean_scope},
audit_event="definition_policy.removed",
)
audit_from_principal(
session,
principal,
action="definition_policy.removed",
scope="system" if clean_scope == "system" else "tenant",
object_type="definition_policy",
object_id=f"{module_id}:{clean_scope}:{scope_id or ''}",
details={
"module_id": module_id,
"scope_type": clean_scope,
"scope_id": scope_id,
},
)
session.commit()
state = definition_policy_state(
session,
module_id=module_id,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
return _definition_policy_response(
module_id=module_id,
scope_type=clean_scope,
scope_id=scope_id,
state=state,
)
except ConfigurationControlError as exc:
session.rollback()
raise _configuration_control_http_error(exc) from exc
except (DefinitionPolicyError, PolicyOverrideError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
def _view_policy_response(
*,
scope_type: str,
scope_id: str | None,
state,
) -> ViewPolicyScopeResponse:
return ViewPolicyScopeResponse(
scope_type=scope_type,
scope_id=scope_id,
**view_policy_response_payload(state),
)
@router.get(
"/view-policies/{scope_type}",
response_model=ViewPolicyScopeResponse,
)
def read_view_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:
state = view_policy_state(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
return _view_policy_response(
scope_type=clean_scope,
scope_id=scope_id,
state=state,
)
except (ViewPolicyError, PolicyOverrideError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.put(
"/view-policies/{scope_type}",
response_model=ViewPolicyScopeResponse,
)
def write_view_policy(
scope_type: str,
payload: ViewPolicyScopeRequest,
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)
policy_value = payload.policy.model_dump(mode="json", exclude_none=True)
try:
before = view_policy_state(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
if clean_scope == "system":
approval = ensure_configuration_change_allowed(
session,
key="view_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},
)
else:
approval = None
state = save_view_policy(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
policy=policy_value,
actor_id=principal.user.id,
)
if clean_scope == "system":
record_configuration_change_applied(
session,
key="view_policy",
before_value=view_policy_response_payload(before)["policy"],
after_value=policy_value,
actor_user_id=principal.user.id,
approval=approval,
target={"scope_type": clean_scope},
audit_event="view_policy.updated",
)
audit_from_principal(
session,
principal,
action="view_policy.updated",
scope="system" if clean_scope == "system" else "tenant",
object_type="view_policy",
object_id=f"{clean_scope}:{scope_id or ''}",
details={
"scope_type": clean_scope,
"scope_id": scope_id,
"fields": sorted(policy_value),
},
)
session.commit()
return _view_policy_response(
scope_type=clean_scope,
scope_id=scope_id,
state=state,
)
except ConfigurationControlError as exc:
session.rollback()
raise _configuration_control_http_error(exc) from exc
except (ViewPolicyError, PolicyOverrideError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.delete(
"/view-policies/{scope_type}",
response_model=ViewPolicyScopeResponse,
)
def delete_view_policy_route(
scope_type: str,
scope_id: str | None = Query(default=None),
change_request_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:
before = view_policy_state(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
if clean_scope == "system":
approval = ensure_configuration_change_allowed(
session,
key="view_policy",
value={},
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=change_request_id,
target={"scope_type": clean_scope},
)
else:
approval = None
removed = remove_view_policy(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
if removed:
if clean_scope == "system":
record_configuration_change_applied(
session,
key="view_policy",
before_value=view_policy_response_payload(before)["policy"],
after_value={},
actor_user_id=principal.user.id,
approval=approval,
target={"scope_type": clean_scope},
audit_event="view_policy.removed",
)
audit_from_principal(
session,
principal,
action="view_policy.removed",
scope="system" if clean_scope == "system" else "tenant",
object_type="view_policy",
object_id=f"{clean_scope}:{scope_id or ''}",
details={"scope_type": clean_scope, "scope_id": scope_id},
)
session.commit()
state = view_policy_state(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
return _view_policy_response(
scope_type=clean_scope,
scope_id=scope_id,
state=state,
)
except ConfigurationControlError as exc:
session.rollback()
raise _configuration_control_http_error(exc) from exc
except (ViewPolicyError, PolicyOverrideError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.get(
"/privacy-retention/policies/{scope_type}",
response_model=PrivacyRetentionPolicyScopeResponse,
)
def read_privacy_retention_policy( def read_privacy_retention_policy(
scope_type: str, scope_type: str,
scope_id: str | None = Query(default=None), scope_id: str | None = Query(default=None),
@@ -76,23 +535,59 @@ def read_privacy_retention_policy(
clean_scope = scope_type.strip().casefold() clean_scope = scope_type.strip().casefold()
_require_privacy_policy_read(principal, clean_scope) _require_privacy_policy_read(principal, clean_scope)
try: try:
policy = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) policy = get_privacy_policy_for_scope(
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) session,
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
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,
)
return PrivacyRetentionPolicyScopeResponse( return PrivacyRetentionPolicyScopeResponse(
scope_type=clean_scope, scope_type=clean_scope,
scope_id=scope_id, scope_id=scope_id,
policy=policy, policy=policy,
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), effective_policy=PrivacyRetentionPolicyItem.model_validate(
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, effective.model_dump(mode="json")
effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), ),
parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), parent_policy=PrivacyRetentionPolicyItem.model_validate(
parent.model_dump(mode="json")
)
if parent
else None,
effective_policy_sources=_effective_privacy_policy_sources_for_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
),
parent_policy_sources=_parent_privacy_policy_sources_for_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
),
) )
except PrivacyPolicyError as exc: except PrivacyPolicyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
@router.put("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse) @router.put(
"/privacy-retention/policies/{scope_type}",
response_model=PrivacyRetentionPolicyScopeResponse,
)
def write_privacy_retention_policy( def write_privacy_retention_policy(
scope_type: str, scope_type: str,
payload: PrivacyRetentionPolicyScopeRequest, payload: PrivacyRetentionPolicyScopeRequest,
@@ -105,7 +600,12 @@ def write_privacy_retention_policy(
policy_value = payload.policy.model_dump(mode="json", exclude_none=True) policy_value = payload.policy.model_dump(mode="json", exclude_none=True)
before_value: dict[str, Any] | None = None before_value: dict[str, Any] | None = None
if clean_scope == "system": 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) before_value = get_privacy_policy_for_scope(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
)
try: try:
approval = ensure_configuration_change_allowed( approval = ensure_configuration_change_allowed(
session, session,
@@ -149,23 +649,54 @@ def write_privacy_retention_policy(
details={"scope_type": clean_scope, "scope_id": scope_id}, details={"scope_type": clean_scope, "scope_id": scope_id},
) )
session.commit() session.commit()
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) effective = _effective_privacy_policy_for_response(
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) 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,
)
return PrivacyRetentionPolicyScopeResponse( return PrivacyRetentionPolicyScopeResponse(
scope_type=clean_scope, scope_type=clean_scope,
scope_id=scope_id, scope_id=scope_id,
policy=policy, policy=policy,
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), effective_policy=PrivacyRetentionPolicyItem.model_validate(
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, effective.model_dump(mode="json")
effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), ),
parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id), parent_policy=PrivacyRetentionPolicyItem.model_validate(
parent.model_dump(mode="json")
)
if parent
else None,
effective_policy_sources=_effective_privacy_policy_sources_for_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
),
parent_policy_sources=_parent_privacy_policy_sources_for_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
),
) )
except PrivacyPolicyError as exc: except PrivacyPolicyError as exc:
session.rollback() session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, 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) @router.get(
"/privacy-retention/policies/{scope_type}/explain",
response_model=PrivacyRetentionPolicyExplainResponse,
)
def explain_privacy_retention_policy( def explain_privacy_retention_policy(
scope_type: str, scope_type: str,
scope_id: str | None = Query(default=None), scope_id: str | None = Query(default=None),
@@ -175,16 +706,40 @@ def explain_privacy_retention_policy(
clean_scope = scope_type.strip().casefold() clean_scope = scope_type.strip().casefold()
_require_privacy_policy_read(principal, clean_scope) _require_privacy_policy_read(principal, clean_scope)
try: try:
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) effective = _effective_privacy_policy_for_response(
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) session,
effective_sources = _effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id) tenant_id=principal.tenant_id,
parent_sources = _parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_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) blocked_fields = _blocked_privacy_retention_fields(parent)
decision_sources = parent_sources or effective_sources decision_sources = parent_sources or effective_sources
decision = PolicyDecision( decision = PolicyDecision(
allowed=not blocked_fields, allowed=not blocked_fields,
reason="Parent retention policy locks lower-level changes." if blocked_fields else None, reason="Parent retention policy locks lower-level changes."
source_path=tuple(PolicySourceStep.from_mapping(source) for source in decision_sources), if blocked_fields
else None,
source_path=tuple(
PolicySourceStep.from_mapping(source) for source in decision_sources
),
requirements=tuple(blocked_fields), requirements=tuple(blocked_fields),
details={"blocked_fields": blocked_fields}, details={"blocked_fields": blocked_fields},
) )
@@ -192,17 +747,28 @@ def explain_privacy_retention_policy(
scope_type=clean_scope, scope_type=clean_scope,
scope_id=scope_id, scope_id=scope_id,
decision=decision.to_dict(), decision=decision.to_dict(),
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")), effective_policy=PrivacyRetentionPolicyItem.model_validate(
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None, effective.model_dump(mode="json")
),
parent_policy=PrivacyRetentionPolicyItem.model_validate(
parent.model_dump(mode="json")
)
if parent
else None,
effective_policy_sources=effective_sources, effective_policy_sources=effective_sources,
parent_policy_sources=parent_sources, parent_policy_sources=parent_sources,
blocked_fields=blocked_fields, blocked_fields=blocked_fields,
) )
except PrivacyPolicyError as exc: except PrivacyPolicyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
@router.post("/privacy-retention/policies/{scope_type}/simulate", response_model=PrivacyRetentionPolicySimulationResponse) @router.post(
"/privacy-retention/policies/{scope_type}/simulate",
response_model=PrivacyRetentionPolicySimulationResponse,
)
def simulate_privacy_retention_policy( def simulate_privacy_retention_policy(
scope_type: str, scope_type: str,
payload: PrivacyRetentionPolicyScopeRequest, payload: PrivacyRetentionPolicyScopeRequest,
@@ -225,7 +791,9 @@ def simulate_privacy_retention_policy(
), ),
) )
except PrivacyPolicyError as exc: except PrivacyPolicyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
def _blocked_privacy_retention_fields(parent) -> list[str]: def _blocked_privacy_retention_fields(parent) -> list[str]:
@@ -233,36 +801,64 @@ def _blocked_privacy_retention_fields(parent) -> list[str]:
return [] return []
payload = parent.model_dump(mode="json") payload = parent.model_dump(mode="json")
allow_lower_level_limits = payload.get("allow_lower_level_limits") or {} 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] 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): def _parent_privacy_policy_sources_for_response(
session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None
):
if scope_type == "system": if scope_type == "system":
return [] return []
return parent_privacy_policy_sources(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None)) return parent_privacy_policy_sources(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id or (tenant_id if scope_type == "tenant" else None),
)
def _effective_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): def _effective_privacy_policy_sources_for_response(
session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None
):
if scope_type == "system": if scope_type == "system":
return effective_privacy_policy_sources(session) return effective_privacy_policy_sources(session)
if scope_type == "tenant": if scope_type == "tenant":
return effective_privacy_policy_sources(session, tenant_id=scope_id or tenant_id) return effective_privacy_policy_sources(
session, tenant_id=scope_id or tenant_id
)
if scope_type == "campaign" and scope_id: if scope_type == "campaign" and scope_id:
return effective_privacy_policy_sources(session, campaign_id=scope_id) return effective_privacy_policy_sources(session, campaign_id=scope_id)
if scope_type == "user" and scope_id: if scope_type == "user" and scope_id:
return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_user_id=scope_id) return effective_privacy_policy_sources(
session, tenant_id=tenant_id, owner_user_id=scope_id
)
if scope_type == "group" and scope_id: if scope_type == "group" and scope_id:
return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_group_id=scope_id) return effective_privacy_policy_sources(
session, tenant_id=tenant_id, owner_group_id=scope_id
)
return effective_privacy_policy_sources(session, tenant_id=tenant_id) return effective_privacy_policy_sources(session, tenant_id=tenant_id)
def _parent_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): def _parent_privacy_policy_for_response(
session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None
):
if scope_type == "system": if scope_type == "system":
return None return None
return parent_privacy_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None)) return parent_privacy_policy(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id or (tenant_id if scope_type == "tenant" else None),
)
def _effective_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): def _effective_privacy_policy_for_response(
session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None
):
if scope_type == "system": if scope_type == "system":
return effective_privacy_policy(session) return effective_privacy_policy(session)
if scope_type == "tenant": if scope_type == "tenant":
@@ -270,9 +866,13 @@ def _effective_privacy_policy_for_response(session: Session, *, tenant_id: str,
if scope_type == "campaign" and scope_id: if scope_type == "campaign" and scope_id:
return effective_privacy_policy(session, campaign_id=scope_id) return effective_privacy_policy(session, campaign_id=scope_id)
if scope_type == "user" and scope_id: if scope_type == "user" and scope_id:
return effective_privacy_policy(session, tenant_id=tenant_id, owner_user_id=scope_id) return effective_privacy_policy(
session, tenant_id=tenant_id, owner_user_id=scope_id
)
if scope_type == "group" and scope_id: if scope_type == "group" and scope_id:
return effective_privacy_policy(session, tenant_id=tenant_id, owner_group_id=scope_id) return effective_privacy_policy(
session, tenant_id=tenant_id, owner_group_id=scope_id
)
return effective_privacy_policy(session, tenant_id=tenant_id) return effective_privacy_policy(session, tenant_id=tenant_id)
+70 -71
View File
@@ -1,45 +1,15 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.privacy.schemas import (
RETENTION_DAY_KEYS = ( PrivacyRetentionPolicyItem,
"raw_campaign_json_retention_days", PrivacyRetentionPolicyPatchItem,
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
) )
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PolicySourceStepItem(BaseModel): class PolicySourceStepItem(BaseModel):
scope_type: str scope_type: str
scope_id: str | None = None scope_id: str | None = None
@@ -49,46 +19,12 @@ class PolicySourceStepItem(BaseModel):
policy: dict[str, Any] = Field(default_factory=dict) policy: dict[str, Any] = Field(default_factory=dict)
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyRetentionPolicyScopeRequest(BaseModel): class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem) policy: PrivacyRetentionPolicyPatchItem = Field(
default_factory=PrivacyRetentionPolicyPatchItem
)
change_request_id: str | None = None change_request_id: str | None = None
@@ -110,6 +46,69 @@ class PolicyDecisionItem(BaseModel):
details: dict[str, Any] = Field(default_factory=dict) details: dict[str, Any] = Field(default_factory=dict)
class DefinitionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
allow_view: bool | None = None
allow_edit: bool | None = None
inherit_to_lower_scopes: bool | None = None
allow_run: bool | None = None
allow_reuse: bool | None = None
allow_automation: bool | None = None
class DefinitionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: DefinitionPolicyItem = Field(default_factory=DefinitionPolicyItem)
change_request_id: str | None = None
class DefinitionPolicyScopeResponse(BaseModel):
module_id: str
scope_type: Literal["system", "tenant", "group", "user"]
scope_id: str | None = None
id: str | None = None
revision: int | None = None
policy: dict[str, Any] = Field(default_factory=dict)
effective_policy: dict[str, bool] = Field(default_factory=dict)
parent_policy: dict[str, bool] = Field(default_factory=dict)
source_path: list[PolicySourceStepItem] = Field(default_factory=list)
diagnostics: list[dict[str, str]] = Field(default_factory=list)
class ViewPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
allow_view: bool | None = None
allow_select: bool | None = None
allow_assign: bool | None = None
allow_edit: bool | None = None
allow_derive: bool | None = None
allow_workflow_activate: bool | None = None
allowed_view_ids: list[str] | None = Field(default=None, max_length=1000)
visible_surface_ids: list[str] | None = Field(default=None, max_length=2000)
class ViewPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: ViewPolicyItem = Field(default_factory=ViewPolicyItem)
change_request_id: str | None = None
class ViewPolicyScopeResponse(BaseModel):
scope_type: Literal["system", "tenant", "group", "user"]
scope_id: str | None = None
id: str | None = None
revision: int | None = None
policy: dict[str, Any] = Field(default_factory=dict)
effective_policy: dict[str, Any] = Field(default_factory=dict)
parent_policy: dict[str, Any] = Field(default_factory=dict)
source_path: list[PolicySourceStepItem] = Field(default_factory=list)
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
class PrivacyRetentionPolicyExplainResponse(BaseModel): class PrivacyRetentionPolicyExplainResponse(BaseModel):
scope_type: Literal["system", "tenant", "user", "group", "campaign"] scope_type: Literal["system", "tenant", "user", "group", "campaign"]
scope_id: str | None = None scope_id: str | None = None
@@ -0,0 +1,5 @@
"""Policy-owned persistence models."""
from govoplan_policy.backend.db.models import PolicyOverride
__all__ = ["PolicyOverride"]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import Index, Integer, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
class PolicyOverride(Base, TimestampMixin):
__tablename__ = "policy_overrides"
__table_args__ = (
UniqueConstraint(
"policy_family",
"target_key",
"scope_key",
name="uq_policy_override_family_target_scope",
),
Index(
"ix_policy_overrides_resolution",
"policy_family",
"target_key",
"tenant_id",
"scope_type",
"scope_id",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
policy_family: Mapped[str] = mapped_column(String(40), nullable=False)
target_key: Mapped[str] = mapped_column(String(120), nullable=False)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False)
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
scope_key: Mapped[str] = mapped_column(String(320), nullable=False)
policy: Mapped[Any] = mapped_column(JSON, default=dict, nullable=False)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
__all__ = ["PolicyOverride"]
@@ -0,0 +1,484 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.policy import (
DefinitionGovernanceRequest,
PolicyDecision,
PolicySourceStep,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.db.session import get_database
from govoplan_policy.backend.policy_overrides import resolution_policy_overrides
DEFINITION_POLICY_FIELDS = (
"allow_view",
"allow_edit",
"inherit_to_lower_scopes",
"allow_run",
"allow_reuse",
"allow_automation",
)
@dataclass(frozen=True, slots=True)
class DefinitionPolicyResolution:
limits: Mapping[str, bool]
source_path: tuple[PolicySourceStep, ...] = ()
diagnostics: tuple[Mapping[str, str], ...] = ()
class DefinitionGovernancePolicyProvider:
"""Resolve flow-library actions without importing a domain module."""
def resolve_definition_action(
self,
session: object | None = None,
*,
request: DefinitionGovernanceRequest,
) -> PolicyDecision:
explicit_policy = _explicit_policy_resolution(session, request)
source_path = _source_path(
request,
policy_sources=explicit_policy.source_path,
)
effective = _effective_limits(
request,
policy_limits=explicit_policy.limits,
)
visible, visibility_reason = _visible(
request,
effective=effective,
)
action = request.action
if action == "view":
return _decision(
visible,
visibility_reason,
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
if action == "edit":
editable, reason = _editable(request)
if editable and not effective["allow_edit"]:
editable = False
reason = "Editing is disabled by explicit Policy restrictions."
return _decision(
editable,
reason,
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
if not visible:
return _decision(
False,
visibility_reason,
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
if action == "run":
if request.definition_kind == "template":
return _decision(
False,
"Templates must be derived into a complete flow before execution.",
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
if request.status != "active":
return _decision(
False,
"Only active flow revisions can be executed.",
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
return _decision(
effective["allow_run"],
(
None
if effective["allow_run"]
else "Execution is disabled by definition or ancestor policy."
),
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
if action in {"reuse", "derive"}:
return _decision(
effective["allow_reuse"],
(
None
if effective["allow_reuse"]
else "Reuse is disabled by definition or ancestor policy."
),
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
if action == "automate":
if request.definition_kind == "template":
return _decision(
False,
"Templates cannot be automated.",
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
if request.status != "active":
return _decision(
False,
"Automation requires an active flow revision.",
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
allowed = effective["allow_run"] and effective["allow_automation"]
return _decision(
allowed,
(
None
if allowed
else "Automation is disabled by definition or ancestor policy."
),
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
return _decision(
False,
f"Unsupported definition action: {action}",
source_path=source_path,
request=request,
effective=effective,
policy_diagnostics=explicit_policy.diagnostics,
)
def _visible(
request: DefinitionGovernanceRequest,
*,
effective: Mapping[str, bool],
) -> tuple[bool, str | None]:
if not effective["allow_view"]:
return False, "Visibility is disabled by explicit Policy restrictions."
scope = request.definition_scope
actor = request.actor
if scope.scope_type == "system":
if _has_scope(actor.scopes, "system:governance:read") or _has_scope(
actor.scopes,
"system:governance:write",
):
return True, None
if effective["inherit_to_lower_scopes"]:
return True, None
return False, "The system definition is not inherited by lower scopes."
if actor.tenant_id != request.tenant_id:
return False, "The definition belongs to another tenant."
if scope.scope_type == "tenant":
if scope.scope_id != request.tenant_id:
return False, "The definition belongs to another tenant."
return True, None
if scope.scope_type == "group":
if scope.scope_id in actor.group_ids:
return True, None
return False, "The definition is limited to another group."
if scope.scope_type == "user":
if scope.scope_id in {actor.membership_id, actor.account_id}:
return True, None
return False, "The definition is limited to another user."
return False, "The definition scope is invalid."
def _editable(request: DefinitionGovernanceRequest) -> tuple[bool, str | None]:
scope = request.definition_scope
actor = request.actor
if scope.scope_type == "system":
allowed = _has_scope(actor.scopes, "system:governance:write")
return (
allowed,
None
if allowed
else "System definitions require system governance permission.",
)
if actor.tenant_id != request.tenant_id:
return False, "Definitions from another tenant are read-only."
if scope.scope_type == "tenant":
allowed = scope.scope_id == request.tenant_id
return (
allowed,
None if allowed else "Inherited tenant definitions are read-only.",
)
if scope.scope_type == "group":
allowed = scope.scope_id in actor.group_ids
return (
allowed,
None if allowed else "Definitions from another group are read-only.",
)
if scope.scope_type == "user":
allowed = scope.scope_id in {actor.membership_id, actor.account_id}
return (
allowed,
None if allowed else "Definitions from another user are read-only.",
)
return False, "The definition scope is invalid."
def _effective_limits(
request: DefinitionGovernanceRequest,
*,
policy_limits: Mapping[str, bool] | None = None,
) -> dict[str, bool]:
ancestor = request.context.get("ancestor_limits")
ancestor_limits = ancestor if isinstance(ancestor, Mapping) else {}
explicit = policy_limits or {}
return {
"allow_view": _policy_flag(explicit, "allow_view"),
"allow_edit": _policy_flag(explicit, "allow_edit"),
"inherit_to_lower_scopes": request.inherit_to_lower_scopes
and _ancestor_flag(ancestor_limits, "inherit_to_lower_scopes")
and _policy_flag(explicit, "inherit_to_lower_scopes"),
"allow_run": request.allow_run
and _ancestor_flag(ancestor_limits, "allow_run")
and _policy_flag(explicit, "allow_run"),
"allow_reuse": request.allow_reuse
and _ancestor_flag(ancestor_limits, "allow_reuse")
and _policy_flag(explicit, "allow_reuse"),
"allow_automation": request.allow_automation
and _ancestor_flag(ancestor_limits, "allow_automation")
and _policy_flag(explicit, "allow_automation"),
}
def _ancestor_flag(value: Mapping[str, Any], key: str) -> bool:
raw = value.get(key)
return True if raw is None else raw is True
def _policy_flag(value: Mapping[str, bool], key: str) -> bool:
raw = value.get(key)
return True if raw is None else raw is True
def _source_path(
request: DefinitionGovernanceRequest,
*,
policy_sources: tuple[PolicySourceStep, ...] = (),
) -> tuple[PolicySourceStep, ...]:
steps: list[PolicySourceStep] = list(policy_sources)
ancestor = request.context.get("ancestor_limits")
if isinstance(ancestor, Mapping):
source = request.context.get("ancestor_source")
source_mapping = source if isinstance(source, Mapping) else {}
scope_type = str(source_mapping.get("scope_type") or "system")
scope_id = source_mapping.get("scope_id")
if scope_type in {"system", "tenant", "group", "user"}:
steps.append(
PolicySourceStep(
scope_type=scope_type, # type: ignore[arg-type]
scope_id=str(scope_id) if scope_id is not None else None,
label=str(source_mapping.get("label") or "Ancestor definition"),
applied_fields=tuple(sorted(str(key) for key in ancestor)),
policy=dict(ancestor),
)
)
scope = request.definition_scope
steps.append(
PolicySourceStep(
scope_type=scope.scope_type,
scope_id=scope.scope_id,
label=f"{scope.scope_type.capitalize()} definition",
applied_fields=(
"definition_kind",
"inherit_to_lower_scopes",
"allow_run",
"allow_reuse",
"allow_automation",
),
policy={
"definition_kind": request.definition_kind,
"status": request.status,
"inherit_to_lower_scopes": request.inherit_to_lower_scopes,
"allow_run": request.allow_run,
"allow_reuse": request.allow_reuse,
"allow_automation": request.allow_automation,
},
)
)
target = request.target_scope
if target.path != scope.path:
steps.append(
PolicySourceStep(
scope_type=target.scope_type,
scope_id=target.scope_id,
label=f"{target.scope_type.capitalize()} use context",
applied_fields=("action",),
policy={"action": request.action},
)
)
return tuple(steps)
def _decision(
allowed: bool,
reason: str | None,
*,
source_path: tuple[PolicySourceStep, ...],
request: DefinitionGovernanceRequest,
effective: Mapping[str, bool],
policy_diagnostics: tuple[Mapping[str, str], ...] = (),
) -> PolicyDecision:
return PolicyDecision(
allowed=allowed,
reason=reason,
source_path=source_path,
requirements=(
() if allowed else (f"{request.module_id}.definition.{request.action}",)
),
details={
"module_id": request.module_id,
"definition_ref": request.definition_ref,
"action": request.action,
"definition_scope": request.definition_scope.path,
"target_scope": request.target_scope.path,
"definition_kind": request.definition_kind,
"effective_limits": dict(effective),
"policy_diagnostics": [dict(item) for item in policy_diagnostics],
},
)
def _explicit_policy_resolution(
session: object | None,
request: DefinitionGovernanceRequest,
) -> DefinitionPolicyResolution:
if not isinstance(session, Session):
try:
with get_database().SessionLocal() as policy_session:
return _explicit_policy_resolution(policy_session, request)
except RuntimeError:
# Contract-only tests and offline tooling may resolve decisions
# without configuring a database. No persisted override exists in
# that context.
return DefinitionPolicyResolution(limits={})
cache_key = (
"definition",
request.module_id,
request.tenant_id,
tuple(sorted(request.actor.group_ids)),
request.actor.account_id,
request.actor.membership_id,
)
cache = session.info.setdefault("govoplan_policy_override_resolution", {})
if isinstance(cache, dict) and cache_key in cache:
cached = cache[cache_key]
if isinstance(cached, DefinitionPolicyResolution):
return cached
rows = resolution_policy_overrides(
session,
policy_family="definition",
target_keys=("*", request.module_id),
tenant_id=request.tenant_id,
group_ids=request.actor.group_ids,
user_ids=(request.actor.account_id, request.actor.membership_id or ""),
)
result = resolve_definition_policy_rows(rows)
if isinstance(cache, dict):
cache[cache_key] = result
return result
def resolve_definition_policy_rows(
rows: object,
) -> DefinitionPolicyResolution:
limits = {field: True for field in DEFINITION_POLICY_FIELDS}
steps: list[PolicySourceStep] = []
diagnostics: list[Mapping[str, str]] = []
for row in rows if isinstance(rows, (list, tuple)) else ():
policy, malformed = validate_definition_policy(row.policy)
if malformed:
limits.update({field: False for field in DEFINITION_POLICY_FIELDS})
applied_fields = DEFINITION_POLICY_FIELDS
source_policy: Mapping[str, Any] = {
"configuration_status": "invalid_fail_closed",
"target_key": row.target_key,
}
diagnostics.append(
{
"code": "definition_policy.invalid",
"scope": row.scope_key,
"target_key": row.target_key,
}
)
else:
for field, value in policy.items():
limits[field] = limits[field] and value
applied_fields = tuple(sorted(policy))
source_policy = {**policy, "target_key": row.target_key}
steps.append(
PolicySourceStep(
scope_type=row.scope_type, # type: ignore[arg-type]
scope_id=row.scope_id,
label=(f"{row.scope_type.capitalize()} definition policy"),
applied_fields=tuple(applied_fields),
policy=source_policy,
)
)
return DefinitionPolicyResolution(
limits=limits,
source_path=tuple(steps),
diagnostics=tuple(diagnostics),
)
def validate_definition_policy(
value: object,
) -> tuple[dict[str, bool], bool]:
if not isinstance(value, Mapping):
return {}, True
if any(str(key) not in DEFINITION_POLICY_FIELDS for key in value):
return {}, True
policy: dict[str, bool] = {}
for key, raw in value.items():
if not isinstance(raw, bool):
return {}, True
policy[str(key)] = raw
return policy, False
def _has_scope(scopes: object, required: str) -> bool:
return scopes_grant_compatible(scopes, required) # type: ignore[arg-type]
__all__ = [
"DEFINITION_POLICY_FIELDS",
"DefinitionGovernancePolicyProvider",
"DefinitionPolicyResolution",
"resolve_definition_policy_rows",
"validate_definition_policy",
]
@@ -0,0 +1,219 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from sqlalchemy.orm import Session
from govoplan_policy.backend.db.models import PolicyOverride
from govoplan_policy.backend.definition_governance import (
DEFINITION_POLICY_FIELDS,
DefinitionPolicyResolution,
resolve_definition_policy_rows,
validate_definition_policy,
)
from govoplan_policy.backend.policy_overrides import (
delete_policy_override,
get_policy_override,
normalize_policy_scope,
resolution_policy_overrides,
set_policy_override,
)
class DefinitionPolicyError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class DefinitionPolicyState:
row: PolicyOverride | None
local_policy: Mapping[str, bool]
effective: DefinitionPolicyResolution
parent: DefinitionPolicyResolution
def definition_policy_state(
session: Session,
*,
module_id: str,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> DefinitionPolicyState:
_, clean_scope_id, _ = normalize_policy_scope(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
row = get_policy_override(
session,
policy_family="definition",
target_key=module_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=clean_scope_id,
)
local_policy, malformed = validate_definition_policy(
row.policy if row is not None else {}
)
if malformed:
local_policy = {}
rows = _rows_for_scope(
session,
module_id=module_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=clean_scope_id,
)
rank = _scope_rank(scope_type)
return DefinitionPolicyState(
row=row,
local_policy=local_policy,
effective=resolve_definition_policy_rows(rows),
parent=resolve_definition_policy_rows(
tuple(item for item in rows if _scope_rank(item.scope_type) < rank)
),
)
def save_definition_policy(
session: Session,
*,
module_id: str,
tenant_id: str,
scope_type: str,
scope_id: str | None,
policy: object,
actor_id: str | None,
) -> DefinitionPolicyState:
clean_policy, malformed = validate_definition_policy(policy)
if malformed:
raise DefinitionPolicyError(
"Definition policy fields must be known boolean values"
)
before = definition_policy_state(
session,
module_id=module_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
broadened = sorted(
field
for field, value in clean_policy.items()
if value and before.parent.limits.get(field) is False
)
if broadened:
raise DefinitionPolicyError(
"Lower-scope policy cannot broaden parent restrictions: "
+ ", ".join(broadened)
)
set_policy_override(
session,
policy_family="definition",
target_key=module_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
policy=clean_policy,
actor_id=actor_id,
)
_clear_resolution_cache(session)
return definition_policy_state(
session,
module_id=module_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
def remove_definition_policy(
session: Session,
*,
module_id: str,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> bool:
row = get_policy_override(
session,
policy_family="definition",
target_key=module_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
if row is None:
return False
delete_policy_override(session, row)
_clear_resolution_cache(session)
return True
def _rows_for_scope(
session: Session,
*,
module_id: str,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> tuple[PolicyOverride, ...]:
clean_scope = scope_type.strip().casefold()
rows = resolution_policy_overrides(
session,
policy_family="definition",
target_keys=("*", module_id),
tenant_id=tenant_id,
group_ids=(scope_id,) if clean_scope == "group" and scope_id else (),
user_ids=(scope_id,) if clean_scope == "user" and scope_id else (),
)
maximum_rank = _scope_rank(clean_scope)
return tuple(row for row in rows if _scope_rank(row.scope_type) <= maximum_rank)
def _scope_rank(scope_type: str) -> int:
try:
return ("system", "tenant", "group", "user").index(
scope_type.strip().casefold()
)
except ValueError as exc:
raise DefinitionPolicyError(
"Definition policy scope must be system, tenant, group, or user"
) from exc
def _clear_resolution_cache(session: Session) -> None:
session.info.pop("govoplan_policy_override_resolution", None)
def definition_policy_response_payload(
state: DefinitionPolicyState,
) -> dict[str, Any]:
local_policy: Mapping[str, Any] = state.local_policy
if state.row is not None:
_, malformed = validate_definition_policy(state.row.policy)
if malformed:
local_policy = {"configuration_status": "invalid_fail_closed"}
return {
"id": state.row.id if state.row is not None else None,
"revision": state.row.revision if state.row is not None else None,
"policy": dict(local_policy),
"effective_policy": dict(state.effective.limits),
"parent_policy": dict(state.parent.limits),
"source_path": [step.to_dict() for step in state.effective.source_path],
"diagnostics": [dict(item) for item in state.effective.diagnostics],
}
__all__ = [
"DEFINITION_POLICY_FIELDS",
"DefinitionPolicyError",
"DefinitionPolicyState",
"definition_policy_response_payload",
"definition_policy_state",
"remove_definition_policy",
"save_definition_policy",
]
@@ -0,0 +1,181 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
import hashlib
from sqlalchemy.orm import Session
from govoplan_core.core.distribution_lists import (
DistributionChannelPolicyDecision,
DistributionChannelPolicyRequest,
)
from govoplan_policy.backend.policy_overrides import resolution_policy_overrides
CHANNELS = frozenset({"email", "postal", "internal_mail", "portal"})
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyResolution:
allowed_channels: frozenset[str]
source_path: tuple[Mapping[str, object], ...] = ()
diagnostics: tuple[Mapping[str, object], ...] = ()
class DistributionChannelPolicyProvider:
def resolve_distribution_channel(
self,
session: object,
principal: object,
*,
request: DistributionChannelPolicyRequest,
) -> DistributionChannelPolicyDecision:
resolution = _resolve(session, principal, request)
channel = request.candidate.channel
allowed = channel in resolution.allowed_channels
malformed = any(
item.get("code") == "distribution_channel_policy.invalid"
for item in resolution.diagnostics
)
if malformed and not allowed:
reason_code = "policy.invalid_fail_closed"
explanation = (
"Distribution is blocked because an applicable channel policy is invalid."
)
elif allowed:
reason_code = "policy.channel_allowed"
explanation = f"The {channel} channel is permitted by the effective Policy."
else:
reason_code = "policy.channel_blocked"
explanation = f"The {channel} channel is blocked by the effective Policy."
return DistributionChannelPolicyDecision(
allowed=allowed,
reason_code=reason_code,
explanation=explanation,
source_path=resolution.source_path,
requirements=(() if allowed else (f"policy.channel.{channel}",)),
details={
"allowed_channels": sorted(resolution.allowed_channels),
"diagnostics": [dict(item) for item in resolution.diagnostics],
},
)
def resolve_distribution_channel_policy_rows(
rows: object,
) -> DistributionChannelPolicyResolution:
allowed_channels = set(CHANNELS)
source_path: list[Mapping[str, object]] = []
diagnostics: list[Mapping[str, object]] = []
for row in rows if isinstance(rows, (list, tuple)) else ():
policy, malformed = validate_distribution_channel_policy(row.policy)
if malformed:
allowed_channels.clear()
applied_fields: tuple[str, ...] = ("configuration_status",)
source_policy: Mapping[str, object] = {
"configuration_status": "invalid_fail_closed",
"target_key": row.target_key,
}
diagnostics.append(
{
"code": "distribution_channel_policy.invalid",
"severity": "error",
"scope": row.scope_key,
"target_key": row.target_key,
}
)
else:
configured = policy.get("allowed_channels")
blocked = policy.get("blocked_channels", ())
if isinstance(configured, tuple):
allowed_channels.intersection_update(configured)
if isinstance(blocked, tuple):
allowed_channels.difference_update(blocked)
applied_fields = tuple(sorted(policy))
source_policy = {
key: list(value) if isinstance(value, tuple) else value
for key, value in policy.items()
}
source_policy = {**source_policy, "target_key": row.target_key}
source_path.append(
{
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"label": f"{row.scope_type.capitalize()} distribution-channel policy",
"applied_fields": list(applied_fields),
"policy": dict(source_policy),
}
)
return DistributionChannelPolicyResolution(
allowed_channels=frozenset(allowed_channels),
source_path=tuple(source_path),
diagnostics=tuple(diagnostics),
)
def validate_distribution_channel_policy(
value: object,
) -> tuple[dict[str, tuple[str, ...]], bool]:
if not isinstance(value, Mapping):
return {}, True
supported = {"allowed_channels", "blocked_channels"}
if any(str(key) not in supported for key in value):
return {}, True
policy: dict[str, tuple[str, ...]] = {}
for key, raw in value.items():
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
return {}, True
channels = tuple(dict.fromkeys(str(item).strip() for item in raw))
if any(channel not in CHANNELS for channel in channels):
return {}, True
policy[str(key)] = channels
return policy, False
def _resolve(
session: object,
principal: object,
request: DistributionChannelPolicyRequest,
) -> DistributionChannelPolicyResolution:
if not isinstance(session, Session):
return DistributionChannelPolicyResolution(allowed_channels=CHANNELS)
group_ids = tuple(getattr(principal, "group_ids", ()) or ())
account_id = str(getattr(principal, "account_id", "") or "")
membership_id = str(getattr(principal, "membership_id", "") or "")
target_keys = ["*", _target_key("list", request.list_id)]
if request.purpose:
target_keys.extend(
(
_target_key("purpose", request.purpose),
_target_key(
f"list:{request.list_id}:purpose",
request.purpose,
),
)
)
rows = resolution_policy_overrides(
session,
policy_family="distribution_channels",
target_keys=target_keys,
tenant_id=request.tenant_id,
group_ids=group_ids,
user_ids=(account_id, membership_id),
)
return resolve_distribution_channel_policy_rows(rows)
def _target_key(prefix: str, value: str) -> str:
candidate = f"{prefix}:{value}"
if len(candidate) <= 120:
return candidate
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
return f"{prefix[:78]}:sha256:{digest[:32]}"
__all__ = [
"CHANNELS",
"DistributionChannelPolicyProvider",
"DistributionChannelPolicyResolution",
"resolve_distribution_channel_policy_rows",
"validate_distribution_channel_policy",
]
@@ -0,0 +1,304 @@
from __future__ import annotations
from collections.abc import Mapping
from govoplan_core.core.policy import (
FunctionAssignmentGovernanceDecision,
FunctionAssignmentGovernanceRequest,
PolicySourceStep,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
SUPPORTED_PROFILES = {
"holder_grant",
"holder_with_authority_clearance",
"authority_only",
"unavailable",
}
class FunctionAssignmentGovernancePolicyProvider:
def resolve_function_assignment_action(
self,
session: object | None = None,
*,
request: FunctionAssignmentGovernanceRequest,
) -> FunctionAssignmentGovernanceDecision:
del session
policy = _policy(request.function_settings)
profile = (
str(policy.get(f"{request.kind}_profile") or "unavailable")
.strip()
.casefold()
)
if profile not in SUPPORTED_PROFILES:
return _decision(
request,
policy,
profile="unavailable",
allowed=False,
reason=f"Unsupported function assignment profile: {profile}.",
requirements=("valid_profile",),
)
required_steps = _required_steps(
request.kind,
profile,
recipient_acceptance=_bool(
policy.get("recipient_acceptance_required"),
default=request.kind == "grant",
),
)
authority_function_id = _text(policy.get("authority_function_id"))
requirements: list[str] = []
if "authority" in required_steps and authority_function_id is None:
requirements.append("authority_function")
evidence_required = _bool(
policy.get("evidence_required"),
default=False,
)
if evidence_required and not request.context.get("has_evidence"):
requirements.append("evidence")
allowed, reason = _action_decision(
request,
profile=profile,
required_steps=required_steps,
)
if profile == "unavailable":
allowed = False
reason = "Function assignment requests and grants are disabled."
if requirements and request.action == "submit":
allowed = False
reason = _requirements_reason(requirements)
return _decision(
request,
policy,
profile=profile,
allowed=allowed,
reason=reason,
required_steps=required_steps,
authority_function_id=authority_function_id,
evidence_required=evidence_required,
requirements=tuple(requirements),
)
def _action_decision(
request: FunctionAssignmentGovernanceRequest,
*,
profile: str,
required_steps: tuple[str, ...],
) -> tuple[bool, str | None]:
context = request.context
action = request.action
if action == "submit":
if request.kind == "request":
if not bool(context.get("candidate_is_actor")):
return False, "A function request must target the requesting identity."
if profile == "authority_only":
allowed = bool(context.get("actor_is_authority"))
return (
allowed,
None
if allowed
else "Only the designated authority may initiate this assignment.",
)
return True, None
if profile == "authority_only":
allowed = bool(context.get("actor_is_authority"))
reason = "Only the designated authority may initiate this grant."
else:
allowed = bool(context.get("actor_is_holder"))
reason = "An effective function holder must initiate this grant."
return allowed, None if allowed else reason
if action == "approve_holder":
allowed = "holder" in required_steps and bool(context.get("actor_is_holder"))
return allowed, None if allowed else "A current holder must approve."
if action == "approve_authority":
allowed = "authority" in required_steps and bool(
context.get("actor_is_authority")
)
return allowed, None if allowed else "The designated authority must approve."
if action == "accept_recipient":
allowed = "recipient" in required_steps and bool(
context.get("candidate_is_actor")
)
return allowed, None if allowed else "The candidate must accept this grant."
if action in {"reject", "request_changes"}:
if request.current_state == "awaiting_holder":
allowed = bool(context.get("actor_is_holder"))
reason = "A current holder must act at the holder review step."
elif request.current_state == "awaiting_authority":
allowed = bool(context.get("actor_is_authority"))
reason = "The designated authority must act at the authority step."
elif request.current_state == "awaiting_recipient":
allowed = bool(context.get("candidate_is_actor"))
reason = "Only the candidate may act at recipient acceptance."
else:
allowed = False
reason = "The current state does not accept this review action."
return allowed, None if allowed else reason
if action == "respond":
allowed = request.current_state == "changes_requested" and (
bool(context.get("actor_is_initiator"))
or bool(context.get("candidate_is_actor"))
)
return (
allowed,
None
if allowed
else "Only the initiator or candidate may respond to requested changes.",
)
if action == "withdraw":
allowed = bool(context.get("actor_is_initiator"))
return (
allowed,
None if allowed else "Only the initiator may withdraw this change.",
)
if action == "recover":
allowed = _has_scope(request, "idm:function_change:admin")
return (
allowed,
None if allowed else "Administrative recovery permission is required.",
)
if action == "apply":
allowed = bool(context.get("approvals_complete"))
return allowed, None if allowed else "Required decisions are incomplete."
return False, f"Unsupported function assignment action: {action}."
def _required_steps(
kind: str,
profile: str,
*,
recipient_acceptance: bool,
) -> tuple[str, ...]:
if profile == "holder_grant":
steps = ["holder"]
elif profile == "holder_with_authority_clearance":
steps = ["holder", "authority"]
elif profile == "authority_only":
steps = ["authority"]
else:
steps = []
if kind == "grant" and recipient_acceptance:
steps.append("recipient")
return tuple(steps)
def _decision(
request: FunctionAssignmentGovernanceRequest,
policy: Mapping[str, object],
*,
profile: str,
allowed: bool,
reason: str | None,
required_steps: tuple[str, ...] = (),
authority_function_id: str | None = None,
evidence_required: bool = False,
requirements: tuple[str, ...] = (),
) -> FunctionAssignmentGovernanceDecision:
recipient_required = "recipient" in required_steps
return FunctionAssignmentGovernanceDecision(
allowed=allowed,
reason=reason,
profile=profile,
required_steps=required_steps,
authority_function_id=authority_function_id,
evidence_required=evidence_required,
recipient_acceptance_required=recipient_required,
separation_of_duties=_bool(
policy.get("separation_of_duties"),
default=True,
),
quorum=_bounded_int(policy.get("quorum"), default=1, minimum=1, maximum=20),
maximum_validity_days=_optional_positive_int(
policy.get("maximum_validity_days"),
maximum=3650,
),
request_expiry_hours=_bounded_int(
policy.get("request_expiry_hours"),
default=336,
minimum=1,
maximum=8760,
),
source_path=(
PolicySourceStep(
scope_type="tenant",
scope_id=request.tenant_id,
label="Organization function assignment policy",
applied_fields=tuple(sorted(policy)),
policy=policy,
),
),
requirements=requirements,
details={
"function_id": request.function_id,
"kind": request.kind,
"action": request.action,
},
)
def _policy(settings: Mapping[str, object]) -> dict[str, object]:
raw = settings.get("assignment_governance")
return dict(raw) if isinstance(raw, Mapping) else {}
def _requirements_reason(requirements: list[str]) -> str:
labels = {
"authority_function": "a designated authority function",
"evidence": "the required evidence",
"valid_profile": "a supported governance profile",
}
return (
"Submission requires "
+ ", ".join(labels.get(item, item.replace("_", " ")) for item in requirements)
+ "."
)
def _has_scope(
request: FunctionAssignmentGovernanceRequest,
scope: str,
) -> bool:
return scopes_grant_compatible(request.actor.scopes, scope)
def _text(value: object) -> str | None:
text = str(value).strip() if value is not None else ""
return text or None
def _bool(value: object, *, default: bool) -> bool:
return value if isinstance(value, bool) else default
def _bounded_int(
value: object,
*,
default: int,
minimum: int,
maximum: int,
) -> int:
try:
number = int(value) if value is not None else default
except (TypeError, ValueError):
return default
return max(minimum, min(number, maximum))
def _optional_positive_int(value: object, *, maximum: int) -> int | None:
if value is None:
return None
try:
number = int(value)
except (TypeError, ValueError):
return None
return max(1, min(number, maximum))
__all__ = [
"FunctionAssignmentGovernancePolicyProvider",
"SUPPORTED_PROFILES",
]
+17 -11
View File
@@ -66,21 +66,27 @@ def validate_hierarchical_policy_patch(
relock_message: Callable[[str], str] | None = None, relock_message: Callable[[str], str] | None = None,
) -> tuple[PolicyValidationIssue, ...]: ) -> tuple[PolicyValidationIssue, ...]:
issues: list[PolicyValidationIssue] = [] issues: list[PolicyValidationIssue] = []
lock_message = locked_field_message or (lambda field: f"{field} is locked by the parent policy.") lock_message = locked_field_message or (lambda field_name: f"{field_name} 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.") reenable_message = relock_message or (
lambda field_name: f"{field_name} limiting cannot be re-enabled below a parent policy lock."
)
for field in field_keys: for field_name in field_keys:
if field in patch and patch.get(field) is not None and not parent_allow_lower_level_limits.get(field, True): if (
field_name in patch
and patch.get(field_name) is not None
and not parent_allow_lower_level_limits.get(field_name, True)
):
issues.append(PolicyValidationIssue( issues.append(PolicyValidationIssue(
code="field_locked_by_parent", code="field_locked_by_parent",
field=field, field=field_name,
message=lock_message(field), message=lock_message(field_name),
)) ))
patch_allow = patch.get("allow_lower_level_limits") patch_allow = patch.get("allow_lower_level_limits")
if isinstance(patch_allow, Mapping): if isinstance(patch_allow, Mapping):
for field, allowed in patch_allow.items(): for field_name, allowed in patch_allow.items():
clean_field = str(field) clean_field = str(field_name)
if bool(allowed) and not parent_allow_lower_level_limits.get(clean_field, True): if bool(allowed) and not parent_allow_lower_level_limits.get(clean_field, True):
issues.append(PolicyValidationIssue( issues.append(PolicyValidationIssue(
code="lower_level_limit_locked_by_parent", code="lower_level_limit_locked_by_parent",
@@ -125,9 +131,9 @@ def simulate_hierarchical_policy_change(
relock_message=relock_message, relock_message=relock_message,
) )
changed_fields = tuple( changed_fields = tuple(
field field_name
for field in (*field_keys, "allow_lower_level_limits") for field_name in (*field_keys, "allow_lower_level_limits")
if field in patch and current_policy.get(field) != patch.get(field) if field_name in patch and current_policy.get(field_name) != patch.get(field_name)
) )
allowed = not any(issue.severity == "blocker" for issue in issues) allowed = not any(issue.severity == "blocker" for issue in issues)
decision = PolicyDecision( decision = PolicyDecision(
+299 -5
View File
@@ -1,8 +1,39 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from pathlib import Path
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION
from govoplan_core.core.modules import FrontendModule, ModuleContext, ModuleManifest from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.distribution_lists import (
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.policy import (
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
CAPABILITY_POLICY_PRIVACY_RETENTION,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
CAPABILITY_POLICY_VIEW_GOVERNANCE,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
)
from govoplan_core.core.reporting import CAPABILITY_POLICY_REPORTING_GOVERNANCE
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_policy.backend.db import models as policy_models
def _route_factory(context: ModuleContext): def _route_factory(context: ModuleContext):
@@ -19,19 +50,282 @@ def _privacy_retention_service(context: ModuleContext) -> object:
return SqlPrivacyRetentionService() return SqlPrivacyRetentionService()
def _scheduling_participant_privacy_policy(context: ModuleContext) -> object:
del context
from govoplan_policy.backend.scheduling_privacy import (
SqlSchedulingParticipantPrivacyPolicy,
)
return SqlSchedulingParticipantPrivacyPolicy()
def _definition_governance_policy(context: ModuleContext) -> object:
del context
from govoplan_policy.backend.definition_governance import (
DefinitionGovernancePolicyProvider,
)
return DefinitionGovernancePolicyProvider()
def _view_governance_policy(context: ModuleContext) -> object:
del context
from govoplan_policy.backend.view_governance import (
ViewGovernancePolicyProvider,
)
return ViewGovernancePolicyProvider()
def _function_assignment_governance_policy(context: ModuleContext) -> object:
del context
from govoplan_policy.backend.function_assignment_governance import (
FunctionAssignmentGovernancePolicyProvider,
)
return FunctionAssignmentGovernancePolicyProvider()
def _distribution_channel_policy(context: ModuleContext) -> object:
del context
from govoplan_policy.backend.distribution_channels import (
DistributionChannelPolicyProvider,
)
return DistributionChannelPolicyProvider()
def _reporting_governance_policy(context: ModuleContext) -> object:
del context
from govoplan_policy.backend.reporting_governance import (
ReportingGovernancePolicyProvider,
)
return ReportingGovernancePolicyProvider()
manifest = ModuleManifest( manifest = ModuleManifest(
id="policy", id="policy",
name="Policy", name="Policy",
version="0.1.8", version="0.1.18",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(
name="policy.definition_governance",
version="0.1.0",
),
ModuleInterfaceProvider(
name="policy.view_governance",
version="0.1.0",
),
ModuleInterfaceProvider(
name="policy.function_assignment_governance",
version="1.0.0",
),
ModuleInterfaceProvider(
name=CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
version="1.0.0",
),
ModuleInterfaceProvider(
name=CAPABILITY_POLICY_REPORTING_GOVERNANCE,
version="1.0.0",
),
),
route_factory=_route_factory, route_factory=_route_factory,
documentation=(
DocumentationTopic(
id="policy.view-governance-administration",
title="Govern View availability and actions",
summary="View policy limits which definitions and surfaces remain available and which View actions lower scopes may perform.",
body=(
"System, tenant, group, and user View policies form a restrictive hierarchy. Each scope may inherit, allow, or block viewing, selecting, assigning, editing, deriving, and workflow activation. Optional View-ID and surface-ID ceilings are intersected through the hierarchy, so a lower scope cannot restore an item excluded above it. Available, default, and required View assignments remain owned by Views; Policy supplies the action and catalogue ceiling and records provenance and malformed-policy diagnostics."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("system_admin", "tenant_admin", "policy_admin"),
related_modules=("views", "admin", "access"),
metadata={
"kind": "reference",
"help_contexts": [
"policy.view-governance",
"policy.admin.system-view-policy",
"policy.admin.tenant-view-policy",
"policy.admin.group-view-policy",
"policy.admin.user-view-policy",
],
},
),
DocumentationTopic(
id="policy.effective-decisions-and-provenance",
title="Understand effective policy decisions",
summary="Policy decisions explain whether an action is allowed, limited, inherited, or unavailable and identify the sources that produced the result.",
body="A lower scope may narrow an inherited ceiling but cannot silently loosen a stronger system or tenant rule. Consuming modules remain responsible for enforcing the returned decision and displaying its reason. Malformed explicit policy fails closed for the affected governed action rather than being treated as absent.",
documentation_types=("user",),
audience=("user", "tenant_admin", "policy_admin"),
metadata={"kind": "reference"},
),
DocumentationTopic(
id="policy.hierarchy-overrides-and-retention",
title="Administer policy hierarchy and overrides",
summary="Policy evaluates versioned system, tenant, group, and user rules for retention and module-owned governed actions.",
body="Administrators can simulate effective retention before saving a lower-level change. Explicit overrides retain source and provenance information and are evaluated through typed capabilities for definitions, Views, function assignments, distribution channels, scheduling privacy, cross-module reporting, and retention. Templates and inherited definitions keep their upstream ceilings when reused or derived.",
documentation_types=("admin",),
audience=("policy_admin", "tenant_admin", "system_admin"),
related_modules=(
"dataflow",
"workflow_engine",
"views",
"idm",
"dist_lists",
"scheduling",
"reporting",
),
metadata={
"kind": "workflow",
"route": "/admin?section=system-retention",
"screen": "Retention administration",
"help_contexts": ["policy.retention", "privacy.retention"],
"prerequisites": [
"Policy and Access are enabled.",
"The actor may read policy settings at the selected scope.",
],
"steps": [
"Inspect the effective value and its policy source path.",
"Narrow only fields that the parent policy permits this scope to override.",
"Save the policy, then run a system dry run before applying retention.",
"Verify bounded outcome and audit evidence after an applied run.",
],
"outcome": "The selected scope has an explainable retention policy and any destructive application is preceded by a dry-run review.",
"verification": "Reload the policy, confirm its source path, and compare the dry-run or applied outcome table with audit evidence.",
},
),
),
migration_spec=MigrationSpec(
module_id="policy",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
policy_models.PolicyOverride,
label="Policy overrides",
),
retirement_notes=(
"Destructive retirement removes explicit definition and View "
"policy overrides after a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
policy_models.PolicyOverride,
label="Policy overrides",
),
),
frontend=FrontendModule( frontend=FrontendModule(
module_id="policy", module_id="policy",
package_name="@govoplan/policy-webui", package_name="@govoplan/policy-webui",
view_surfaces=(
ViewSurface(
id="policy.admin.system-view-policy",
module_id="policy",
kind="section",
label="System View policy",
order=70,
),
ViewSurface(
id="policy.admin.tenant-view-policy",
module_id="policy",
kind="section",
label="Tenant View policy",
order=70,
),
ViewSurface(
id="policy.admin.group-view-policy",
module_id="policy",
kind="section",
label="Group View policy",
order=70,
),
ViewSurface(
id="policy.admin.user-view-policy",
module_id="policy",
kind="section",
label="User View policy",
order=70,
),
ViewSurface(
id="policy.admin.system-retention",
module_id="policy",
kind="section",
label="System retention",
order=80,
),
ViewSurface(
id="policy.admin.tenant-retention",
module_id="policy",
kind="section",
label="Tenant retention",
order=80,
),
ViewSurface(
id="policy.admin.group-retention",
module_id="policy",
kind="section",
label="Group retention",
order=80,
),
ViewSurface(
id="policy.admin.user-retention",
module_id="policy",
kind="section",
label="User retention",
order=80,
),
),
), ),
capability_factories={ capability_factories={
CAPABILITY_POLICY_DEFINITION_GOVERNANCE: _definition_governance_policy,
CAPABILITY_POLICY_VIEW_GOVERNANCE: _view_governance_policy,
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE: (
_function_assignment_governance_policy
),
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS: _distribution_channel_policy,
CAPABILITY_POLICY_REPORTING_GOVERNANCE: _reporting_governance_policy,
CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service, CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: _scheduling_participant_privacy_policy,
}, },
capability_documentation={
CAPABILITY_POLICY_REPORTING_GOVERNANCE: CapabilityDocumentation(
label="Reporting privacy governance",
summary=(
"Tightens report minimization, retention, export formats, and "
"re-identification-risk decisions across system and tenant scopes."
),
contract_version="1.0",
documentation_types=("admin",),
audience=("policy_admin", "privacy_officer", "system_admin"),
),
},
architecture=declared_module_architecture(
layer="governance_accountability",
kind="governance",
maturity="vertical_slice",
documentation_ref="docs/POLICY_DECISION_PROVENANCE.md",
test_ref="tests/test_policy_hierarchy.py",
known_limits=(
"The current policy families do not yet form a universal expression or enforcement engine.",
),
owned_concepts=(
"policy definition",
"policy override",
"policy decision provenance",
),
non_owned_concepts=("application permission", "domain record", "audit record"),
recovery_docs=("docs/POLICY_DECISION_PROVENANCE.md",),
security_docs=("docs/POLICY_DECISION_PROVENANCE.md",),
),
) )
@@ -0,0 +1 @@
"""Policy database migrations."""
@@ -0,0 +1 @@
"""Policy migration revisions."""
@@ -0,0 +1,75 @@
"""Add governed hierarchical Policy overrides.
Revision ID: a9c4e7b2d5f8
Revises: None
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a9c4e7b2d5f8"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"policy_overrides",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("policy_family", sa.String(length=40), nullable=False),
sa.Column("target_key", sa.String(length=120), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("scope_key", sa.String(length=320), nullable=False),
sa.Column("policy", sa.JSON(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_policy_overrides")),
sa.UniqueConstraint(
"policy_family",
"target_key",
"scope_key",
name="uq_policy_override_family_target_scope",
),
)
for column in ("tenant_id", "scope_id", "created_by", "updated_by"):
op.create_index(
op.f(f"ix_policy_overrides_{column}"),
"policy_overrides",
[column],
unique=False,
)
op.create_index(
"ix_policy_overrides_resolution",
"policy_overrides",
[
"policy_family",
"target_key",
"tenant_id",
"scope_type",
"scope_id",
],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_policy_overrides_resolution",
table_name="policy_overrides",
)
for column in ("updated_by", "created_by", "scope_id", "tenant_id"):
op.drop_index(
op.f(f"ix_policy_overrides_{column}"),
table_name="policy_overrides",
)
op.drop_table("policy_overrides")
@@ -0,0 +1,204 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_policy.backend.db.models import PolicyOverride
POLICY_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user"})
POLICY_FAMILIES = frozenset({"definition", "distribution_channels", "view"})
class PolicyOverrideError(ValueError):
pass
def normalize_policy_target(policy_family: str, target_key: str) -> tuple[str, str]:
family = policy_family.strip().casefold()
target = target_key.strip().casefold()
if family not in POLICY_FAMILIES:
raise PolicyOverrideError(
"Policy family must be definition, distribution_channels, or view"
)
if not target or len(target) > 120:
raise PolicyOverrideError("Policy target must contain 1 to 120 characters")
if family == "view" and target != "*":
raise PolicyOverrideError("View policy uses the shared '*' target")
return family, target
def normalize_policy_scope(
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> tuple[str | None, str | None, str]:
clean_scope = scope_type.strip().casefold()
clean_id = str(scope_id or "").strip() or None
if clean_scope not in POLICY_SCOPE_TYPES:
raise PolicyOverrideError("Policy scope must be system, tenant, group, or user")
if clean_scope == "system":
if clean_id is not None:
raise PolicyOverrideError("System policy cannot declare a scope ID")
return None, None, "system"
if clean_scope == "tenant":
if clean_id not in {None, tenant_id}:
raise PolicyOverrideError("Tenant policy must target the active tenant")
return tenant_id, tenant_id, f"tenant:{tenant_id}"
if clean_id is None:
raise PolicyOverrideError(
f"{clean_scope.capitalize()} policy requires a scope ID"
)
return tenant_id, clean_id, f"{clean_scope}:{tenant_id}:{clean_id}"
def get_policy_override(
session: Session,
*,
policy_family: str,
target_key: str,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> PolicyOverride | None:
family, target = normalize_policy_target(policy_family, target_key)
_, _, scope_key = normalize_policy_scope(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
return (
session.query(PolicyOverride)
.filter(
PolicyOverride.policy_family == family,
PolicyOverride.target_key == target,
PolicyOverride.scope_key == scope_key,
)
.first()
)
def set_policy_override(
session: Session,
*,
policy_family: str,
target_key: str,
tenant_id: str,
scope_type: str,
scope_id: str | None,
policy: Any,
actor_id: str | None,
) -> PolicyOverride:
family, target = normalize_policy_target(policy_family, target_key)
row_tenant_id, clean_scope_id, scope_key = normalize_policy_scope(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
row = (
session.query(PolicyOverride)
.filter(
PolicyOverride.policy_family == family,
PolicyOverride.target_key == target,
PolicyOverride.scope_key == scope_key,
)
.first()
)
if row is None:
row = PolicyOverride(
policy_family=family,
target_key=target,
tenant_id=row_tenant_id,
scope_type=scope_type.strip().casefold(),
scope_id=clean_scope_id,
scope_key=scope_key,
policy=policy,
created_by=actor_id,
updated_by=actor_id,
)
session.add(row)
else:
row.policy = policy
row.revision += 1
row.updated_by = actor_id
session.flush()
return row
def delete_policy_override(session: Session, row: PolicyOverride) -> None:
session.delete(row)
session.flush()
def resolution_policy_overrides(
session: Session,
*,
policy_family: str,
target_keys: Iterable[str],
tenant_id: str,
group_ids: Iterable[str] = (),
user_ids: Iterable[str] = (),
) -> tuple[PolicyOverride, ...]:
family = policy_family.strip().casefold()
targets = tuple(
dict.fromkeys(
normalize_policy_target(family, target)[1] for target in target_keys
)
)
groups = tuple(sorted({str(value) for value in group_ids if str(value)}))
users = tuple(sorted({str(value) for value in user_ids if str(value)}))
scope_filters = [
PolicyOverride.scope_key == "system",
PolicyOverride.scope_key == f"tenant:{tenant_id}",
]
if groups:
scope_filters.append(
PolicyOverride.scope_key.in_(
[f"group:{tenant_id}:{group_id}" for group_id in groups]
)
)
if users:
scope_filters.append(
PolicyOverride.scope_key.in_(
[f"user:{tenant_id}:{user_id}" for user_id in users]
)
)
rows = (
session.query(PolicyOverride)
.filter(
PolicyOverride.policy_family == family,
PolicyOverride.target_key.in_(targets),
or_(*scope_filters),
)
.all()
)
target_order = {target: index for index, target in enumerate(targets)}
scope_order = {"system": 0, "tenant": 1, "group": 2, "user": 3}
return tuple(
sorted(
rows,
key=lambda row: (
scope_order.get(row.scope_type, 99),
row.scope_id or "",
target_order.get(row.target_key, 99),
row.id,
),
)
)
__all__ = [
"POLICY_FAMILIES",
"POLICY_SCOPE_TYPES",
"PolicyOverrideError",
"delete_policy_override",
"get_policy_override",
"normalize_policy_scope",
"normalize_policy_target",
"resolution_policy_overrides",
"set_policy_override",
]
@@ -0,0 +1,263 @@
"""Hierarchical privacy and export policy for cross-module reports."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.reporting import (
ReportingGovernanceDecision,
ReportingGovernanceRequest,
)
from govoplan_core.tenancy.scope import Tenant
from govoplan_policy.backend.retention import effective_privacy_policy
REPORTING_POLICY_SETTINGS_KEY = "reporting_governance_policy"
_ALLOWED_KEYS = {
"allow_exports",
"allowed_export_formats",
"allow_high_reidentification_risk",
"max_retention_days",
"required_privacy_transforms",
}
@dataclass(frozen=True, slots=True)
class _Policy:
allow_exports: bool = True
allowed_export_formats: tuple[str, ...] = ("json", "csv")
allow_high_reidentification_risk: bool = False
max_retention_days: int | None = 30
required_privacy_transforms: tuple[str, ...] = ()
class ReportingGovernancePolicyProvider:
def decide_reporting_action(
self,
session: object,
principal: object,
*,
request: ReportingGovernanceRequest,
) -> ReportingGovernanceDecision:
if not isinstance(session, Session):
raise TypeError("Reporting governance requires a SQLAlchemy Session")
try:
policy, sources = _effective_policy(
session,
tenant_id=request.tenant_id,
)
retention = effective_privacy_policy(
session,
tenant_id=request.tenant_id,
).stored_report_detail_retention_days
except (LookupError, TypeError, ValueError) as exc:
return ReportingGovernanceDecision(
allowed=False,
reason="Reporting governance Policy is malformed or unavailable.",
retention_days=0,
export_formats=(),
required_privacy_transforms=request.declared_privacy_transforms,
provenance={
"provider": "policy.reporting_governance",
"version": "1",
"decision": "fail_closed",
"error_type": type(exc).__name__,
},
)
del principal
retention_days = _minimum_optional(
policy.max_retention_days,
retention,
)
allowed = True
reason = None
if (
request.reidentification_risk == "high"
and not policy.allow_high_reidentification_risk
):
allowed = False
reason = "Policy blocks reports with high re-identification risk."
if request.action == "export":
if not policy.allow_exports:
allowed = False
reason = "Policy disables cross-module report exports."
elif request.export_format not in policy.allowed_export_formats:
allowed = False
reason = "Policy does not allow the requested report export format."
required = tuple(
sorted(
set(request.declared_privacy_transforms)
| set(policy.required_privacy_transforms)
)
)
return ReportingGovernanceDecision(
allowed=allowed,
reason=reason,
retention_days=retention_days,
export_formats=(
policy.allowed_export_formats if policy.allow_exports else ()
),
required_privacy_transforms=required,
provenance={
"provider": "policy.reporting_governance",
"version": "1",
"sources": sources,
"effective": {
"allow_exports": policy.allow_exports,
"allowed_export_formats": list(policy.allowed_export_formats),
"allow_high_reidentification_risk": (
policy.allow_high_reidentification_risk
),
"max_retention_days": policy.max_retention_days,
"privacy_retention_days": retention,
"required_privacy_transforms": list(required),
},
},
)
def _effective_policy(
session: Session,
*,
tenant_id: str,
) -> tuple[_Policy, list[dict[str, object]]]:
system_settings = get_system_settings(session).settings or {}
system_patch = _patch(system_settings, "system")
policy = _apply_patch(_Policy(), system_patch)
sources = [
{
"scope_type": "system",
"scope_id": None,
"applied_fields": sorted(system_patch),
}
]
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise LookupError("Tenant not found for Reporting Policy")
tenant_patch = _patch(tenant.settings or {}, "tenant")
policy = _tighten(policy, tenant_patch)
sources.append(
{
"scope_type": "tenant",
"scope_id": tenant_id,
"applied_fields": sorted(tenant_patch),
}
)
return policy, sources
def _patch(settings: Mapping[str, object], source: str) -> dict[str, object]:
raw = settings.get(REPORTING_POLICY_SETTINGS_KEY)
if raw in (None, ""):
return {}
if not isinstance(raw, Mapping):
raise ValueError(f"{source} Reporting Policy must be an object")
unknown = set(raw) - _ALLOWED_KEYS
if unknown:
raise ValueError(
f"{source} Reporting Policy has unknown fields: "
+ ", ".join(sorted(str(item) for item in unknown))
)
return dict(raw)
def _apply_patch(parent: _Policy, patch: Mapping[str, object]) -> _Policy:
return _Policy(
allow_exports=_boolean(patch, "allow_exports", parent.allow_exports),
allowed_export_formats=_formats(
patch.get("allowed_export_formats"),
default=parent.allowed_export_formats,
),
allow_high_reidentification_risk=_boolean(
patch,
"allow_high_reidentification_risk",
parent.allow_high_reidentification_risk,
),
max_retention_days=_days(
patch.get("max_retention_days"),
default=parent.max_retention_days,
),
required_privacy_transforms=_transforms(
patch.get("required_privacy_transforms"),
default=parent.required_privacy_transforms,
),
)
def _tighten(parent: _Policy, patch: Mapping[str, object]) -> _Policy:
child = _apply_patch(parent, patch)
return _Policy(
allow_exports=parent.allow_exports and child.allow_exports,
allowed_export_formats=tuple(
item
for item in parent.allowed_export_formats
if item in child.allowed_export_formats
),
allow_high_reidentification_risk=(
parent.allow_high_reidentification_risk
and child.allow_high_reidentification_risk
),
max_retention_days=_minimum_optional(
parent.max_retention_days,
child.max_retention_days,
),
required_privacy_transforms=tuple(
sorted(
set(parent.required_privacy_transforms)
| set(child.required_privacy_transforms)
)
),
)
def _boolean(
value: Mapping[str, object],
key: str,
default: bool,
) -> bool:
raw = value.get(key)
if raw is None:
return default
if not isinstance(raw, bool):
raise ValueError(f"Reporting Policy {key} must be boolean")
return raw
def _formats(value: object, *, default: tuple[str, ...]) -> tuple[str, ...]:
if value is None:
return default
if not isinstance(value, list) or any(
item not in {"json", "csv"} for item in value
):
raise ValueError("Reporting Policy export formats must contain json or csv")
return tuple(dict.fromkeys(str(item) for item in value))
def _transforms(value: object, *, default: tuple[str, ...]) -> tuple[str, ...]:
if value is None:
return default
if not isinstance(value, list) or any(
not isinstance(item, str) or not item.strip() for item in value
):
raise ValueError("Reporting Policy privacy transforms must be strings")
return tuple(sorted(set(item.strip() for item in value)))
def _days(value: object, *, default: int | None) -> int | None:
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError("Reporting Policy retention days must be a positive integer")
return value
def _minimum_optional(left: int | None, right: int | None) -> int | None:
values = [item for item in (left, right) if item is not None]
return min(values) if values else None
__all__ = ["ReportingGovernancePolicyProvider"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal, cast
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.core.policy import (
PolicySourceStep,
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyRequest,
SchedulingParticipantVisibility,
)
from govoplan_core.tenancy.scope import Tenant
SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY = "scheduling_participant_privacy_policy"
MAXIMUM_VISIBILITY_KEY = "maximum_visibility"
_AGGREGATES_ONLY: SchedulingParticipantVisibility = "aggregates_only"
_NAMES_AND_STATUSES: SchedulingParticipantVisibility = "names_and_statuses"
@dataclass(frozen=True, slots=True)
class _VisibilityCeiling:
value: SchedulingParticipantVisibility
configured: bool = False
issue: str | None = None
def _visibility_ceiling(settings_payload: object) -> _VisibilityCeiling:
"""Read one ceiling, treating malformed explicit policy as restrictive."""
if settings_payload is None:
return _VisibilityCeiling(value=_NAMES_AND_STATUSES)
if not isinstance(settings_payload, Mapping):
return _VisibilityCeiling(
value=_AGGREGATES_ONLY,
configured=True,
issue="invalid_settings_shape",
)
if SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY not in settings_payload:
return _VisibilityCeiling(value=_NAMES_AND_STATUSES)
raw_policy = settings_payload.get(SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY)
if not isinstance(raw_policy, Mapping) or MAXIMUM_VISIBILITY_KEY not in raw_policy:
return _VisibilityCeiling(
value=_AGGREGATES_ONLY,
configured=True,
issue="invalid_policy_shape",
)
raw_visibility = raw_policy.get(MAXIMUM_VISIBILITY_KEY)
if raw_visibility == _AGGREGATES_ONLY:
return _VisibilityCeiling(value=_AGGREGATES_ONLY, configured=True)
if raw_visibility == _NAMES_AND_STATUSES:
return _VisibilityCeiling(value=_NAMES_AND_STATUSES, configured=True)
return _VisibilityCeiling(
value=_AGGREGATES_ONLY,
configured=True,
issue="invalid_maximum_visibility",
)
def _restrict_visibility(
current: SchedulingParticipantVisibility,
ceiling: SchedulingParticipantVisibility,
) -> SchedulingParticipantVisibility:
if current == _AGGREGATES_ONLY or ceiling == _AGGREGATES_ONLY:
return _AGGREGATES_ONLY
return _NAMES_AND_STATUSES
def _source_step(
*,
scope_type: Literal["system", "tenant"],
scope_id: str | None,
label: str,
ceiling: _VisibilityCeiling,
) -> PolicySourceStep | None:
if not ceiling.configured:
return None
policy: dict[str, Any] = {MAXIMUM_VISIBILITY_KEY: ceiling.value}
if ceiling.issue is not None:
policy["configuration_status"] = "invalid_fail_closed"
return PolicySourceStep(
scope_type=scope_type,
scope_id=scope_id,
label=label,
applied_fields=(MAXIMUM_VISIBILITY_KEY,),
policy=policy,
)
def _invalid_requested_visibility(value: object) -> bool:
return value not in {_AGGREGATES_ONLY, _NAMES_AND_STATUSES}
class SqlSchedulingParticipantPrivacyPolicy:
"""Resolve Scheduling roster disclosure against system and tenant ceilings.
Scheduling owns the request-level visibility setting. This provider is an
optional upper bound: absent policy preserves that setting, while an
explicit or malformed policy may only reduce it.
"""
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
db_session = cast(Session, session)
configuration_errors: list[dict[str, str]] = []
requested_invalid = _invalid_requested_visibility(request.requested_visibility)
requested_visibility: SchedulingParticipantVisibility = (
_AGGREGATES_ONLY if requested_invalid else request.requested_visibility
)
if requested_invalid:
configuration_errors.append({"scope": "request", "code": "invalid_requested_visibility"})
system_settings = db_session.get(SystemSettings, SYSTEM_SETTINGS_ID)
system_ceiling = _visibility_ceiling(system_settings.settings if system_settings is not None else None)
if system_ceiling.issue is not None:
configuration_errors.append({"scope": "system", "code": system_ceiling.issue})
tenant = db_session.get(Tenant, request.tenant_id)
if tenant is None:
tenant_ceiling = _VisibilityCeiling(
value=_AGGREGATES_ONLY,
configured=True,
issue="tenant_not_found",
)
else:
tenant_ceiling = _visibility_ceiling(tenant.settings)
if tenant_ceiling.issue is not None:
configuration_errors.append({"scope": "tenant", "code": tenant_ceiling.issue})
policy_ceiling = _restrict_visibility(system_ceiling.value, tenant_ceiling.value)
effective_visibility = _restrict_visibility(requested_visibility, policy_ceiling)
source_path = tuple(
step
for step in (
_source_step(
scope_type="system",
scope_id=None,
label="System",
ceiling=system_ceiling,
),
_source_step(
scope_type="tenant",
scope_id=request.tenant_id,
label="Tenant",
ceiling=tenant_ceiling,
),
)
if step is not None
)
reason: str | None = None
if configuration_errors:
reason = (
"Participant roster visibility was restricted because policy configuration "
"could not be validated."
)
elif effective_visibility != requested_visibility:
reason = "Participant roster visibility is restricted by policy."
return SchedulingParticipantPrivacyDecision(
effective_visibility=effective_visibility,
reason=reason,
source_path=source_path,
details={
"requested_visibility": request.requested_visibility,
"policy_ceiling": policy_ceiling,
"configured_scopes": [
scope
for scope, ceiling in (("system", system_ceiling), ("tenant", tenant_ceiling))
if ceiling.configured
],
"configuration_errors": configuration_errors,
},
)
__all__ = [
"MAXIMUM_VISIBILITY_KEY",
"SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY",
"SqlSchedulingParticipantPrivacyPolicy",
]
@@ -0,0 +1,319 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.policy import (
PolicySourceStep,
ViewGovernanceDecision,
ViewGovernanceRequest,
)
from govoplan_core.db.session import get_database
from govoplan_policy.backend.policy_overrides import resolution_policy_overrides
VIEW_POLICY_BOOLEAN_FIELDS = (
"allow_view",
"allow_select",
"allow_assign",
"allow_edit",
"allow_derive",
"allow_workflow_activate",
)
VIEW_POLICY_SET_FIELDS = ("allowed_view_ids", "visible_surface_ids")
VIEW_POLICY_FIELDS = (*VIEW_POLICY_BOOLEAN_FIELDS, *VIEW_POLICY_SET_FIELDS)
@dataclass(frozen=True, slots=True)
class ViewPolicyResolution:
limits: Mapping[str, bool]
allowed_view_ids: frozenset[str] | None = None
visible_surface_ids: frozenset[str] | None = None
source_path: tuple[PolicySourceStep, ...] = ()
diagnostics: tuple[Mapping[str, Any], ...] = ()
class ViewGovernancePolicyProvider:
def resolve_view_action(
self,
session: object | None = None,
*,
request: ViewGovernanceRequest,
) -> ViewGovernanceDecision:
resolution = _explicit_view_policy_resolution(session, request)
action_field = f"allow_{request.action}"
allowed = resolution.limits["allow_view"] and resolution.limits.get(
action_field,
False,
)
reason = None
if not allowed:
reason = f"View action '{request.action}' is disabled by Policy."
allowed_view_ids = _bounded_candidates(
request.candidate_view_ids,
resolution.allowed_view_ids,
)
visible_surface_ids = _bounded_candidates(
request.candidate_surface_ids,
resolution.visible_surface_ids,
)
unavailable_view_ids = sorted(
set(request.candidate_view_ids) - set(allowed_view_ids or ())
if resolution.allowed_view_ids is not None
else ()
)
hidden_surface_ids = sorted(
set(request.candidate_surface_ids) - set(visible_surface_ids or ())
if resolution.visible_surface_ids is not None
else ()
)
if (
request.view_id is not None
and resolution.allowed_view_ids is not None
and request.view_id not in resolution.allowed_view_ids
):
allowed = False
reason = "The requested View is outside the effective Policy ceiling."
requested_outside_ceiling = sorted(
set(request.requested_surface_ids) - set(visible_surface_ids or ())
if resolution.visible_surface_ids is not None
else ()
)
if requested_outside_ceiling and request.action in {"edit", "derive"}:
allowed = False
reason = (
"The requested View surfaces are outside the effective Policy ceiling."
)
diagnostics = list(resolution.diagnostics)
if requested_outside_ceiling:
diagnostics.append(
{
"code": "view_policy.workflow_surface_bounded",
"severity": "warning",
"surface_ids": requested_outside_ceiling,
"message": (
"Workflow surfaces outside the effective Policy ceiling "
"remain hidden."
),
}
)
return ViewGovernanceDecision(
allowed=allowed,
reason=reason,
allowed_view_ids=allowed_view_ids,
visible_surface_ids=visible_surface_ids,
source_path=resolution.source_path,
requirements=(() if allowed else (f"policy.view.{request.action}",)),
diagnostics=tuple(diagnostics),
details={
"action": request.action,
"target_scope": request.target_scope.path,
"unavailable_view_ids": unavailable_view_ids,
"hidden_surface_ids": hidden_surface_ids,
"requested_surfaces_outside_ceiling": requested_outside_ceiling,
"view_provenance": _excluded_candidate_provenance(
request.candidate_view_ids,
resolution.source_path,
field="allowed_view_ids",
),
"surface_provenance": _excluded_candidate_provenance(
request.candidate_surface_ids,
resolution.source_path,
field="visible_surface_ids",
),
},
)
def _bounded_candidates(
candidates: tuple[str, ...],
ceiling: frozenset[str] | None,
) -> frozenset[str] | None:
if ceiling is None:
return None
return frozenset(candidates).intersection(ceiling)
def _excluded_candidate_provenance(
candidates: tuple[str, ...],
source_path: tuple[PolicySourceStep, ...],
*,
field: str,
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for candidate in candidates:
sources = [
step.path
for step in source_path
if isinstance(step.policy.get(field), list)
and candidate not in step.policy[field]
]
if sources:
result.append({"id": candidate, "sources": sources})
return result
def _explicit_view_policy_resolution(
session: object | None,
request: ViewGovernanceRequest,
) -> ViewPolicyResolution:
if not isinstance(session, Session):
try:
with get_database().SessionLocal() as policy_session:
return _explicit_view_policy_resolution(policy_session, request)
except RuntimeError:
return ViewPolicyResolution(
limits={field: True for field in VIEW_POLICY_BOOLEAN_FIELDS}
)
cache_key = (
"view",
request.tenant_id,
request.target_scope.path,
tuple(sorted(_target_group_ids(request))),
tuple(sorted(_target_user_ids(request))),
)
cache = session.info.setdefault("govoplan_policy_override_resolution", {})
if isinstance(cache, dict) and cache_key in cache:
cached = cache[cache_key]
if isinstance(cached, ViewPolicyResolution):
return cached
rows = resolution_policy_overrides(
session,
policy_family="view",
target_keys=("*",),
tenant_id=request.tenant_id,
group_ids=_target_group_ids(request),
user_ids=_target_user_ids(request),
)
result = resolve_view_policy_rows(rows)
if isinstance(cache, dict):
cache[cache_key] = result
return result
def _target_group_ids(request: ViewGovernanceRequest) -> tuple[str, ...]:
target = request.target_scope
if target.scope_type == "group" and target.scope_id:
return (target.scope_id,)
if target.scope_type == "user" and target.scope_id in {
request.actor.account_id,
request.actor.membership_id,
}:
return tuple(sorted(request.actor.group_ids))
return ()
def _target_user_ids(request: ViewGovernanceRequest) -> tuple[str, ...]:
target = request.target_scope
if target.scope_type == "user" and target.scope_id:
return (target.scope_id,)
return ()
def resolve_view_policy_rows(rows: object) -> ViewPolicyResolution:
limits = {field: True for field in VIEW_POLICY_BOOLEAN_FIELDS}
allowed_view_ids: frozenset[str] | None = None
visible_surface_ids: frozenset[str] | None = None
source_path: list[PolicySourceStep] = []
diagnostics: list[Mapping[str, Any]] = []
for row in rows if isinstance(rows, (list, tuple)) else ():
policy, malformed = validate_view_policy(row.policy)
if malformed:
limits.update({field: False for field in VIEW_POLICY_BOOLEAN_FIELDS})
allowed_view_ids = frozenset()
visible_surface_ids = frozenset()
applied_fields = VIEW_POLICY_FIELDS
source_policy: Mapping[str, Any] = {
"configuration_status": "invalid_fail_closed",
"target_key": row.target_key,
}
diagnostics.append(
{
"code": "view_policy.invalid",
"severity": "error",
"scope": row.scope_key,
"message": "A malformed View policy record was ignored safely.",
}
)
else:
for field in VIEW_POLICY_BOOLEAN_FIELDS:
value = policy.get(field)
if isinstance(value, bool):
limits[field] = limits[field] and value
allowed = policy.get("allowed_view_ids")
if isinstance(allowed, tuple):
candidate = frozenset(allowed)
allowed_view_ids = (
candidate
if allowed_view_ids is None
else allowed_view_ids.intersection(candidate)
)
visible = policy.get("visible_surface_ids")
if isinstance(visible, tuple):
candidate = frozenset(visible)
visible_surface_ids = (
candidate
if visible_surface_ids is None
else visible_surface_ids.intersection(candidate)
)
applied_fields = tuple(sorted(policy))
source_policy = {
key: list(value) if isinstance(value, tuple) else value
for key, value in policy.items()
}
source_path.append(
PolicySourceStep(
scope_type=row.scope_type, # type: ignore[arg-type]
scope_id=row.scope_id,
label=f"{row.scope_type.capitalize()} View policy",
applied_fields=tuple(applied_fields),
policy=source_policy,
)
)
return ViewPolicyResolution(
limits=limits,
allowed_view_ids=allowed_view_ids,
visible_surface_ids=visible_surface_ids,
source_path=tuple(source_path),
diagnostics=tuple(diagnostics),
)
def validate_view_policy(
value: object,
) -> tuple[dict[str, bool | tuple[str, ...]], bool]:
if not isinstance(value, Mapping):
return {}, True
if any(str(key) not in VIEW_POLICY_FIELDS for key in value):
return {}, True
result: dict[str, bool | tuple[str, ...]] = {}
for raw_key, raw_value in value.items():
key = str(raw_key)
if key in VIEW_POLICY_BOOLEAN_FIELDS:
if not isinstance(raw_value, bool):
return {}, True
result[key] = raw_value
continue
if not isinstance(raw_value, (list, tuple)):
return {}, True
values = tuple(dict.fromkeys(str(item).strip() for item in raw_value))
if any(not item or len(item) > 160 for item in values):
return {}, True
result[key] = values
return result, False
__all__ = [
"VIEW_POLICY_BOOLEAN_FIELDS",
"VIEW_POLICY_FIELDS",
"VIEW_POLICY_SET_FIELDS",
"ViewGovernancePolicyProvider",
"ViewPolicyResolution",
"resolve_view_policy_rows",
"validate_view_policy",
]
@@ -0,0 +1,244 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from sqlalchemy.orm import Session
from govoplan_policy.backend.db.models import PolicyOverride
from govoplan_policy.backend.policy_overrides import (
delete_policy_override,
get_policy_override,
normalize_policy_scope,
resolution_policy_overrides,
set_policy_override,
)
from govoplan_policy.backend.view_governance import (
VIEW_POLICY_BOOLEAN_FIELDS,
ViewPolicyResolution,
resolve_view_policy_rows,
validate_view_policy,
)
class ViewPolicyError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ViewPolicyState:
row: PolicyOverride | None
local_policy: Mapping[str, bool | tuple[str, ...]]
effective: ViewPolicyResolution
parent: ViewPolicyResolution
def view_policy_state(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> ViewPolicyState:
_, clean_scope_id, _ = normalize_policy_scope(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
row = get_policy_override(
session,
policy_family="view",
target_key="*",
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=clean_scope_id,
)
local_policy, malformed = validate_view_policy(
row.policy if row is not None else {}
)
if malformed:
local_policy = {}
rows = _rows_for_scope(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=clean_scope_id,
)
rank = _scope_rank(scope_type)
return ViewPolicyState(
row=row,
local_policy=local_policy,
effective=resolve_view_policy_rows(rows),
parent=resolve_view_policy_rows(
tuple(item for item in rows if _scope_rank(item.scope_type) < rank)
),
)
def save_view_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
policy: object,
actor_id: str | None,
) -> ViewPolicyState:
clean_policy, malformed = validate_view_policy(policy)
if malformed:
raise ViewPolicyError("View policy fields have invalid names or values")
before = view_policy_state(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
broadened = sorted(
field
for field in VIEW_POLICY_BOOLEAN_FIELDS
if clean_policy.get(field) is True and before.parent.limits[field] is False
)
for field, parent_values in (
("allowed_view_ids", before.parent.allowed_view_ids),
("visible_surface_ids", before.parent.visible_surface_ids),
):
local_values = clean_policy.get(field)
if (
isinstance(local_values, tuple)
and parent_values is not None
and not set(local_values).issubset(parent_values)
):
broadened.append(field)
if broadened:
raise ViewPolicyError(
"Lower-scope View policy cannot broaden parent restrictions: "
+ ", ".join(broadened)
)
set_policy_override(
session,
policy_family="view",
target_key="*",
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
policy={
key: list(value) if isinstance(value, tuple) else value
for key, value in clean_policy.items()
},
actor_id=actor_id,
)
_clear_resolution_cache(session)
return view_policy_state(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
def remove_view_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> bool:
row = get_policy_override(
session,
policy_family="view",
target_key="*",
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
if row is None:
return False
delete_policy_override(session, row)
_clear_resolution_cache(session)
return True
def view_policy_response_payload(state: ViewPolicyState) -> dict[str, Any]:
local_policy: Mapping[str, Any] = state.local_policy
if state.row is not None:
_, malformed = validate_view_policy(state.row.policy)
if malformed:
local_policy = {"configuration_status": "invalid_fail_closed"}
return {
"id": state.row.id if state.row is not None else None,
"revision": state.row.revision if state.row is not None else None,
"policy": _json_policy(local_policy),
"effective_policy": _resolution_payload(state.effective),
"parent_policy": _resolution_payload(state.parent),
"source_path": [step.to_dict() for step in state.effective.source_path],
"diagnostics": [dict(item) for item in state.effective.diagnostics],
}
def _resolution_payload(resolution: ViewPolicyResolution) -> dict[str, Any]:
return {
**dict(resolution.limits),
"allowed_view_ids": (
sorted(resolution.allowed_view_ids)
if resolution.allowed_view_ids is not None
else None
),
"visible_surface_ids": (
sorted(resolution.visible_surface_ids)
if resolution.visible_surface_ids is not None
else None
),
}
def _json_policy(policy: Mapping[str, Any]) -> dict[str, Any]:
return {
key: list(value) if isinstance(value, tuple) else value
for key, value in policy.items()
}
def _rows_for_scope(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> tuple[PolicyOverride, ...]:
clean_scope = scope_type.strip().casefold()
rows = resolution_policy_overrides(
session,
policy_family="view",
target_keys=("*",),
tenant_id=tenant_id,
group_ids=(scope_id,) if clean_scope == "group" and scope_id else (),
user_ids=(scope_id,) if clean_scope == "user" and scope_id else (),
)
maximum_rank = _scope_rank(clean_scope)
return tuple(row for row in rows if _scope_rank(row.scope_type) <= maximum_rank)
def _scope_rank(scope_type: str) -> int:
try:
return ("system", "tenant", "group", "user").index(
scope_type.strip().casefold()
)
except ValueError as exc:
raise ViewPolicyError(
"View policy scope must be system, tenant, group, or user"
) from exc
def _clear_resolution_cache(session: Session) -> None:
session.info.pop("govoplan_policy_override_resolution", None)
__all__ = [
"ViewPolicyError",
"ViewPolicyState",
"remove_view_policy",
"save_view_policy",
"view_policy_response_payload",
"view_policy_state",
]
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
import unittest
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import (
DefinitionGovernanceRequest,
DefinitionScopeRef,
)
from govoplan_policy.backend.definition_governance import (
DefinitionGovernancePolicyProvider,
)
class DefinitionGovernancePolicyTests(unittest.TestCase):
def setUp(self) -> None:
self.policy = DefinitionGovernancePolicyProvider()
self.actor = PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
group_ids=frozenset({"group-1"}),
)
def _resolve(self, **overrides):
values = {
"module_id": "dataflow",
"definition_ref": "pipeline:1",
"tenant_id": "tenant-1",
"definition_scope": DefinitionScopeRef(
"tenant",
"tenant-1",
),
"target_scope": DefinitionScopeRef("tenant", "tenant-1"),
"definition_kind": "flow",
"action": "view",
"actor": self.actor,
"status": "active",
"inherit_to_lower_scopes": False,
"allow_run": True,
"allow_reuse": False,
"allow_automation": False,
}
values.update(overrides)
return self.policy.resolve_definition_action(
request=DefinitionGovernanceRequest(**values)
)
def test_system_definition_is_read_only_when_inherited(self) -> None:
view = self._resolve(
definition_scope=DefinitionScopeRef("system"),
inherit_to_lower_scopes=True,
)
edit = self._resolve(
definition_scope=DefinitionScopeRef("system"),
inherit_to_lower_scopes=True,
action="edit",
)
self.assertTrue(view.allowed)
self.assertFalse(edit.allowed)
self.assertEqual(
["system", "tenant:tenant-1"],
[step.path for step in view.source_path],
)
def test_templates_cannot_run_or_be_automated(self) -> None:
run = self._resolve(
definition_kind="template",
action="run",
allow_run=True,
)
automated = self._resolve(
definition_kind="template",
action="automate",
allow_run=True,
allow_automation=True,
)
self.assertFalse(run.allowed)
self.assertFalse(automated.allowed)
def test_reuse_and_automation_are_independent_narrowing_grants(self) -> None:
reuse = self._resolve(action="reuse", allow_reuse=True)
automated = self._resolve(
action="automate",
allow_run=True,
allow_automation=False,
)
self.assertTrue(reuse.allowed)
self.assertFalse(automated.allowed)
def test_ancestor_limits_cannot_be_broadened(self) -> None:
decision = self._resolve(
action="reuse",
allow_reuse=True,
context={
"ancestor_limits": {"allow_reuse": False},
"ancestor_source": {
"scope_type": "system",
"label": "System template",
},
},
)
self.assertFalse(decision.allowed)
self.assertFalse(
decision.details["effective_limits"]["allow_reuse"]
)
self.assertEqual("system", decision.source_path[0].path)
def test_ancestor_inheritance_limit_controls_visibility(self) -> None:
decision = self._resolve(
definition_scope=DefinitionScopeRef("system"),
inherit_to_lower_scopes=True,
context={
"ancestor_limits": {
"inherit_to_lower_scopes": False,
},
"ancestor_source": {
"scope_type": "system",
"label": "Restricted ancestor",
},
},
)
self.assertFalse(decision.allowed)
self.assertEqual(
"The system definition is not inherited by lower scopes.",
decision.reason,
)
def test_group_and_user_scopes_are_visible_only_to_the_actor(self) -> None:
group = self._resolve(
definition_scope=DefinitionScopeRef("group", "group-1")
)
user = self._resolve(
definition_scope=DefinitionScopeRef("user", "user-2")
)
self.assertTrue(group.allowed)
self.assertFalse(user.allowed)
if __name__ == "__main__":
unittest.main()
+215
View File
@@ -0,0 +1,215 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import DefinitionGovernanceRequest, DefinitionScopeRef
from govoplan_policy.backend.db.models import PolicyOverride
from govoplan_policy.backend.definition_governance import (
DEFINITION_POLICY_FIELDS,
DefinitionGovernancePolicyProvider,
)
from govoplan_policy.backend.definition_policy_service import (
DefinitionPolicyError,
definition_policy_response_payload,
definition_policy_state,
save_definition_policy,
)
class DefinitionPolicyOverrideTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
PolicyOverride.__table__.create(self.engine)
self.session_factory = sessionmaker(
bind=self.engine,
expire_on_commit=False,
)
self.session: Session = self.session_factory()
self.provider = DefinitionGovernancePolicyProvider()
self.actor = PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
group_ids=frozenset({"group-1"}),
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _save(
self,
*,
scope_type: str,
policy: object,
scope_id: str | None = None,
module_id: str = "dataflow",
):
return save_definition_policy(
self.session,
module_id=module_id,
tenant_id="tenant-1",
scope_type=scope_type,
scope_id=scope_id,
policy=policy,
actor_id="account-1",
)
def _resolve(self, *, action: str, definition_scope: DefinitionScopeRef):
return self.provider.resolve_definition_action(
self.session,
request=DefinitionGovernanceRequest(
module_id="dataflow",
definition_ref="pipeline:1",
tenant_id="tenant-1",
definition_scope=definition_scope,
target_scope=DefinitionScopeRef("tenant", "tenant-1"),
definition_kind="flow",
action=action, # type: ignore[arg-type]
actor=self.actor,
status="active",
inherit_to_lower_scopes=True,
allow_run=True,
allow_reuse=True,
allow_automation=True,
),
)
def test_persists_and_revises_an_explicit_policy(self) -> None:
first = self._save(scope_type="tenant", policy={"allow_run": False})
second = self._save(
scope_type="tenant",
policy={"allow_run": False, "allow_reuse": False},
)
self.assertEqual(first.row.id, second.row.id)
self.assertEqual(2, second.row.revision)
self.assertEqual(
{"allow_run": False, "allow_reuse": False},
second.local_policy,
)
def test_system_and_tenant_limits_apply_to_group_and_user_targets(self) -> None:
self._save(
scope_type="system",
module_id="*",
policy={"allow_automation": False},
)
self._save(
scope_type="tenant",
policy={"allow_reuse": False},
)
group = definition_policy_state(
self.session,
module_id="dataflow",
tenant_id="tenant-1",
scope_type="group",
scope_id="group-1",
)
user = definition_policy_state(
self.session,
module_id="dataflow",
tenant_id="tenant-1",
scope_type="user",
scope_id="user-1",
)
for state in (group, user):
self.assertFalse(state.effective.limits["allow_automation"])
self.assertFalse(state.effective.limits["allow_reuse"])
self.assertEqual(
["system", "tenant:tenant-1"],
[step.path for step in state.effective.source_path],
)
def test_lower_scopes_cannot_broaden_parent_restrictions(self) -> None:
self._save(scope_type="tenant", policy={"allow_run": False})
with self.assertRaisesRegex(
DefinitionPolicyError,
"cannot broaden parent restrictions: allow_run",
):
self._save(
scope_type="group",
scope_id="group-1",
policy={"allow_run": True},
)
def test_explicit_policy_restricts_provider_actions(self) -> None:
self._save(
scope_type="tenant",
policy={"allow_edit": False, "allow_reuse": False},
)
edit = self._resolve(
action="edit",
definition_scope=DefinitionScopeRef("tenant", "tenant-1"),
)
reuse = self._resolve(
action="reuse",
definition_scope=DefinitionScopeRef("group", "group-1"),
)
self.assertFalse(edit.allowed)
self.assertFalse(reuse.allowed)
self.assertEqual(
"Editing is disabled by explicit Policy restrictions.",
edit.reason,
)
self.assertEqual("tenant:tenant-1", edit.source_path[0].path)
def test_malformed_persisted_policy_fails_closed_and_is_redacted(self) -> None:
row = PolicyOverride(
policy_family="definition",
target_key="dataflow",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
scope_key="tenant:tenant-1",
policy={"allow_run": "definitely", "secret": "do-not-echo"},
)
self.session.add(row)
self.session.flush()
decision = self._resolve(
action="run",
definition_scope=DefinitionScopeRef("tenant", "tenant-1"),
)
state = definition_policy_state(
self.session,
module_id="dataflow",
tenant_id="tenant-1",
scope_type="tenant",
)
payload = definition_policy_response_payload(state)
self.assertFalse(decision.allowed)
self.assertEqual(
{field: False for field in DEFINITION_POLICY_FIELDS},
decision.details["effective_limits"],
)
self.assertEqual(
[
{
"code": "definition_policy.invalid",
"scope": "tenant:tenant-1",
"target_key": "dataflow",
}
],
decision.details["policy_diagnostics"],
)
self.assertEqual(
{"configuration_status": "invalid_fail_closed"},
payload["policy"],
)
self.assertNotIn("secret", repr(decision.to_dict()))
self.assertNotIn("do-not-echo", repr(payload))
if __name__ == "__main__":
unittest.main()
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from datetime import UTC, datetime
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.distribution_lists import (
DistributionChannelCandidate,
DistributionChannelPolicyRequest,
DistributionRecipientRef,
)
from govoplan_policy.backend.db.models import PolicyOverride
from govoplan_policy.backend.distribution_channels import (
DistributionChannelPolicyProvider,
resolve_distribution_channel_policy_rows,
validate_distribution_channel_policy,
)
class _Principal:
account_id = "account-1"
membership_id = "membership-1"
group_ids = frozenset({"group-1"})
def _request(channel: str = "email") -> DistributionChannelPolicyRequest:
candidate = DistributionChannelCandidate(
channel=channel, # type: ignore[arg-type]
target="recipient@example.test",
target_key=f"{channel}:recipient@example.test",
)
return DistributionChannelPolicyRequest(
tenant_id="tenant-1",
list_id="list-1",
purpose="monthly-notice",
effective_at=datetime(2026, 1, 1, tzinfo=UTC),
recipient=DistributionRecipientRef(
recipient_key="recipient-1",
display_name="Recipient",
status="usable",
channels=(candidate,),
),
candidate=candidate,
)
class DistributionChannelPolicyTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
PolicyOverride.__table__.create(self.engine)
def tearDown(self) -> None:
self.engine.dispose()
def test_hierarchy_can_only_reduce_permitted_channels(self) -> None:
with Session(self.engine) as session:
session.add_all(
(
PolicyOverride(
policy_family="distribution_channels",
target_key="*",
tenant_id=None,
scope_type="system",
scope_id=None,
scope_key="system",
policy={"allowed_channels": ["email", "postal"]},
),
PolicyOverride(
policy_family="distribution_channels",
target_key="purpose:monthly-notice",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
scope_key="tenant:tenant-1",
policy={"blocked_channels": ["postal"]},
),
)
)
session.commit()
provider = DistributionChannelPolicyProvider()
email = provider.resolve_distribution_channel(
session,
_Principal(),
request=_request("email"),
)
postal = provider.resolve_distribution_channel(
session,
_Principal(),
request=_request("postal"),
)
self.assertTrue(email.allowed)
self.assertFalse(postal.allowed)
self.assertEqual("policy.channel_blocked", postal.reason_code)
self.assertEqual(2, len(postal.source_path))
def test_malformed_policy_fails_closed_with_diagnostic(self) -> None:
row = type(
"Row",
(),
{
"policy": {"allowed_channels": "email"},
"target_key": "*",
"scope_type": "tenant",
"scope_id": "tenant-1",
"scope_key": "tenant:tenant-1",
},
)()
result = resolve_distribution_channel_policy_rows((row,))
self.assertEqual(frozenset(), result.allowed_channels)
self.assertEqual("distribution_channel_policy.invalid", result.diagnostics[0]["code"])
def test_schema_rejects_unknown_channels_and_fields(self) -> None:
self.assertEqual(
({"allowed_channels": ("email",)}, False),
validate_distribution_channel_policy({"allowed_channels": ["email"]}),
)
self.assertTrue(validate_distribution_channel_policy({"allowed_channels": ["fax"]})[1])
self.assertTrue(validate_distribution_channel_policy({"unknown": []})[1])
def test_long_purpose_is_resolved_without_exceeding_storage_key_limit(self) -> None:
request = _request("email")
request = DistributionChannelPolicyRequest(
tenant_id=request.tenant_id,
list_id=request.list_id,
purpose="purpose-" + "x" * 120,
effective_at=request.effective_at,
recipient=request.recipient,
candidate=request.candidate,
)
with Session(self.engine) as session:
decision = DistributionChannelPolicyProvider().resolve_distribution_channel(
session,
_Principal(),
request=request,
)
self.assertTrue(decision.allowed)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,137 @@
from __future__ import annotations
import unittest
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import FunctionAssignmentGovernanceRequest
from govoplan_policy.backend.function_assignment_governance import (
FunctionAssignmentGovernancePolicyProvider,
)
class FunctionAssignmentGovernancePolicyTests(unittest.TestCase):
def setUp(self) -> None:
self.provider = FunctionAssignmentGovernancePolicyProvider()
self.actor = PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
identity_id="identity-1",
)
def resolve(self, **overrides):
values = {
"tenant_id": "tenant-1",
"kind": "request",
"action": "submit",
"function_id": "function-1",
"actor": self.actor,
"candidate_identity_id": "identity-1",
"function_settings": {
"assignment_governance": {
"request_profile": "holder_with_authority_clearance",
"grant_profile": "holder_with_authority_clearance",
"authority_function_id": "authority-1",
},
},
"context": {
"candidate_is_actor": True,
"actor_is_holder": False,
"actor_is_authority": False,
"has_evidence": True,
},
}
values.update(overrides)
return self.provider.resolve_function_assignment_action(
request=FunctionAssignmentGovernanceRequest(**values)
)
def test_self_request_resolves_holder_and_authority_steps(self) -> None:
decision = self.resolve()
self.assertTrue(decision.allowed)
self.assertEqual(("holder", "authority"), decision.required_steps)
self.assertEqual("authority-1", decision.authority_function_id)
def test_grant_profiles_recheck_holder_and_authority(self) -> None:
holder_grant = self.resolve(
kind="grant",
context={"actor_is_holder": True, "has_evidence": True},
)
unauthorized = self.resolve(
kind="grant",
context={"actor_is_holder": False, "has_evidence": True},
)
authority_approval = self.resolve(
kind="grant",
action="approve_authority",
context={"actor_is_authority": True, "has_evidence": True},
)
self.assertTrue(holder_grant.allowed)
self.assertIn("recipient", holder_grant.required_steps)
self.assertFalse(unauthorized.allowed)
self.assertTrue(authority_approval.allowed)
def test_missing_authority_and_evidence_fail_closed(self) -> None:
decision = self.resolve(
function_settings={
"assignment_governance": {
"request_profile": "holder_with_authority_clearance",
"evidence_required": True,
},
},
context={"candidate_is_actor": True, "has_evidence": False},
)
self.assertFalse(decision.allowed)
self.assertEqual(
("authority_function", "evidence"),
decision.requirements,
)
def test_rejection_is_limited_to_the_current_reviewer(self) -> None:
holder_reject = self.resolve(
action="reject",
current_state="awaiting_holder",
context={"actor_is_holder": True},
)
authority_cannot_reject_holder_step = self.resolve(
action="reject",
current_state="awaiting_holder",
context={"actor_is_authority": True},
)
recipient_reject = self.resolve(
action="reject",
current_state="awaiting_recipient",
context={"candidate_is_actor": True},
)
self.assertTrue(holder_reject.allowed)
self.assertFalse(authority_cannot_reject_holder_step.allowed)
self.assertTrue(recipient_reject.allowed)
def test_change_request_and_response_follow_current_participants(self) -> None:
reviewer = self.resolve(
action="request_changes",
current_state="awaiting_holder",
context={"actor_is_holder": True},
)
responder = self.resolve(
action="respond",
current_state="changes_requested",
context={"actor_is_initiator": True},
)
unrelated = self.resolve(
action="respond",
current_state="changes_requested",
context={},
)
self.assertTrue(reviewer.allowed)
self.assertTrue(responder.allowed)
self.assertFalse(unrelated.allowed)
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -2,6 +2,14 @@ from __future__ import annotations
import unittest 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 ( from govoplan_policy.backend.hierarchy import (
PolicyRestrictionRule, PolicyRestrictionRule,
simulate_hierarchical_policy_change, simulate_hierarchical_policy_change,
@@ -10,6 +18,36 @@ from govoplan_policy.backend.hierarchy import (
class PolicyHierarchyTests(unittest.TestCase): 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: def test_parent_locks_block_lower_level_field_changes_and_limit_reenable(self) -> None:
issues = validate_hierarchical_policy_patch( issues = validate_hierarchical_policy_patch(
parent_policy={"retention_days": 30}, parent_policy={"retention_days": 30},
+71 -3
View File
@@ -4,17 +4,36 @@ import pathlib
import tomllib import tomllib
import unittest import unittest
from govoplan_core.core.policy import (
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
CAPABILITY_POLICY_PRIVACY_RETENTION,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
CAPABILITY_POLICY_VIEW_GOVERNANCE,
)
from govoplan_core.core.distribution_lists import (
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
)
from govoplan_core.core.reporting import CAPABILITY_POLICY_REPORTING_GOVERNANCE
from govoplan_policy.backend.manifest import manifest
ROOT = pathlib.Path(__file__).resolve().parents[1] ROOT = pathlib.Path(__file__).resolve().parents[1]
class PolicyModuleContractTests(unittest.TestCase): class PolicyModuleContractTests(unittest.TestCase):
def test_policy_package_does_not_hard_require_access(self) -> None: def test_policy_package_does_not_hard_require_access(self) -> None:
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[
"project"
]
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.6", dependencies) self.assertTrue(
self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies)) 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: def test_policy_source_does_not_import_access_implementation(self) -> None:
offenders: list[str] = [] offenders: list[str] = []
@@ -25,6 +44,55 @@ class PolicyModuleContractTests(unittest.TestCase):
self.assertEqual([], offenders) self.assertEqual([], offenders)
def test_policy_manifest_exposes_policy_capabilities(self) -> None:
self.assertEqual(
{
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
CAPABILITY_POLICY_PRIVACY_RETENTION,
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
CAPABILITY_POLICY_VIEW_GOVERNANCE,
},
set(manifest.capability_factories),
)
def test_retention_documentation_exposes_stable_help_contexts(self) -> None:
topic = next(
item
for item in manifest.documentation
if item.id == "policy.hierarchy-overrides-and-retention"
)
self.assertEqual(
["policy.retention", "privacy.retention"],
topic.metadata["help_contexts"],
)
self.assertEqual("workflow", topic.metadata["kind"])
self.assertIn("/admin", topic.metadata["route"])
def test_view_policy_administration_contract_is_documented_and_exposed(self) -> None:
topic = next(
item
for item in manifest.documentation
if item.id == "policy.view-governance-administration"
)
self.assertIn("policy.view-governance", topic.metadata["help_contexts"])
self.assertEqual(
{
"policy.admin.system-view-policy",
"policy.admin.tenant-view-policy",
"policy.admin.group-view-policy",
"policy.admin.user-view-policy",
},
{
surface.id
for surface in manifest.frontend.view_surfaces
if "view-policy" in surface.id
},
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.reporting import ReportingGovernanceRequest
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
from govoplan_policy.backend.reporting_governance import (
ReportingGovernancePolicyProvider,
)
from govoplan_policy.backend import retention as retention_module
def _request(action: str, *, export_format: str | None = None):
return ReportingGovernanceRequest(
action=action, # type: ignore[arg-type]
tenant_id="tenant-1",
provider_id="campaigns",
report_id="delivery-outcomes",
purpose="Operational review",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
retention_class="stored_report_detail",
export_format=export_format,
reidentification_risk="low",
declared_privacy_transforms=("small_cell_suppression",),
applied_privacy_transforms=("small_cell_suppression",),
)
def test_tenant_reporting_policy_can_tighten_export_and_retention() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
create_scope_tables(engine)
Base.metadata.create_all(engine)
with Session(engine) as session:
session.add(
Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
settings={
"privacy_retention_policy": {
"stored_report_detail_retention_days": 10
},
"reporting_governance_policy": {
"allow_exports": False,
"required_privacy_transforms": ["explicit_denominator"],
},
},
)
)
session.commit()
provider = ReportingGovernancePolicyProvider()
execute = provider.decide_reporting_action(
session,
object(),
request=_request("execute"),
)
export = provider.decide_reporting_action(
session,
object(),
request=_request("export", export_format="json"),
)
assert execute.allowed is True
assert execute.retention_days == 10
assert set(execute.required_privacy_transforms) == {
"small_cell_suppression",
"explicit_denominator",
}
assert export.allowed is False
assert export.export_formats == ()
engine.dispose()
def test_malformed_reporting_policy_fails_closed() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
create_scope_tables(engine)
Base.metadata.create_all(engine)
with Session(engine) as session:
session.add(
Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
settings={"reporting_governance_policy": {"allow_exports": "yes"}},
)
)
session.commit()
decision = ReportingGovernancePolicyProvider().decide_reporting_action(
session,
object(),
request=_request("execute"),
)
assert decision.allowed is False
assert decision.provenance["decision"] == "fail_closed"
engine.dispose()
def test_shared_retention_run_invokes_reporting_without_model_imports(
monkeypatch,
) -> None:
class _ReportingRetention:
def apply_retention(self, session, *, dry_run, now, limit=500):
del session, now, limit
return {
"eligible": 2,
"redacted": 0 if dry_run else 2,
"remaining_in_batch": 0,
}
class _Registry:
def has_capability(self, name):
return name == "reporting.retention"
def require_capability(self, name):
assert name == "reporting.retention"
return _ReportingRetention()
monkeypatch.setattr(retention_module, "get_registry", lambda: _Registry())
engine = create_engine("sqlite+pysqlite:///:memory:")
create_scope_tables(engine)
Base.metadata.create_all(engine)
with Session(engine) as session:
result = retention_module.apply_retention_policy(session, dry_run=False)
assert result["counts"]["stored_report_detail"]["provider_reports"] == {
"eligible": 2,
"redacted": 2,
"remaining_in_batch": 0,
}
engine.dispose()
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.policy import (
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
SchedulingParticipantPrivacyPolicy,
SchedulingParticipantPrivacyRequest,
)
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import Tenant, scope_registry
from govoplan_policy.backend.manifest import manifest
from govoplan_policy.backend.scheduling_privacy import (
SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY,
SqlSchedulingParticipantPrivacyPolicy,
)
def _policy_settings(maximum_visibility: object) -> dict[str, object]:
return {
SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY: {
"maximum_visibility": maximum_visibility,
}
}
class SchedulingParticipantPrivacyTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(bind=self.engine, tables=[SystemSettings.__table__])
scope_registry.metadata.create_all(bind=self.engine, tables=[Tenant.__table__])
self.session = Session(self.engine)
self.provider = SqlSchedulingParticipantPrivacyPolicy()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _add_settings(
self,
*,
system: dict[str, object] | None = None,
tenant: dict[str, object] | None = None,
) -> None:
self.session.add(SystemSettings(id="global", settings=system or {}))
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings=tenant or {}))
self.session.flush()
def _resolve(self, requested_visibility: str = "names_and_statuses"):
return self.provider.resolve_scheduling_participant_visibility(
self.session,
request=SchedulingParticipantPrivacyRequest(
tenant_id="tenant-1",
scheduling_request_id="request-1",
participant_id="participant-1",
requested_visibility=requested_visibility, # type: ignore[arg-type]
actor_user_id="user-1",
),
)
def test_manifest_registers_the_core_capability(self) -> None:
factory = manifest.capability_factories[CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY]
provider = factory(ModuleContext(registry=object(), settings=object()))
self.assertIsInstance(provider, SchedulingParticipantPrivacyPolicy)
def test_missing_policy_preserves_scheduling_visibility(self) -> None:
self._add_settings()
decision = self._resolve()
self.assertEqual("names_and_statuses", decision.effective_visibility)
self.assertIsNone(decision.reason)
self.assertEqual((), decision.source_path)
self.assertEqual([], decision.details["configured_scopes"])
def test_missing_system_settings_is_an_unrestricted_read_only_default(self) -> None:
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}))
self.session.flush()
decision = self._resolve()
self.assertEqual("names_and_statuses", decision.effective_visibility)
self.assertIsNone(self.session.get(SystemSettings, "global"))
def test_system_ceiling_restricts_participant_roster(self) -> None:
self._add_settings(system=_policy_settings("aggregates_only"))
decision = self._resolve()
self.assertEqual("aggregates_only", decision.effective_visibility)
self.assertEqual("Participant roster visibility is restricted by policy.", decision.reason)
self.assertEqual(["system"], [step.path for step in decision.source_path])
self.assertEqual("aggregates_only", decision.source_path[0].policy["maximum_visibility"])
def test_tenant_ceiling_can_narrow_system_but_cannot_widen_it(self) -> None:
self._add_settings(
system=_policy_settings("aggregates_only"),
tenant=_policy_settings("names_and_statuses"),
)
decision = self._resolve()
self.assertEqual("aggregates_only", decision.effective_visibility)
self.assertEqual(["system", "tenant:tenant-1"], [step.path for step in decision.source_path])
self.assertEqual("aggregates_only", decision.details["policy_ceiling"])
def test_policy_never_broadens_an_aggregate_only_request(self) -> None:
self._add_settings(
system=_policy_settings("names_and_statuses"),
tenant=_policy_settings("names_and_statuses"),
)
decision = self._resolve("aggregates_only")
self.assertEqual("aggregates_only", decision.effective_visibility)
self.assertIsNone(decision.reason)
def test_invalid_explicit_policy_fails_closed_without_echoing_value(self) -> None:
self._add_settings(tenant=_policy_settings("surprise"))
decision = self._resolve()
self.assertEqual("aggregates_only", decision.effective_visibility)
self.assertIn("could not be validated", decision.reason or "")
self.assertEqual(
[{"scope": "tenant", "code": "invalid_maximum_visibility"}],
decision.details["configuration_errors"],
)
self.assertNotIn("surprise", repr(decision.to_dict()))
self.assertEqual("invalid_fail_closed", decision.source_path[0].policy["configuration_status"])
def test_invalid_policy_shape_fails_closed(self) -> None:
self._add_settings(
system={SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY: "not-an-object"},
)
decision = self._resolve()
self.assertEqual("aggregates_only", decision.effective_visibility)
self.assertEqual(
[{"scope": "system", "code": "invalid_policy_shape"}],
decision.details["configuration_errors"],
)
def test_missing_tenant_fails_closed(self) -> None:
self.session.add(SystemSettings(id="global", settings={}))
self.session.flush()
decision = self._resolve()
self.assertEqual("aggregates_only", decision.effective_visibility)
self.assertEqual(
[{"scope": "tenant", "code": "tenant_not_found"}],
decision.details["configuration_errors"],
)
self.assertEqual(["tenant:tenant-1"], [step.path for step in decision.source_path])
def test_invalid_requested_visibility_fails_closed(self) -> None:
self._add_settings()
decision = self._resolve("invalid")
self.assertEqual("aggregates_only", decision.effective_visibility)
self.assertEqual(
[{"scope": "request", "code": "invalid_requested_visibility"}],
decision.details["configuration_errors"],
)
if __name__ == "__main__":
unittest.main()
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import DefinitionScopeRef, ViewGovernanceRequest
from govoplan_policy.backend.db.models import PolicyOverride
from govoplan_policy.backend.view_governance import ViewGovernancePolicyProvider
from govoplan_policy.backend.view_policy_service import (
ViewPolicyError,
save_view_policy,
view_policy_response_payload,
view_policy_state,
)
class ViewGovernanceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
PolicyOverride.__table__.create(self.engine)
self.session: Session = sessionmaker(
bind=self.engine,
expire_on_commit=False,
)()
self.provider = ViewGovernancePolicyProvider()
self.actor = PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
group_ids=frozenset({"group-1"}),
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _save(
self,
scope_type: str,
policy: object,
scope_id: str | None = None,
):
return save_view_policy(
self.session,
tenant_id="tenant-1",
scope_type=scope_type,
scope_id=scope_id,
policy=policy,
actor_id="account-1",
)
def _resolve(
self,
*,
action: str = "view",
view_id: str | None = None,
requested_surface_ids: tuple[str, ...] = (),
):
return self.provider.resolve_view_action(
self.session,
request=ViewGovernanceRequest(
tenant_id="tenant-1",
action=action, # type: ignore[arg-type]
actor=self.actor,
target_scope=DefinitionScopeRef("user", "account-1"),
view_id=view_id,
candidate_view_ids=("view-1", "view-2", "view-3"),
candidate_surface_ids=("surface.a", "surface.b", "surface.c"),
requested_surface_ids=requested_surface_ids,
),
)
def test_hierarchy_intersects_view_and_surface_ceilings(self) -> None:
self._save(
"system",
{
"allowed_view_ids": ["view-1", "view-2"],
"visible_surface_ids": ["surface.a", "surface.b", "surface.c"],
},
)
self._save(
"tenant",
{
"allowed_view_ids": ["view-2"],
"visible_surface_ids": ["surface.a", "surface.b"],
},
)
decision = self._resolve(
action="workflow_activate",
view_id="view-2",
requested_surface_ids=("surface.b", "surface.c"),
)
self.assertTrue(decision.allowed)
self.assertEqual(frozenset({"view-2"}), decision.allowed_view_ids)
self.assertEqual(
frozenset({"surface.a", "surface.b"}),
decision.visible_surface_ids,
)
self.assertEqual(
["system", "tenant:tenant-1"],
[step.path for step in decision.source_path],
)
self.assertEqual(
["surface.c"],
decision.details["requested_surfaces_outside_ceiling"],
)
def test_lower_scope_cannot_broaden_boolean_or_set_ceiling(self) -> None:
self._save(
"tenant",
{
"allow_assign": False,
"allowed_view_ids": ["view-1"],
},
)
with self.assertRaisesRegex(
ViewPolicyError,
"allow_assign, allowed_view_ids",
):
self._save(
"group",
{
"allow_assign": True,
"allowed_view_ids": ["view-1", "view-2"],
},
"group-1",
)
def test_action_and_requested_view_are_bounded_independently(self) -> None:
self._save(
"tenant",
{
"allow_assign": False,
"allowed_view_ids": ["view-1"],
},
)
assignment = self._resolve(action="assign", view_id="view-1")
selection = self._resolve(action="select", view_id="view-2")
self.assertFalse(assignment.allowed)
self.assertIn("disabled by Policy", assignment.reason or "")
self.assertFalse(selection.allowed)
self.assertIn("outside the effective Policy ceiling", selection.reason or "")
def test_edit_cannot_store_surfaces_outside_the_policy_ceiling(self) -> None:
self._save(
"tenant",
{"visible_surface_ids": ["surface.a", "surface.b"]},
)
decision = self._resolve(
action="edit",
requested_surface_ids=("surface.a", "surface.c"),
)
self.assertFalse(decision.allowed)
self.assertIn("surfaces are outside", decision.reason or "")
self.assertEqual(
[{"id": "surface.c", "sources": ["tenant:tenant-1"]}],
decision.details["surface_provenance"],
)
def test_assignment_uses_the_target_group_policy(self) -> None:
self._save(
"group",
{"allow_assign": False},
"group-2",
)
decision = self.provider.resolve_view_action(
self.session,
request=ViewGovernanceRequest(
tenant_id="tenant-1",
action="assign",
actor=self.actor,
target_scope=DefinitionScopeRef("group", "group-2"),
view_id="view-1",
candidate_view_ids=("view-1",),
),
)
self.assertFalse(decision.allowed)
self.assertEqual("group:group-2", decision.source_path[-1].path)
def test_malformed_policy_fails_closed_without_echoing_record(self) -> None:
self.session.add(
PolicyOverride(
policy_family="view",
target_key="*",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
scope_key="tenant:tenant-1",
policy={"visible_surface_ids": "secret-value"},
)
)
self.session.flush()
decision = self._resolve(action="select", view_id="view-1")
payload = view_policy_response_payload(
view_policy_state(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
)
)
self.assertFalse(decision.allowed)
self.assertEqual(frozenset(), decision.allowed_view_ids)
self.assertEqual(frozenset(), decision.visible_surface_ids)
self.assertEqual(
{"configuration_status": "invalid_fail_closed"},
payload["policy"],
)
self.assertNotIn("secret-value", repr(decision.to_dict()))
self.assertNotIn("secret-value", repr(payload))
if __name__ == "__main__":
unittest.main()
+8 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/policy-webui", "name": "@govoplan/policy-webui",
"version": "0.1.8", "version": "0.1.18",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -12,12 +12,15 @@
"import": "./src/index.ts" "import": "./src/index.ts"
} }
}, },
"scripts": {
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const panel = readFileSync(resolve(webuiRoot, "src/features/policy/RetentionPoliciesPanel.tsx"), "utf8");
assert.match(panel, /<RetentionPolicyScopeManager/, "Policy delegates effective-policy blockers and provenance to the shared Core contract");
assert.match(panel, /contextId: "policy\.retention"/, "retention exposes stable contextual documentation");
assert.match(panel, /disabledReason=\{actionDisabledReason\}/, "retention execution explains unavailable actions");
assert.match(panel, /<ConfirmDialog/, "destructive retention uses shared confirmation");
assert.match(panel, /<DataGrid/, "retention outcomes use the shared data-grid pattern");
assert.doesNotMatch(panel, /admin-json-preview/, "retention outcome is not presented as raw JSON");
assert.doesNotMatch(panel, /<pre/, "retention outcome is a typed projection");
console.log("Policy interface-pattern contracts passed.");
+98
View File
@@ -0,0 +1,98 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type ViewPolicyScope = "system" | "tenant" | "group" | "user";
export type ViewPolicyItem = {
allow_view?: boolean;
allow_select?: boolean;
allow_assign?: boolean;
allow_edit?: boolean;
allow_derive?: boolean;
allow_workflow_activate?: boolean;
allowed_view_ids?: string[];
visible_surface_ids?: string[];
};
export type EffectiveViewPolicy = {
allow_view: boolean;
allow_select: boolean;
allow_assign: boolean;
allow_edit: boolean;
allow_derive: boolean;
allow_workflow_activate: boolean;
allowed_view_ids?: string[] | null;
visible_surface_ids?: string[] | null;
};
export type ViewPolicyScopeResponse = {
scope_type: ViewPolicyScope;
scope_id?: string | null;
id?: string | null;
revision?: number | null;
policy: ViewPolicyItem;
effective_policy: EffectiveViewPolicy;
parent_policy: EffectiveViewPolicy;
source_path: Array<{
scope_type: string;
scope_id?: string | null;
source_id?: string | null;
fields?: string[];
}>;
diagnostics: Array<Record<string, unknown>>;
};
export type ViewPolicyReferenceData = {
views: Array<{ id: string; name: string; scope_type?: string }>;
surfaces: Array<{ id: string; label: string; module_id: string; kind: string }>;
};
export function fetchViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId?: string | null
): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
}));
}
export function updateViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId: string | null | undefined,
policy: ViewPolicyItem
): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
}), {
method: "PUT",
body: JSON.stringify({ policy })
});
}
export function deleteViewPolicy(
settings: ApiSettings,
scope: ViewPolicyScope,
scopeId?: string | null
): Promise<ViewPolicyScopeResponse> {
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
scope_id: scopeId || undefined
}), { method: "DELETE" });
}
export async function fetchViewPolicyReferences(settings: ApiSettings): Promise<ViewPolicyReferenceData> {
const [definitionResult, surfaceResult] = await Promise.allSettled([
apiFetch<{ definitions: Array<{ id: string; name: string; scope_type?: string }> }>(
settings,
apiPath("/api/v1/views/definitions", { scope_type: "tenant", include_inherited: true })
),
apiFetch<{ surfaces: Array<{ id: string; label: string; module_id: string; kind: string }> }>(
settings,
"/api/v1/views/surfaces"
)
]);
return {
views: definitionResult.status === "fulfilled" ? definitionResult.value.definitions : [],
surfaces: surfaceResult.status === "fulfilled" ? surfaceResult.value.surfaces : []
};
}
@@ -5,16 +5,21 @@ import {
Button, Button,
Card, Card,
ConfirmDialog, ConfirmDialog,
DataGrid,
DocumentationHelpLink,
mergeDeltaRows, mergeDeltaRows,
RetentionPolicyScopeManager, RetentionPolicyScopeManager,
runRetentionPolicy, runRetentionPolicy,
StatusBadge,
useDeltaWatermarks, useDeltaWatermarks,
type ApiSettings, type ApiSettings,
type DataGridColumn,
type DeltaDeletedItem, type DeltaDeletedItem,
type PrivacyRetentionPolicyScope, type PrivacyRetentionPolicyScope,
type RetentionPolicyTargetOption, type RetentionPolicyTargetOption,
type RetentionRunResponse type RetentionRunResponse
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { RefreshCw } from "lucide-react";
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets"; import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
type Props = { type Props = {
@@ -30,6 +35,28 @@ type DeltaResponse = {
full: boolean; full: boolean;
}; };
interface RetentionCountTree {
[key: string]: number | RetentionCountTree;
}
type RetentionCountRow = {
id: string;
area: string;
measure: string;
count: number;
};
const RETENTION_DOCUMENTATION = {
contextId: "policy.retention",
documentationType: "admin" as const
};
const RETENTION_RESULT_COLUMNS: DataGridColumn<RetentionCountRow>[] = [
{ id: "area", header: "Area", value: (row) => row.area, width: "minmax(180px, 1fr)", filterType: "list", sortable: true },
{ id: "measure", header: "Outcome", value: (row) => row.measure, width: "minmax(220px, 1.4fr)", filterType: "text", sortable: true },
{ id: "count", header: "Records", value: (row) => row.count, width: "120px", align: "right", sortable: true }
];
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = { const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
system: { system: {
title: "System retention", title: "System retention",
@@ -151,10 +178,38 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
} }
const labels = copy[scopeType]; const labels = copy[scopeType];
const resultRows = flattenRetentionCounts(retentionResult?.result.counts);
const actionDisabledReason = busy
? "A retention operation is already running."
: !canWrite
? "Your account may inspect retention policy but cannot run retention operations."
: undefined;
return ( return (
<> <>
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}> <AdminPageLayout
title={labels.title}
description={labels.description}
loading={loadingTargets}
error={targetError || runError}
success={success}
actions={
<>
{(scopeType === "user" || scopeType === "group") && (
<Button
title="Reload policy targets"
aria-label="Reload policy targets"
onClick={() => void loadTargets()}
disabled={loadingTargets}
disabledReason={loadingTargets ? "Policy targets are already loading." : undefined}
>
<RefreshCw size={16} />
</Button>
)}
<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
</>
}
>
<RetentionPolicyScopeManager <RetentionPolicyScopeManager
settings={settings} settings={settings}
scopeType={scopeType} scopeType={scopeType}
@@ -166,22 +221,46 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
/> />
{scopeType === "system" && ( {scopeType === "system" && (
<div className="retention-run-card"> <div className="retention-run-section">
<Card title="Retention execution"> <Card
title="Retention execution"
actions={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
>
<p className="muted small-note">Run the saved effective retention policy against retained platform data.</p> <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"> <div className="button-row compact-actions subsection-bottom-actions">
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button> <Button onClick={() => void runRetention(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button> <Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Apply retention</Button>
</div> </div>
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
</Card> </Card>
{retentionResult && (
<Card title="Latest retention outcome">
<dl className="detail-list compact-detail-list">
<div>
<dt>Operation</dt>
<dd><StatusBadge status={retentionResult.result.dry_run ? "info" : "success"} label={retentionResult.result.dry_run ? "Dry run" : "Applied"} /></dd>
</div>
<div><dt>Policy scope</dt><dd>{humanize(retentionResult.result.effective_policy_scope || "system")}</dd></div>
<div><dt>Reported outcomes</dt><dd>{resultRows.length}</dd></div>
</dl>
<div className="admin-table-surface">
<DataGrid
id="policy-retention-outcomes"
rows={resultRows}
columns={RETENTION_RESULT_COLUMNS}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="No retained records currently match the effective policy."
/>
</div>
</Card>
)}
</div> </div>
)} )}
</AdminPageLayout> </AdminPageLayout>
<ConfirmDialog <ConfirmDialog
open={confirmRetentionRun} open={confirmRetentionRun}
title="Apply retention policy" title="Apply retention policy"
message="This will redact or delete eligible retained data according to the saved policy." message="This will redact or delete eligible retained data according to the saved policy. The application cannot restore deleted content; the run and bounded outcome counts remain in audit evidence. Run a dry run first and verify recovery evidence before continuing."
confirmLabel="Apply retention" confirmLabel="Apply retention"
tone="danger" tone="danger"
busy={busy} busy={busy}
@@ -192,6 +271,35 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
); );
} }
function flattenRetentionCounts(
counts: RetentionRunResponse["result"]["counts"] | undefined
): RetentionCountRow[] {
const rows: RetentionCountRow[] = [];
const visit = (value: number | RetentionCountTree, path: string[]) => {
if (typeof value === "number") {
const [area = "retention", ...measureParts] = path;
rows.push({
id: path.join("."),
area: humanize(area),
measure: humanize(measureParts.join(" ") || "records"),
count: value
});
return;
}
for (const [key, child] of Object.entries(value)) visit(child, [...path, key]);
};
if (counts) visit(counts as unknown as RetentionCountTree, []);
return rows;
}
function humanize(value: string): string {
return value
.split(/[._\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
async function loadDeltaRows<TItem, TResponse extends DeltaResponse>( async function loadDeltaRows<TItem, TResponse extends DeltaResponse>(
current: TItem[], current: TItem[],
key: string, key: string,
@@ -0,0 +1,440 @@
import { useEffect, useMemo, useState } from "react";
import {
AdminPageLayout,
adminErrorMessage,
Button,
Card,
ConfirmDialog,
DocumentationHelpLink,
FormField,
ReferenceMultiSelect,
SearchableSelect,
SegmentedControl,
staticReferenceOptionProvider,
StatusBadge,
ToggleSwitch,
useUnsavedDraftGuard,
type ApiSettings,
type ReferenceOption,
type SearchableSelectOption
} from "@govoplan/core-webui";
import { RefreshCw, Save, Trash2, Undo2 } from "lucide-react";
import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
import {
deleteViewPolicy,
fetchViewPolicy,
fetchViewPolicyReferences,
updateViewPolicy,
type EffectiveViewPolicy,
type ViewPolicyItem,
type ViewPolicyScope,
type ViewPolicyScopeResponse
} from "../../api/viewPolicies";
type Props = {
settings: ApiSettings;
scopeType: ViewPolicyScope;
canWrite: boolean;
};
type Decision = "inherit" | "allow" | "block";
type Draft = {
allow_view: Decision;
allow_select: Decision;
allow_assign: Decision;
allow_edit: Decision;
allow_derive: Decision;
allow_workflow_activate: Decision;
limitViews: boolean;
allowedViewIds: string[];
limitSurfaces: boolean;
visibleSurfaceIds: string[];
};
type Target = SearchableSelectOption;
const BOOLEAN_FIELDS: Array<{
id: keyof Pick<Draft, "allow_view" | "allow_select" | "allow_assign" | "allow_edit" | "allow_derive" | "allow_workflow_activate">;
label: string;
description: string;
}> = [
{ id: "allow_view", label: "View", description: "Allow affected accounts to apply and use Views." },
{ id: "allow_select", label: "Select", description: "Allow affected accounts to choose among available Views." },
{ id: "allow_assign", label: "Assign", description: "Allow administrators at this scope to assign Views." },
{ id: "allow_edit", label: "Edit", description: "Allow View definitions to be edited at this scope." },
{ id: "allow_derive", label: "Derive", description: "Allow a new View to derive from an inherited definition." },
{ id: "allow_workflow_activate", label: "Workflow activation", description: "Allow workflows to activate a View for affected accounts." }
];
const DOCUMENTATION = {
contextId: "policy.view-governance",
documentationType: "admin" as const
};
const DECISION_OPTIONS = [
{ id: "inherit" as const, label: "Inherit" },
{ id: "allow" as const, label: "Allow" },
{ id: "block" as const, label: "Block" }
];
export default function ViewPoliciesPanel({ settings, scopeType, canWrite }: Props) {
const [targets, setTargets] = useState<Target[]>([]);
const [targetId, setTargetId] = useState("");
const [state, setState] = useState<ViewPolicyScopeResponse | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [viewOptions, setViewOptions] = useState<ReferenceOption[]>([]);
const [surfaceOptions, setSurfaceOptions] = useState<ReferenceOption[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [confirmReset, setConfirmReset] = useState(false);
const needsTarget = scopeType === "group" || scopeType === "user";
const parentViewIds = state?.parent_policy.allowed_view_ids;
const parentSurfaceIds = state?.parent_policy.visible_surface_ids;
const viewProvider = useMemo(
() => staticReferenceOptionProvider(optionsWithinCeiling(viewOptions, parentViewIds)),
[parentViewIds, viewOptions]
);
const surfaceProvider = useMemo(
() => staticReferenceOptionProvider(optionsWithinCeiling(surfaceOptions, parentSurfaceIds)),
[parentSurfaceIds, surfaceOptions]
);
const dirty = Boolean(
state
&& draft
&& stablePolicy(buildPolicy(draft))
!== stablePolicy(buildPolicy(draftFromPolicy(state.policy)))
);
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard });
useEffect(() => {
void initialize();
}, [scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function initialize() {
setLoading(true);
setError("");
setSuccess("");
try {
const [references, loadedTargets] = await Promise.all([
fetchViewPolicyReferences(settings),
loadTargets(settings, scopeType)
]);
setViewOptions(references.views.map((view) => ({
value: view.id,
label: view.name,
description: view.scope_type ? `${view.scope_type} View` : "View",
searchText: `${view.name} ${view.id}`
})));
setSurfaceOptions(references.surfaces.map((surface) => ({
value: surface.id,
label: surface.label || surface.id,
description: `${surface.module_id} - ${surface.kind}`,
searchText: `${surface.id} ${surface.module_id} ${surface.label}`
})));
setTargets(loadedTargets);
const nextTarget = needsTarget
? (loadedTargets.some((target) => target.value === targetId) ? targetId : loadedTargets[0]?.value ?? "")
: "";
setTargetId(nextTarget);
if (!needsTarget || nextTarget) await load(nextTarget, false);
else {
setState(null);
setDraft(null);
}
} catch (err) {
setError(adminErrorMessage(err));
setState(null);
setDraft(null);
} finally {
setLoading(false);
}
}
async function load(nextTargetId = targetId, manageLoading = true) {
if (needsTarget && !nextTargetId) return;
if (manageLoading) setLoading(true);
setError("");
setSuccess("");
try {
const loaded = await fetchViewPolicy(settings, scopeType, nextTargetId || null);
setState(loaded);
setDraft(draftFromPolicy(loaded.policy));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
if (manageLoading) setLoading(false);
}
}
async function selectTarget(nextTargetId: string) {
if (!nextTargetId || nextTargetId === targetId) return;
setTargetId(nextTargetId);
await load(nextTargetId);
}
function discard() {
if (state) setDraft(draftFromPolicy(state.policy));
setError("");
setSuccess("");
}
async function save(): Promise<boolean> {
if (!draft || !state || !dirty) return true;
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await updateViewPolicy(settings, scopeType, targetId || null, buildPolicy(draft));
setState(loaded);
setDraft(draftFromPolicy(loaded.policy));
setSuccess("View policy saved.");
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function resetPolicy() {
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = await deleteViewPolicy(settings, scopeType, targetId || null);
setState(loaded);
setDraft(draftFromPolicy(loaded.policy));
setSuccess("Local View policy removed; inherited policy now applies.");
setConfirmReset(false);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
const scopeLabel = scopeType === "system" ? "System" : scopeType === "tenant" ? "Tenant" : scopeType === "group" ? "Group" : "User";
return (
<>
<AdminPageLayout
title={`${scopeLabel} View policy`}
description="Control which Views and surfaces are available, forced by assignment, selectable, editable, derivable, or workflow-activatable at this scope."
loading={loading}
error={error}
success={success}
actions={
<>
<Button title="Reload saved View policy" aria-label="Reload saved View policy" onClick={() => void load()} disabled={loading || busy || (needsTarget && !targetId)}>
<RefreshCw size={16} />
</Button>
<Button onClick={discard} disabled={!dirty || busy}><Undo2 size={16} /> Discard</Button>
<Button onClick={() => setConfirmReset(true)} disabled={!canWrite || !state?.id || busy}><Trash2 size={16} /> Use inherited</Button>
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}><Save size={16} /> {busy ? "Saving..." : "Save"}</Button>
<DocumentationHelpLink reference={DOCUMENTATION} label="Open View policy documentation" />
</>
}
>
{needsTarget && (
<FormField label={scopeType === "group" ? "Group" : "User"} documentation={DOCUMENTATION}>
<SearchableSelect
value={targetId}
options={targets}
onChange={(value) => void selectTarget(value)}
placeholder={`Select ${scopeType}`}
searchPlaceholder={`Search ${scopeType}s...`}
disabled={loading || busy || targets.length === 0}
/>
</FormField>
)}
{state && draft && (
<>
<Card title="Actions">
<div className="settings-list">
{BOOLEAN_FIELDS.map((field) => (
<div className="admin-tenant-assignment-row" key={field.id}>
<span>
<strong>{field.label}</strong>
<small>{field.description} Effective: {effectiveLabel(state.effective_policy, field.id)}.</small>
</span>
<SegmentedControl
options={DECISION_OPTIONS.map((option) => (
option.id === "allow" && state.parent_policy[field.id] === false
? {
...option,
disabled: true,
title: "A parent policy blocks this action."
}
: option
))}
value={draft[field.id]}
onChange={(value) => setDraft({ ...draft, [field.id]: value })}
role="group"
size="equal"
width="fill"
disabled={!canWrite || busy}
ariaLabel={`${field.label} policy`}
/>
</div>
))}
</div>
</Card>
<Card title="Availability ceilings">
<div className="settings-list">
<ToggleSwitch
label="Limit available Views"
checked={draft.limitViews}
onChange={(checked) => setDraft({ ...draft, limitViews: checked, allowedViewIds: checked ? draft.allowedViewIds : [] })}
disabled={!canWrite || busy}
help="A lower scope may narrow this list but cannot add Views excluded by an ancestor."
/>
{draft.limitViews && (
<FormField label="Available Views" documentation={DOCUMENTATION}>
<ReferenceMultiSelect
values={draft.allowedViewIds}
onChange={(values) => setDraft({ ...draft, allowedViewIds: values })}
provider={viewProvider}
createCustomOption={(value) => customReference(value, parentViewIds)}
placeholder="Add View"
searchPlaceholder="Search Views or enter an ID..."
disabled={!canWrite || busy}
/>
</FormField>
)}
<ToggleSwitch
label="Limit visible surfaces"
checked={draft.limitSurfaces}
onChange={(checked) => setDraft({ ...draft, limitSurfaces: checked, visibleSurfaceIds: checked ? draft.visibleSurfaceIds : [] })}
disabled={!canWrite || busy}
help="The effective View may only expose surfaces retained by every ancestor policy."
/>
{draft.limitSurfaces && (
<FormField label="Visible surfaces" documentation={DOCUMENTATION}>
<ReferenceMultiSelect
values={draft.visibleSurfaceIds}
onChange={(values) => setDraft({ ...draft, visibleSurfaceIds: values })}
provider={surfaceProvider}
createCustomOption={(value) => customReference(value, parentSurfaceIds)}
placeholder="Add surface"
searchPlaceholder="Search surfaces or enter an ID..."
disabled={!canWrite || busy}
/>
</FormField>
)}
</div>
</Card>
<Card title="Effective policy and provenance">
<dl className="admin-details-grid">
<div><dt>Local override</dt><dd><StatusBadge status={state.id ? "info" : "neutral"} label={state.id ? `Revision ${state.revision}` : "Inherited"} /></dd></div>
<div><dt>Allowed Views</dt><dd>{ceilingLabel(state.effective_policy.allowed_view_ids)}</dd></div>
<div><dt>Visible surfaces</dt><dd>{ceilingLabel(state.effective_policy.visible_surface_ids)}</dd></div>
<div><dt>Policy path</dt><dd>{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"}</dd></div>
</dl>
</Card>
</>
)}
</AdminPageLayout>
<ConfirmDialog
open={confirmReset}
title="Use inherited View policy?"
message="The local override will be removed. All restrictions inherited from higher scopes continue to apply."
confirmLabel="Use inherited policy"
busy={busy}
onConfirm={() => void resetPolicy()}
onCancel={() => setConfirmReset(false)}
/>
</>
);
}
async function loadTargets(settings: ApiSettings, scope: ViewPolicyScope): Promise<Target[]> {
if (scope === "user") {
const response = await fetchUsersDelta(settings, { limit: 200 });
return response.users.map((user) => ({
value: user.id,
label: user.display_name || user.email,
description: user.display_name ? user.email : undefined,
searchText: `${user.display_name ?? ""} ${user.email}`
}));
}
if (scope === "group") {
const response = await fetchGroupsDelta(settings, { limit: 200 });
return response.groups.map((group) => ({
value: group.id,
label: group.name,
description: group.slug,
searchText: `${group.name} ${group.slug}`
}));
}
return [];
}
function draftFromPolicy(policy: ViewPolicyItem): Draft {
return {
allow_view: decision(policy.allow_view),
allow_select: decision(policy.allow_select),
allow_assign: decision(policy.allow_assign),
allow_edit: decision(policy.allow_edit),
allow_derive: decision(policy.allow_derive),
allow_workflow_activate: decision(policy.allow_workflow_activate),
limitViews: Array.isArray(policy.allowed_view_ids),
allowedViewIds: [...(policy.allowed_view_ids ?? [])].sort(),
limitSurfaces: Array.isArray(policy.visible_surface_ids),
visibleSurfaceIds: [...(policy.visible_surface_ids ?? [])].sort()
};
}
function buildPolicy(draft: Draft): ViewPolicyItem {
const policy: ViewPolicyItem = {};
for (const field of BOOLEAN_FIELDS) {
const value = draft[field.id];
if (value !== "inherit") policy[field.id] = value === "allow";
}
if (draft.limitViews) policy.allowed_view_ids = [...new Set(draft.allowedViewIds)].sort();
if (draft.limitSurfaces) policy.visible_surface_ids = [...new Set(draft.visibleSurfaceIds)].sort();
return policy;
}
function decision(value: boolean | undefined): Decision {
return value === undefined ? "inherit" : value ? "allow" : "block";
}
function stablePolicy(policy: ViewPolicyItem): string {
return JSON.stringify(Object.fromEntries(Object.entries(policy).sort(([left], [right]) => left.localeCompare(right))));
}
function effectiveLabel(policy: EffectiveViewPolicy, field: keyof EffectiveViewPolicy): string {
return policy[field] === true ? "allowed" : "blocked";
}
function ceilingLabel(values: string[] | null | undefined): string {
return values == null ? "Unrestricted by ID" : values.length ? `${values.length} entries` : "None";
}
function optionsWithinCeiling(
options: ReferenceOption[],
ceiling: string[] | null | undefined
): ReferenceOption[] {
if (!Array.isArray(ceiling)) return options;
const allowed = new Set(ceiling);
return options.filter((option) => allowed.has(option.value));
}
function customReference(
value: string,
ceiling: string[] | null | undefined
): ReferenceOption | null {
const clean = value.trim();
if (!clean || (Array.isArray(ceiling) && !ceiling.includes(clean))) return null;
return { value: clean, label: clean, description: "Unresolved identifier", custom: true };
}
+1
View File
@@ -1,4 +1,5 @@
export { default } from "./module"; export { default } from "./module";
export { default as ViewPoliciesPanel } from "./features/policy/ViewPoliciesPanel";
export * from "./module"; export * from "./module";
export * from "./api/adminTargets"; export * from "./api/adminTargets";
export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel"; export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel";
+84 -1
View File
@@ -2,11 +2,75 @@ import { createElement, lazy } from "react";
import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui"; import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel")); const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel"));
const ViewPoliciesPanel = lazy(() => import("./features/policy/ViewPoliciesPanel"));
const policyAdminSections: AdminSectionsUiCapability = { const policyAdminSections: AdminSectionsUiCapability = {
sections: [ sections: [
{
id: "system-view-policy",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.system-view-policy",
label: "View policy",
group: "SYSTEM",
order: 70,
allOf: ["system:settings:read"],
render: ({ settings, auth }) => createElement(ViewPoliciesPanel, {
settings,
scopeType: "system",
canWrite: hasScope(auth, "system:settings:write")
})
},
{
id: "tenant-view-policy",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.tenant-view-policy",
label: "View policy",
group: "TENANT",
order: 70,
allOf: ["admin:policies:read"],
render: ({ settings, auth }) => createElement(ViewPoliciesPanel, {
settings,
scopeType: "tenant",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "group-view-policy",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.group-view-policy",
label: "View policy",
group: "GROUP",
order: 20,
allOf: ["admin:policies:read", "admin:groups:read"],
render: ({ settings, auth }) => createElement(ViewPoliciesPanel, {
settings,
scopeType: "group",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{
id: "user-view-policy",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.user-view-policy",
label: "View policy",
group: "USER",
order: 20,
allOf: ["admin:policies:read", "admin:users:read"],
render: ({ settings, auth }) => createElement(ViewPoliciesPanel, {
settings,
scopeType: "user",
canWrite: hasScope(auth, "admin:policies:write")
})
},
{ {
id: "system-retention", id: "system-retention",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.system-retention",
label: "Retention", label: "Retention",
group: "SYSTEM", group: "SYSTEM",
order: 80, order: 80,
@@ -19,6 +83,9 @@ const policyAdminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "tenant-retention", id: "tenant-retention",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.tenant-retention",
label: "Retention", label: "Retention",
group: "TENANT", group: "TENANT",
order: 80, order: 80,
@@ -31,6 +98,9 @@ const policyAdminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "tenant-group-retention", id: "tenant-group-retention",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.group-retention",
label: "Retention", label: "Retention",
group: "GROUP", group: "GROUP",
order: 30, order: 30,
@@ -43,6 +113,9 @@ const policyAdminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "tenant-user-retention", id: "tenant-user-retention",
moduleId: "policy",
kind: "settings",
surfaceId: "policy.admin.user-retention",
label: "Retention", label: "Retention",
group: "USER", group: "USER",
order: 30, order: 30,
@@ -59,8 +132,18 @@ const policyAdminSections: AdminSectionsUiCapability = {
export const policyModule: PlatformWebModule = { export const policyModule: PlatformWebModule = {
id: "policy", id: "policy",
label: "Policy", label: "Policy",
version: "0.1.6", version: "0.1.9",
dependencies: ["access", "admin"], dependencies: ["access", "admin"],
viewSurfaces: [
{ id: "policy.admin.system-view-policy", moduleId: "policy", kind: "section", label: "System View policy", order: 70 },
{ id: "policy.admin.tenant-view-policy", moduleId: "policy", kind: "section", label: "Tenant View policy", order: 70 },
{ id: "policy.admin.group-view-policy", moduleId: "policy", kind: "section", label: "Group View policy", order: 70 },
{ id: "policy.admin.user-view-policy", moduleId: "policy", kind: "section", label: "User View policy", order: 70 },
{ id: "policy.admin.system-retention", moduleId: "policy", kind: "section", label: "System retention", order: 80 },
{ id: "policy.admin.tenant-retention", moduleId: "policy", kind: "section", label: "Tenant retention", order: 80 },
{ id: "policy.admin.group-retention", moduleId: "policy", kind: "section", label: "Group retention", order: 80 },
{ id: "policy.admin.user-retention", moduleId: "policy", kind: "section", label: "User retention", order: 80 }
],
uiCapabilities: { uiCapabilities: {
"admin.sections": policyAdminSections "admin.sections": policyAdminSections
} }