2 Commits
Author SHA1 Message Date
zemion b5f431c766 docs(policy): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 20:11:46 +02:00
zemion 5753488375 feat(policy): govern delegation and review escalation
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 03:12:30 +02:00
8 changed files with 387 additions and 9 deletions
+15
View File
@@ -130,3 +130,18 @@ 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/policy-webui",
"version": "0.1.18",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-policy"
version = "0.1.19"
version = "0.1.21"
description = "GovOPlaN policy platform module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.20",
"govoplan-core>=0.1.29",
]
[tool.setuptools.packages.find]
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping
from govoplan_core.core.policy import (
FunctionAssignmentEscalationRule,
FunctionAssignmentGovernanceDecision,
FunctionAssignmentGovernanceRequest,
PolicySourceStep,
@@ -50,6 +51,25 @@ class FunctionAssignmentGovernancePolicyProvider:
),
)
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")
@@ -59,6 +79,7 @@ class FunctionAssignmentGovernancePolicyProvider:
)
if evidence_required and not request.context.get("has_evidence"):
requirements.append("evidence")
requirements.extend(escalation_requirements)
allowed, reason = _action_decision(
request,
profile=profile,
@@ -79,6 +100,10 @@ class FunctionAssignmentGovernancePolicyProvider:
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),
)
@@ -106,19 +131,44 @@ def _action_decision(
return True, None
if profile == "authority_only":
allowed = bool(context.get("actor_is_authority"))
reason = "Only the designated authority may initiate this grant."
reason = _route_reason(
context,
"authority",
"Only the designated authority may initiate this grant.",
)
else:
allowed = bool(context.get("actor_is_holder"))
reason = "An effective function holder must initiate this grant."
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 "A current holder must approve."
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 "The designated authority must approve."
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")
@@ -134,6 +184,13 @@ def _action_decision(
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."
@@ -196,6 +253,10 @@ def _decision(
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
@@ -216,6 +277,10 @@ def _decision(
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,
@@ -236,6 +301,7 @@ def _decision(
"function_id": request.function_id,
"kind": request.kind,
"action": request.action,
"actor_routes": dict(request.context.get("actor_routes") or {}),
},
)
@@ -250,6 +316,9 @@ def _requirements_reason(requirements: list[str]) -> str:
"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 "
@@ -258,6 +327,55 @@ def _requirements_reason(requirements: list[str]) -> str:
)
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,
+182 -1
View File
@@ -150,7 +150,7 @@ POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details"
manifest = ModuleManifest(
id="policy",
name="Policy",
version="0.1.19",
version="0.1.21",
permissions=(
PermissionDefinition(
scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
@@ -230,6 +230,25 @@ manifest = ModuleManifest(
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(
@@ -253,6 +272,23 @@ manifest = ModuleManifest(
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": {
@@ -280,6 +316,22 @@ manifest = ModuleManifest(
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"],
@@ -296,6 +348,23 @@ manifest = ModuleManifest(
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": [
@@ -307,6 +376,66 @@ manifest = ModuleManifest(
],
},
),
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",
@@ -314,6 +443,21 @@ manifest = ModuleManifest(
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(
@@ -341,6 +485,26 @@ manifest = ModuleManifest(
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",
@@ -382,6 +546,23 @@ manifest = ModuleManifest(
"campaign_manager",
),
related_modules=("campaign", "audit", "access"),
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."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
@@ -132,6 +132,58 @@ class FunctionAssignmentGovernancePolicyTests(unittest.TestCase):
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()
+12
View File
@@ -26,6 +26,18 @@ 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"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/policy-webui",
"version": "0.1.19",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",