From 0c1358b8623d7d0c16a0ad082994d28c656bf2a0 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 22 Aug 2026 03:12:30 +0200 Subject: [PATCH] feat(policy): add delegation and escalation contracts --- docs/POLICY_CONTRACTS.md | 17 +++++++++++++++++ pyproject.toml | 2 +- src/govoplan_core/core/policy.py | 32 ++++++++++++++++++++++++++++++++ tests/test_policy_contracts.py | 30 ++++++++++++++++++++++++++++++ webui/package-lock.json | 4 ++-- webui/package.json | 2 +- 6 files changed, 83 insertions(+), 4 deletions(-) diff --git a/docs/POLICY_CONTRACTS.md b/docs/POLICY_CONTRACTS.md index 04fde61..7838ec9 100644 --- a/docs/POLICY_CONTRACTS.md +++ b/docs/POLICY_CONTRACTS.md @@ -14,6 +14,7 @@ consistent while each module still owns its domain rules. | Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. | | Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. | | Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. | +| Function assignment governance | `govoplan-policy` | capability `policy.functionAssignmentGovernance` | Returns current review steps, delegation depth/validity ceilings, and explicit timed-escalation targets consumed by IDM. | ## Policy Decision @@ -126,6 +127,22 @@ When the capability is absent, modules must not silently emulate cross-scope inheritance. Their conservative fallback is limited to local tenant definitions and disables reuse, derivation, and automation. +## Function Assignment Delegation And Escalation + +`FunctionAssignmentGovernanceDecision` is the versioned cross-module contract +for request/grant review. In addition to the required holder, authority, and +recipient steps, it returns `delegation_allowed`, +`maximum_delegation_depth`, `maximum_delegated_validity_days`, and typed +`FunctionAssignmentEscalationRule` entries. Each escalation entry binds one +review step to an exact target function and timeout. + +The decision is a current ceiling, not durable authorization. IDM must recheck +the complete assignment-source chain and all recorded decisions before final +application. An elapsed timeout creates explicit state and evidence; it must +never be interpreted as approval or as permission to silently substitute an +approver. Missing providers, malformed rules, invalid chains, or tightened +limits fail closed with an explainable reason. + ## Bounded Impact-Subject Providers Policy impact previews discover optional subject providers through capability diff --git a/pyproject.toml b/pyproject.toml index c1b7712..2ce001d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.28" +version = "0.1.29" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/core/policy.py b/src/govoplan_core/core/policy.py index e1d184e..a1fe271 100644 --- a/src/govoplan_core/core/policy.py +++ b/src/govoplan_core/core/policy.py @@ -41,10 +41,12 @@ ViewGovernanceAction = Literal[ "workflow_activate", ] FunctionAssignmentChangeKind = Literal["request", "grant"] +FunctionAssignmentReviewStep = Literal["holder", "authority", "recipient"] FunctionAssignmentGovernanceAction = Literal[ "submit", "approve_holder", "approve_authority", + "approve_escalation", "accept_recipient", "request_changes", "respond", @@ -419,6 +421,20 @@ class FunctionAssignmentGovernanceRequest: context: Mapping[str, Any] = field(default_factory=dict) +@dataclass(frozen=True, slots=True) +class FunctionAssignmentEscalationRule: + step: FunctionAssignmentReviewStep + target_function_id: str + timeout_hours: int + + def to_dict(self) -> dict[str, Any]: + return { + "step": self.step, + "target_function_id": self.target_function_id, + "timeout_hours": self.timeout_hours, + } + + @dataclass(frozen=True, slots=True) class FunctionAssignmentGovernanceDecision: allowed: bool @@ -431,6 +447,10 @@ class FunctionAssignmentGovernanceDecision: separation_of_duties: bool = True quorum: int = 1 maximum_validity_days: int | None = None + delegation_allowed: bool = False + maximum_delegation_depth: int = 0 + maximum_delegated_validity_days: int | None = None + escalation_rules: tuple[FunctionAssignmentEscalationRule, ...] = () request_expiry_hours: int = 336 source_path: tuple[PolicySourceStep, ...] = () requirements: tuple[str, ...] = () @@ -448,12 +468,24 @@ class FunctionAssignmentGovernanceDecision: "separation_of_duties": self.separation_of_duties, "quorum": self.quorum, "maximum_validity_days": self.maximum_validity_days, + "delegation_allowed": self.delegation_allowed, + "maximum_delegation_depth": self.maximum_delegation_depth, + "maximum_delegated_validity_days": ( + self.maximum_delegated_validity_days + ), + "escalation_rules": [rule.to_dict() for rule in self.escalation_rules], "request_expiry_hours": self.request_expiry_hours, "source_path": [step.to_dict() for step in self.source_path], "requirements": list(self.requirements), "details": dict(self.details), } + def escalation_rule( + self, + step: FunctionAssignmentReviewStep, + ) -> FunctionAssignmentEscalationRule | None: + return next((rule for rule in self.escalation_rules if rule.step == step), None) + @runtime_checkable class FunctionAssignmentGovernancePolicy(Protocol): diff --git a/tests/test_policy_contracts.py b/tests/test_policy_contracts.py index 508cfdf..9e42fd1 100644 --- a/tests/test_policy_contracts.py +++ b/tests/test_policy_contracts.py @@ -10,6 +10,8 @@ from govoplan_core.core.configuration_safety import ( ui_managed_configuration_fields_requiring_approval, ) from govoplan_core.core.policy import ( + FunctionAssignmentEscalationRule, + FunctionAssignmentGovernanceDecision, PolicyDecision, PolicySourceStep, parse_policy_source_path, @@ -19,6 +21,34 @@ from govoplan_core.core.policy import ( class PolicyContractTests(unittest.TestCase): + def test_function_assignment_policy_serializes_delegation_and_escalation(self) -> None: + decision = FunctionAssignmentGovernanceDecision( + allowed=True, + delegation_allowed=True, + maximum_delegation_depth=2, + maximum_delegated_validity_days=30, + escalation_rules=( + FunctionAssignmentEscalationRule( + step="authority", + target_function_id="function-escalation", + timeout_hours=48, + ), + ), + ) + + payload = decision.to_dict() + + self.assertEqual(2, payload["maximum_delegation_depth"]) + self.assertEqual(30, payload["maximum_delegated_validity_days"]) + self.assertEqual( + "function-escalation", + payload["escalation_rules"][0]["target_function_id"], + ) + self.assertEqual( + "function-escalation", + decision.escalation_rule("authority").target_function_id, + ) + def test_policy_source_paths_are_stable_and_round_trip(self) -> None: self.assertEqual(policy_source_path("system"), "system") self.assertEqual(policy_source_path("tenant", "tenant-1"), "tenant:tenant-1") diff --git a/webui/package-lock.json b/webui/package-lock.json index 3d3f6ec..1a00dcb 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.28", + "version": "0.1.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.28", + "version": "0.1.29", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", diff --git a/webui/package.json b/webui/package.json index 24e0070..9d71197 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.28", + "version": "0.1.29", "private": true, "type": "module", "main": "src/index.ts",