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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user