Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd7487ba7 | ||
|
|
29a9aea3b1 | ||
|
|
b5f431c766 | ||
|
|
5753488375 | ||
|
|
72779d0277 | ||
|
|
016965917e | ||
|
|
861e9b8b8d | ||
|
|
be5e3a7d72 | ||
|
|
8fcc12dbb2 | ||
|
|
a8ec72d8a9 | ||
|
|
fba4117b0c | ||
|
|
65159dec5f | ||
|
|
0941268f6b | ||
|
|
107cc3b654 | ||
|
|
44d72914ae | ||
|
|
1fa6d1dcfb | ||
|
|
d34bdc2ac3 | ||
|
|
fc6d333a64 | ||
|
|
86c95f85bb | ||
|
|
f964ed7dc0 | ||
|
|
344e15dea4 | ||
|
|
3aaa842ee6 | ||
|
|
a89c39862a | ||
|
|
e061f230f2 | ||
|
|
84acc34f08 | ||
|
|
798138ef7d | ||
|
|
4d8bcec1f0 | ||
|
|
9b0eeb162f | ||
|
|
546b2a6e9d | ||
|
|
242d023474 | ||
|
|
d6e09fbbd1 | ||
|
|
1063622d31 | ||
|
|
b68c3f0473 | ||
|
|
bab9402c29 | ||
|
|
2511fbb5a8 | ||
|
|
664eb38ab9 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,5 +1,9 @@
|
||||
# GovOPlaN Policy
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-policy` owns policy and retention API route contributions and the
|
||||
retention administration WebUI sections during the GovOPlaN module split.
|
||||
|
||||
@@ -8,6 +12,25 @@ user retention sections through the shared `admin.sections` UI capability. The
|
||||
admin shell does not render retention policy panels unless this module is
|
||||
installed and enabled.
|
||||
|
||||
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.
|
||||
|
||||
Before saving a View-policy draft, administrators can call
|
||||
`POST /api/v1/admin/policy-impact/preview` with one to ten explicitly selected,
|
||||
bounded subject populations. The dry run does not persist the proposal. It
|
||||
groups newly allowed, newly denied, unchanged, and indeterminate effects and
|
||||
reports complete, sampled, truncated, unavailable, or permission-hidden
|
||||
coverage with rule and source provenance. Aggregate counts follow normal
|
||||
policy-read authority; resource details additionally require
|
||||
`policy:impact:details`. System-wide View-policy commits require a login less
|
||||
than 15 minutes old. Preview and commit are recorded as separate audit events.
|
||||
Optional modules contribute subjects through the Core provider contract, so
|
||||
Policy never imports their implementation.
|
||||
|
||||
Policy decision and provenance payloads use the shared kernel DTOs documented
|
||||
in [docs/POLICY_DECISION_PROVENANCE.md](docs/POLICY_DECISION_PROVENANCE.md)
|
||||
and `/mnt/DATA/git/govoplan-core/docs/POLICY_CONTRACTS.md`.
|
||||
@@ -16,3 +39,32 @@ Hierarchical policy evaluation, delegation ceilings, and write simulations are
|
||||
implemented in `govoplan_policy.backend.hierarchy`. Privacy retention uses that
|
||||
shared helper and exposes `/api/v1/admin/privacy-retention/policies/{scope}/simulate`
|
||||
for preflight checks before saving lower-level policy changes.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Use `govoplan_core.core.policy.PolicyDecision` for explainable policy results:
|
||||
@@ -92,6 +120,28 @@ System: Allow
|
||||
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.
|
||||
|
||||
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
|
||||
for module UIs. Modules may use their own field layout, but the data contract
|
||||
should remain this shape.
|
||||
# Function assignment delegation and escalation
|
||||
|
||||
The `policy.functionAssignmentGovernance` decision includes the effective
|
||||
`delegation_allowed`, `maximum_delegation_depth`, and
|
||||
`maximum_delegated_validity_days` values plus zero or more per-step escalation
|
||||
rules. Each rule binds `holder`, `authority`, or `recipient` review to one exact
|
||||
target function and a bounded timeout. Consumers must treat the decision as a
|
||||
current limit, not a captured grant: IDM rechecks it across the complete source
|
||||
chain at every consequential transition.
|
||||
|
||||
An elapsed timeout does not change the approval result. IDM records an explicit
|
||||
escalated state and the target function; Policy authorizes only a current holder
|
||||
of that target for the escalated decision. Missing, malformed, vacant, expired,
|
||||
cyclic, over-depth, or tightened routes fail closed with their reason preserved
|
||||
in the decision and transition evidence.
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/policy-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,11 +18,11 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-policy"
|
||||
version = "0.1.7"
|
||||
version = "0.1.23"
|
||||
description = "GovOPlaN policy platform module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.7",
|
||||
"govoplan-core>=0.1.45",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
AccessExplanationSubjectDecision,
|
||||
PrincipalRef,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
|
||||
|
||||
ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user"
|
||||
|
||||
|
||||
class AccessExplanationSubjectPolicyProvider:
|
||||
"""Decide whether an actor may run an explanation for another user."""
|
||||
|
||||
def decide_subject_selection(
|
||||
self,
|
||||
session: object,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> AccessExplanationSubjectDecision:
|
||||
del session
|
||||
if principal.tenant_id != tenant_id:
|
||||
return AccessExplanationSubjectDecision(
|
||||
allow_other_users=False,
|
||||
reason="Access explanations are limited to the active tenant.",
|
||||
source="policy.tenant_boundary",
|
||||
required_scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
||||
provenance={"tenant_id": tenant_id, "mode": "current_user"},
|
||||
)
|
||||
|
||||
allowed = scopes_grant_compatible(
|
||||
principal.scopes,
|
||||
ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
||||
)
|
||||
return AccessExplanationSubjectDecision(
|
||||
allow_other_users=allowed,
|
||||
reason=(
|
||||
"Policy permits selected-user access diagnostics."
|
||||
if allowed
|
||||
else "Policy limits access explanations to the signed-in user."
|
||||
),
|
||||
source="policy.permission",
|
||||
required_scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
||||
provenance={
|
||||
"tenant_id": tenant_id,
|
||||
"mode": "cross_user" if allowed else "current_user",
|
||||
},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
RETENTION_DAY_KEYS = (
|
||||
"raw_campaign_json_retention_days",
|
||||
"generated_eml_retention_days",
|
||||
"stored_report_detail_retention_days",
|
||||
"mock_mailbox_retention_days",
|
||||
"audit_detail_retention_days",
|
||||
from govoplan_core.privacy.schemas import (
|
||||
PrivacyRetentionPolicyItem,
|
||||
PrivacyRetentionPolicyPatchItem,
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
scope_type: str
|
||||
scope_id: str | None = None
|
||||
@@ -49,46 +19,12 @@ class PolicySourceStepItem(BaseModel):
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
store_raw_campaign_json: bool = True
|
||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
||||
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
||||
|
||||
@field_validator("allow_lower_level_limits", mode="before")
|
||||
@classmethod
|
||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyPatchItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
store_raw_campaign_json: bool | None = None
|
||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
|
||||
allow_lower_level_limits: dict[str, bool] | None = None
|
||||
|
||||
@field_validator("allow_lower_level_limits", mode="before")
|
||||
@classmethod
|
||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||
return normalize_allow_lower_level_limits(value, fill_defaults=False)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyScopeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem)
|
||||
policy: PrivacyRetentionPolicyPatchItem = Field(
|
||||
default_factory=PrivacyRetentionPolicyPatchItem
|
||||
)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
@@ -110,6 +46,182 @@ class PolicyDecisionItem(BaseModel):
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionPolicyItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
allowed_password_encryption_methods: list[
|
||||
Literal["aes", "zip_standard"]
|
||||
] | None = None
|
||||
allowed_password_delivery_channels: list[
|
||||
Literal["separate_mail", "sms", "letter", "phone", "in_person"]
|
||||
] | None = None
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionPolicyScopeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy: CampaignArchiveEncryptionPolicyItem = Field(
|
||||
default_factory=CampaignArchiveEncryptionPolicyItem
|
||||
)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionPolicyScopeResponse(BaseModel):
|
||||
scope_type: Literal["system", "tenant", "group", "user", "campaign"]
|
||||
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]
|
||||
parent_policy: dict[str, Any]
|
||||
|
||||
|
||||
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
|
||||
impact_preview_id: str | None = Field(default=None, max_length=36)
|
||||
impact_proposal_hash: str | None = Field(default=None, min_length=64, max_length=64)
|
||||
|
||||
|
||||
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 PolicyImpactPopulationRequestItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider_id: str = Field(min_length=1, max_length=120)
|
||||
selector: dict[str, Any] = Field(default_factory=dict)
|
||||
limit: int = Field(default=200, ge=1, le=500)
|
||||
|
||||
|
||||
class PolicyImpactPreviewRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy_family: Literal["view"] = "view"
|
||||
scope_type: Literal["system", "tenant", "group", "user"]
|
||||
scope_id: str | None = Field(default=None, max_length=240)
|
||||
proposed_policy: ViewPolicyItem = Field(default_factory=ViewPolicyItem)
|
||||
populations: list[PolicyImpactPopulationRequestItem] = Field(
|
||||
min_length=1,
|
||||
max_length=10,
|
||||
)
|
||||
include_details: bool = False
|
||||
|
||||
|
||||
class PolicyImpactSubjectItem(BaseModel):
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
action: str
|
||||
label: str | None = None
|
||||
scope_type: str | None = None
|
||||
scope_id: str | None = None
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PolicyImpactEffectItem(BaseModel):
|
||||
category: Literal[
|
||||
"newly_allowed",
|
||||
"newly_denied",
|
||||
"unchanged",
|
||||
"indeterminate",
|
||||
]
|
||||
subject: PolicyImpactSubjectItem
|
||||
current_allowed: bool | None = None
|
||||
proposed_allowed: bool | None = None
|
||||
rule: str
|
||||
current_sources: list[PolicySourceStepItem] = Field(default_factory=list)
|
||||
proposed_sources: list[PolicySourceStepItem] = Field(default_factory=list)
|
||||
explanation: str | None = None
|
||||
|
||||
|
||||
class PolicyImpactPopulationResponseItem(BaseModel):
|
||||
provider_id: str
|
||||
state: Literal["complete", "sampled", "truncated", "unavailable"]
|
||||
returned: int
|
||||
total_available: int | None = None
|
||||
explanation: str | None = None
|
||||
subjects: list[PolicyImpactSubjectItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyImpactPreviewResponse(BaseModel):
|
||||
preview_id: str
|
||||
proposal_hash: str
|
||||
policy_family: str
|
||||
scope_type: str
|
||||
scope_id: str | None = None
|
||||
base_revision: int | None = None
|
||||
counts: dict[
|
||||
Literal[
|
||||
"newly_allowed",
|
||||
"newly_denied",
|
||||
"unchanged",
|
||||
"indeterminate",
|
||||
],
|
||||
int,
|
||||
]
|
||||
effects: list[PolicyImpactEffectItem] = Field(default_factory=list)
|
||||
populations: list[PolicyImpactPopulationResponseItem] = Field(default_factory=list)
|
||||
details_hidden: bool = False
|
||||
details_explanation: str | None = None
|
||||
high_impact: bool = False
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyExplainResponse(BaseModel):
|
||||
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
|
||||
scope_id: str | None = None
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
CampaignArchiveEncryptionDecision,
|
||||
CampaignArchiveEncryptionRequest,
|
||||
PolicySourceStep,
|
||||
)
|
||||
from govoplan_policy.backend.policy_overrides import (
|
||||
PolicyOverride,
|
||||
get_policy_override,
|
||||
resolution_policy_overrides,
|
||||
set_policy_override,
|
||||
)
|
||||
|
||||
|
||||
POLICY_FAMILY = "campaign_archive_encryption"
|
||||
POLICY_TARGET = "*"
|
||||
ENCRYPTION_METHODS = frozenset({"aes", "zip_standard"})
|
||||
PASSWORD_DELIVERY_CHANNELS = frozenset(
|
||||
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||
)
|
||||
DEFAULT_METHODS = frozenset({"aes"})
|
||||
DEFAULT_DELIVERY_CHANNELS = PASSWORD_DELIVERY_CHANNELS
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionPolicyError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignArchiveEncryptionPolicyState:
|
||||
row: PolicyOverride | None
|
||||
effective: CampaignArchiveEncryptionDecision
|
||||
parent: CampaignArchiveEncryptionDecision
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionPolicyProvider:
|
||||
def resolve_campaign_archive_encryption(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: CampaignArchiveEncryptionRequest,
|
||||
) -> CampaignArchiveEncryptionDecision:
|
||||
if not isinstance(session, Session):
|
||||
return _decision((), reason="Policy storage is unavailable; only AES is safe by default.")
|
||||
rows = resolution_policy_overrides(
|
||||
session,
|
||||
policy_family=POLICY_FAMILY,
|
||||
target_keys=(POLICY_TARGET,),
|
||||
tenant_id=request.tenant_id,
|
||||
group_ids=(request.owner_id,)
|
||||
if request.owner_type == "group" and request.owner_id
|
||||
else (),
|
||||
user_ids=(request.owner_id,)
|
||||
if request.owner_type == "user" and request.owner_id
|
||||
else (),
|
||||
campaign_ids=(request.campaign_id,),
|
||||
)
|
||||
return resolve_campaign_archive_encryption_rows(rows)
|
||||
|
||||
|
||||
def validate_campaign_archive_encryption_policy(
|
||||
value: object,
|
||||
) -> dict[str, tuple[str, ...]]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise CampaignArchiveEncryptionPolicyError("Archive-encryption policy must be an object")
|
||||
supported = {
|
||||
"allowed_password_encryption_methods": ENCRYPTION_METHODS,
|
||||
"allowed_password_delivery_channels": PASSWORD_DELIVERY_CHANNELS,
|
||||
}
|
||||
unknown = sorted(str(key) for key in value if str(key) not in supported)
|
||||
if unknown:
|
||||
raise CampaignArchiveEncryptionPolicyError(
|
||||
f"Unsupported archive-encryption policy fields: {', '.join(unknown)}"
|
||||
)
|
||||
result: dict[str, tuple[str, ...]] = {}
|
||||
for key, raw in value.items():
|
||||
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
|
||||
raise CampaignArchiveEncryptionPolicyError(f"{key} must be a list")
|
||||
normalized = tuple(dict.fromkeys(str(item).strip() for item in raw))
|
||||
invalid = sorted(set(normalized).difference(supported[str(key)]))
|
||||
if invalid:
|
||||
raise CampaignArchiveEncryptionPolicyError(
|
||||
f"Unsupported values for {key}: {', '.join(invalid)}"
|
||||
)
|
||||
result[str(key)] = normalized
|
||||
return result
|
||||
|
||||
|
||||
def resolve_campaign_archive_encryption_rows(
|
||||
rows: Sequence[PolicyOverride],
|
||||
) -> CampaignArchiveEncryptionDecision:
|
||||
methods = set(DEFAULT_METHODS)
|
||||
channels = set(DEFAULT_DELIVERY_CHANNELS)
|
||||
sources: list[PolicySourceStep] = [
|
||||
PolicySourceStep(
|
||||
scope_type="system",
|
||||
label="Secure archive-encryption baseline",
|
||||
applied_fields=(
|
||||
"allowed_password_encryption_methods",
|
||||
"allowed_password_delivery_channels",
|
||||
),
|
||||
policy={
|
||||
"allowed_password_encryption_methods": sorted(DEFAULT_METHODS),
|
||||
"allowed_password_delivery_channels": sorted(DEFAULT_DELIVERY_CHANNELS),
|
||||
"implicit": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
diagnostics: list[Mapping[str, Any]] = []
|
||||
explicit_system = False
|
||||
for row in rows:
|
||||
try:
|
||||
policy = validate_campaign_archive_encryption_policy(row.policy)
|
||||
except CampaignArchiveEncryptionPolicyError as exc:
|
||||
methods.clear()
|
||||
channels.clear()
|
||||
diagnostics.append(
|
||||
{
|
||||
"code": "campaign_archive_encryption.invalid_fail_closed",
|
||||
"scope": row.scope_key,
|
||||
"message": str(exc),
|
||||
}
|
||||
)
|
||||
sources.append(
|
||||
PolicySourceStep(
|
||||
scope_type=row.scope_type, # type: ignore[arg-type]
|
||||
scope_id=row.scope_id,
|
||||
label=f"{row.scope_type.capitalize()} archive-encryption policy",
|
||||
applied_fields=("configuration_status",),
|
||||
policy={"configuration_status": "invalid_fail_closed"},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
configured_methods = policy.get("allowed_password_encryption_methods")
|
||||
configured_channels = policy.get("allowed_password_delivery_channels")
|
||||
if row.scope_type == "system" and not explicit_system:
|
||||
explicit_system = True
|
||||
if configured_methods is not None:
|
||||
methods = set(configured_methods)
|
||||
if configured_channels is not None:
|
||||
channels = set(configured_channels)
|
||||
sources[0] = PolicySourceStep(
|
||||
scope_type="system",
|
||||
label="System archive-encryption policy",
|
||||
applied_fields=tuple(sorted(policy)),
|
||||
policy={key: list(items) for key, items in policy.items()},
|
||||
)
|
||||
continue
|
||||
if configured_methods is not None:
|
||||
methods.intersection_update(configured_methods)
|
||||
if configured_channels is not None:
|
||||
channels.intersection_update(configured_channels)
|
||||
sources.append(
|
||||
PolicySourceStep(
|
||||
scope_type=row.scope_type, # type: ignore[arg-type]
|
||||
scope_id=row.scope_id,
|
||||
label=f"{row.scope_type.capitalize()} archive-encryption policy",
|
||||
applied_fields=tuple(sorted(policy)),
|
||||
policy={key: list(items) for key, items in policy.items()},
|
||||
)
|
||||
)
|
||||
return _decision(tuple(sources), methods=methods, channels=channels, diagnostics=diagnostics)
|
||||
|
||||
|
||||
def campaign_archive_encryption_policy_state(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
owner_type: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
) -> CampaignArchiveEncryptionPolicyState:
|
||||
clean_scope = scope_type.strip().casefold()
|
||||
if clean_scope not in {"system", "tenant", "group", "user", "campaign"}:
|
||||
raise CampaignArchiveEncryptionPolicyError("Unsupported policy scope")
|
||||
effective_rows = _rows_for_scope(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
parent_rows = tuple(row for row in effective_rows if row.scope_type != clean_scope)
|
||||
row = get_policy_override(
|
||||
session,
|
||||
policy_family=POLICY_FAMILY,
|
||||
target_key=POLICY_TARGET,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return CampaignArchiveEncryptionPolicyState(
|
||||
row=row,
|
||||
effective=resolve_campaign_archive_encryption_rows(effective_rows),
|
||||
parent=resolve_campaign_archive_encryption_rows(parent_rows),
|
||||
)
|
||||
|
||||
|
||||
def save_campaign_archive_encryption_policy(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
owner_type: str | None,
|
||||
owner_id: str | None,
|
||||
policy: object,
|
||||
actor_id: str | None,
|
||||
) -> CampaignArchiveEncryptionPolicyState:
|
||||
clean_policy = validate_campaign_archive_encryption_policy(policy)
|
||||
before = campaign_archive_encryption_policy_state(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if scope_type.strip().casefold() != "system":
|
||||
requested_methods = set(
|
||||
clean_policy.get(
|
||||
"allowed_password_encryption_methods",
|
||||
tuple(before.parent.allowed_password_encryption_methods),
|
||||
)
|
||||
)
|
||||
requested_channels = set(
|
||||
clean_policy.get(
|
||||
"allowed_password_delivery_channels",
|
||||
tuple(before.parent.allowed_password_delivery_channels),
|
||||
)
|
||||
)
|
||||
if not requested_methods.issubset(before.parent.allowed_password_encryption_methods):
|
||||
raise CampaignArchiveEncryptionPolicyError(
|
||||
"A child scope cannot enable an archive-encryption method blocked by its parent"
|
||||
)
|
||||
if not requested_channels.issubset(before.parent.allowed_password_delivery_channels):
|
||||
raise CampaignArchiveEncryptionPolicyError(
|
||||
"A child scope cannot enable a password-delivery channel blocked by its parent"
|
||||
)
|
||||
set_policy_override(
|
||||
session,
|
||||
policy_family=POLICY_FAMILY,
|
||||
target_key=POLICY_TARGET,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
policy={key: list(items) for key, items in clean_policy.items()},
|
||||
actor_id=actor_id,
|
||||
)
|
||||
return campaign_archive_encryption_policy_state(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
|
||||
def _rows_for_scope(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
owner_type: str | None,
|
||||
owner_id: str | None,
|
||||
) -> tuple[PolicyOverride, ...]:
|
||||
group_ids: tuple[str, ...] = ()
|
||||
user_ids: tuple[str, ...] = ()
|
||||
campaign_ids: tuple[str, ...] = ()
|
||||
if scope_type == "group" and scope_id:
|
||||
group_ids = (scope_id,)
|
||||
elif scope_type == "user" and scope_id:
|
||||
user_ids = (scope_id,)
|
||||
elif scope_type == "campaign":
|
||||
if not scope_id:
|
||||
raise CampaignArchiveEncryptionPolicyError("Campaign scope requires scope_id")
|
||||
campaign_ids = (scope_id,)
|
||||
if owner_type == "group" and owner_id:
|
||||
group_ids = (owner_id,)
|
||||
elif owner_type == "user" and owner_id:
|
||||
user_ids = (owner_id,)
|
||||
elif owner_type or owner_id:
|
||||
raise CampaignArchiveEncryptionPolicyError("Campaign owner context is invalid")
|
||||
rows = resolution_policy_overrides(
|
||||
session,
|
||||
policy_family=POLICY_FAMILY,
|
||||
target_keys=(POLICY_TARGET,),
|
||||
tenant_id=tenant_id,
|
||||
group_ids=group_ids,
|
||||
user_ids=user_ids,
|
||||
campaign_ids=campaign_ids,
|
||||
)
|
||||
maximum = {"system": 0, "tenant": 1, "group": 2, "user": 2, "campaign": 3}[scope_type]
|
||||
rank = {"system": 0, "tenant": 1, "group": 2, "user": 2, "campaign": 3}
|
||||
return tuple(row for row in rows if rank.get(row.scope_type, 99) <= maximum)
|
||||
|
||||
|
||||
def _decision(
|
||||
source_path: tuple[PolicySourceStep, ...],
|
||||
*,
|
||||
methods: set[str] | frozenset[str] = DEFAULT_METHODS,
|
||||
channels: set[str] | frozenset[str] = DEFAULT_DELIVERY_CHANNELS,
|
||||
reason: str | None = None,
|
||||
diagnostics: Sequence[Mapping[str, Any]] = (),
|
||||
) -> CampaignArchiveEncryptionDecision:
|
||||
payload = {
|
||||
"allowed_password_encryption_methods": sorted(methods),
|
||||
"allowed_password_delivery_channels": sorted(channels),
|
||||
"source_path": [step.to_dict() for step in source_path],
|
||||
"diagnostics": [dict(item) for item in diagnostics],
|
||||
}
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()
|
||||
).hexdigest()
|
||||
if reason is None:
|
||||
reason = (
|
||||
"Legacy ZipCrypto is permitted by the effective policy."
|
||||
if "zip_standard" in methods
|
||||
else "Legacy ZipCrypto is blocked by the effective archive-encryption policy."
|
||||
)
|
||||
return CampaignArchiveEncryptionDecision(
|
||||
allowed_password_encryption_methods=frozenset(methods), # type: ignore[arg-type]
|
||||
allowed_password_delivery_channels=frozenset(channels), # type: ignore[arg-type]
|
||||
policy_hash=digest,
|
||||
source_path=source_path,
|
||||
reason=reason,
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignArchiveEncryptionPolicyError",
|
||||
"CampaignArchiveEncryptionPolicyProvider",
|
||||
"CampaignArchiveEncryptionPolicyState",
|
||||
"campaign_archive_encryption_policy_state",
|
||||
"resolve_campaign_archive_encryption_rows",
|
||||
"save_campaign_archive_encryption_policy",
|
||||
"validate_campaign_archive_encryption_policy",
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.datasources import (
|
||||
DatasourceVisibilityPolicyDecision,
|
||||
DatasourceVisibilityPolicyRequest,
|
||||
)
|
||||
from govoplan_policy.backend.policy_overrides import resolution_policy_overrides
|
||||
|
||||
|
||||
POLICY_FAMILY = "datasource_visibility"
|
||||
GLOBAL_TARGET = "*"
|
||||
|
||||
|
||||
class DatasourceVisibilityPolicyProvider:
|
||||
"""Resolve referenced and hierarchical policy overlays without reading rows."""
|
||||
|
||||
def decide_datasource_visibility(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: DatasourceVisibilityPolicyRequest,
|
||||
) -> DatasourceVisibilityPolicyDecision:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError(
|
||||
"Datasource visibility policy requires a SQLAlchemy session"
|
||||
)
|
||||
target_keys = tuple(
|
||||
dict.fromkeys(
|
||||
item for item in (GLOBAL_TARGET, request.policy_ref) if item is not None
|
||||
)
|
||||
)
|
||||
rows = resolution_policy_overrides(
|
||||
session,
|
||||
policy_family=POLICY_FAMILY,
|
||||
target_keys=target_keys,
|
||||
tenant_id=request.tenant_id,
|
||||
group_ids=request.principal.group_ids,
|
||||
user_ids=tuple(
|
||||
dict.fromkeys(
|
||||
item
|
||||
for item in (
|
||||
request.principal.account_id,
|
||||
request.principal.membership_id,
|
||||
)
|
||||
if item
|
||||
)
|
||||
),
|
||||
)
|
||||
normalized_ref = str(request.policy_ref or "").strip().casefold()
|
||||
if normalized_ref and not any(row.target_key == normalized_ref for row in rows):
|
||||
return DatasourceVisibilityPolicyDecision(
|
||||
allowed=False,
|
||||
reason="The referenced Datasource visibility policy is unavailable.",
|
||||
decision_ref=_decision_ref(request, rows),
|
||||
provenance={
|
||||
"provider": "policy.datasource_visibility",
|
||||
"version": "1",
|
||||
"status": "reference_unresolved",
|
||||
"policy_ref": request.policy_ref,
|
||||
},
|
||||
)
|
||||
policies: list[Mapping[str, object]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row.policy, Mapping):
|
||||
return DatasourceVisibilityPolicyDecision(
|
||||
allowed=False,
|
||||
reason="A Datasource visibility policy is malformed.",
|
||||
decision_ref=_decision_ref(request, rows),
|
||||
provenance={
|
||||
"provider": "policy.datasource_visibility",
|
||||
"version": "1",
|
||||
"status": "malformed",
|
||||
"policy_id": row.id,
|
||||
},
|
||||
)
|
||||
policies.append({str(key): value for key, value in row.policy.items()})
|
||||
return DatasourceVisibilityPolicyDecision(
|
||||
allowed=True,
|
||||
policies=tuple(policies),
|
||||
decision_ref=_decision_ref(request, rows),
|
||||
provenance={
|
||||
"provider": "policy.datasource_visibility",
|
||||
"version": "1",
|
||||
"status": "resolved",
|
||||
"sources": [
|
||||
{
|
||||
"policy_id": row.id,
|
||||
"target_key": row.target_key,
|
||||
"scope_type": row.scope_type,
|
||||
"scope_id": row.scope_id,
|
||||
"revision": row.revision,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _decision_ref(request, rows) -> str:
|
||||
payload = {
|
||||
"tenant_id": request.tenant_id,
|
||||
"datasource_ref": request.datasource_ref,
|
||||
"action": request.action,
|
||||
"consistency": request.consistency,
|
||||
"materialization_ref": request.materialization_ref,
|
||||
"policy_ref": request.policy_ref,
|
||||
"rows": [
|
||||
{
|
||||
"id": row.id,
|
||||
"revision": row.revision,
|
||||
"target_key": row.target_key,
|
||||
"scope_key": row.scope_key,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"datasource-visibility:{digest}"
|
||||
|
||||
|
||||
__all__ = ["DatasourceVisibilityPolicyProvider", "POLICY_FAMILY"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Policy-owned persistence models."""
|
||||
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
|
||||
__all__ = ["PolicyOverride"]
|
||||
@@ -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,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
|
||||
|
||||
POLICY_DSAR_CAPABILITY = dsar_capability_name("policy")
|
||||
_MAX_RECORDS = 5_000
|
||||
_CONFLICT = object()
|
||||
|
||||
|
||||
class PolicyDsarProvider:
|
||||
provider_id = "policy"
|
||||
module_id = "policy"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
account_id, override_id = selectors
|
||||
query = db.query(PolicyOverride).filter(
|
||||
PolicyOverride.tenant_id == tenant_id,
|
||||
or_(
|
||||
PolicyOverride.created_by == account_id,
|
||||
PolicyOverride.updated_by == account_id,
|
||||
),
|
||||
)
|
||||
if override_id:
|
||||
query = query.filter(PolicyOverride.id == override_id)
|
||||
rows = (
|
||||
query.order_by(PolicyOverride.created_at, PolicyOverride.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError("Policy DSAR result limit exceeded; narrow selectors.")
|
||||
return tuple(_record(row, account_id) for row in rows)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _selectors(subject) is None:
|
||||
raise ValueError("Policy DSAR subject selectors conflict.")
|
||||
actions = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"policy:retain:{record.resource_id}",
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="retain",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Retain {record.title}",
|
||||
rationale=(
|
||||
record.retention_reason
|
||||
or "Policy-change attribution remains governance evidence."
|
||||
),
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _selectors(subject) is None:
|
||||
raise ValueError("Policy DSAR subject selectors conflict.")
|
||||
results = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable or action.kind != "retain":
|
||||
raise ValueError("Policy DSAR publishes retain actions only.")
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary="Policy-change attribution remains governance evidence.",
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _selectors(subject: DsarSubjectRef) -> tuple[str, str | None] | None:
|
||||
references = subject.external_references
|
||||
account = _coalesce(
|
||||
subject.account_id,
|
||||
references.get("policy.account"),
|
||||
references.get("access.account"),
|
||||
)
|
||||
override_id = _coalesce(
|
||||
references.get("policy.override"), references.get("policy.override_id")
|
||||
)
|
||||
if account is _CONFLICT or override_id is _CONFLICT:
|
||||
return None
|
||||
if not isinstance(account, str) or not account:
|
||||
return None
|
||||
return account, override_id if isinstance(override_id, str) else None
|
||||
|
||||
|
||||
def _record(row: PolicyOverride, account_id: str) -> DsarRecordRef:
|
||||
activities = []
|
||||
if row.created_by == account_id:
|
||||
activities.append("created_policy_override")
|
||||
if row.updated_by == account_id:
|
||||
activities.append("updated_policy_override")
|
||||
return DsarRecordRef(
|
||||
provider_id="policy",
|
||||
module_id="policy",
|
||||
resource_type="policy_override_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="policy_governance_attribution",
|
||||
title="Policy override actor attribution",
|
||||
data={
|
||||
"override_id": row.id,
|
||||
"policy_family": row.policy_family,
|
||||
"scope_type": row.scope_type,
|
||||
"revision": row.revision,
|
||||
"activities": activities,
|
||||
"created_at": _iso(row.created_at),
|
||||
"updated_at": _iso(row.updated_at),
|
||||
},
|
||||
observed_at=_aware(row.updated_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason=(
|
||||
"Policy-change attribution is retained for governance and accountability."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
aware = _aware(value)
|
||||
return aware.isoformat() if aware else None
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Policy DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "policy" or record.module_id != "policy":
|
||||
raise ValueError("Policy DSAR cannot plan a foreign provider record.")
|
||||
if (
|
||||
record.resource_type != "policy_override_actor_attribution"
|
||||
or not record.resource_id
|
||||
):
|
||||
raise ValueError("Policy DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "policy" or action.module_id != "policy":
|
||||
raise ValueError("Policy DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("policy:retain:"):
|
||||
raise ValueError("Policy DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["POLICY_DSAR_CAPABILITY", "PolicyDsarProvider"]
|
||||
@@ -0,0 +1,422 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
FunctionAssignmentEscalationRule,
|
||||
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"))
|
||||
delegation_allowed = _bool(
|
||||
policy.get("delegation_allowed"),
|
||||
default=False,
|
||||
)
|
||||
maximum_delegation_depth = (
|
||||
_bounded_int(
|
||||
policy.get("maximum_delegation_depth"),
|
||||
default=1,
|
||||
minimum=1,
|
||||
maximum=20,
|
||||
)
|
||||
if delegation_allowed
|
||||
else 0
|
||||
)
|
||||
maximum_delegated_validity_days = _optional_positive_int(
|
||||
policy.get("maximum_delegated_validity_days"),
|
||||
maximum=3650,
|
||||
)
|
||||
escalation_rules, escalation_requirements = _escalation_rules(policy)
|
||||
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")
|
||||
requirements.extend(escalation_requirements)
|
||||
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,
|
||||
delegation_allowed=delegation_allowed,
|
||||
maximum_delegation_depth=maximum_delegation_depth,
|
||||
maximum_delegated_validity_days=maximum_delegated_validity_days,
|
||||
escalation_rules=escalation_rules,
|
||||
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 = _route_reason(
|
||||
context,
|
||||
"authority",
|
||||
"Only the designated authority may initiate this grant.",
|
||||
)
|
||||
else:
|
||||
allowed = bool(context.get("actor_is_holder"))
|
||||
reason = _route_reason(
|
||||
context,
|
||||
"holder",
|
||||
"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 _route_reason(
|
||||
context,
|
||||
"holder",
|
||||
"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 _route_reason(
|
||||
context,
|
||||
"authority",
|
||||
"The designated authority must approve.",
|
||||
)
|
||||
if action == "approve_escalation":
|
||||
allowed = request.current_state == "escalated" and bool(
|
||||
context.get("actor_is_escalation_target")
|
||||
)
|
||||
return allowed, None if allowed else _route_reason(
|
||||
context,
|
||||
"escalation",
|
||||
"A current holder of the explicit escalation target 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."
|
||||
elif request.current_state == "escalated":
|
||||
allowed = bool(context.get("actor_is_escalation_target"))
|
||||
reason = _route_reason(
|
||||
context,
|
||||
"escalation",
|
||||
"Only a current holder of the explicit escalation target may act.",
|
||||
)
|
||||
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,
|
||||
delegation_allowed: bool = False,
|
||||
maximum_delegation_depth: int = 0,
|
||||
maximum_delegated_validity_days: int | None = None,
|
||||
escalation_rules: tuple[FunctionAssignmentEscalationRule, ...] = (),
|
||||
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,
|
||||
),
|
||||
delegation_allowed=delegation_allowed,
|
||||
maximum_delegation_depth=maximum_delegation_depth,
|
||||
maximum_delegated_validity_days=maximum_delegated_validity_days,
|
||||
escalation_rules=escalation_rules,
|
||||
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,
|
||||
"actor_routes": dict(request.context.get("actor_routes") or {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
"escalation_holder": "a valid holder-step escalation rule",
|
||||
"escalation_authority": "a valid authority-step escalation rule",
|
||||
"escalation_recipient": "a valid recipient-step escalation rule",
|
||||
}
|
||||
return (
|
||||
"Submission requires "
|
||||
+ ", ".join(labels.get(item, item.replace("_", " ")) for item in requirements)
|
||||
+ "."
|
||||
)
|
||||
|
||||
|
||||
def _escalation_rules(
|
||||
policy: Mapping[str, object],
|
||||
) -> tuple[tuple[FunctionAssignmentEscalationRule, ...], list[str]]:
|
||||
raw = policy.get("escalation")
|
||||
if raw is None:
|
||||
return (), []
|
||||
if not isinstance(raw, Mapping):
|
||||
return (), ["escalation_holder"]
|
||||
rules: list[FunctionAssignmentEscalationRule] = []
|
||||
requirements: list[str] = []
|
||||
for step in ("holder", "authority", "recipient"):
|
||||
value = raw.get(step)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, Mapping):
|
||||
requirements.append(f"escalation_{step}")
|
||||
continue
|
||||
target_function_id = _text(value.get("target_function_id"))
|
||||
timeout_hours = _optional_positive_int(
|
||||
value.get("timeout_hours"),
|
||||
maximum=8760,
|
||||
)
|
||||
if target_function_id is None or timeout_hours is None:
|
||||
requirements.append(f"escalation_{step}")
|
||||
continue
|
||||
rules.append(
|
||||
FunctionAssignmentEscalationRule(
|
||||
step=step, # type: ignore[arg-type]
|
||||
target_function_id=target_function_id,
|
||||
timeout_hours=timeout_hours,
|
||||
)
|
||||
)
|
||||
return tuple(rules), requirements
|
||||
|
||||
|
||||
def _route_reason(
|
||||
context: Mapping[str, object],
|
||||
route: str,
|
||||
fallback: str,
|
||||
) -> str:
|
||||
routes = context.get("actor_routes")
|
||||
if not isinstance(routes, Mapping):
|
||||
return fallback
|
||||
value = routes.get(route)
|
||||
if not isinstance(value, Mapping):
|
||||
return fallback
|
||||
return _text(value.get("reason")) or fallback
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'policy.data-subject-requests': {'consequence_classes': {'export_policy_attribution': 'Returns '
|
||||
'minimiert '
|
||||
'Politik '
|
||||
'Aktivität '
|
||||
'für das '
|
||||
'genaue '
|
||||
'Konto.',
|
||||
'retain_policy_evidence': 'Bewahrt die '
|
||||
'politische '
|
||||
'Governance-Rechenschaftspflicht.'}},
|
||||
'policy.function-assignment-delegation-escalation': {'fields': [{'consequence': 'Erlaubt nur dann '
|
||||
'geregelte '
|
||||
'abgeleitete '
|
||||
'Zuweisungen, '
|
||||
'wenn '
|
||||
'Organisationen '
|
||||
'auch die '
|
||||
'Funktion '
|
||||
'delegierbar '
|
||||
'markieren.',
|
||||
'key': 'delegation_allowed'},
|
||||
{'consequence': 'Lehnt längere '
|
||||
'aktuelle Ketten '
|
||||
'ab, '
|
||||
'einschließlich '
|
||||
'Ketten, die vor '
|
||||
'einem engeren '
|
||||
'Limit akzeptiert '
|
||||
'wurden.',
|
||||
'key': 'maximum_delegation_depth'},
|
||||
{'consequence': 'Caps jedes '
|
||||
'delegierte '
|
||||
'Gültigkeitsfenster '
|
||||
'zusätzlich zu '
|
||||
'seinem '
|
||||
'Quellfenster.',
|
||||
'key': 'maximum_delegated_validity_days'},
|
||||
{'consequence': 'Pins eine '
|
||||
'Zielfunktion und '
|
||||
'Frist ohne '
|
||||
'Erteilung oder '
|
||||
'Ersatz '
|
||||
'Genehmigung.',
|
||||
'key': 'escalation.<step>'}]},
|
||||
'policy.hierarchy-overrides-and-retention': {'outcome': 'Der ausgewählte Berechtigungsumfang hat '
|
||||
'eine erklärbare Aufbewahrungsrichtlinie '
|
||||
'und jeder destruktiven Anwendung wird '
|
||||
'eine Dry-Run-Überprüfung vorausgegangen.',
|
||||
'prerequisites': ['Policy und Access sind aktiviert.',
|
||||
'Die handelnde Person kann die '
|
||||
'Richtlinieneinstellungen am '
|
||||
'ausgewählten Bereich lesen.'],
|
||||
'steps': ['Überprüfen Sie den effektiven Wert und '
|
||||
'seinen Policy Source Path.',
|
||||
'Schmale nur Felder, die die übergeordnete '
|
||||
'Richtlinie diesen Bereich außer Kraft '
|
||||
'setzt.',
|
||||
'Speichern Sie die Richtlinie und führen '
|
||||
'Sie dann einen System-Dry-Run aus, bevor '
|
||||
'Sie die Retention anwenden.',
|
||||
'Überprüfen Sie das Bounded Outcome und '
|
||||
'prüfen Sie den Nachweis nach einem '
|
||||
'angewandten Durchlauf.'],
|
||||
'verification': 'Laden Sie die Richtlinie neu, '
|
||||
'bestätigen Sie ihren Quellpfad und '
|
||||
'vergleichen Sie die Trockenlauf- '
|
||||
'oder angewandte Ergebnistabelle mit '
|
||||
'den Prüfungsnachweisen.'},
|
||||
'policy.impact-preview': {'limitations': ['Nicht verfügbare optionale Anbieter werden erklärt und '
|
||||
'niemals als Null-Auswirkungen behandelt.',
|
||||
'Ressourcendetails werden ohne policy:impact:details '
|
||||
'ausgeblendet.'],
|
||||
'steps': ['Wählen Sie eine explizite Impact-Provider-Population und ein '
|
||||
'begrenztes Limit.',
|
||||
'Preview und Inspect Outcome Counts, Coverage State und '
|
||||
'Provenienz.',
|
||||
'Reauthentifizieren, wenn eine systemweite Änderung als hohe '
|
||||
'Auswirkungen eingestuft wird.',
|
||||
'Speichern Sie erst, nachdem die Vorschau mit dem aktuellen '
|
||||
'Dirty Draft übereinstimmt.']},
|
||||
'policy.retention-execution-and-recovery': {'limitations': ['Die Anwendung kann gelöschte EML- '
|
||||
'oder Mock-Mailbox-Inhalte nicht '
|
||||
'wiederherstellen.',
|
||||
'Ein Trockenlauf ist eine Vorschau '
|
||||
'und reserviert den gemeldeten Satz '
|
||||
'nicht gegen gleichzeitige '
|
||||
'Änderungen.'],
|
||||
'outcome': 'Förderfähige Details werden redigiert und '
|
||||
'förderfähige generierte Artefakte werden '
|
||||
'mit begrenztem Ergebnis und '
|
||||
'Prüfungsnachweis gelöscht.',
|
||||
'prerequisites': ['Die handelnde Person kann '
|
||||
'Systemeinstellungen schreiben.',
|
||||
'Die vorgesehene '
|
||||
'Systemaufbewahrungsrichtlinie wird '
|
||||
'gespeichert und neu geladen.',
|
||||
'Recovery Evidenz ist aktuell für '
|
||||
'generierte Artefakte.'],
|
||||
'steps': ['Führen Sie einen Trockenlauf durch und '
|
||||
'überprüfen Sie jede Datenklasse und '
|
||||
'Ergebniszahl.',
|
||||
'Stoppen Sie, wenn Anbieter ausfallen, die '
|
||||
'Wiederherstellung blockiert wird oder '
|
||||
'Zählungen unerwartet sind.',
|
||||
'Bestätigen Sie den destruktiven Lauf erst '
|
||||
'nach einer Überprüfung der Politik und der '
|
||||
'Wiederherstellung.',
|
||||
'Vergleichen Sie das angewandte Ergebnis '
|
||||
'mit den Prüfungsnachweisen.'],
|
||||
'verification': 'Überprüfen Sie das neueste Ergebnis, '
|
||||
'die Fehler- und '
|
||||
'Wiederherstellungszahlen des '
|
||||
'Anbieters und suchen Sie dann den '
|
||||
'Auditdatensatz retention '
|
||||
'policy.run.'}}
|
||||
@@ -66,21 +66,27 @@ def validate_hierarchical_policy_patch(
|
||||
relock_message: Callable[[str], str] | None = None,
|
||||
) -> tuple[PolicyValidationIssue, ...]:
|
||||
issues: list[PolicyValidationIssue] = []
|
||||
lock_message = locked_field_message or (lambda field: f"{field} is locked by the parent policy.")
|
||||
reenable_message = relock_message or (lambda field: f"{field} limiting cannot be re-enabled below a parent policy lock.")
|
||||
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_name: f"{field_name} limiting cannot be re-enabled below a parent policy lock."
|
||||
)
|
||||
|
||||
for field in field_keys:
|
||||
if field in patch and patch.get(field) is not None and not parent_allow_lower_level_limits.get(field, True):
|
||||
for field_name in field_keys:
|
||||
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(
|
||||
code="field_locked_by_parent",
|
||||
field=field,
|
||||
message=lock_message(field),
|
||||
field=field_name,
|
||||
message=lock_message(field_name),
|
||||
))
|
||||
|
||||
patch_allow = patch.get("allow_lower_level_limits")
|
||||
if isinstance(patch_allow, Mapping):
|
||||
for field, allowed in patch_allow.items():
|
||||
clean_field = str(field)
|
||||
for field_name, allowed in patch_allow.items():
|
||||
clean_field = str(field_name)
|
||||
if bool(allowed) and not parent_allow_lower_level_limits.get(clean_field, True):
|
||||
issues.append(PolicyValidationIssue(
|
||||
code="lower_level_limit_locked_by_parent",
|
||||
@@ -125,9 +131,9 @@ def simulate_hierarchical_policy_change(
|
||||
relock_message=relock_message,
|
||||
)
|
||||
changed_fields = tuple(
|
||||
field
|
||||
for field in (*field_keys, "allow_lower_level_limits")
|
||||
if field in patch and current_policy.get(field) != patch.get(field)
|
||||
field_name
|
||||
for field_name in (*field_keys, "allow_lower_level_limits")
|
||||
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)
|
||||
decision = PolicyDecision(
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
PolicyImpactPopulationRequest,
|
||||
PolicyImpactSubject,
|
||||
PolicyImpactSubjectBatch,
|
||||
PolicySourceStep,
|
||||
normalize_policy_scope_type,
|
||||
policy_impact_subject_provider,
|
||||
)
|
||||
from govoplan_policy.backend.view_governance import (
|
||||
VIEW_POLICY_BOOLEAN_FIELDS,
|
||||
ViewPolicyResolution,
|
||||
)
|
||||
from govoplan_policy.backend.view_policy_service import (
|
||||
validate_view_policy_change,
|
||||
)
|
||||
|
||||
|
||||
PolicyImpactCategory = Literal[
|
||||
"newly_allowed",
|
||||
"newly_denied",
|
||||
"unchanged",
|
||||
"indeterminate",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PolicyImpactPreviewError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactPopulationSpec:
|
||||
provider_id: str
|
||||
selector: Mapping[str, Any] = field(default_factory=dict)
|
||||
limit: int = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactEffect:
|
||||
category: PolicyImpactCategory
|
||||
subject: PolicyImpactSubject
|
||||
current_allowed: bool | None
|
||||
proposed_allowed: bool | None
|
||||
rule: str
|
||||
current_sources: tuple[PolicySourceStep, ...] = ()
|
||||
proposed_sources: tuple[PolicySourceStep, ...] = ()
|
||||
explanation: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"category": self.category,
|
||||
"subject": self.subject.to_dict(),
|
||||
"current_allowed": self.current_allowed,
|
||||
"proposed_allowed": self.proposed_allowed,
|
||||
"rule": self.rule,
|
||||
"current_sources": [step.to_dict() for step in self.current_sources],
|
||||
"proposed_sources": [step.to_dict() for step in self.proposed_sources],
|
||||
"explanation": self.explanation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyImpactPreview:
|
||||
preview_id: str
|
||||
proposal_hash: str
|
||||
policy_family: str
|
||||
scope_type: str
|
||||
scope_id: str | None
|
||||
base_revision: int | None
|
||||
counts: Mapping[PolicyImpactCategory, int]
|
||||
effects: tuple[PolicyImpactEffect, ...]
|
||||
populations: tuple[Mapping[str, Any], ...]
|
||||
details_hidden: bool
|
||||
details_explanation: str | None
|
||||
high_impact: bool
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"preview_id": self.preview_id,
|
||||
"proposal_hash": self.proposal_hash,
|
||||
"policy_family": self.policy_family,
|
||||
"scope_type": self.scope_type,
|
||||
"scope_id": self.scope_id,
|
||||
"base_revision": self.base_revision,
|
||||
"counts": dict(self.counts),
|
||||
"effects": [effect.to_dict() for effect in self.effects],
|
||||
"populations": [dict(population) for population in self.populations],
|
||||
"details_hidden": self.details_hidden,
|
||||
"details_explanation": self.details_explanation,
|
||||
"high_impact": self.high_impact,
|
||||
}
|
||||
|
||||
|
||||
def preview_policy_impact(
|
||||
session: Session,
|
||||
*,
|
||||
registry: object,
|
||||
tenant_id: str,
|
||||
policy_family: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
proposed_policy: object,
|
||||
populations: Sequence[PolicyImpactPopulationSpec],
|
||||
actor_scopes: Sequence[str] = (),
|
||||
include_details: bool = False,
|
||||
details_allowed: bool = False,
|
||||
) -> PolicyImpactPreview:
|
||||
clean_family = policy_family.strip().casefold()
|
||||
clean_scope = normalize_policy_scope_type(scope_type)
|
||||
if clean_family != "view":
|
||||
raise PolicyImpactPreviewError(
|
||||
"The current impact evaluator supports the View policy family."
|
||||
)
|
||||
if not populations or len(populations) > 10:
|
||||
raise PolicyImpactPreviewError(
|
||||
"Policy impact preview requires between 1 and 10 explicit populations."
|
||||
)
|
||||
|
||||
clean_policy, current_state = validate_view_policy_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
policy=proposed_policy,
|
||||
)
|
||||
proposed = _proposed_view_resolution(
|
||||
parent=current_state.parent,
|
||||
policy=clean_policy,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
allow_details = include_details and details_allowed
|
||||
counts: dict[PolicyImpactCategory, int] = {
|
||||
"newly_allowed": 0,
|
||||
"newly_denied": 0,
|
||||
"unchanged": 0,
|
||||
"indeterminate": 0,
|
||||
}
|
||||
effects: list[PolicyImpactEffect] = []
|
||||
population_results: list[Mapping[str, Any]] = []
|
||||
seen_subjects: set[tuple[str, str, str, str]] = set()
|
||||
|
||||
for specification in populations:
|
||||
provider_id = specification.provider_id.strip()
|
||||
provider = policy_impact_subject_provider(registry, provider_id)
|
||||
if provider is None:
|
||||
population_results.append(
|
||||
PolicyImpactSubjectBatch(
|
||||
provider_id=provider_id or "unknown",
|
||||
state="unavailable",
|
||||
explanation=(
|
||||
"The requested impact-subject provider is not enabled or "
|
||||
"does not implement the Core contract."
|
||||
),
|
||||
).to_dict(include_subjects=False)
|
||||
)
|
||||
continue
|
||||
if clean_family not in provider.supported_policy_families:
|
||||
population_results.append(
|
||||
PolicyImpactSubjectBatch(
|
||||
provider_id=provider_id,
|
||||
state="unavailable",
|
||||
explanation=(
|
||||
"The provider does not support the requested policy family."
|
||||
),
|
||||
).to_dict(include_subjects=False)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
batch = provider.collect_policy_impact_subjects(
|
||||
session,
|
||||
request=PolicyImpactPopulationRequest(
|
||||
tenant_id=tenant_id,
|
||||
policy_family=clean_family,
|
||||
selector=specification.selector,
|
||||
limit=specification.limit,
|
||||
actor_scopes=tuple(actor_scopes),
|
||||
allow_sensitive_details=allow_details,
|
||||
),
|
||||
)
|
||||
if batch.provider_id != provider_id:
|
||||
raise PolicyImpactPreviewError(
|
||||
"Policy impact provider returned a mismatched provider ID."
|
||||
)
|
||||
except Exception: # noqa: BLE001 - isolate optional providers.
|
||||
logger.exception(
|
||||
"Policy impact subject provider failed provider_id=%s family=%s",
|
||||
provider_id,
|
||||
clean_family,
|
||||
)
|
||||
batch = PolicyImpactSubjectBatch(
|
||||
provider_id=provider_id,
|
||||
state="unavailable",
|
||||
explanation=(
|
||||
"The provider could not evaluate this population. Inspect "
|
||||
"operator logs before committing the proposed change."
|
||||
),
|
||||
)
|
||||
population_results.append(batch.to_dict(include_subjects=False))
|
||||
for subject in batch.subjects:
|
||||
if subject.key in seen_subjects:
|
||||
continue
|
||||
seen_subjects.add(subject.key)
|
||||
effect = _compare_view_subject(
|
||||
subject,
|
||||
current=current_state.effective,
|
||||
proposed=proposed,
|
||||
)
|
||||
counts[effect.category] += 1
|
||||
if allow_details:
|
||||
effects.append(effect)
|
||||
|
||||
changed = counts["newly_allowed"] + counts["newly_denied"]
|
||||
return PolicyImpactPreview(
|
||||
preview_id=str(uuid4()),
|
||||
proposal_hash=policy_impact_proposal_hash(
|
||||
family=clean_family,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
policy=clean_policy,
|
||||
base_policy=current_state.local_policy,
|
||||
base_revision=(
|
||||
current_state.row.revision if current_state.row is not None else None
|
||||
),
|
||||
),
|
||||
policy_family=clean_family,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
base_revision=(
|
||||
current_state.row.revision if current_state.row is not None else None
|
||||
),
|
||||
counts=counts,
|
||||
effects=tuple(effects),
|
||||
populations=tuple(population_results),
|
||||
details_hidden=include_details and not details_allowed,
|
||||
details_explanation=(
|
||||
None
|
||||
if not include_details or details_allowed
|
||||
else "Subject details require policy:impact:details; aggregate counts remain visible."
|
||||
),
|
||||
high_impact=changed > 0 and clean_scope == "system",
|
||||
)
|
||||
|
||||
|
||||
def _proposed_view_resolution(
|
||||
*,
|
||||
parent: ViewPolicyResolution,
|
||||
policy: Mapping[str, bool | tuple[str, ...]],
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> ViewPolicyResolution:
|
||||
limits = dict(parent.limits)
|
||||
for field_name in VIEW_POLICY_BOOLEAN_FIELDS:
|
||||
value = policy.get(field_name)
|
||||
if isinstance(value, bool):
|
||||
limits[field_name] = limits[field_name] and value
|
||||
allowed_view_ids = _narrow_set(
|
||||
parent.allowed_view_ids,
|
||||
policy.get("allowed_view_ids"),
|
||||
)
|
||||
visible_surface_ids = _narrow_set(
|
||||
parent.visible_surface_ids,
|
||||
policy.get("visible_surface_ids"),
|
||||
)
|
||||
source_path = parent.source_path
|
||||
if policy:
|
||||
source_path = (
|
||||
*source_path,
|
||||
PolicySourceStep(
|
||||
scope_type=normalize_policy_scope_type(scope_type),
|
||||
scope_id=tenant_id if scope_type == "tenant" else scope_id,
|
||||
label=f"Proposed {scope_type.capitalize()} View policy",
|
||||
applied_fields=tuple(sorted(policy)),
|
||||
policy={
|
||||
key: list(value) if isinstance(value, tuple) else value
|
||||
for key, value in policy.items()
|
||||
},
|
||||
),
|
||||
)
|
||||
return ViewPolicyResolution(
|
||||
limits=limits,
|
||||
allowed_view_ids=allowed_view_ids,
|
||||
visible_surface_ids=visible_surface_ids,
|
||||
source_path=source_path,
|
||||
diagnostics=parent.diagnostics,
|
||||
)
|
||||
|
||||
|
||||
def _narrow_set(
|
||||
parent: frozenset[str] | None,
|
||||
value: object,
|
||||
) -> frozenset[str] | None:
|
||||
if not isinstance(value, tuple):
|
||||
return parent
|
||||
proposed = frozenset(value)
|
||||
return proposed if parent is None else parent.intersection(proposed)
|
||||
|
||||
|
||||
def _compare_view_subject(
|
||||
subject: PolicyImpactSubject,
|
||||
*,
|
||||
current: ViewPolicyResolution,
|
||||
proposed: ViewPolicyResolution,
|
||||
) -> PolicyImpactEffect:
|
||||
current_allowed, rule = _view_subject_decision(subject, current)
|
||||
proposed_allowed, _ = _view_subject_decision(subject, proposed)
|
||||
if current_allowed is None or proposed_allowed is None:
|
||||
category: PolicyImpactCategory = "indeterminate"
|
||||
elif current_allowed == proposed_allowed:
|
||||
category = "unchanged"
|
||||
elif proposed_allowed:
|
||||
category = "newly_allowed"
|
||||
else:
|
||||
category = "newly_denied"
|
||||
source_fields = [
|
||||
field_name
|
||||
for field_name in (rule.removeprefix("view."), "allow_view")
|
||||
if field_name
|
||||
]
|
||||
if subject.resource_type == "view":
|
||||
source_fields.append("allowed_view_ids")
|
||||
elif subject.resource_type == "surface":
|
||||
source_fields.append("visible_surface_ids")
|
||||
return PolicyImpactEffect(
|
||||
category=category,
|
||||
subject=subject,
|
||||
current_allowed=current_allowed,
|
||||
proposed_allowed=proposed_allowed,
|
||||
rule=rule,
|
||||
current_sources=_sources_for_fields(current.source_path, tuple(source_fields)),
|
||||
proposed_sources=_sources_for_fields(proposed.source_path, tuple(source_fields)),
|
||||
explanation=(
|
||||
"The provider subject or action is not supported by the View evaluator."
|
||||
if category == "indeterminate"
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _view_subject_decision(
|
||||
subject: PolicyImpactSubject,
|
||||
resolution: ViewPolicyResolution,
|
||||
) -> tuple[bool | None, str]:
|
||||
if subject.resource_type == "surface":
|
||||
allowed = resolution.limits["allow_view"]
|
||||
if resolution.visible_surface_ids is not None:
|
||||
allowed = allowed and subject.resource_id in resolution.visible_surface_ids
|
||||
return allowed, "view.visible_surface_ids"
|
||||
if subject.resource_type != "view" or subject.action not in {
|
||||
"view",
|
||||
"select",
|
||||
"assign",
|
||||
"edit",
|
||||
"derive",
|
||||
"workflow_activate",
|
||||
}:
|
||||
return None, "view.unsupported"
|
||||
action_field = f"allow_{subject.action}"
|
||||
allowed = resolution.limits["allow_view"] and resolution.limits[action_field]
|
||||
if resolution.allowed_view_ids is not None:
|
||||
allowed = allowed and subject.resource_id in resolution.allowed_view_ids
|
||||
return allowed, f"view.{action_field}"
|
||||
|
||||
|
||||
def _sources_for_fields(
|
||||
source_path: Sequence[PolicySourceStep],
|
||||
fields: Sequence[str],
|
||||
) -> tuple[PolicySourceStep, ...]:
|
||||
relevant = set(fields)
|
||||
return tuple(
|
||||
step
|
||||
for step in source_path
|
||||
if relevant.intersection(step.applied_fields)
|
||||
)
|
||||
|
||||
|
||||
def policy_impact_proposal_hash(
|
||||
*,
|
||||
family: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
policy: Mapping[str, Any],
|
||||
base_policy: Mapping[str, Any],
|
||||
base_revision: int | None,
|
||||
) -> str:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"policy_family": family,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"base_revision": base_revision,
|
||||
"base_policy": {
|
||||
key: list(value) if isinstance(value, tuple) else value
|
||||
for key, value in base_policy.items()
|
||||
},
|
||||
"policy": {
|
||||
key: list(value) if isinstance(value, tuple) else value
|
||||
for key, value in policy.items()
|
||||
},
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PolicyImpactEffect",
|
||||
"PolicyImpactPopulationSpec",
|
||||
"PolicyImpactPreview",
|
||||
"PolicyImpactPreviewError",
|
||||
"policy_impact_proposal_hash",
|
||||
"preview_policy_impact",
|
||||
]
|
||||
@@ -1,8 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION
|
||||
from govoplan_core.core.modules import FrontendModule, ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_policy.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||
)
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
)
|
||||
from govoplan_core.core.datasources import CAPABILITY_POLICY_DATASOURCE_VISIBILITY
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
|
||||
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,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
)
|
||||
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
|
||||
from govoplan_policy.backend.dsar_provider import (
|
||||
POLICY_DSAR_CAPABILITY,
|
||||
PolicyDsarProvider,
|
||||
)
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
@@ -19,19 +62,848 @@ def _privacy_retention_service(context: ModuleContext) -> object:
|
||||
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()
|
||||
|
||||
|
||||
def _access_explanation_subject_policy(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_policy.backend.access_explanation_subjects import (
|
||||
AccessExplanationSubjectPolicyProvider,
|
||||
)
|
||||
|
||||
return AccessExplanationSubjectPolicyProvider()
|
||||
|
||||
|
||||
def _campaign_archive_encryption_policy(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_policy.backend.campaign_archive_encryption import (
|
||||
CampaignArchiveEncryptionPolicyProvider,
|
||||
)
|
||||
|
||||
return CampaignArchiveEncryptionPolicyProvider()
|
||||
|
||||
|
||||
def _datasource_visibility_policy(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_policy.backend.datasource_visibility import (
|
||||
DatasourceVisibilityPolicyProvider,
|
||||
)
|
||||
|
||||
return DatasourceVisibilityPolicyProvider()
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> PolicyDsarProvider:
|
||||
return PolicyDsarProvider()
|
||||
|
||||
|
||||
ACCESS_EXPLANATION_SUBJECT_SCOPE = "policy:access_explanation:select_user"
|
||||
POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details"
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="policy",
|
||||
name="Policy",
|
||||
version="0.1.7",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
version="0.1.23",
|
||||
permissions=(
|
||||
PermissionDefinition(
|
||||
scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
||||
label="Select users for access diagnostics",
|
||||
description=(
|
||||
"Run resource-access explanations for another user in the active tenant."
|
||||
),
|
||||
category="Policy",
|
||||
level="tenant",
|
||||
module_id="policy",
|
||||
resource="access_explanation",
|
||||
action="select_user",
|
||||
),
|
||||
PermissionDefinition(
|
||||
scope=POLICY_IMPACT_DETAILS_SCOPE,
|
||||
label="Inspect policy impact subjects",
|
||||
description=(
|
||||
"Inspect resource identifiers and provenance in bounded policy "
|
||||
"impact previews; aggregate counts require only policy-read authority."
|
||||
),
|
||||
category="Policy",
|
||||
level="tenant",
|
||||
module_id="policy",
|
||||
resource="impact",
|
||||
action="details",
|
||||
),
|
||||
),
|
||||
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="policy.impact_preview",
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="policy.datasource_visibility",
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(name=POLICY_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
route_factory=_route_factory,
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="policy.datasource-visibility",
|
||||
title="Datasource visibility policy overlays",
|
||||
summary="Tighten Datasources-owned ACL, field, and row visibility through referenced hierarchical policies.",
|
||||
body=(
|
||||
"Datasources owns enforcement and a local visibility baseline. Policy can add system, tenant, group, or user overlays for the global target and an explicitly referenced policy key. Every matching overlay is applied as an additional restriction; it cannot restore a source, field, or row removed by another layer. An unresolved referenced policy, malformed payload, or unavailable decision fails closed. Decision evidence retains policy identifiers, scopes, revisions, and a stable hash but never row values, field values, connector endpoints, or credentials. When Policy is not installed and no external policy reference is configured, Datasources continues to enforce its local scope, ACL, projection, redaction, and row-filter rules."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("policy_admin", "data_steward", "auditor"),
|
||||
related_modules=("datasources", "access", "audit"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Richtlinienebenen für die Sichtbarkeit von Datenquellen",
|
||||
"summary": (
|
||||
"Die von Datasources verwaltete Sichtbarkeit für ACLs, Felder und Zeilen durch referenzierte hierarchische "
|
||||
"Richtlinien weiter einschränken."
|
||||
),
|
||||
"body": (
|
||||
"Datasources besitzt die Durchsetzung und eine lokale Sichtbarkeitsgrundlage. Policy kann für das globale Ziel und "
|
||||
"einen ausdrücklich referenzierten Richtlinienschlüssel zusätzliche Ebenen auf System-, Mandanten-, Gruppen- oder "
|
||||
"Benutzerebene liefern. Jede passende Ebene wirkt als weitere Einschränkung und kann keine Quelle, kein Feld und keine "
|
||||
"Zeile wiederherstellen, die eine andere Ebene entfernt hat. Eine nicht auflösbare Referenz, fehlerhafte Nutzdaten oder "
|
||||
"eine nicht verfügbare Entscheidung schließen den Zugriff sicher. Entscheidungsnachweise enthalten Richtlinienkennungen, "
|
||||
"Geltungsbereiche, Revisionen und einen stabilen Hash, aber niemals Zeilen- oder Feldwerte, Connector-Endpunkte oder "
|
||||
"Zugangsdaten. Ist Policy nicht installiert und keine externe Richtlinienreferenz konfiguriert, setzt Datasources seine "
|
||||
"lokalen Regeln für Umfang, ACL, Projektion, Schwärzung und Zeilenfilterung weiterhin durch."
|
||||
),
|
||||
}
|
||||
},
|
||||
order=28,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="policy.data-subject-requests",
|
||||
title="Policy data-subject requests",
|
||||
summary=(
|
||||
"Export policy-change attribution without disclosing policy documents "
|
||||
"or scoped subject identifiers."
|
||||
),
|
||||
body=(
|
||||
"Policy correlates only an exact account identifier within the active "
|
||||
"tenant and can narrow an already verified search to one override. It "
|
||||
"returns minimized creation and update activity with the policy family, "
|
||||
"scope type, revision, and timestamps. Policy values, target and scope "
|
||||
"keys, scope identifiers, and decision provenance are not included. "
|
||||
"System-scoped overrides are not projected into a tenant request. Policy "
|
||||
"change attribution remains governance evidence and is retained rather "
|
||||
"than automatically erased."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "policy_admin", "privacy_officer", "auditor"),
|
||||
related_modules=("core", "access", "audit"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Betroffenenanfragen für Richtlinien",
|
||||
"summary": (
|
||||
"Zuordnung von Richtlinienänderungen exportieren, ohne Richtliniendokumente oder eingegrenzte "
|
||||
"Betroffenenkennungen offenzulegen."
|
||||
),
|
||||
"body": (
|
||||
"Policy gleicht innerhalb des aktiven Mandanten nur eine exakte Kontokennung ab und kann eine bereits verifizierte Suche "
|
||||
"auf eine einzelne Überschreibung begrenzen. Ausgegeben werden minimierte Erstellungs- und Änderungsaktivitäten mit "
|
||||
"Richtlinienfamilie, Bereichstyp, Revision und Zeitpunkten. Richtlinienwerte, Ziel- und Bereichsschlüssel, "
|
||||
"Bereichskennungen und Entscheidungsherkunft sind nicht enthalten. Systemweite Überschreibungen werden nicht in eine "
|
||||
"Mandantenanfrage projiziert. Die Zuordnung von Richtlinienänderungen bleibt Governance-Nachweis und wird aufbewahrt, "
|
||||
"statt automatisch gelöscht zu werden."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"help_contexts": ["privacy.data-subject-requests"],
|
||||
"consequence_classes": {
|
||||
"export_policy_attribution": "Returns minimized policy activity for the exact account.",
|
||||
"retain_policy_evidence": "Preserves policy governance accountability.",
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="policy.access-explanation-subjects",
|
||||
title="Choose subjects for access diagnostics",
|
||||
summary=(
|
||||
"Policy keeps access explanations on the signed-in user unless "
|
||||
"the actor has the selected-user diagnostic permission."
|
||||
),
|
||||
body=(
|
||||
"Files and Campaign use the shared access-explanation picker. "
|
||||
"Without policy:access_explanation:select_user, Access returns "
|
||||
"only the signed-in user and does not disclose tenant-directory "
|
||||
"metadata. Permitted cross-user explanations remain limited to "
|
||||
"the active tenant and are recorded as administrator diagnostics "
|
||||
"in audit evidence. The permission changes diagnostic visibility; "
|
||||
"it does not grant access to the explained resource."
|
||||
),
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "tenant_admin", "policy_admin"),
|
||||
related_modules=("access", "audit", "campaign", "files"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Zielpersonen für Zugriffsdiagnosen auswählen",
|
||||
"summary": (
|
||||
"Policy beschränkt Zugriffserklärungen auf die angemeldete Person, sofern die handelnde Person nicht die Berechtigung "
|
||||
"zur Diagnose für ausgewählte Benutzende besitzt."
|
||||
),
|
||||
"body": (
|
||||
"Files und Campaign verwenden die gemeinsame Auswahl für Zugriffserklärungen. Ohne "
|
||||
"policy:access_explanation:select_user liefert Access nur die angemeldete Person und legt keine Metadaten des "
|
||||
"Mandantenverzeichnisses offen. Erlaubte Erklärungen für andere Personen bleiben auf den aktiven Mandanten begrenzt und "
|
||||
"werden als administrative Diagnose im Auditnachweis festgehalten. Die Berechtigung erweitert nur die Sichtbarkeit der "
|
||||
"Diagnose; sie gewährt keinen Zugriff auf die erklärte Ressource."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": ["access.resource-explanation.subject"],
|
||||
},
|
||||
),
|
||||
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"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "View-Verfügbarkeit und -Aktionen steuern",
|
||||
"summary": (
|
||||
"View-Richtlinien begrenzen verfügbare Definitionen und Oberflächen sowie die View-Aktionen, die untergeordnete "
|
||||
"Ebenen ausführen dürfen."
|
||||
),
|
||||
"body": (
|
||||
"View-Richtlinien auf System-, Mandanten-, Gruppen- und Benutzerebene bilden eine einschränkende Hierarchie. Jede Ebene "
|
||||
"kann Anzeigen, Auswählen, Zuweisen, Bearbeiten, Ableiten und Workflow-Aktivierung erben, erlauben oder blockieren. "
|
||||
"Optionale Obergrenzen für View- und Oberflächenkennungen werden entlang der Hierarchie geschnitten, sodass eine "
|
||||
"untergeordnete Ebene einen darüber ausgeschlossenen Eintrag nicht wiederherstellen kann. Verfügbare, standardmäßige "
|
||||
"und verpflichtende View-Zuweisungen gehören weiterhin Views; Policy liefert die Aktions- und Katalogobergrenze und "
|
||||
"zeichnet Herkunft sowie Diagnosen fehlerhafter Richtlinien auf."
|
||||
),
|
||||
}
|
||||
},
|
||||
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.function-assignment-delegation-escalation",
|
||||
title="Govern function delegation and review escalation",
|
||||
summary="Policy bounds complete delegation chains and defines explicit target functions for overdue assignment reviews.",
|
||||
body=(
|
||||
"Tenant defaults and function settings may allow delegation, cap its chain depth and validity, and configure a holder, authority, or recipient review timeout with an exact escalation target function. IDM rechecks the complete current chain and the effective Policy at submission, every decision, recovery, and application. A tightened limit invalidates an old route with an explanation. A timeout creates a visible escalated state but never substitutes an approver or completes the review; a current target-function holder must decide explicitly. Malformed or incomplete escalation rules fail closed."
|
||||
),
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "policy_admin", "access_admin", "user"),
|
||||
related_modules=(
|
||||
"idm",
|
||||
"organizations",
|
||||
"workflow_engine",
|
||||
"notifications",
|
||||
"audit",
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Funktionsdelegation und Prüfeskalation steuern",
|
||||
"summary": (
|
||||
"Policy begrenzt vollständige Delegationsketten und definiert ausdrückliche Zielfunktionen für überfällige "
|
||||
"Zuweisungsprüfungen."
|
||||
),
|
||||
"body": (
|
||||
"Mandantenstandards und Funktionseinstellungen können Delegation erlauben, Kettentiefe und Gültigkeit begrenzen und "
|
||||
"eine Prüfungsfrist für Inhaber, verantwortliche Stelle oder empfangende Person mit exakter Eskalations-Zielfunktion "
|
||||
"festlegen. IDM prüft die vollständige aktuelle Kette und die wirksame Policy bei Einreichung, jeder Entscheidung, "
|
||||
"Wiederherstellung und Anwendung erneut. Eine verschärfte Grenze verwirft einen älteren Weg mit Begründung. Eine "
|
||||
"Fristüberschreitung erzeugt einen sichtbaren eskalierten Zustand, ersetzt aber keine freigebende Person und schließt "
|
||||
"die Prüfung nicht ab; eine aktuelle Inhaberin oder ein aktueller Inhaber der Zielfunktion muss ausdrücklich entscheiden. "
|
||||
"Fehlerhafte oder unvollständige Eskalationsregeln schließen sicher."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"idm.field.delegation-ceilings",
|
||||
"idm.field.escalation",
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"key": "delegation_allowed",
|
||||
"consequence": "Allows governed derived assignments only when Organizations also marks the function delegable.",
|
||||
},
|
||||
{
|
||||
"key": "maximum_delegation_depth",
|
||||
"consequence": "Rejects longer current chains, including chains accepted before a tighter limit.",
|
||||
},
|
||||
{
|
||||
"key": "maximum_delegated_validity_days",
|
||||
"consequence": "Caps each delegated validity window in addition to its source window.",
|
||||
},
|
||||
{
|
||||
"key": "escalation.<step>",
|
||||
"consequence": "Pins a target function and deadline without granting or substituting approval.",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
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"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Wirksame Richtlinienentscheidungen verstehen",
|
||||
"summary": (
|
||||
"Richtlinienentscheidungen erläutern, ob eine Aktion erlaubt, begrenzt, geerbt oder nicht verfügbar ist, und nennen "
|
||||
"die Quellen des Ergebnisses."
|
||||
),
|
||||
"body": (
|
||||
"Eine untergeordnete Ebene darf eine geerbte Obergrenze verschärfen, aber eine stärkere System- oder Mandantenregel "
|
||||
"nicht stillschweigend lockern. Die nutzenden Module bleiben dafür verantwortlich, die gelieferte Entscheidung "
|
||||
"durchzusetzen und ihre Begründung anzuzeigen. Eine ausdrücklich konfigurierte fehlerhafte Richtlinie schließt die "
|
||||
"betroffene gesteuerte Aktion sicher, statt als nicht vorhanden zu gelten."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"kind": "reference"},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="policy.impact-preview",
|
||||
title="Preview policy impact before saving",
|
||||
summary=(
|
||||
"Compare the current and proposed effective policy over explicit, "
|
||||
"bounded provider populations without persisting the proposal."
|
||||
),
|
||||
body=(
|
||||
"The Policy impact preview groups newly allowed, newly denied, "
|
||||
"unchanged, and indeterminate effects and retains rule, source, and "
|
||||
"scope provenance. Callers must select one to ten provider populations "
|
||||
"and a limit of at most 500 subjects per population; Policy never scans "
|
||||
"the platform implicitly. Population evidence says whether results are "
|
||||
"complete, sampled, truncated, or unavailable. Policy-read authority "
|
||||
"may inspect aggregate counts, while policy:impact:details controls "
|
||||
"resource identifiers and labels. Every preview is audited using its "
|
||||
"proposal hash and bounded counts. System-wide View-policy commits "
|
||||
"require authentication within the last 15 minutes and retain their "
|
||||
"existing commit audit and configuration-approval evidence. Optional "
|
||||
"modules contribute subjects through the Core provider contract; Policy "
|
||||
"does not import their models or services."
|
||||
),
|
||||
documentation_types=("admin",),
|
||||
audience=("system_admin", "tenant_admin", "policy_admin"),
|
||||
related_modules=("admin", "audit", "views"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Richtlinienauswirkung vor dem Speichern prüfen",
|
||||
"summary": (
|
||||
"Aktuelle und vorgeschlagene wirksame Richtlinie über ausdrücklich ausgewählte, begrenzte Provider-Populationen "
|
||||
"vergleichen, ohne den Vorschlag zu speichern."
|
||||
),
|
||||
"body": (
|
||||
"Die Policy-Auswirkungsvorschau gruppiert neu erlaubte, neu verweigerte, unveränderte und unbestimmte Wirkungen und "
|
||||
"hält Regel-, Quellen- und Bereichsherkunft fest. Aufrufende müssen eine bis zehn Provider-Populationen und je Population "
|
||||
"eine Grenze von höchstens 500 Subjekten wählen; Policy durchsucht die Plattform niemals implizit. Der "
|
||||
"Populationsnachweis kennzeichnet Ergebnisse als vollständig, stichprobenartig, abgeschnitten oder nicht verfügbar. "
|
||||
"Mit Leseberechtigung für Richtlinien sind aggregierte Anzahlen sichtbar, während policy:impact:details "
|
||||
"Ressourcenkennungen und -bezeichnungen steuert. Jede Vorschau wird mit Vorschlagshash und begrenzten Anzahlen auditiert. "
|
||||
"Systemweite View-Richtlinienänderungen verlangen eine Authentifizierung innerhalb der letzten 15 Minuten und behalten "
|
||||
"ihre vorhandenen Audit- und Konfigurationsfreigabenachweise. Optionale Module liefern Subjekte über den Core-Providervertrag; "
|
||||
"Policy importiert weder ihre Modelle noch ihre Dienste."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=system-view-policy",
|
||||
"screen": "View policy impact preview",
|
||||
"help_contexts": [
|
||||
"policy.impact-preview",
|
||||
"policy.impact-preview.action.preview",
|
||||
"policy.impact-preview.results",
|
||||
],
|
||||
"api": {
|
||||
"preview": "/api/v1/admin/policy-impact/preview",
|
||||
"maximum_populations": 10,
|
||||
"maximum_subjects_per_population": 500,
|
||||
},
|
||||
"steps": [
|
||||
"Select an explicit impact provider population and bounded limit.",
|
||||
"Preview and inspect outcome counts, coverage state, and provenance.",
|
||||
"Reauthenticate when a system-wide change is classified as high impact.",
|
||||
"Save only after the preview matches the current dirty draft.",
|
||||
],
|
||||
"limitations": [
|
||||
"Unavailable optional providers are explained and are never treated as zero impact.",
|
||||
"Resource details are hidden without policy:impact:details.",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="policy.campaign-archive-encryption",
|
||||
title="Govern Campaign archive encryption",
|
||||
summary="Restrict password-protected Campaign ZIP formats and password-delivery channels through an explainable hierarchy.",
|
||||
body=(
|
||||
"The secure baseline permits AES only. An authorized policy administrator may explicitly permit legacy ZipCrypto at system scope, after which tenant, owner group or user, and campaign rules may only narrow the inherited methods. The same intersection controls the separate channel used to convey a password. Policy records the complete source path and a stable policy hash; malformed configuration fails closed. Policy changes never rewrite old build evidence, while Campaign rejects a queued or sent build whose effective policy is now more restrictive."
|
||||
" To configure the exception, open Administration → SYSTEM → Campaign archive encryption, enable Legacy ZipCrypto, and Save. The system methods and channels remain editable before any explicit override exists; opening default settings alone does not create an override or unsaved changes. Lower scopes inherit until their inheritance switch is disabled and may select only parent-permitted methods and channels. Campaign Settings, Policies, and Attachments link authorized readers to the system and tenant settings and let them reload effective policy. Reading requires admin:policies:read. Saving the global system ceiling requires both system:settings:write and admin:policies:write; lower-scope saves require admin:policies:write. Core's configuration safety catalog validates this registered setting and retains audited before/after and rollback choices; only the two validated format/channel enum lists are exempted from password-name redaction, never real secrets or unknown values. Using the exception additionally requires Campaign's dedicated legacy-encryption permission and a weak-encryption acknowledgment with a reason of at least 10 characters. Saving policy does not send mail or silently change any archive's selected method."
|
||||
),
|
||||
documentation_types=("admin", "user"),
|
||||
audience=(
|
||||
"system_admin",
|
||||
"tenant_admin",
|
||||
"policy_admin",
|
||||
"campaign_manager",
|
||||
),
|
||||
related_modules=("campaign", "audit", "access"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="System Campaign archive encryption",
|
||||
href="/admin?section=system-campaign-archive-encryption",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Tenant Campaign archive encryption",
|
||||
href="/admin?section=tenant-campaign-archive-encryption",
|
||||
kind="runtime",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Verschlüsselung von Campaign-Archiven steuern",
|
||||
"summary": (
|
||||
"Formate passwortgeschützter Campaign-ZIP-Dateien und Übertragungskanäle für Passwörter über eine erklärbare "
|
||||
"Hierarchie einschränken."
|
||||
),
|
||||
"body": (
|
||||
"Die sichere Grundlage erlaubt nur AES. Eine berechtigte Richtlinienadministration kann das veraltete ZipCrypto auf "
|
||||
"Systemebene ausdrücklich zulassen; Regeln auf Mandanten-, Eigentümergruppen-, Benutzer- und Campaign-Ebene dürfen die "
|
||||
"geerbten Methoden anschließend nur weiter einschränken. Derselbe Schnitt steuert getrennt den Kanal zur Übermittlung "
|
||||
"eines Passworts. Policy zeichnet den vollständigen Quellenpfad und einen stabilen Richtlinienhash auf; fehlerhafte "
|
||||
"Konfiguration schließt sicher. Richtlinienänderungen schreiben alte Erstellungsnachweise niemals um, während Campaign "
|
||||
"einen eingereihten oder versandten Build zurückweist, wenn dessen wirksame Richtlinie inzwischen strenger ist."
|
||||
" Öffnen Sie zur Konfiguration Administration → SYSTEM → Campaign archive encryption, aktivieren Sie Legacy ZipCrypto und speichern Sie. "
|
||||
"Methoden und Kanäle auf Systemebene sind schon vor der ersten ausdrücklichen Ausnahme bearbeitbar; das bloße Öffnen erzeugt weder eine Ausnahme noch ungespeicherte Änderungen. "
|
||||
"Untergeordnete Ebenen erben bis zum Abschalten ihres Vererbungsschalters und dürfen nur übergeordnet erlaubte Methoden und Kanäle wählen. "
|
||||
"Kampagneneinstellungen, Richtlinien und Anhänge verlinken berechtigte Lesende auf System- und Mandantenkonfiguration und erlauben das Neuladen der wirksamen Richtlinie. "
|
||||
"Lesen erfordert admin:policies:read. Das Speichern der globalen Systemgrenze benötigt system:settings:write und admin:policies:write gemeinsam; untergeordnete Ebenen benötigen admin:policies:write. "
|
||||
"Der zentrale Konfigurations-Sicherheitskatalog prüft dieses registrierte Feld und bewahrt auditierte Vorher-/Nachherwerte sowie Rücknahmewerte. Nur die beiden validierten Format-/Kanal-Enumlisten bleiben trotz Passwortbegriff im Feldnamen sichtbar, niemals echte Geheimnisse oder unbekannte Werte. "
|
||||
"Die Nutzung benötigt zusätzlich Campaigns gesonderte Legacy-Verschlüsselungsberechtigung und die Bestätigung schwacher Verschlüsselung mit mindestens 10 Zeichen Begründung. "
|
||||
"Das Speichern einer Richtlinie versendet keine E-Mail und ändert keine gewählte Archivmethode stillschweigend."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"policy.campaign-archive-encryption",
|
||||
"campaign.archive-encryption",
|
||||
],
|
||||
},
|
||||
),
|
||||
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=(
|
||||
"Retention fields govern separate data classes: raw campaign JSON, generated EML artifacts, stored report details, mock-mailbox records, and audit details. A blank system day limit keeps the class indefinitely; a blank lower-scope value inherits its parent. Lower scopes may only shorten an allowed limit or reduce audit detail. Disabling raw campaign JSON makes it immediately eligible for redaction when retention is applied. Audit detail level controls how new audit details are recorded, while audit-detail retention redacts eligible historical detail but preserves the audit record and a bounded retention marker. The lower-level switch controls whether child scopes may narrow that specific field. Inspect the effective value and source path before saving."
|
||||
),
|
||||
documentation_types=("admin",),
|
||||
audience=("policy_admin", "tenant_admin", "system_admin"),
|
||||
related_modules=(
|
||||
"audit",
|
||||
"campaign",
|
||||
"dataflow",
|
||||
"mail",
|
||||
"reporting",
|
||||
"workflow_engine",
|
||||
"views",
|
||||
"idm",
|
||||
"dist_lists",
|
||||
"scheduling",
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Richtlinienhierarchie und Aufbewahrung verwalten",
|
||||
"summary": "Policy wertet versionierte System-, Mandanten-, Gruppen- und Benutzerregeln für Aufbewahrung sowie modulbezogene Steuerungsentscheidungen aus.",
|
||||
"body": "Die Felder steuern getrennte Datenklassen: Kampagnen-Rohdaten im JSON-Format, erzeugte EML-Dateien, gespeicherte Berichtsdetails, Einträge im Testpostfach und Auditdetails. Ein leeres Tageslimit auf Systemebene bedeutet unbegrenzte Aufbewahrung; auf tieferen Ebenen wird der Elternwert geerbt. Tiefere Ebenen dürfen ein erlaubtes Limit nur verkürzen oder Auditdetails weiter reduzieren. Wenn die Speicherung von Kampagnen-Rohdaten deaktiviert wird, werden diese bei der nächsten Ausführung sofort zur Schwärzung vorgemerkt. Die Auditdetailstufe steuert neue Auditdetails; die Aufbewahrungsfrist für Auditdetails schwärzt historische Details, erhält aber den Auditdatensatz und einen begrenzten Aufbewahrungsnachweis. Der Schalter für tiefere Ebenen bestimmt, ob Kindebenen genau dieses Feld weiter einschränken dürfen. Prüfen Sie vor dem Speichern den effektiven Wert und seinen Quellenpfad.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=system-retention",
|
||||
"screen": "Retention administration",
|
||||
"help_contexts": [
|
||||
"policy.retention",
|
||||
"privacy.retention",
|
||||
"policy.admin.system-retention",
|
||||
"policy.admin.tenant-retention",
|
||||
"policy.admin.group-retention",
|
||||
"policy.admin.user-retention",
|
||||
"policy.retention.target",
|
||||
"policy.retention.action.reload-targets",
|
||||
"policy.retention.action.reload",
|
||||
"policy.retention.action.save",
|
||||
"policy.retention.field.store-raw-campaign-json",
|
||||
"policy.retention.field.raw-campaign-json-retention-days",
|
||||
"policy.retention.field.generated-eml-retention-days",
|
||||
"policy.retention.field.stored-report-detail-retention-days",
|
||||
"policy.retention.field.mock-mailbox-retention-days",
|
||||
"policy.retention.field.audit-detail-retention-days",
|
||||
"policy.retention.field.audit-detail-level",
|
||||
"policy.retention.field.allow-lower-level-limits",
|
||||
],
|
||||
"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.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="policy.retention-execution-and-recovery",
|
||||
title="Preview and apply retention safely",
|
||||
summary="A dry run reports eligible records without changing them; applying retention redacts details and deletes eligible generated artifacts.",
|
||||
body=(
|
||||
"Save and reload the intended system policy before execution. Run a dry run first and review every reported data class and count. Apply retention only when those counts match the approved policy and recovery evidence is current. An applied run redacts eligible raw campaign JSON, stored report summaries, reporting details, and audit details; it deletes eligible generated EML and mock-mailbox artifacts. The application cannot restore deleted content. Generated EML deletion uses the Campaign recovery boundary, while the applied run and bounded outcome counts remain in audit evidence. Treat provider failures, recovery blocks, missing artifacts, or unexpected counts as a stop condition and investigate before another run."
|
||||
),
|
||||
documentation_types=("admin",),
|
||||
audience=("system_admin", "policy_admin", "privacy_officer"),
|
||||
related_modules=("audit", "campaign", "mail", "reporting"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Aufbewahrung sicher prüfen und anwenden",
|
||||
"summary": "Ein Probelauf meldet betroffene Datensätze ohne Änderung; die Anwendung schwärzt Details und löscht vorgemerkte erzeugte Artefakte.",
|
||||
"body": "Speichern und laden Sie die beabsichtigte Systemrichtlinie vor der Ausführung neu. Führen Sie zuerst einen Probelauf aus und prüfen Sie jede Datenklasse und Anzahl. Wenden Sie die Aufbewahrung nur an, wenn die Zahlen der genehmigten Richtlinie entsprechen und die Wiederherstellungsnachweise aktuell sind. Ein angewendeter Lauf schwärzt vorgemerkte Kampagnen-Rohdaten, gespeicherte Berichtsdetails und Auditdetails; vorgemerkte EML-Dateien und Testpostfach-Artefakte werden gelöscht. Die Anwendung kann gelöschte Inhalte nicht wiederherstellen. Die EML-Löschung verwendet die Wiederherstellungsgrenze des Campaign-Moduls; der Lauf und begrenzte Ergebniszahlen bleiben als Auditnachweis erhalten. Anbieterfehler, blockierte Wiederherstellung, fehlende Artefakte oder unerwartete Zahlen sind ein Abbruchgrund und müssen vor einem weiteren Lauf untersucht werden.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=system-retention",
|
||||
"screen": "Retention execution",
|
||||
"help_contexts": [
|
||||
"policy.retention.execution",
|
||||
"policy.retention.action.dry-run",
|
||||
"policy.retention.action.apply",
|
||||
"policy.retention.confirm-apply",
|
||||
"policy.retention.outcome",
|
||||
],
|
||||
"prerequisites": [
|
||||
"The actor may write system settings.",
|
||||
"The intended system retention policy is saved and reloaded.",
|
||||
"Recovery evidence is current for generated artifacts.",
|
||||
],
|
||||
"steps": [
|
||||
"Run a dry run and review each data class and outcome count.",
|
||||
"Stop if providers fail, recovery is blocked, or counts are unexpected.",
|
||||
"Confirm the destructive run only after policy and recovery review.",
|
||||
"Compare the applied outcome with audit evidence.",
|
||||
],
|
||||
"outcome": "Eligible details are redacted and eligible generated artifacts are deleted with bounded outcome and audit evidence.",
|
||||
"limitations": [
|
||||
"The application cannot restore deleted EML or mock-mailbox content.",
|
||||
"A dry run is a preview and does not reserve the reported set against concurrent changes.",
|
||||
],
|
||||
"verification": "Review the latest outcome, provider failure and recovery counts, then locate the retention_policy.run audit record.",
|
||||
},
|
||||
),
|
||||
),
|
||||
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(
|
||||
module_id="policy",
|
||||
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-campaign-archive-encryption",
|
||||
module_id="policy",
|
||||
kind="section",
|
||||
label="System Campaign archive encryption",
|
||||
order=75,
|
||||
),
|
||||
ViewSurface(
|
||||
id="policy.admin.tenant-campaign-archive-encryption",
|
||||
module_id="policy",
|
||||
kind="section",
|
||||
label="Tenant Campaign archive encryption",
|
||||
order=75,
|
||||
),
|
||||
ViewSurface(
|
||||
id="policy.admin.group-campaign-archive-encryption",
|
||||
module_id="policy",
|
||||
kind="section",
|
||||
label="Group Campaign archive encryption",
|
||||
order=75,
|
||||
),
|
||||
ViewSurface(
|
||||
id="policy.admin.user-campaign-archive-encryption",
|
||||
module_id="policy",
|
||||
kind="section",
|
||||
label="User Campaign archive encryption",
|
||||
order=75,
|
||||
),
|
||||
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_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_ACCESS_EXPLANATION_SUBJECTS: (
|
||||
_access_explanation_subject_policy
|
||||
),
|
||||
CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service,
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: _scheduling_participant_privacy_policy,
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION: _campaign_archive_encryption_policy,
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY: _datasource_visibility_policy,
|
||||
POLICY_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS: CapabilityDocumentation(
|
||||
label="Access-explanation subject policy",
|
||||
summary=(
|
||||
"Limits access explanations to the current user or permits "
|
||||
"audited selected-user administrator diagnostics."
|
||||
),
|
||||
contract_version="1.0",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "policy_admin"),
|
||||
),
|
||||
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"),
|
||||
),
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION: CapabilityDocumentation(
|
||||
label="Campaign archive-encryption policy",
|
||||
summary="Returns the restrictive format and password-delivery ceiling with complete provenance and a stable hash.",
|
||||
contract_version="1.0",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("policy_admin", "campaign_manager"),
|
||||
),
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY: CapabilityDocumentation(
|
||||
label="Datasource visibility policy",
|
||||
summary="Returns restrictive referenced policy overlays without reading or exposing datasource content.",
|
||||
contract_version="1.0",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("policy_admin", "data_steward", "auditor"),
|
||||
),
|
||||
POLICY_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Policy data-subject request provider",
|
||||
summary=(
|
||||
"Exports minimized tenant policy-change attribution without policy payloads."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
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",
|
||||
"bounded policy impact preview",
|
||||
),
|
||||
non_owned_concepts=("application permission", "domain record", "audit record"),
|
||||
recovery_docs=("docs/POLICY_DECISION_PROVENANCE.md",),
|
||||
security_docs=("docs/POLICY_DECISION_PROVENANCE.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Policy database migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Policy migration revisions."""
|
||||
+75
@@ -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,233 @@
|
||||
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", "campaign"})
|
||||
POLICY_FAMILIES = frozenset(
|
||||
{
|
||||
"campaign_archive_encryption",
|
||||
"datasource_visibility",
|
||||
"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 campaign_archive_encryption, datasource_visibility, 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, user, or campaign"
|
||||
)
|
||||
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] = (),
|
||||
campaign_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)}))
|
||||
campaigns = tuple(
|
||||
sorted({str(value) for value in campaign_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]
|
||||
)
|
||||
)
|
||||
if campaigns:
|
||||
scope_filters.append(
|
||||
PolicyOverride.scope_key.in_(
|
||||
[
|
||||
f"campaign:{tenant_id}:{campaign_id}"
|
||||
for campaign_id in campaigns
|
||||
]
|
||||
)
|
||||
)
|
||||
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": 2,
|
||||
"campaign": 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,263 @@
|
||||
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, _before = validate_view_policy_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
policy=policy,
|
||||
)
|
||||
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 validate_view_policy_change(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
policy: object,
|
||||
) -> tuple[dict[str, bool | tuple[str, ...]], 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)
|
||||
)
|
||||
return clean_policy, before
|
||||
|
||||
|
||||
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",
|
||||
"validate_view_policy_change",
|
||||
"view_policy_response_payload",
|
||||
"view_policy_state",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_policy.backend.access_explanation_subjects import (
|
||||
ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
||||
AccessExplanationSubjectPolicyProvider,
|
||||
)
|
||||
|
||||
|
||||
class AccessExplanationSubjectPolicyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.provider = AccessExplanationSubjectPolicyProvider()
|
||||
|
||||
def test_defaults_to_current_user_without_permission(self) -> None:
|
||||
decision = self.provider.decide_subject_selection(
|
||||
object(),
|
||||
PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertFalse(decision.allow_other_users)
|
||||
self.assertEqual(ACCESS_EXPLANATION_SUBJECT_SCOPE, decision.required_scope)
|
||||
self.assertEqual("current_user", decision.provenance["mode"])
|
||||
|
||||
def test_permission_enables_cross_user_diagnostics(self) -> None:
|
||||
decision = self.provider.decide_subject_selection(
|
||||
object(),
|
||||
PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset({ACCESS_EXPLANATION_SUBJECT_SCOPE}),
|
||||
),
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertTrue(decision.allow_other_users)
|
||||
self.assertEqual("cross_user", decision.provenance["mode"])
|
||||
|
||||
def test_never_crosses_the_active_tenant(self) -> None:
|
||||
decision = self.provider.decide_subject_selection(
|
||||
object(),
|
||||
PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset({ACCESS_EXPLANATION_SUBJECT_SCOPE}),
|
||||
),
|
||||
tenant_id="tenant-2",
|
||||
)
|
||||
|
||||
self.assertFalse(decision.allow_other_users)
|
||||
self.assertEqual("policy.tenant_boundary", decision.source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.configuration_control import (
|
||||
configuration_control_snapshot,
|
||||
create_configuration_change_request,
|
||||
)
|
||||
from govoplan_core.core.configuration_safety import (
|
||||
classify_configuration_field,
|
||||
plan_configuration_change,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_policy.backend.api.v1.routes import router
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionApiTests(unittest.TestCase):
|
||||
"""Exercise the real HTTP route, safety catalog, persistence, and history."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
self.addCleanup(self.engine.dispose)
|
||||
for table in (SystemSettings.__table__, PolicyOverride.__table__, ChangeSequenceEntry.__table__):
|
||||
table.create(self.engine)
|
||||
self.principal = self._principal("admin:policies:read", "admin:policies:write", "system:settings:write")
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
def session_dependency():
|
||||
with Session(self.engine) as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = session_dependency
|
||||
app.dependency_overrides[get_api_principal] = lambda: self.principal
|
||||
self.client = TestClient(app)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
@staticmethod
|
||||
def _principal(*scopes: str) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="admin-account", membership_id="admin-user", tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="admin-account"),
|
||||
user=SimpleNamespace(id="admin-user"),
|
||||
)
|
||||
|
||||
def test_system_legacy_opt_in_passes_real_catalog_and_retains_history(self) -> None:
|
||||
path = "/api/v1/admin/campaign-archive-encryption/policies/system"
|
||||
field = classify_configuration_field("campaign_archive_encryption_policy")
|
||||
self.assertIsNotNone(field)
|
||||
self.assertEqual("policy", field.owner_module)
|
||||
self.assertTrue(field.validation_required)
|
||||
self.assertTrue(field.rollback_history_required)
|
||||
self.assertEqual({}, self.client.get(path).json()["policy"])
|
||||
policy = {
|
||||
"allowed_password_encryption_methods": ["aes", "zip_standard"],
|
||||
"allowed_password_delivery_channels": ["phone", "letter"],
|
||||
}
|
||||
response = self.client.put(path, json={"policy": policy})
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual(policy["allowed_password_encryption_methods"], response.json()["effective_policy"]["allowed_password_encryption_methods"])
|
||||
loaded = self.client.get(path)
|
||||
self.assertEqual(200, loaded.status_code)
|
||||
self.assertEqual(policy, loaded.json()["policy"])
|
||||
with Session(self.engine) as session:
|
||||
history = configuration_control_snapshot(session)["history"]
|
||||
self.assertEqual(1, len(history))
|
||||
self.assertEqual("campaign_archive_encryption_policy", history[0]["key"])
|
||||
self.assertEqual("campaign_archive_encryption_policy.updated", history[0]["audit_event"])
|
||||
self.assertEqual({}, history[0]["before"])
|
||||
self.assertEqual(policy, history[0]["after"])
|
||||
self.assertTrue(history[0]["plan"]["allowed"])
|
||||
self.assertEqual([], history[0]["plan"]["blockers"])
|
||||
audit_changes = session.query(ChangeSequenceEntry).filter(
|
||||
ChangeSequenceEntry.module_id == "audit"
|
||||
).all()
|
||||
self.assertEqual(1, len(audit_changes))
|
||||
self.assertEqual("campaign_archive_encryption_policy.updated", audit_changes[0].payload["action"])
|
||||
|
||||
narrowed_policy = {"allowed_password_encryption_methods": ["aes"]}
|
||||
narrowed = self.client.put(path, json={"policy": narrowed_policy})
|
||||
self.assertEqual(200, narrowed.status_code, narrowed.text)
|
||||
with Session(self.engine) as session:
|
||||
history = configuration_control_snapshot(session)["history"]
|
||||
self.assertEqual(2, len(history))
|
||||
self.assertEqual(policy, history[0]["before"])
|
||||
self.assertEqual(policy, history[0]["rollback_value"])
|
||||
self.assertEqual(narrowed_policy, history[0]["after"])
|
||||
|
||||
def test_read_only_actor_cannot_change_system_policy(self) -> None:
|
||||
self.principal = self._principal("admin:policies:read")
|
||||
policy = {"allowed_password_encryption_methods": ["aes", "zip_standard"]}
|
||||
response = self.client.put(
|
||||
"/api/v1/admin/campaign-archive-encryption/policies/system", json={"policy": policy}
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
plan = plan_configuration_change("campaign_archive_encryption_policy", actor_scopes=tuple(self.principal.scopes), value=policy)
|
||||
self.assertFalse(plan.allowed)
|
||||
self.assertEqual(("system:settings:write", "admin:policies:write"), plan.missing_scopes)
|
||||
with Session(self.engine) as session:
|
||||
self.assertEqual(0, session.query(PolicyOverride).count())
|
||||
|
||||
def test_tenant_policy_writer_cannot_loosen_global_system_ceiling(self) -> None:
|
||||
self.principal = self._principal("admin:policies:read", "admin:policies:write")
|
||||
policy = {"allowed_password_encryption_methods": ["aes", "zip_standard"]}
|
||||
response = self.client.put(
|
||||
"/api/v1/admin/campaign-archive-encryption/policies/system", json={"policy": policy}
|
||||
)
|
||||
self.assertIn(response.status_code, (403, 409))
|
||||
self.assertIn("system:settings:write", response.text)
|
||||
with Session(self.engine) as session:
|
||||
self.assertEqual(0, session.query(PolicyOverride).count())
|
||||
self.assertEqual(0, session.query(ChangeSequenceEntry).count())
|
||||
|
||||
narrowed = self.client.put(
|
||||
"/api/v1/admin/campaign-archive-encryption/policies/tenant",
|
||||
json={"policy": {"allowed_password_encryption_methods": ["aes"]}},
|
||||
)
|
||||
self.assertEqual(200, narrowed.status_code, narrowed.text)
|
||||
|
||||
def test_invalid_method_and_child_ceiling_still_fail_closed(self) -> None:
|
||||
path = "/api/v1/admin/campaign-archive-encryption/policies"
|
||||
invalid = self.client.put(f"{path}/system", json={"policy": {"allowed_password_encryption_methods": ["plaintext"]}})
|
||||
self.assertEqual(422, invalid.status_code)
|
||||
child = self.client.put(f"{path}/tenant", json={"policy": {"allowed_password_encryption_methods": ["aes", "zip_standard"]}})
|
||||
self.assertEqual(422, child.status_code)
|
||||
self.assertIn("parent", child.text)
|
||||
with Session(self.engine) as session:
|
||||
self.assertEqual(0, session.query(PolicyOverride).count())
|
||||
|
||||
def test_configuration_preview_preserves_only_known_non_secret_enum_lists(self) -> None:
|
||||
unsafe = {
|
||||
"allowed_password_encryption_methods": ["aes", "literal-secret"],
|
||||
"allowed_password_delivery_channels": {"password": "nested-secret"},
|
||||
"password": "actual-secret",
|
||||
"arbitrary_field": ["unknown-secret"],
|
||||
}
|
||||
with Session(self.engine) as session:
|
||||
request = create_configuration_change_request(
|
||||
session,
|
||||
key="campaign_archive_encryption_policy",
|
||||
value=unsafe,
|
||||
actor_user_id="admin-user",
|
||||
actor_scopes=tuple(self.principal.scopes),
|
||||
dry_run=False,
|
||||
target={"scope_type": "system"},
|
||||
)
|
||||
self.assertEqual({key: "<redacted>" for key in unsafe}, request["value_preview"])
|
||||
self.assertNotIn("literal-secret", str(configuration_control_snapshot(session)))
|
||||
self.assertNotIn("actual-secret", str(configuration_control_snapshot(session)))
|
||||
for malformed in ("scalar-secret", ["list-secret"], None, 7):
|
||||
with self.subTest(malformed=type(malformed).__name__):
|
||||
malformed_request = create_configuration_change_request(
|
||||
session,
|
||||
key="campaign_archive_encryption_policy",
|
||||
value=malformed,
|
||||
actor_user_id="admin-user",
|
||||
actor_scopes=tuple(self.principal.scopes),
|
||||
dry_run=False,
|
||||
target={"scope_type": "system"},
|
||||
)
|
||||
self.assertEqual("<redacted>", malformed_request["value_preview"])
|
||||
snapshot = str(configuration_control_snapshot(session))
|
||||
self.assertNotIn("scalar-secret", snapshot)
|
||||
self.assertNotIn("list-secret", snapshot)
|
||||
invalid = self.client.put(
|
||||
"/api/v1/admin/campaign-archive-encryption/policies/system",
|
||||
json={"policy": unsafe},
|
||||
)
|
||||
self.assertEqual(422, invalid.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_policy.backend.campaign_archive_encryption import (
|
||||
CampaignArchiveEncryptionPolicyError,
|
||||
campaign_archive_encryption_policy_state,
|
||||
resolve_campaign_archive_encryption_rows,
|
||||
save_campaign_archive_encryption_policy,
|
||||
)
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionPolicyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
PolicyOverride.__table__.create(self.engine)
|
||||
|
||||
def test_secure_default_denies_legacy(self) -> None:
|
||||
decision = resolve_campaign_archive_encryption_rows(())
|
||||
|
||||
self.assertEqual(frozenset({"aes"}), decision.allowed_password_encryption_methods)
|
||||
self.assertNotIn("zip_standard", decision.allowed_password_encryption_methods)
|
||||
self.assertEqual("system", decision.source_path[0].path)
|
||||
self.assertTrue(decision.policy_hash)
|
||||
|
||||
def test_child_scope_can_narrow_but_not_loosen_parent(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
save_campaign_archive_encryption_policy(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
owner_type=None,
|
||||
owner_id=None,
|
||||
policy={"allowed_password_encryption_methods": ["aes", "zip_standard"]},
|
||||
actor_id="admin",
|
||||
)
|
||||
tenant = save_campaign_archive_encryption_policy(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id=None,
|
||||
owner_type=None,
|
||||
owner_id=None,
|
||||
policy={"allowed_password_encryption_methods": ["aes"]},
|
||||
actor_id="admin",
|
||||
)
|
||||
self.assertEqual(
|
||||
frozenset({"aes"}),
|
||||
tenant.effective.allowed_password_encryption_methods,
|
||||
)
|
||||
|
||||
with self.assertRaises(CampaignArchiveEncryptionPolicyError):
|
||||
save_campaign_archive_encryption_policy(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
scope_id="owner-1",
|
||||
owner_type=None,
|
||||
owner_id=None,
|
||||
policy={"allowed_password_encryption_methods": ["aes", "zip_standard"]},
|
||||
actor_id="admin",
|
||||
)
|
||||
|
||||
def test_owner_and_campaign_sources_are_complete(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
for scope_type, scope_id, methods in (
|
||||
("system", None, ["aes", "zip_standard"]),
|
||||
("tenant", None, ["aes", "zip_standard"]),
|
||||
("group", "owner-group", ["aes", "zip_standard"]),
|
||||
("campaign", "campaign-1", ["aes"]),
|
||||
):
|
||||
save_campaign_archive_encryption_policy(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
owner_type="group" if scope_type == "campaign" else None,
|
||||
owner_id="owner-group" if scope_type == "campaign" else None,
|
||||
policy={"allowed_password_encryption_methods": methods},
|
||||
actor_id="admin",
|
||||
)
|
||||
state = campaign_archive_encryption_policy_state(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="campaign",
|
||||
scope_id="campaign-1",
|
||||
owner_type="group",
|
||||
owner_id="owner-group",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["system", "tenant", "group", "campaign"],
|
||||
[step.scope_type for step in state.effective.source_path],
|
||||
)
|
||||
self.assertEqual(
|
||||
frozenset({"aes"}),
|
||||
state.effective.allowed_password_encryption_methods,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.datasources import DatasourceVisibilityPolicyRequest
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_policy.backend.datasource_visibility import (
|
||||
DatasourceVisibilityPolicyProvider,
|
||||
)
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
from govoplan_policy.backend.policy_overrides import set_policy_override
|
||||
|
||||
|
||||
class DatasourceVisibilityPolicyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine, tables=[PolicyOverride.__table__])
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.provider = DatasourceVisibilityPolicyProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(self.engine, tables=[PolicyOverride.__table__])
|
||||
self.engine.dispose()
|
||||
|
||||
def _request(self, policy_ref: str | None) -> DatasourceVisibilityPolicyRequest:
|
||||
return DatasourceVisibilityPolicyRequest(
|
||||
tenant_id="tenant-1",
|
||||
datasource_ref="datasource:cases",
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="member-1",
|
||||
tenant_id="tenant-1",
|
||||
group_ids=frozenset({"group-1"}),
|
||||
),
|
||||
action="read",
|
||||
policy_ref=policy_ref,
|
||||
)
|
||||
|
||||
def test_unresolved_explicit_reference_fails_closed(self) -> None:
|
||||
decision = self.provider.decide_datasource_visibility(
|
||||
self.session,
|
||||
request=self._request("missing"),
|
||||
)
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual("reference_unresolved", decision.provenance["status"])
|
||||
self.assertTrue(decision.decision_ref.startswith("datasource-visibility:"))
|
||||
|
||||
def test_global_and_referenced_hierarchy_are_returned_as_overlays(self) -> None:
|
||||
set_policy_override(
|
||||
self.session,
|
||||
policy_family="datasource_visibility",
|
||||
target_key="*",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
policy={"source_acl": {"auth_methods": ["session"]}},
|
||||
actor_id="admin",
|
||||
)
|
||||
set_policy_override(
|
||||
self.session,
|
||||
policy_family="datasource_visibility",
|
||||
target_key="Case-Workers",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
policy={"source_acl": {"group_ids": ["group-1"]}},
|
||||
actor_id="admin",
|
||||
)
|
||||
set_policy_override(
|
||||
self.session,
|
||||
policy_family="datasource_visibility",
|
||||
target_key="case-workers",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
scope_id="member-1",
|
||||
policy={
|
||||
"fields": {
|
||||
"secret": {
|
||||
"action": "omit",
|
||||
"allow": {"role_ids": ["privileged"]},
|
||||
}
|
||||
}
|
||||
},
|
||||
actor_id="admin",
|
||||
)
|
||||
|
||||
decision = self.provider.decide_datasource_visibility(
|
||||
self.session,
|
||||
request=self._request("CASE-WORKERS"),
|
||||
)
|
||||
|
||||
self.assertTrue(decision.allowed)
|
||||
self.assertEqual(3, len(decision.policies))
|
||||
self.assertEqual("resolved", decision.provenance["status"])
|
||||
self.assertEqual(
|
||||
["system", "tenant", "user"],
|
||||
[source["scope_type"] for source in decision.provenance["sources"]],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
from govoplan_policy.backend.dsar_provider import (
|
||||
POLICY_DSAR_CAPABILITY,
|
||||
PolicyDsarProvider,
|
||||
)
|
||||
from govoplan_policy.backend.manifest import manifest
|
||||
|
||||
|
||||
class PolicyDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = PolicyDsarProvider()
|
||||
self.session.add_all(
|
||||
(
|
||||
PolicyOverride(
|
||||
id="override-1",
|
||||
policy_family="retention",
|
||||
target_key="target-secret-do-not-export",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
scope_id="scope-subject-do-not-export",
|
||||
scope_key="scope-key-do-not-export",
|
||||
policy={"secret": "policy-payload-do-not-export"},
|
||||
revision=2,
|
||||
created_by="account-1",
|
||||
updated_by="account-1",
|
||||
),
|
||||
PolicyOverride(
|
||||
id="override-system",
|
||||
policy_family="retention",
|
||||
target_key="system",
|
||||
tenant_id=None,
|
||||
scope_type="system",
|
||||
scope_key="system",
|
||||
policy={},
|
||||
revision=1,
|
||||
created_by="account-1",
|
||||
updated_by="account-1",
|
||||
),
|
||||
PolicyOverride(
|
||||
id="override-other",
|
||||
policy_family="retention",
|
||||
target_key="other",
|
||||
tenant_id="tenant-2",
|
||||
scope_type="tenant",
|
||||
scope_key="tenant-2",
|
||||
policy={},
|
||||
revision=1,
|
||||
created_by="account-1",
|
||||
updated_by="account-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_search_is_tenant_safe_and_minimized(self) -> None:
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id="account-1"),
|
||||
)
|
||||
self.assertEqual(["override-1"], [record.resource_id for record in records])
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
for excluded in (
|
||||
"target-secret-do-not-export",
|
||||
"scope-subject-do-not-export",
|
||||
"scope-key-do-not-export",
|
||||
"policy-payload-do-not-export",
|
||||
):
|
||||
self.assertNotIn(excluded, exported)
|
||||
|
||||
def test_requires_exact_account_and_supports_narrowing(self) -> None:
|
||||
self.assertEqual(
|
||||
(),
|
||||
self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(email="policy@example.test"),
|
||||
),
|
||||
)
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"policy.override": "override-1"},
|
||||
),
|
||||
)
|
||||
self.assertEqual(1, len(records))
|
||||
|
||||
def test_records_are_retained_and_manifest_is_complete(self) -> None:
|
||||
subject = DsarSubjectRef(account_id="account-1")
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=subject
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=records,
|
||||
)
|
||||
self.assertTrue(all(action.kind == "retain" for action in actions))
|
||||
self.assertIn(POLICY_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
self.assertIn(
|
||||
"policy.data-subject-requests",
|
||||
{topic.id for topic in manifest.documentation},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,189 @@
|
||||
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)
|
||||
|
||||
def test_delegation_ceilings_and_escalation_rules_are_bounded(self) -> None:
|
||||
decision = self.resolve(
|
||||
function_settings={
|
||||
"assignment_governance": {
|
||||
"request_profile": "holder_with_authority_clearance",
|
||||
"authority_function_id": "authority-1",
|
||||
"delegation_allowed": True,
|
||||
"maximum_delegation_depth": 3,
|
||||
"maximum_delegated_validity_days": 45,
|
||||
"escalation": {
|
||||
"holder": {
|
||||
"target_function_id": "escalation-1",
|
||||
"timeout_hours": 24,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(decision.delegation_allowed)
|
||||
self.assertEqual(3, decision.maximum_delegation_depth)
|
||||
self.assertEqual(45, decision.maximum_delegated_validity_days)
|
||||
self.assertEqual("escalation-1", decision.escalation_rules[0].target_function_id)
|
||||
self.assertEqual(24, decision.escalation_rules[0].timeout_hours)
|
||||
|
||||
def test_escalated_review_requires_explicit_target_holder(self) -> None:
|
||||
allowed = self.resolve(
|
||||
action="approve_escalation",
|
||||
current_state="escalated",
|
||||
context={
|
||||
"actor_is_escalation_target": True,
|
||||
"actor_routes": {"escalation": {"effective": True}},
|
||||
},
|
||||
)
|
||||
unavailable = self.resolve(
|
||||
action="approve_escalation",
|
||||
current_state="escalated",
|
||||
context={
|
||||
"actor_is_escalation_target": False,
|
||||
"actor_routes": {
|
||||
"escalation": {
|
||||
"effective": False,
|
||||
"reason": "The target function is vacant.",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(allowed.allowed)
|
||||
self.assertFalse(unavailable.allowed)
|
||||
self.assertEqual("The target function is vacant.", unavailable.reason)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX,
|
||||
PolicyImpactPopulationRequest,
|
||||
PolicyImpactSubject,
|
||||
PolicyImpactSubjectBatch,
|
||||
)
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
from govoplan_policy.backend.impact_preview import (
|
||||
PolicyImpactPopulationSpec,
|
||||
preview_policy_impact,
|
||||
)
|
||||
from govoplan_policy.backend.api.v1.routes import (
|
||||
_require_recent_policy_authentication,
|
||||
)
|
||||
|
||||
|
||||
class _SubjectProvider:
|
||||
provider_id = "example"
|
||||
supported_policy_families = ("view",)
|
||||
|
||||
def collect_policy_impact_subjects(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: PolicyImpactPopulationRequest,
|
||||
) -> PolicyImpactSubjectBatch:
|
||||
del session
|
||||
subjects = (
|
||||
PolicyImpactSubject(
|
||||
module_id="views",
|
||||
resource_type="view",
|
||||
resource_id="view-1",
|
||||
action="edit",
|
||||
label="First" if request.allow_sensitive_details else None,
|
||||
),
|
||||
PolicyImpactSubject(
|
||||
module_id="views",
|
||||
resource_type="view",
|
||||
resource_id="view-2",
|
||||
action="view",
|
||||
label="Second" if request.allow_sensitive_details else None,
|
||||
),
|
||||
)
|
||||
return PolicyImpactSubjectBatch(
|
||||
provider_id=self.provider_id,
|
||||
subjects=subjects[: request.limit],
|
||||
state="truncated" if request.limit < len(subjects) else "complete",
|
||||
total_available=len(subjects),
|
||||
explanation="Explicit test population.",
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: object | None = None) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
self.provider is not None
|
||||
and name == f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}example"
|
||||
)
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
del name
|
||||
return self.provider
|
||||
|
||||
|
||||
class PolicyImpactPreviewTests(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,
|
||||
)()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_preview_compares_effective_view_policy_without_persistence(self) -> None:
|
||||
preview = preview_policy_impact(
|
||||
self.session,
|
||||
registry=_Registry(_SubjectProvider()),
|
||||
tenant_id="tenant-1",
|
||||
policy_family="view",
|
||||
scope_type="tenant",
|
||||
scope_id=None,
|
||||
proposed_policy={
|
||||
"allow_edit": False,
|
||||
"allowed_view_ids": ["view-1"],
|
||||
},
|
||||
populations=(PolicyImpactPopulationSpec(provider_id="example"),),
|
||||
actor_scopes=("admin:policies:read", "policy:impact:details"),
|
||||
include_details=True,
|
||||
details_allowed=True,
|
||||
)
|
||||
|
||||
self.assertEqual(2, preview.counts["newly_denied"])
|
||||
self.assertEqual(2, len(preview.effects))
|
||||
self.assertEqual(
|
||||
{"view.allow_edit", "view.allow_view"},
|
||||
{effect.rule for effect in preview.effects},
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
effect.proposed_sources[-1].label.startswith("Proposed Tenant")
|
||||
for effect in preview.effects
|
||||
)
|
||||
)
|
||||
self.assertEqual(0, self.session.query(PolicyOverride).count())
|
||||
|
||||
def test_details_are_hidden_but_permission_filtered_counts_remain(self) -> None:
|
||||
preview = preview_policy_impact(
|
||||
self.session,
|
||||
registry=_Registry(_SubjectProvider()),
|
||||
tenant_id="tenant-1",
|
||||
policy_family="view",
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
proposed_policy={"allow_view": False},
|
||||
populations=(
|
||||
PolicyImpactPopulationSpec(provider_id="example", limit=1),
|
||||
),
|
||||
include_details=True,
|
||||
details_allowed=False,
|
||||
)
|
||||
|
||||
self.assertEqual(1, preview.counts["newly_denied"])
|
||||
self.assertEqual((), preview.effects)
|
||||
self.assertTrue(preview.details_hidden)
|
||||
self.assertIn("policy:impact:details", preview.details_explanation or "")
|
||||
self.assertEqual("truncated", preview.populations[0]["state"])
|
||||
self.assertTrue(preview.high_impact)
|
||||
|
||||
def test_unavailable_provider_is_explained_instead_of_counted_as_zero(self) -> None:
|
||||
preview = preview_policy_impact(
|
||||
self.session,
|
||||
registry=_Registry(),
|
||||
tenant_id="tenant-1",
|
||||
policy_family="view",
|
||||
scope_type="tenant",
|
||||
scope_id=None,
|
||||
proposed_policy={},
|
||||
populations=(PolicyImpactPopulationSpec(provider_id="missing"),),
|
||||
)
|
||||
|
||||
self.assertEqual("unavailable", preview.populations[0]["state"])
|
||||
self.assertIn("not enabled", preview.populations[0]["explanation"])
|
||||
|
||||
def test_system_policy_guard_requires_a_recent_interactive_session(self) -> None:
|
||||
fresh = SimpleNamespace(
|
||||
auth_session=SimpleNamespace(
|
||||
created_at=datetime.now(timezone.utc) - timedelta(minutes=2)
|
||||
)
|
||||
)
|
||||
_require_recent_policy_authentication(fresh) # type: ignore[arg-type]
|
||||
|
||||
stale = SimpleNamespace(
|
||||
auth_session=SimpleNamespace(
|
||||
created_at=datetime.now(timezone.utc) - timedelta(minutes=30)
|
||||
)
|
||||
)
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
_require_recent_policy_authentication(stale) # type: ignore[arg-type]
|
||||
self.assertEqual(403, context.exception.status_code)
|
||||
self.assertEqual(
|
||||
"recent_authentication_required",
|
||||
context.exception.detail["code"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,6 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_policy.backend.retention import (
|
||||
PrivacyRetentionPolicy,
|
||||
PrivacyRetentionPolicyPatch,
|
||||
PrivacyPolicyError,
|
||||
_parent_privacy_policy_scope_id,
|
||||
_privacy_policy_patch_payload,
|
||||
_required_privacy_policy_scope_id,
|
||||
)
|
||||
from govoplan_policy.backend.hierarchy import (
|
||||
PolicyRestrictionRule,
|
||||
simulate_hierarchical_policy_change,
|
||||
@@ -10,6 +18,36 @@ from govoplan_policy.backend.hierarchy import (
|
||||
|
||||
|
||||
class PolicyHierarchyTests(unittest.TestCase):
|
||||
def test_privacy_policy_parent_scope_ids_follow_scope_precedence(self) -> None:
|
||||
self.assertEqual(
|
||||
"tenant-1",
|
||||
_parent_privacy_policy_scope_id(tenant_id="tenant-1", scope_type="tenant", scope_id=None),
|
||||
)
|
||||
self.assertIsNone(_parent_privacy_policy_scope_id(tenant_id="tenant-1", scope_type="user", scope_id=None))
|
||||
self.assertIsNone(_parent_privacy_policy_scope_id(tenant_id="tenant-1", scope_type="group", scope_id=None))
|
||||
self.assertEqual(
|
||||
"campaign-1",
|
||||
_parent_privacy_policy_scope_id(tenant_id="tenant-1", scope_type="campaign", scope_id="campaign-1"),
|
||||
)
|
||||
|
||||
def test_privacy_policy_patch_payload_validates_and_removes_unset_fields(self) -> None:
|
||||
payload = _privacy_policy_patch_payload({"audit_detail_level": "minimal", "generated_eml_retention_days": None})
|
||||
|
||||
self.assertEqual({"audit_detail_level": "minimal"}, payload)
|
||||
|
||||
def test_privacy_policy_models_extend_shared_schema_contract(self) -> None:
|
||||
policy = PrivacyRetentionPolicy.model_validate({"generated_eml_retention_days": ""})
|
||||
patch = PrivacyRetentionPolicyPatch.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
|
||||
|
||||
self.assertIsNone(policy.generated_eml_retention_days)
|
||||
self.assertEqual({"audit_detail_level": False}, patch.allow_lower_level_limits)
|
||||
|
||||
def test_required_privacy_policy_scope_id_reports_missing_scope(self) -> None:
|
||||
with self.assertRaises(PrivacyPolicyError) as captured:
|
||||
_required_privacy_policy_scope_id("group", None)
|
||||
|
||||
self.assertEqual("Group privacy policy requires scope_id", str(captured.exception))
|
||||
|
||||
def test_parent_locks_block_lower_level_field_changes_and_limit_reenable(self) -> None:
|
||||
issues = validate_hierarchical_policy_patch(
|
||||
parent_policy={"retention_days": 30},
|
||||
|
||||
@@ -4,17 +4,52 @@ import pathlib
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
|
||||
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.access import CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
)
|
||||
from govoplan_core.core.datasources import CAPABILITY_POLICY_DATASOURCE_VISIBILITY
|
||||
from govoplan_core.core.reporting import CAPABILITY_POLICY_REPORTING_GOVERNANCE
|
||||
from govoplan_policy.backend.manifest import manifest
|
||||
from govoplan_policy.backend.dsar_provider import POLICY_DSAR_CAPABILITY
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PolicyModuleContractTests(unittest.TestCase):
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(german),
|
||||
topic.id,
|
||||
)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
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"])
|
||||
|
||||
self.assertIn("govoplan-core>=0.1.6", dependencies)
|
||||
self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies))
|
||||
self.assertTrue(
|
||||
any(item.startswith("govoplan-core>=") for item in dependencies)
|
||||
)
|
||||
self.assertFalse(
|
||||
any(item.startswith("govoplan-access") for item in dependencies)
|
||||
)
|
||||
|
||||
def test_policy_source_does_not_import_access_implementation(self) -> None:
|
||||
offenders: list[str] = []
|
||||
@@ -25,6 +60,89 @@ class PolicyModuleContractTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual([], offenders)
|
||||
|
||||
def test_policy_manifest_exposes_policy_capabilities(self) -> None:
|
||||
self.assertEqual(
|
||||
{
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
CAPABILITY_POLICY_DATASOURCE_VISIBILITY,
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||
CAPABILITY_POLICY_PRIVACY_RETENTION,
|
||||
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||
CAPABILITY_POLICY_VIEW_GOVERNANCE,
|
||||
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
|
||||
POLICY_DSAR_CAPABILITY,
|
||||
},
|
||||
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.assertTrue(
|
||||
{
|
||||
"policy.retention",
|
||||
"privacy.retention",
|
||||
"policy.retention.action.save",
|
||||
"policy.retention.field.store-raw-campaign-json",
|
||||
"policy.retention.field.generated-eml-retention-days",
|
||||
"policy.retention.field.audit-detail-level",
|
||||
"policy.retention.field.allow-lower-level-limits",
|
||||
}.issubset(topic.metadata["help_contexts"])
|
||||
)
|
||||
self.assertEqual("workflow", topic.metadata["kind"])
|
||||
self.assertIn("/admin", topic.metadata["route"])
|
||||
self.assertEqual({"title", "summary", "body"}, set(topic.translations["de"]))
|
||||
self.assertIn("Quellenpfad", topic.translations["de"]["body"])
|
||||
|
||||
execution_topic = next(
|
||||
item
|
||||
for item in manifest.documentation
|
||||
if item.id == "policy.retention-execution-and-recovery"
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"policy.retention.execution",
|
||||
"policy.retention.action.dry-run",
|
||||
"policy.retention.action.apply",
|
||||
"policy.retention.confirm-apply",
|
||||
"policy.retention.outcome",
|
||||
},
|
||||
set(execution_topic.metadata["help_contexts"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"nicht wiederherstellen", execution_topic.translations["de"]["body"]
|
||||
)
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+9
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/policy-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -12,12 +12,16 @@
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
|
||||
"test:archive-encryption": "node --experimental-strip-types scripts/test-archive-encryption-draft.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
buildPolicy, draftFromPolicy, inheritedControlDisabled, setDraftChannel,
|
||||
setDraftMethod, stable
|
||||
} from "../src/features/policy/archiveEncryptionDraft.ts";
|
||||
|
||||
const baseline = {
|
||||
allowed_password_encryption_methods: ["aes"],
|
||||
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
|
||||
policy_hash: "baseline", source_path: [], reason: "Secure baseline", diagnostics: []
|
||||
};
|
||||
const initial = draftFromPolicy({}, baseline);
|
||||
assert.deepEqual(buildPolicy(initial), {}, "Opening default system settings must not create an override or dirty state");
|
||||
assert.equal(inheritedControlDisabled("system", initial.inheritMethods), false, "System defaults must be editable without a hidden inheritance toggle");
|
||||
assert.equal(inheritedControlDisabled("system", initial.inheritChannels), false);
|
||||
const enabled = setDraftMethod(initial, "zip_standard", true);
|
||||
assert.deepEqual(buildPolicy(enabled), { allowed_password_encryption_methods: ["aes", "zip_standard"] }, "The first system Legacy click must produce an explicit override");
|
||||
assert.notEqual(stable(buildPolicy(enabled)), stable({}));
|
||||
assert.deepEqual(buildPolicy(initial), {}, "Changing a draft must preserve the original policy");
|
||||
const narrowedChannels = setDraftChannel(initial, "sms", false);
|
||||
assert.deepEqual(buildPolicy(narrowedChannels), { allowed_password_delivery_channels: ["separate_mail", "letter", "phone", "in_person"] });
|
||||
assert.equal(inheritedControlDisabled("tenant", initial.inheritMethods), true, "Child scopes retain explicit inheritance controls");
|
||||
assert.equal(inheritedControlDisabled("user", false), false);
|
||||
assert.deepEqual(buildPolicy(draftFromPolicy(buildPolicy(enabled), baseline)), buildPolicy(enabled), "An explicit system policy survives save/reload");
|
||||
assert.deepEqual(buildPolicy(setDraftMethod(enabled, "zip_standard", false)), { allowed_password_encryption_methods: ["aes"] });
|
||||
|
||||
const panel = readFileSync(new URL("../src/features/policy/ArchiveEncryptionPoliciesPanel.tsx", import.meta.url), "utf8");
|
||||
assert.match(panel, /inheritedControlDisabled\(scopeType, draft\.inheritMethods\)/);
|
||||
assert.match(panel, /inheritedControlDisabled\(scopeType, draft\.inheritChannels\)/);
|
||||
assert.match(panel, /setDraft\(setDraftMethod\(draft, method\.id, checked\)\)/);
|
||||
assert.match(panel, /setDraft\(setDraftChannel\(draft, channel\.id, checked\)\)/);
|
||||
assert.match(panel, /scopeType !== "system" && !parentMethods\.includes\(method\.id\)/, "Child scopes must still respect parent ceilings");
|
||||
const moduleSource = readFileSync(new URL("../src/module.ts", import.meta.url), "utf8");
|
||||
assert.match(moduleSource, /scopeType: "system",\s*canWrite: hasScope\(auth, "system:settings:write"\) && hasScope\(auth, "admin:policies:write"\)/, "Tenant policy administration alone must not enable edits to the global system archive ceiling");
|
||||
console.log("Archive encryption settings regressions passed.");
|
||||
@@ -0,0 +1,29 @@
|
||||
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");
|
||||
const viewPanel = readFileSync(resolve(webuiRoot, "src/features/policy/ViewPoliciesPanel.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, /policy\.retention\.action\.dry-run/, "retention dry runs expose exact contextual help");
|
||||
assert.match(panel, /policy\.retention\.action\.apply/, "destructive retention exposes exact contextual help");
|
||||
assert.match(panel, /policy\.retention\.confirm-apply/, "retention confirmation exposes consequence and recovery help");
|
||||
assert.match(panel, /helpModuleId="policy"/, "retention confirmation retains Policy as its documentation owner");
|
||||
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");
|
||||
|
||||
assert.match(viewPanel, /policy\.impact-preview\.action\.preview/, "View policy exposes stable impact-preview help");
|
||||
assert.match(viewPanel, /previewCurrent/, "View policy binds Save to the current dirty-draft preview");
|
||||
assert.match(viewPanel, /updateViewPolicy\([\s\S]*impactPreview/, "View policy carries preview evidence into the commit request");
|
||||
assert.match(viewPanel, /prepareResetPolicy/, "inherited-policy removal receives its own impact preview");
|
||||
assert.match(viewPanel, /newly_allowed/, "View policy presents typed impact outcome counts");
|
||||
assert.match(viewPanel, /populations\.map/, "View policy explains provider coverage state");
|
||||
|
||||
console.log("Policy interface-pattern contracts passed.");
|
||||
@@ -0,0 +1,56 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type ArchiveEncryptionPolicyScope = "system" | "tenant" | "group" | "user";
|
||||
export type ArchiveEncryptionMethod = "aes" | "zip_standard";
|
||||
export type PasswordDeliveryChannel = "separate_mail" | "sms" | "letter" | "phone" | "in_person";
|
||||
|
||||
export type ArchiveEncryptionPolicyItem = {
|
||||
allowed_password_encryption_methods?: ArchiveEncryptionMethod[];
|
||||
allowed_password_delivery_channels?: PasswordDeliveryChannel[];
|
||||
};
|
||||
|
||||
export type EffectiveArchiveEncryptionPolicy = {
|
||||
allowed_password_encryption_methods: ArchiveEncryptionMethod[];
|
||||
allowed_password_delivery_channels: PasswordDeliveryChannel[];
|
||||
policy_hash: string;
|
||||
source_path: Array<{ path: string; label: string }>;
|
||||
reason: string;
|
||||
diagnostics: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type ArchiveEncryptionPolicyResponse = {
|
||||
scope_type: ArchiveEncryptionPolicyScope;
|
||||
scope_id?: string | null;
|
||||
id?: string | null;
|
||||
revision?: number | null;
|
||||
policy: ArchiveEncryptionPolicyItem;
|
||||
effective_policy: EffectiveArchiveEncryptionPolicy;
|
||||
parent_policy: EffectiveArchiveEncryptionPolicy;
|
||||
};
|
||||
|
||||
function policyPath(scope: ArchiveEncryptionPolicyScope, scopeId?: string | null): string {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString();
|
||||
return `/api/v1/admin/campaign-archive-encryption/policies/${scope}${suffix ? `?${suffix}` : ""}`;
|
||||
}
|
||||
|
||||
export function fetchArchiveEncryptionPolicy(
|
||||
settings: ApiSettings,
|
||||
scope: ArchiveEncryptionPolicyScope,
|
||||
scopeId?: string | null
|
||||
): Promise<ArchiveEncryptionPolicyResponse> {
|
||||
return apiFetch(settings, policyPath(scope, scopeId));
|
||||
}
|
||||
|
||||
export function updateArchiveEncryptionPolicy(
|
||||
settings: ApiSettings,
|
||||
scope: ArchiveEncryptionPolicyScope,
|
||||
scopeId: string | null,
|
||||
policy: ArchiveEncryptionPolicyItem
|
||||
): Promise<ArchiveEncryptionPolicyResponse> {
|
||||
return apiFetch(settings, policyPath(scope, scopeId), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ policy })
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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 type PolicyImpactCategory = "newly_allowed" | "newly_denied" | "unchanged" | "indeterminate";
|
||||
|
||||
export type PolicyImpactPreviewResponse = {
|
||||
preview_id: string;
|
||||
proposal_hash: string;
|
||||
policy_family: string;
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
base_revision?: number | null;
|
||||
counts: Record<PolicyImpactCategory, number>;
|
||||
effects: Array<{
|
||||
category: PolicyImpactCategory;
|
||||
subject: {
|
||||
module_id: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
label?: string | null;
|
||||
scope_type?: string | null;
|
||||
scope_id?: string | null;
|
||||
};
|
||||
current_allowed?: boolean | null;
|
||||
proposed_allowed?: boolean | null;
|
||||
rule: string;
|
||||
current_sources: Array<{ path: string; label: string }>;
|
||||
proposed_sources: Array<{ path: string; label: string }>;
|
||||
explanation?: string | null;
|
||||
}>;
|
||||
populations: Array<{
|
||||
provider_id: string;
|
||||
state: "complete" | "sampled" | "truncated" | "unavailable";
|
||||
returned: number;
|
||||
total_available?: number | null;
|
||||
explanation?: string | null;
|
||||
}>;
|
||||
details_hidden: boolean;
|
||||
details_explanation?: string | null;
|
||||
high_impact: boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
impactPreview?: Pick<PolicyImpactPreviewResponse, "preview_id" | "proposal_hash"> | null
|
||||
): Promise<ViewPolicyScopeResponse> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
|
||||
scope_id: scopeId || undefined
|
||||
}), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
policy,
|
||||
impact_preview_id: impactPreview?.preview_id,
|
||||
impact_proposal_hash: impactPreview?.proposal_hash
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteViewPolicy(
|
||||
settings: ApiSettings,
|
||||
scope: ViewPolicyScope,
|
||||
scopeId?: string | null,
|
||||
impactPreview?: Pick<PolicyImpactPreviewResponse, "preview_id" | "proposal_hash"> | null
|
||||
): Promise<ViewPolicyScopeResponse> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/admin/view-policies/${scope}`, {
|
||||
scope_id: scopeId || undefined,
|
||||
impact_preview_id: impactPreview?.preview_id,
|
||||
impact_proposal_hash: impactPreview?.proposal_hash
|
||||
}), { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function previewViewPolicyImpact(
|
||||
settings: ApiSettings,
|
||||
scope: ViewPolicyScope,
|
||||
scopeId: string | null | undefined,
|
||||
policy: ViewPolicyItem,
|
||||
population: { viewIds: string[]; surfaceIds: string[] }
|
||||
): Promise<PolicyImpactPreviewResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/policy-impact/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
policy_family: "view",
|
||||
scope_type: scope,
|
||||
scope_id: scopeId || null,
|
||||
proposed_policy: policy,
|
||||
populations: [
|
||||
{
|
||||
provider_id: "views",
|
||||
selector: {
|
||||
include_views: true,
|
||||
include_surfaces: false,
|
||||
view_ids: population.viewIds.slice(0, 500)
|
||||
},
|
||||
limit: 500
|
||||
},
|
||||
{
|
||||
provider_id: "views",
|
||||
selector: {
|
||||
include_views: false,
|
||||
include_surfaces: true,
|
||||
surface_ids: population.surfaceIds.slice(0, 500)
|
||||
},
|
||||
limit: 500
|
||||
}
|
||||
],
|
||||
include_details: true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
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 : []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
adminErrorMessage,
|
||||
Button,
|
||||
Card,
|
||||
DescriptionItem,
|
||||
DescriptionList,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
SearchableSelect,
|
||||
ToggleSwitch,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type SearchableSelectOption
|
||||
} from "@govoplan/core-webui";
|
||||
import { RefreshCw, Save, Undo2 } from "lucide-react";
|
||||
import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
|
||||
import {
|
||||
fetchArchiveEncryptionPolicy,
|
||||
updateArchiveEncryptionPolicy,
|
||||
type ArchiveEncryptionMethod,
|
||||
type ArchiveEncryptionPolicyResponse,
|
||||
type ArchiveEncryptionPolicyScope,
|
||||
type PasswordDeliveryChannel
|
||||
} from "../../api/archiveEncryptionPolicies";
|
||||
import {
|
||||
buildPolicy,
|
||||
draftFromPolicy,
|
||||
inheritedControlDisabled,
|
||||
setDraftChannel,
|
||||
setDraftMethod,
|
||||
stable,
|
||||
type ArchiveEncryptionDraft
|
||||
} from "./archiveEncryptionDraft";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scopeType: ArchiveEncryptionPolicyScope;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
const METHODS: Array<{ id: ArchiveEncryptionMethod; label: string; description: string }> = [
|
||||
{ id: "aes", label: "AES (strong, default)", description: "Modern AES encryption for compatible ZIP clients." },
|
||||
{ id: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption", description: "Requires a separate Campaign permission and reasoned acknowledgement." }
|
||||
];
|
||||
|
||||
const CHANNELS: Array<{ id: PasswordDeliveryChannel; label: string }> = [
|
||||
{ id: "separate_mail", label: "Separate email (never the campaign message)" },
|
||||
{ id: "sms", label: "SMS" },
|
||||
{ id: "letter", label: "Letter" },
|
||||
{ id: "phone", label: "Telephone" },
|
||||
{ id: "in_person", label: "In person" }
|
||||
];
|
||||
|
||||
export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
|
||||
const [targets, setTargets] = useState<SearchableSelectOption[]>([]);
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [state, setState] = useState<ArchiveEncryptionPolicyResponse | null>(null);
|
||||
const [draft, setDraft] = useState<ArchiveEncryptionDraft | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const needsTarget = scopeType === "group" || scopeType === "user";
|
||||
const dirty = Boolean(state && draft && stable(buildPolicy(draft)) !== stable(state.policy));
|
||||
|
||||
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard });
|
||||
|
||||
useEffect(() => { void initialize(); }, [scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
async function initialize() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const loadedTargets = await loadTargets(settings, scopeType);
|
||||
setTargets(loadedTargets);
|
||||
const next = needsTarget ? loadedTargets[0]?.value ?? "" : "";
|
||||
setTargetId(next);
|
||||
if (!needsTarget || next) await load(next, false);
|
||||
} catch (cause) {
|
||||
setError(adminErrorMessage(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function load(nextTarget = targetId, manageLoading = true) {
|
||||
if (needsTarget && !nextTarget) return;
|
||||
if (manageLoading) setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await fetchArchiveEncryptionPolicy(settings, scopeType, nextTarget || null);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy, loaded.parent_policy));
|
||||
} catch (cause) {
|
||||
setError(adminErrorMessage(cause));
|
||||
} finally {
|
||||
if (manageLoading) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTarget(value: string) {
|
||||
if (!value || value === targetId) return;
|
||||
setTargetId(value);
|
||||
await load(value);
|
||||
}
|
||||
|
||||
function discard() {
|
||||
if (state) setDraft(draftFromPolicy(state.policy, state.parent_policy));
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!draft || !dirty) return true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await updateArchiveEncryptionPolicy(settings, scopeType, targetId || null, buildPolicy(draft));
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy, loaded.parent_policy));
|
||||
setSuccess("Campaign archive-encryption policy saved.");
|
||||
return true;
|
||||
} catch (cause) {
|
||||
setError(adminErrorMessage(cause));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const scopeLabel = scopeType[0].toUpperCase() + scopeType.slice(1);
|
||||
const parentMethods = state?.parent_policy.allowed_password_encryption_methods ?? ["aes"];
|
||||
const parentChannels = state?.parent_policy.allowed_password_delivery_channels ?? [];
|
||||
|
||||
return <AdminPageLayout
|
||||
title={`${scopeLabel} Campaign archive encryption`}
|
||||
description="Restrict password-protected ZIP methods and the separate channel used to convey passwords. Lower scopes can only narrow inherited choices."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<>
|
||||
<Button title="Reload saved archive policy" aria-label="Reload saved archive 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 variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}><Save size={16} /> {busy ? "Saving..." : "Save"}</Button>
|
||||
</>}>
|
||||
{needsTarget && <Card title={`${scopeLabel} target`}>
|
||||
<FormField label={`Select ${scopeType}`}>
|
||||
<SearchableSelect value={targetId} options={targets} onChange={(value) => void selectTarget(value)} disabled={busy} />
|
||||
</FormField>
|
||||
</Card>}
|
||||
{draft && state && <>
|
||||
<DismissibleAlert tone={state.effective_policy.allowed_password_encryption_methods.includes("zip_standard") ? "warning" : "info"} dismissible={false}>
|
||||
{state.effective_policy.reason} Legacy ZipCrypto remains a weak compatibility exception and is never an automatic fallback.
|
||||
</DismissibleAlert>
|
||||
<Card title="Allowed password-encryption methods">
|
||||
{scopeType !== "system" && <ToggleSwitch label="Inherit methods from the parent scope" checked={draft.inheritMethods} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritMethods: checked, methods: checked ? [...parentMethods] : draft.methods })} />}
|
||||
{METHODS.map((method) => <ToggleSwitch key={method.id} label={method.label} help={method.description} checked={draft.methods.includes(method.id)} disabled={!canWrite || busy || inheritedControlDisabled(scopeType, draft.inheritMethods) || (scopeType !== "system" && !parentMethods.includes(method.id))} onChange={(checked) => setDraft(setDraftMethod(draft, method.id, checked))} />)}
|
||||
</Card>
|
||||
<Card title="Allowed separate password-delivery channels">
|
||||
{scopeType !== "system" && <ToggleSwitch label="Inherit channels from the parent scope" checked={draft.inheritChannels} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritChannels: checked, channels: checked ? [...parentChannels] : draft.channels })} />}
|
||||
{CHANNELS.map((channel) => <ToggleSwitch key={channel.id} label={channel.label} checked={draft.channels.includes(channel.id)} disabled={!canWrite || busy || inheritedControlDisabled(scopeType, draft.inheritChannels) || (scopeType !== "system" && !parentChannels.includes(channel.id))} onChange={(checked) => setDraft(setDraftChannel(draft, channel.id, checked))} />)}
|
||||
</Card>
|
||||
<Card title="Effective policy evidence">
|
||||
<DescriptionList>
|
||||
<DescriptionItem term="Policy hash"><code>{state.effective_policy.policy_hash}</code></DescriptionItem>
|
||||
<DescriptionItem term="Source path">{state.effective_policy.source_path.map((step) => step.label).join(" → ")}</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</Card>
|
||||
</>}
|
||||
</AdminPageLayout>;
|
||||
}
|
||||
|
||||
async function loadTargets(settings: ApiSettings, scope: ArchiveEncryptionPolicyScope): Promise<SearchableSelectOption[]> {
|
||||
if (scope === "group") {
|
||||
const response = await fetchGroupsDelta(settings, { limit: 1000 });
|
||||
return response.groups.map((group) => ({ value: group.id, label: group.name, description: group.slug }));
|
||||
}
|
||||
if (scope === "user") {
|
||||
const response = await fetchUsersDelta(settings, { limit: 1000 });
|
||||
return response.users.map((user) => ({ value: user.id, label: user.display_name || user.email, description: user.email }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
@@ -5,16 +6,21 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
DocumentationHelpLink,
|
||||
mergeDeltaRows,
|
||||
RetentionPolicyScopeManager,
|
||||
runRetentionPolicy,
|
||||
StatusBadge,
|
||||
useDeltaWatermarks,
|
||||
type ApiSettings,
|
||||
type DataGridColumn,
|
||||
type DeltaDeletedItem,
|
||||
type PrivacyRetentionPolicyScope,
|
||||
type RetentionPolicyTargetOption,
|
||||
type RetentionRunResponse
|
||||
} from "@govoplan/core-webui";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets";
|
||||
|
||||
type Props = {
|
||||
@@ -30,6 +36,28 @@ type DeltaResponse = {
|
||||
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 }> = {
|
||||
system: {
|
||||
title: "System retention",
|
||||
@@ -151,10 +179,40 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
|
||||
<AdminPageLayout
|
||||
title={labels.title}
|
||||
description={labels.description}
|
||||
helpContextId="policy.retention"
|
||||
loading={loadingTargets}
|
||||
error={targetError || runError}
|
||||
success={success}
|
||||
actions={
|
||||
<>
|
||||
{(scopeType === "user" || scopeType === "group") && (
|
||||
<Button
|
||||
title="Reload policy targets"
|
||||
aria-label="Reload policy targets"
|
||||
helpContextId="policy.retention.action.reload-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
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
@@ -166,22 +224,49 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
|
||||
/>
|
||||
|
||||
{scopeType === "system" && (
|
||||
<div className="retention-run-card">
|
||||
<Card title="Retention execution">
|
||||
<div className="retention-run-section">
|
||||
<Card
|
||||
title="Retention execution"
|
||||
helpContextId="policy.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>
|
||||
<div className="button-row compact-actions subsection-bottom-actions">
|
||||
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
|
||||
<Button helpContextId="policy.retention.action.dry-run" onClick={() => void runRetention(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Dry run</Button>
|
||||
<Button helpContextId="policy.retention.action.apply" variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={Boolean(actionDisabledReason)} disabledReason={actionDisabledReason}>Apply retention</Button>
|
||||
</div>
|
||||
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
|
||||
</Card>
|
||||
{retentionResult && (
|
||||
<Card title="Latest retention outcome" helpContextId="policy.retention.outcome">
|
||||
<DescriptionList variant="inline" density="compact">
|
||||
<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>
|
||||
</DescriptionList>
|
||||
<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>
|
||||
)}
|
||||
</AdminPageLayout>
|
||||
<ConfirmDialog
|
||||
open={confirmRetentionRun}
|
||||
helpContextId="policy.retention.confirm-apply"
|
||||
helpModuleId="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"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
@@ -192,6 +277,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>(
|
||||
current: TItem[],
|
||||
key: string,
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||
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, ScanSearch, Trash2, Undo2 } from "lucide-react";
|
||||
import { fetchGroupsDelta, fetchUsersDelta } from "../../api/adminTargets";
|
||||
import {
|
||||
deleteViewPolicy,
|
||||
fetchViewPolicy,
|
||||
fetchViewPolicyReferences,
|
||||
previewViewPolicyImpact,
|
||||
updateViewPolicy,
|
||||
type EffectiveViewPolicy,
|
||||
type PolicyImpactPreviewResponse,
|
||||
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 [impactPreview, setImpactPreview] = useState<PolicyImpactPreviewResponse | null>(null);
|
||||
const [previewDraftKey, setPreviewDraftKey] = useState("");
|
||||
const [resetImpactPreview, setResetImpactPreview] = useState<PolicyImpactPreviewResponse | null>(null);
|
||||
|
||||
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)))
|
||||
);
|
||||
const draftKey = draft ? stablePolicy(buildPolicy(draft)) : "";
|
||||
const previewCurrent = Boolean(impactPreview && previewDraftKey === draftKey);
|
||||
|
||||
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));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
} 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));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}
|
||||
|
||||
async function previewImpact() {
|
||||
if (!draft || !state || !dirty) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const policy = buildPolicy(draft);
|
||||
const preview = await previewViewPolicyImpact(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
policy,
|
||||
{
|
||||
viewIds: viewOptions.map((option) => option.value),
|
||||
surfaceIds: surfaceOptions.map((option) => option.value)
|
||||
}
|
||||
);
|
||||
setImpactPreview(preview);
|
||||
setPreviewDraftKey(stablePolicy(policy));
|
||||
setSuccess("Policy impact preview completed without saving the draft.");
|
||||
} catch (err) {
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!draft || !state || !dirty) return true;
|
||||
if (!previewCurrent) {
|
||||
setError("Preview the current policy draft before saving it.");
|
||||
return false;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await updateViewPolicy(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
buildPolicy(draft),
|
||||
impactPreview
|
||||
);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setSuccess("View policy saved.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareResetPolicy() {
|
||||
if (!state?.id) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const preview = await previewViewPolicyImpact(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
{},
|
||||
{
|
||||
viewIds: viewOptions.map((option) => option.value),
|
||||
surfaceIds: surfaceOptions.map((option) => option.value)
|
||||
}
|
||||
);
|
||||
setResetImpactPreview(preview);
|
||||
setConfirmReset(true);
|
||||
setSuccess("Inherited-policy impact preview completed without removing the override.");
|
||||
} catch (err) {
|
||||
setResetImpactPreview(null);
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPolicy() {
|
||||
if (!resetImpactPreview) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = await deleteViewPolicy(
|
||||
settings,
|
||||
scopeType,
|
||||
targetId || null,
|
||||
resetImpactPreview
|
||||
);
|
||||
setState(loaded);
|
||||
setDraft(draftFromPolicy(loaded.policy));
|
||||
setImpactPreview(null);
|
||||
setPreviewDraftKey("");
|
||||
setResetImpactPreview(null);
|
||||
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={() => void prepareResetPolicy()} disabled={!canWrite || !state?.id || busy}><Trash2 size={16} /> Use inherited</Button>
|
||||
<Button helpContextId="policy.impact-preview.action.preview" onClick={() => void previewImpact()} disabled={!canWrite || !dirty || busy}><ScanSearch size={16} /> Preview impact</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy || !previewCurrent} disabledReason={dirty && !previewCurrent ? "Preview the current draft before saving." : undefined}><Save size={16} /> {busy ? "Working..." : "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">
|
||||
<DescriptionList>
|
||||
<DescriptionItem term={<>Local override</>}><StatusBadge status={state.id ? "info" : "neutral"} label={state.id ? `Revision ${state.revision}` : "Inherited"} /></DescriptionItem>
|
||||
<DescriptionItem term={<>Allowed Views</>}>{ceilingLabel(state.effective_policy.allowed_view_ids)}</DescriptionItem>
|
||||
<DescriptionItem term={<>Visible surfaces</>}>{ceilingLabel(state.effective_policy.visible_surface_ids)}</DescriptionItem>
|
||||
<DescriptionItem term={<>Policy path</>}>{state.source_path.length ? state.source_path.map((step) => `${step.scope_type}${step.scope_id ? `:${step.scope_id}` : ""}`).join(" -> ") : "Platform defaults"}</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</Card>
|
||||
|
||||
{impactPreview && (
|
||||
<Card title="Policy impact preview">
|
||||
<DescriptionList>
|
||||
<DescriptionItem term={<>Preview</>}><code>{impactPreview.preview_id}</code></DescriptionItem>
|
||||
<DescriptionItem term={<>Draft state</>}><StatusBadge status={previewCurrent ? "success" : "warning"} label={previewCurrent ? "Current" : "Outdated"} /></DescriptionItem>
|
||||
<DescriptionItem term={<>Newly allowed</>}>{impactPreview.counts.newly_allowed}</DescriptionItem>
|
||||
<DescriptionItem term={<>Newly denied</>}>{impactPreview.counts.newly_denied}</DescriptionItem>
|
||||
<DescriptionItem term={<>Unchanged</>}>{impactPreview.counts.unchanged}</DescriptionItem>
|
||||
<DescriptionItem term={<>Indeterminate</>}>{impactPreview.counts.indeterminate}</DescriptionItem>
|
||||
<DescriptionItem term={<>Risk</>}><StatusBadge status={impactPreview.high_impact ? "warning" : "neutral"} label={impactPreview.high_impact ? "High impact - recent login required" : "Bounded change"} /></DescriptionItem>
|
||||
<DescriptionItem term={<>Coverage</>}>{impactPreview.populations.map((population) => `${population.provider_id}: ${population.state} (${population.returned}${population.total_available == null ? "" : `/${population.total_available}`})${population.explanation ? ` - ${population.explanation}` : ""}`).join("; ")}</DescriptionItem>
|
||||
{impactPreview.details_hidden && <DescriptionItem term={<>Details</>}>{impactPreview.details_explanation || "Subject details are hidden by policy."}</DescriptionItem>}
|
||||
</DescriptionList>
|
||||
{impactPreview.effects.length > 0 && (
|
||||
<div help-context-id="policy.impact-preview.results">
|
||||
<h4>Changed subjects</h4>
|
||||
<ul>
|
||||
{impactPreview.effects.filter((effect) => effect.category !== "unchanged").slice(0, 20).map((effect) => (
|
||||
<li key={`${effect.subject.module_id}:${effect.subject.resource_type}:${effect.subject.resource_id}:${effect.subject.action}`}>
|
||||
<strong>{effect.category.replaceAll("_", " ")}</strong>: {effect.subject.label || effect.subject.resource_id} - {effect.subject.action} ({effect.rule})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{impactPreview.effects.filter((effect) => effect.category !== "unchanged").length > 20 && <p>Only the first 20 changed subjects are shown; aggregate counts cover the complete returned population.</p>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AdminPageLayout>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmReset}
|
||||
title="Use inherited View policy?"
|
||||
message={resetImpactPreview ? `The local override will be removed. The bounded preview found ${resetImpactPreview.counts.newly_allowed} newly allowed, ${resetImpactPreview.counts.newly_denied} newly denied, and ${resetImpactPreview.counts.indeterminate} indeterminate effects. All restrictions inherited from higher scopes continue to apply.` : "Previewing inherited-policy impact..."}
|
||||
confirmLabel="Use inherited policy"
|
||||
busy={busy}
|
||||
onConfirm={() => void resetPolicy()}
|
||||
onCancel={() => {
|
||||
setConfirmReset(false);
|
||||
setResetImpactPreview(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
ArchiveEncryptionMethod,
|
||||
ArchiveEncryptionPolicyItem,
|
||||
ArchiveEncryptionPolicyResponse,
|
||||
ArchiveEncryptionPolicyScope,
|
||||
PasswordDeliveryChannel
|
||||
} from "../../api/archiveEncryptionPolicies";
|
||||
|
||||
export type ArchiveEncryptionDraft = {
|
||||
inheritMethods: boolean;
|
||||
methods: ArchiveEncryptionMethod[];
|
||||
inheritChannels: boolean;
|
||||
channels: PasswordDeliveryChannel[];
|
||||
};
|
||||
|
||||
export function draftFromPolicy(policy: ArchiveEncryptionPolicyItem, parent: ArchiveEncryptionPolicyResponse["parent_policy"]): ArchiveEncryptionDraft {
|
||||
return {
|
||||
inheritMethods: policy.allowed_password_encryption_methods === undefined,
|
||||
methods: [...(policy.allowed_password_encryption_methods ?? parent.allowed_password_encryption_methods)],
|
||||
inheritChannels: policy.allowed_password_delivery_channels === undefined,
|
||||
channels: [...(policy.allowed_password_delivery_channels ?? parent.allowed_password_delivery_channels)]
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPolicy(draft: ArchiveEncryptionDraft): ArchiveEncryptionPolicyItem {
|
||||
return {
|
||||
...(draft.inheritMethods ? {} : { allowed_password_encryption_methods: draft.methods }),
|
||||
...(draft.inheritChannels ? {} : { allowed_password_delivery_channels: draft.channels })
|
||||
};
|
||||
}
|
||||
|
||||
export function stable(value: ArchiveEncryptionPolicyItem): string {
|
||||
return JSON.stringify({
|
||||
methods: value.allowed_password_encryption_methods ? [...value.allowed_password_encryption_methods].sort() : null,
|
||||
channels: value.allowed_password_delivery_channels ? [...value.allowed_password_delivery_channels].sort() : null
|
||||
});
|
||||
}
|
||||
|
||||
/** System defaults are editable even before the first explicit override exists. */
|
||||
export function inheritedControlDisabled(scope: ArchiveEncryptionPolicyScope, inherited: boolean): boolean {
|
||||
return scope !== "system" && inherited;
|
||||
}
|
||||
|
||||
export function setDraftMethod(draft: ArchiveEncryptionDraft, method: ArchiveEncryptionMethod, checked: boolean): ArchiveEncryptionDraft {
|
||||
return { ...draft, inheritMethods: false, methods: toggle(draft.methods, method, checked) };
|
||||
}
|
||||
|
||||
export function setDraftChannel(draft: ArchiveEncryptionDraft, channel: PasswordDeliveryChannel, checked: boolean): ArchiveEncryptionDraft {
|
||||
return { ...draft, inheritChannels: false, channels: toggle(draft.channels, channel, checked) };
|
||||
}
|
||||
|
||||
function toggle<T extends string>(values: T[], value: T, checked: boolean): T[] {
|
||||
return checked ? Array.from(new Set([...values, value])) : values.filter((item) => item !== value);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export { default } from "./module";
|
||||
export { default as ViewPoliciesPanel } from "./features/policy/ViewPoliciesPanel";
|
||||
export * from "./module";
|
||||
export * from "./api/adminTargets";
|
||||
export { default as RetentionPoliciesPanel } from "./features/policy/RetentionPoliciesPanel";
|
||||
export { default as ArchiveEncryptionPoliciesPanel } from "./features/policy/ArchiveEncryptionPoliciesPanel";
|
||||
export type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
|
||||
+149
-1
@@ -2,11 +2,136 @@ import { createElement, lazy } from "react";
|
||||
import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
|
||||
const RetentionPoliciesPanel = lazy(() => import("./features/policy/RetentionPoliciesPanel"));
|
||||
const ViewPoliciesPanel = lazy(() => import("./features/policy/ViewPoliciesPanel"));
|
||||
const ArchiveEncryptionPoliciesPanel = lazy(() => import("./features/policy/ArchiveEncryptionPoliciesPanel"));
|
||||
|
||||
const policyAdminSections: AdminSectionsUiCapability = {
|
||||
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-campaign-archive-encryption",
|
||||
moduleId: "policy",
|
||||
kind: "settings",
|
||||
surfaceId: "policy.admin.system-campaign-archive-encryption",
|
||||
label: "Campaign archive encryption",
|
||||
group: "SYSTEM",
|
||||
order: 75,
|
||||
allOf: ["admin:policies:read"],
|
||||
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
|
||||
settings,
|
||||
scopeType: "system",
|
||||
canWrite: hasScope(auth, "system:settings:write") && hasScope(auth, "admin:policies:write")
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "tenant-campaign-archive-encryption",
|
||||
moduleId: "policy",
|
||||
kind: "settings",
|
||||
surfaceId: "policy.admin.tenant-campaign-archive-encryption",
|
||||
label: "Campaign archive encryption",
|
||||
group: "TENANT",
|
||||
order: 75,
|
||||
allOf: ["admin:policies:read"],
|
||||
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
|
||||
settings,
|
||||
scopeType: "tenant",
|
||||
canWrite: hasScope(auth, "admin:policies:write")
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "group-campaign-archive-encryption",
|
||||
moduleId: "policy",
|
||||
kind: "settings",
|
||||
surfaceId: "policy.admin.group-campaign-archive-encryption",
|
||||
label: "Campaign archive encryption",
|
||||
group: "GROUP",
|
||||
order: 25,
|
||||
allOf: ["admin:policies:read", "admin:groups:read"],
|
||||
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
|
||||
settings,
|
||||
scopeType: "group",
|
||||
canWrite: hasScope(auth, "admin:policies:write")
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "user-campaign-archive-encryption",
|
||||
moduleId: "policy",
|
||||
kind: "settings",
|
||||
surfaceId: "policy.admin.user-campaign-archive-encryption",
|
||||
label: "Campaign archive encryption",
|
||||
group: "USER",
|
||||
order: 25,
|
||||
allOf: ["admin:policies:read", "admin:users:read"],
|
||||
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
|
||||
settings,
|
||||
scopeType: "user",
|
||||
canWrite: hasScope(auth, "admin:policies:write")
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "system-retention",
|
||||
moduleId: "policy",
|
||||
kind: "settings",
|
||||
surfaceId: "policy.admin.system-retention",
|
||||
label: "Retention",
|
||||
group: "SYSTEM",
|
||||
order: 80,
|
||||
@@ -19,6 +144,9 @@ const policyAdminSections: AdminSectionsUiCapability = {
|
||||
},
|
||||
{
|
||||
id: "tenant-retention",
|
||||
moduleId: "policy",
|
||||
kind: "settings",
|
||||
surfaceId: "policy.admin.tenant-retention",
|
||||
label: "Retention",
|
||||
group: "TENANT",
|
||||
order: 80,
|
||||
@@ -31,6 +159,9 @@ const policyAdminSections: AdminSectionsUiCapability = {
|
||||
},
|
||||
{
|
||||
id: "tenant-group-retention",
|
||||
moduleId: "policy",
|
||||
kind: "settings",
|
||||
surfaceId: "policy.admin.group-retention",
|
||||
label: "Retention",
|
||||
group: "GROUP",
|
||||
order: 30,
|
||||
@@ -43,6 +174,9 @@ const policyAdminSections: AdminSectionsUiCapability = {
|
||||
},
|
||||
{
|
||||
id: "tenant-user-retention",
|
||||
moduleId: "policy",
|
||||
kind: "settings",
|
||||
surfaceId: "policy.admin.user-retention",
|
||||
label: "Retention",
|
||||
group: "USER",
|
||||
order: 30,
|
||||
@@ -59,8 +193,22 @@ const policyAdminSections: AdminSectionsUiCapability = {
|
||||
export const policyModule: PlatformWebModule = {
|
||||
id: "policy",
|
||||
label: "Policy",
|
||||
version: "0.1.6",
|
||||
version: "0.1.9",
|
||||
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-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "System Campaign archive encryption", order: 75 },
|
||||
{ id: "policy.admin.tenant-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "Tenant Campaign archive encryption", order: 75 },
|
||||
{ id: "policy.admin.group-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "Group Campaign archive encryption", order: 75 },
|
||||
{ id: "policy.admin.user-campaign-archive-encryption", moduleId: "policy", kind: "section", label: "User Campaign archive encryption", order: 75 },
|
||||
{ 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: {
|
||||
"admin.sections": policyAdminSections
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user