feat(idm): govern delegation chains and timed escalation
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user