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
+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()