feat(policy): govern delegation and review escalation
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -130,3 +130,18 @@ bounded outcome counts in audit evidence.
|
|||||||
The shared core WebUI helper `PolicySourcePath` renders the source path shape
|
The shared core WebUI helper `PolicySourcePath` renders the source path shape
|
||||||
for module UIs. Modules may use their own field layout, but the data contract
|
for module UIs. Modules may use their own field layout, but the data contract
|
||||||
should remain this shape.
|
should remain this shape.
|
||||||
|
# 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.
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-policy"
|
name = "govoplan-policy"
|
||||||
version = "0.1.19"
|
version = "0.1.20"
|
||||||
description = "GovOPlaN policy platform module."
|
description = "GovOPlaN policy platform module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.20",
|
"govoplan-core>=0.1.29",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from govoplan_core.core.policy import (
|
from govoplan_core.core.policy import (
|
||||||
|
FunctionAssignmentEscalationRule,
|
||||||
FunctionAssignmentGovernanceDecision,
|
FunctionAssignmentGovernanceDecision,
|
||||||
FunctionAssignmentGovernanceRequest,
|
FunctionAssignmentGovernanceRequest,
|
||||||
PolicySourceStep,
|
PolicySourceStep,
|
||||||
@@ -50,6 +51,25 @@ class FunctionAssignmentGovernancePolicyProvider:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
authority_function_id = _text(policy.get("authority_function_id"))
|
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] = []
|
requirements: list[str] = []
|
||||||
if "authority" in required_steps and authority_function_id is None:
|
if "authority" in required_steps and authority_function_id is None:
|
||||||
requirements.append("authority_function")
|
requirements.append("authority_function")
|
||||||
@@ -59,6 +79,7 @@ class FunctionAssignmentGovernancePolicyProvider:
|
|||||||
)
|
)
|
||||||
if evidence_required and not request.context.get("has_evidence"):
|
if evidence_required and not request.context.get("has_evidence"):
|
||||||
requirements.append("evidence")
|
requirements.append("evidence")
|
||||||
|
requirements.extend(escalation_requirements)
|
||||||
allowed, reason = _action_decision(
|
allowed, reason = _action_decision(
|
||||||
request,
|
request,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
@@ -79,6 +100,10 @@ class FunctionAssignmentGovernancePolicyProvider:
|
|||||||
required_steps=required_steps,
|
required_steps=required_steps,
|
||||||
authority_function_id=authority_function_id,
|
authority_function_id=authority_function_id,
|
||||||
evidence_required=evidence_required,
|
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),
|
requirements=tuple(requirements),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,19 +131,44 @@ def _action_decision(
|
|||||||
return True, None
|
return True, None
|
||||||
if profile == "authority_only":
|
if profile == "authority_only":
|
||||||
allowed = bool(context.get("actor_is_authority"))
|
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:
|
else:
|
||||||
allowed = bool(context.get("actor_is_holder"))
|
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
|
return allowed, None if allowed else reason
|
||||||
if action == "approve_holder":
|
if action == "approve_holder":
|
||||||
allowed = "holder" in required_steps and bool(context.get("actor_is_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":
|
if action == "approve_authority":
|
||||||
allowed = "authority" in required_steps and bool(
|
allowed = "authority" in required_steps and bool(
|
||||||
context.get("actor_is_authority")
|
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":
|
if action == "accept_recipient":
|
||||||
allowed = "recipient" in required_steps and bool(
|
allowed = "recipient" in required_steps and bool(
|
||||||
context.get("candidate_is_actor")
|
context.get("candidate_is_actor")
|
||||||
@@ -134,6 +184,13 @@ def _action_decision(
|
|||||||
elif request.current_state == "awaiting_recipient":
|
elif request.current_state == "awaiting_recipient":
|
||||||
allowed = bool(context.get("candidate_is_actor"))
|
allowed = bool(context.get("candidate_is_actor"))
|
||||||
reason = "Only the candidate may act at recipient acceptance."
|
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:
|
else:
|
||||||
allowed = False
|
allowed = False
|
||||||
reason = "The current state does not accept this review action."
|
reason = "The current state does not accept this review action."
|
||||||
@@ -196,6 +253,10 @@ def _decision(
|
|||||||
required_steps: tuple[str, ...] = (),
|
required_steps: tuple[str, ...] = (),
|
||||||
authority_function_id: str | None = None,
|
authority_function_id: str | None = None,
|
||||||
evidence_required: bool = False,
|
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, ...] = (),
|
requirements: tuple[str, ...] = (),
|
||||||
) -> FunctionAssignmentGovernanceDecision:
|
) -> FunctionAssignmentGovernanceDecision:
|
||||||
recipient_required = "recipient" in required_steps
|
recipient_required = "recipient" in required_steps
|
||||||
@@ -216,6 +277,10 @@ def _decision(
|
|||||||
policy.get("maximum_validity_days"),
|
policy.get("maximum_validity_days"),
|
||||||
maximum=3650,
|
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(
|
request_expiry_hours=_bounded_int(
|
||||||
policy.get("request_expiry_hours"),
|
policy.get("request_expiry_hours"),
|
||||||
default=336,
|
default=336,
|
||||||
@@ -236,6 +301,7 @@ def _decision(
|
|||||||
"function_id": request.function_id,
|
"function_id": request.function_id,
|
||||||
"kind": request.kind,
|
"kind": request.kind,
|
||||||
"action": request.action,
|
"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",
|
"authority_function": "a designated authority function",
|
||||||
"evidence": "the required evidence",
|
"evidence": "the required evidence",
|
||||||
"valid_profile": "a supported governance profile",
|
"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 (
|
return (
|
||||||
"Submission requires "
|
"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(
|
def _has_scope(
|
||||||
request: FunctionAssignmentGovernanceRequest,
|
request: FunctionAssignmentGovernanceRequest,
|
||||||
scope: str,
|
scope: str,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details"
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="policy",
|
id="policy",
|
||||||
name="Policy",
|
name="Policy",
|
||||||
version="0.1.19",
|
version="0.1.20",
|
||||||
permissions=(
|
permissions=(
|
||||||
PermissionDefinition(
|
PermissionDefinition(
|
||||||
scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
||||||
@@ -307,6 +307,30 @@ 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"),
|
||||||
|
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(
|
DocumentationTopic(
|
||||||
id="policy.effective-decisions-and-provenance",
|
id="policy.effective-decisions-and-provenance",
|
||||||
title="Understand effective policy decisions",
|
title="Understand effective policy decisions",
|
||||||
|
|||||||
@@ -132,6 +132,58 @@ class FunctionAssignmentGovernancePolicyTests(unittest.TestCase):
|
|||||||
self.assertTrue(responder.allowed)
|
self.assertTrue(responder.allowed)
|
||||||
self.assertFalse(unrelated.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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/policy-webui",
|
"name": "@govoplan/policy-webui",
|
||||||
"version": "0.1.19",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user