Files
govoplan-idm/tests/test_function_assignment_changes.py
T
zemion 21e8f0bc39
Module Package Release / publish-packages (push) Successful in 11s
feat(idm): govern delegation chains and timed escalation
2026-08-22 03:12:30 +02:00

503 lines
18 KiB
Python

from __future__ import annotations
from dataclasses import replace
from datetime import timedelta
import unittest
from unittest.mock import patch
from govoplan_core.auth import ApiPrincipal
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 (
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
from govoplan_identity.backend.db import models as identity_models # noqa: F401
from govoplan_idm.backend.api.v1.schemas import FunctionAssignmentChangeCreateRequest
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,
)
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
class _Policy:
def resolve_function_assignment_action(self, session=None, *, request):
del session
steps = ("holder", "authority")
context = request.context
allowed = {
"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")
),
"respond": bool(
context.get("actor_is_initiator") or context.get("candidate_is_actor")
),
"reject": bool(
context.get("actor_is_holder") or context.get("actor_is_authority")
),
"withdraw": bool(context.get("actor_is_initiator")),
"recover": request.actor.account_id == "admin",
"apply": bool(context.get("approvals_complete")),
}.get(request.action, False)
return FunctionAssignmentGovernanceDecision(
allowed=allowed,
reason=None if allowed else "Not eligible for this action.",
profile="holder_with_authority_clearance",
required_steps=steps,
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,
),
),
)
class _Workflow:
nodes = ("holder_review", "authority_review", "recipient_review")
def __init__(self) -> None:
self.instances: dict[str, WorkflowInstanceRef] = {}
def start_standard(self, session, principal, *, request):
del session, principal
instance_id = f"workflow-{request.idempotency_key}"
existing = self.instances.get(instance_id)
if existing is not None:
return replace(existing, replayed=True)
reference = WorkflowInstanceRef(
id=instance_id,
tenant_id=request.tenant_id,
definition_id="definition-1",
definition_revision_id="revision-1",
definition_revision=1,
definition_hash="a" * 64,
status="waiting",
current_step_id="step-holder_review",
current_node_id="holder_review",
)
self.instances[instance_id] = reference
return reference
def resolve_current_step(
self,
session,
principal,
*,
tenant_id,
instance_id,
resolution,
):
del session, principal, tenant_id
current = self.instances[instance_id]
if resolution.expected_step_id != current.current_step_id:
raise ValueError("Workflow current step changed.")
if resolution.action == "changes":
return current
if resolution.action in {"reject", "cancel"}:
result = replace(
current,
status="completed",
current_step_id=None,
current_node_id=None,
)
else:
index = self.nodes.index(current.current_node_id or "") + 1
if index >= len(self.nodes):
result = replace(
current,
status="completed",
current_step_id=None,
current_node_id=None,
)
else:
node = self.nodes[index]
result = replace(
current,
current_step_id=f"step-{node}",
current_node_id=node,
)
self.instances[instance_id] = result
return result
def get_instance(self, session, *, tenant_id, instance_id):
del session, tenant_id
return self.instances[instance_id]
class _Registry:
def __init__(self) -> None:
self.policy = _Policy()
self.workflow = _Workflow()
def has_capability(self, name: str) -> bool:
return name in {
"policy.functionAssignmentGovernance",
"workflow.orchestration",
}
def capability(self, name: str):
if name == "policy.functionAssignmentGovernance":
return self.policy
if name == "workflow.orchestration":
return self.workflow
return None
def principal(account_id: str, identity_id: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=f"membership-{account_id}",
tenant_id="tenant-1",
identity_id=identity_id,
scopes=frozenset({"idm:function_change:decide"}),
),
account=object(),
user=object(),
)
def function() -> OrganizationFunctionRef:
return OrganizationFunctionRef(
id="target-function",
tenant_id="tenant-1",
organization_unit_id="unit-1",
slug="target",
name="Target function",
settings={
"assignment_governance": {
"request_profile": "holder_with_authority_clearance",
"authority_function_id": "authority-function",
}
},
)
def payload() -> FunctionAssignmentChangeCreateRequest:
return FunctionAssignmentChangeCreateRequest(
kind="request",
function_id="target-function",
candidate_identity_id="candidate-identity",
candidate_account_id="candidate",
justification="The function is needed for the assigned work.",
idempotency_key="request-1",
)
class FunctionAssignmentChangeTests(unittest.TestCase):
def setUp(self) -> None:
self.database = configure_database("sqlite:///:memory:")
Base.metadata.create_all(
self.database.engine,
tables=[
IdmOrganizationFunctionAssignment.__table__,
IdmFunctionAssignmentChange.__table__,
IdmFunctionAssignmentChangeEvent.__table__,
IdmTenantSettings.__table__,
IdmTypedGroup.__table__,
IdmIdentityRelationship.__table__,
ChangeSequenceEntry.__table__,
],
)
self.registry = _Registry()
def tearDown(self) -> None:
reset_database(dispose=True)
def _add_reviewer_assignments(self, session) -> None:
session.add_all(
(
IdmOrganizationFunctionAssignment(
id="holder-assignment",
tenant_id="tenant-1",
identity_id="holder-identity",
account_id="holder",
function_id="target-function",
organization_unit_id="unit-1",
source="direct",
is_active=True,
settings={},
),
IdmOrganizationFunctionAssignment(
id="authority-assignment",
tenant_id="tenant-1",
identity_id="authority-identity",
account_id="authority",
function_id="authority-function",
organization_unit_id="unit-1",
source="direct",
is_active=True,
settings={},
),
)
)
session.flush()
def test_request_is_idempotent_and_applies_once_after_required_steps(self) -> None:
with self.database.session() as session:
self._add_reviewer_assignments(session)
change, replayed = create_function_assignment_change(
session,
principal=principal("candidate", "candidate-identity"),
registry=self.registry,
function=function(),
payload=payload(),
)
session.flush()
same, replay = create_function_assignment_change(
session,
principal=principal("candidate", "candidate-identity"),
registry=self.registry,
function=function(),
payload=payload(),
)
self.assertFalse(replayed)
self.assertTrue(replay)
self.assertEqual(change.id, same.id)
self.assertEqual("awaiting_holder", change.state)
transition_function_assignment_change(
session,
principal=principal("holder", "holder-identity"),
registry=self.registry,
change=change,
function=function(),
action="approve",
base_revision=1,
comment="Holder approval",
evidence=(),
)
self.assertEqual("awaiting_authority", change.state)
transition_function_assignment_change(
session,
principal=principal("authority", "authority-identity"),
registry=self.registry,
change=change,
function=function(),
action="approve",
base_revision=2,
comment="Authority approval",
evidence=(),
)
session.commit()
self.assertEqual("applied", change.state)
self.assertIsNotNone(change.resulting_assignment_id)
resulting = session.get(
IdmOrganizationFunctionAssignment,
change.resulting_assignment_id,
)
self.assertEqual("candidate-identity", resulting.identity_id)
self.assertEqual("governance", resulting.source)
self.assertEqual(
change.id,
resulting.settings["governance"]["change_id"],
)
self.assertEqual(
1,
session.query(IdmOrganizationFunctionAssignment)
.filter(
IdmOrganizationFunctionAssignment.identity_id
== "candidate-identity"
)
.count(),
)
def test_vacant_function_blocks_and_revision_claim_rejects_stale_action(
self,
) -> None:
with self.database.session() as session:
change, _ = create_function_assignment_change(
session,
principal=principal("candidate", "candidate-identity"),
registry=self.registry,
function=function(),
payload=payload(),
)
session.flush()
self.assertEqual("blocked", change.state)
self.assertIn("vacant", change.outcome_reason)
self._add_reviewer_assignments(session)
change.state = "awaiting_holder"
change.resource_revision = 2
session.flush()
with self.assertRaises(RevisionConflictError):
transition_function_assignment_change(
session,
principal=principal("holder", "holder-identity"),
registry=self.registry,
change=change,
function=function(),
action="approve",
base_revision=1,
comment=None,
evidence=(),
)
def test_historical_change_remains_readable_after_function_removal(self) -> None:
with self.database.session() as session:
change, _ = create_function_assignment_change(
session,
principal=principal("candidate", "candidate-identity"),
registry=self.registry,
function=function(),
payload=payload(),
)
session.flush()
with patch(
"govoplan_idm.backend.api.v1.function_changes._historical_function",
return_value=None,
):
item = _change_item(
session,
principal("candidate", "candidate-identity"),
change,
include_events=False,
)
self.assertEqual(change.id, item.id)
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()