feat(idm): govern delegation chains and timed escalation
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-08-22 03:12:30 +02:00
parent b0eda35195
commit 21e8f0bc39
21 changed files with 1931 additions and 95 deletions
+76
View File
@@ -208,6 +208,82 @@ class AssignmentExpiryTests(unittest.TestCase):
history = session.query(IdmFunctionAssignmentChangeEvent).all()
self.assertEqual(["expired"], [item.action for item in history])
def test_sweep_escalates_due_review_once_without_substituting_approval(self) -> None:
boundary = datetime(2026, 8, 22, 12, tzinfo=timezone.utc)
with self.database.session() as session:
session.add(
IdmFunctionAssignmentChange(
id="change-escalate",
tenant_id="tenant-1",
kind="grant",
state="awaiting_holder",
profile="holder_grant",
function_id="function-1",
organization_unit_id="unit-1",
candidate_identity_id="identity-1",
initiator_account_id="account-1",
justification="Timed governed review",
evidence=[],
assignment_source="governance",
required_steps=["holder"],
completed_steps=[],
policy_decision={
"escalation_rules": [
{
"step": "holder",
"target_function_id": "function-escalation",
"timeout_hours": 4,
}
]
},
idempotency_key="grant-escalate-1",
expires_at=boundary + timedelta(days=2),
review_deadline_at=boundary - timedelta(seconds=1),
escalation_target_function_id="function-escalation",
metadata_={"step_approvals": {}},
)
)
session.commit()
events: list[PlatformEvent] = []
audit_events: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("idm.function_change.escalated.v1", events.append)
bus.subscribe(
"idm.function_assignment_change.escalated",
audit_events.append,
)
with self.database.SessionLocal() as session, event_bus_context(bus):
result = self.lifecycle.process_expired(
session,
tenant_id="tenant-1",
effective_at=boundary,
)
session.commit()
repeated = self.lifecycle.process_expired(
session,
tenant_id="tenant-1",
effective_at=boundary,
)
session.commit()
self.assertEqual(["change-escalate"], result["escalated_change_ids"])
self.assertEqual(0, repeated["escalated_changes"])
self.assertEqual(1, len(events))
self.assertEqual(1, len(audit_events))
with self.database.session() as session:
change = session.get(IdmFunctionAssignmentChange, "change-escalate")
self.assertEqual("escalated", change.state)
self.assertEqual("awaiting_holder", change.escalation_from_state)
self.assertEqual("function-escalation", change.escalation_target_function_id)
self.assertEqual([], change.completed_steps)
self.assertIsNone(change.resulting_assignment_id)
history = session.query(IdmFunctionAssignmentChangeEvent).all()
self.assertEqual(["escalated"], [item.action for item in history])
self.assertFalse(
history[0].details["automatic_approver_substitution"]
)
def test_sweep_emits_relationship_expiry_once(self) -> None:
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
with self.database.session() as session:
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import unittest
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
from govoplan_identity.backend.db import models as identity_models # noqa: F401
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_idm.backend.delegation_routes import validate_delegation_chain
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
class DelegationRouteTests(unittest.TestCase):
def setUp(self) -> None:
self.database = configure_database("sqlite:///:memory:")
Base.metadata.create_all(
self.database.engine,
tables=[IdmOrganizationFunctionAssignment.__table__],
)
self.now = datetime(2026, 8, 22, 12, tzinfo=timezone.utc)
def tearDown(self) -> None:
reset_database(dispose=True)
def assignment(
self,
assignment_id: str,
*,
source: str = "direct",
parent: str | None = None,
active: bool = True,
valid_from: datetime | None = None,
valid_until: datetime | None = None,
) -> IdmOrganizationFunctionAssignment:
return IdmOrganizationFunctionAssignment(
id=assignment_id,
tenant_id="tenant-1",
identity_id=f"identity-{assignment_id}",
account_id=f"account-{assignment_id}",
function_id="function-1",
organization_unit_id="unit-1",
source=source,
delegated_from_assignment_id=parent,
is_active=active,
valid_from=valid_from,
valid_until=valid_until,
settings={},
)
@staticmethod
def decision(
*,
allowed: bool = True,
depth: int = 3,
validity_days: int | None = None,
) -> FunctionAssignmentGovernanceDecision:
return FunctionAssignmentGovernanceDecision(
allowed=True,
delegation_allowed=allowed,
maximum_delegation_depth=depth if allowed else 0,
maximum_delegated_validity_days=validity_days,
)
def test_complete_effective_chain_is_accepted(self) -> None:
with self.database.session() as session:
root = self.assignment(
"root",
valid_from=self.now - timedelta(days=30),
valid_until=self.now + timedelta(days=30),
)
first = self.assignment(
"first",
source="delegated",
parent="root",
valid_from=self.now - timedelta(days=10),
valid_until=self.now + timedelta(days=20),
)
second = self.assignment(
"second",
source="delegated",
parent="first",
valid_from=self.now - timedelta(days=1),
valid_until=self.now + timedelta(days=5),
)
session.add_all((root, first, second))
session.flush()
route = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(depth=2),
effective_at=self.now,
)
self.assertTrue(route.effective)
self.assertEqual(("second", "first", "root"), route.chain_assignment_ids)
self.assertEqual(2, route.delegation_depth)
def test_policy_tightening_and_over_depth_fail_closed(self) -> None:
with self.database.session() as session:
root = self.assignment("root")
first = self.assignment("first", source="delegated", parent="root")
second = self.assignment("second", source="delegated", parent="first")
session.add_all((root, first, second))
session.flush()
disabled = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(allowed=False),
effective_at=self.now,
)
shallow = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(depth=1),
effective_at=self.now,
)
self.assertEqual("policy_tightened", disabled.code)
self.assertEqual("over_depth", shallow.code)
def test_expired_cyclic_and_overlong_routes_are_explained(self) -> None:
with self.database.session() as session:
expired = self.assignment(
"expired",
valid_until=self.now - timedelta(seconds=1),
)
cycle_a = self.assignment("cycle-a", source="delegated", parent="cycle-b")
cycle_b = self.assignment("cycle-b", source="delegated", parent="cycle-a")
overlong = self.assignment(
"overlong",
source="delegated",
parent="root",
valid_from=self.now,
valid_until=self.now + timedelta(days=31),
)
root = self.assignment("root")
session.add_all(
(expired, cycle_a, cycle_b, root, overlong)
)
session.flush()
expired_route = validate_delegation_chain(
session,
assignment=expired,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(),
effective_at=self.now,
)
cyclic_route = validate_delegation_chain(
session,
assignment=cycle_a,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(),
effective_at=self.now,
)
overlong_route = validate_delegation_chain(
session,
assignment=overlong,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(validity_days=30),
effective_at=self.now,
)
self.assertEqual("expired", expired_route.code)
self.assertEqual("cyclic", cyclic_route.code)
self.assertEqual("policy_tightened", overlong_route.code)
if __name__ == "__main__":
unittest.main()
+128 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import replace
from datetime import timedelta
import unittest
from unittest.mock import patch
@@ -9,7 +10,12 @@ from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.concurrency import RevisionConflictError
from govoplan_core.core.organizations import OrganizationFunctionRef
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
from govoplan_core.core.policy import (
FunctionAssignmentEscalationRule,
FunctionAssignmentGovernanceDecision,
)
from govoplan_core.security.time import utc_now
from govoplan_idm.backend.assignment_lifecycle import SqlIdmAssignmentLifecycle
from govoplan_core.core.workflows import WorkflowInstanceRef
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
@@ -19,10 +25,13 @@ from govoplan_idm.backend.api.v1.function_changes import _change_item
from govoplan_idm.backend.db.models import (
IdmFunctionAssignmentChange,
IdmFunctionAssignmentChangeEvent,
IdmIdentityRelationship,
IdmOrganizationFunctionAssignment,
IdmTenantSettings,
IdmTypedGroup,
)
from govoplan_idm.backend.function_assignment_changes import (
FunctionAssignmentChangeConflict,
create_function_assignment_change,
transition_function_assignment_change,
)
@@ -38,6 +47,9 @@ class _Policy:
"submit": bool(context.get("candidate_is_actor")),
"approve_holder": bool(context.get("actor_is_holder")),
"approve_authority": bool(context.get("actor_is_authority")),
"approve_escalation": bool(
context.get("actor_is_escalation_target")
),
"accept_recipient": bool(context.get("candidate_is_actor")),
"request_changes": bool(
context.get("actor_is_holder") or context.get("actor_is_authority")
@@ -60,6 +72,13 @@ class _Policy:
authority_function_id="authority-function",
separation_of_duties=False,
request_expiry_hours=24,
escalation_rules=(
FunctionAssignmentEscalationRule(
step="holder",
target_function_id="escalation-function",
timeout_hours=1,
),
),
)
@@ -205,6 +224,8 @@ class FunctionAssignmentChangeTests(unittest.TestCase):
IdmFunctionAssignmentChange.__table__,
IdmFunctionAssignmentChangeEvent.__table__,
IdmTenantSettings.__table__,
IdmTypedGroup.__table__,
IdmIdentityRelationship.__table__,
ChangeSequenceEntry.__table__,
],
)
@@ -370,6 +391,112 @@ class FunctionAssignmentChangeTests(unittest.TestCase):
self.assertEqual([], item.available_actions)
self.assertIn("no longer available", item.availability_reason)
def test_escalated_approval_rechecks_changed_routes_and_applies_exactly_once(
self,
) -> None:
with self.database.session() as session:
self._add_reviewer_assignments(session)
escalation_assignment = IdmOrganizationFunctionAssignment(
id="escalation-assignment",
tenant_id="tenant-1",
identity_id="escalation-identity",
account_id="escalation",
function_id="escalation-function",
organization_unit_id="unit-1",
source="direct",
is_active=True,
settings={},
)
session.add(escalation_assignment)
change, _ = create_function_assignment_change(
session,
principal=principal("candidate", "candidate-identity"),
registry=self.registry,
function=function(),
payload=payload(),
)
change.review_deadline_at = utc_now() - timedelta(seconds=1)
session.commit()
lifecycle = SqlIdmAssignmentLifecycle()
result = lifecycle.process_expired(
session,
tenant_id="tenant-1",
effective_at=utc_now(),
)
session.flush()
self.assertEqual([change.id], result["escalated_change_ids"])
self.assertEqual("escalated", change.state)
transition_function_assignment_change(
session,
principal=principal("escalation", "escalation-identity"),
registry=self.registry,
change=change,
function=function(),
action="approve",
base_revision=2,
comment="Explicit escalated holder decision",
evidence=(),
)
self.assertEqual("awaiting_authority", change.state)
escalation_assignment.is_active = False
session.flush()
transition_function_assignment_change(
session,
principal=principal("authority", "authority-identity"),
registry=self.registry,
change=change,
function=function(),
action="approve",
base_revision=3,
comment="Authority approval",
evidence=(),
)
self.assertEqual("failed_manual_review", change.state)
self.assertIn("no longer active", change.outcome_reason)
self.assertIsNone(change.resulting_assignment_id)
escalation_assignment.is_active = True
session.flush()
transition_function_assignment_change(
session,
principal=principal("admin", "admin-identity"),
registry=self.registry,
change=change,
function=function(),
action="recover",
base_revision=4,
comment="Current routes rechecked",
evidence=(),
)
session.commit()
self.assertEqual("applied", change.state)
self.assertIsNotNone(change.resulting_assignment_id)
self.assertEqual(
1,
session.query(IdmOrganizationFunctionAssignment)
.filter(
IdmOrganizationFunctionAssignment.identity_id
== "candidate-identity"
)
.count(),
)
with self.assertRaises(FunctionAssignmentChangeConflict):
transition_function_assignment_change(
session,
principal=principal("admin", "admin-identity"),
registry=self.registry,
change=change,
function=function(),
action="recover",
base_revision=5,
comment=None,
evidence=(),
)
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import unittest
from fastapi import HTTPException
from govoplan_idm.backend.api.v1.routes import (
_validate_function_governance_defaults,
)
class FunctionGovernanceSettingsTests(unittest.TestCase):
def test_bounded_delegation_and_escalation_defaults_are_accepted(self) -> None:
_validate_function_governance_defaults(
{
"function_assignment_governance_defaults": {
"delegation_allowed": True,
"maximum_delegation_depth": 2,
"maximum_delegated_validity_days": 30,
"escalation": {
"holder": {
"target_function_id": "function-escalation",
"timeout_hours": 24,
}
},
}
}
)
def test_incomplete_or_unbounded_rules_are_rejected(self) -> None:
invalid = (
{"maximum_delegation_depth": 0},
{"maximum_delegated_validity_days": 3651},
{"escalation": {"holder": {"timeout_hours": 24}}},
{
"escalation": {
"authority": {
"target_function_id": "function-escalation",
"timeout_hours": 0,
}
}
},
{
"escalation": {
"unknown": {
"target_function_id": "function-escalation",
"timeout_hours": 24,
}
}
},
)
for defaults in invalid:
with self.subTest(defaults=defaults), self.assertRaises(HTTPException):
_validate_function_governance_defaults(
{"function_assignment_governance_defaults": defaults}
)
if __name__ == "__main__":
unittest.main()
@@ -33,7 +33,9 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("function_decision", governance.metadata["consequence_classes"])
reference = topics["idm.reference.fields-and-consequences"]
self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"])
self.assertIn("idm.field.escalation", reference.metadata["help_contexts"])
self.assertIn("deactivate_or_expire", reference.metadata["consequence_classes"])
self.assertIn("escalation", reference.metadata["consequence_classes"])
relationships = topics["idm.reference.typed-relationships"]
self.assertEqual(
+15 -1
View File
@@ -32,7 +32,7 @@ class IdmMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"b1c2d3e4f5a6",
"c2d3e4f5a6b7",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
@@ -50,6 +50,20 @@ class IdmMigrationTests(unittest.TestCase):
if name.startswith("idm_")
},
)
change_columns = {
item["name"]
for item in inspect(connection).get_columns(
"idm_function_assignment_changes"
)
}
self.assertTrue(
{
"review_deadline_at",
"escalated_at",
"escalation_from_state",
"escalation_target_function_id",
}.issubset(change_columns)
)
finally:
engine.dispose()