Implement governed function assignment workflows

This commit is contained in:
2026-07-31 19:40:27 +02:00
parent f025b0c25b
commit c14719d55a
16 changed files with 4079 additions and 27 deletions
+62 -1
View File
@@ -9,7 +9,11 @@ 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.assignment_lifecycle import SqlIdmAssignmentLifecycle
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_idm.backend.db.models import (
IdmFunctionAssignmentChange,
IdmFunctionAssignmentChangeEvent,
IdmOrganizationFunctionAssignment,
)
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
@@ -20,6 +24,8 @@ class AssignmentExpiryTests(unittest.TestCase):
self.database.engine,
tables=[
IdmOrganizationFunctionAssignment.__table__,
IdmFunctionAssignmentChange.__table__,
IdmFunctionAssignmentChangeEvent.__table__,
ChangeSequenceEntry.__table__,
],
)
@@ -143,6 +149,61 @@ class AssignmentExpiryTests(unittest.TestCase):
with self.subTest(limit=value), self.assertRaises(ValueError):
self.lifecycle.process_expired(session, limit=value)
def test_sweep_expires_open_governed_changes_once(self) -> None:
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
with self.database.session() as session:
session.add(
IdmFunctionAssignmentChange(
id="change-due",
tenant_id="tenant-1",
kind="request",
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="Need the function",
evidence=[],
assignment_source="governance",
required_steps=["holder"],
completed_steps=[],
policy_decision={},
idempotency_key="request-1",
expires_at=boundary - timedelta(seconds=1),
metadata_={},
)
)
session.commit()
events: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("idm.function_change.expired.v1", 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(1, result["expired_changes"])
self.assertEqual(["change-due"], result["change_ids"])
self.assertEqual(0, repeated["expired_changes"])
self.assertEqual(1, len(events))
with self.database.session() as session:
change = session.get(IdmFunctionAssignmentChange, "change-due")
self.assertEqual("expired", change.state)
self.assertEqual(2, change.resource_revision)
history = session.query(IdmFunctionAssignmentChangeEvent).all()
self.assertEqual(["expired"], [item.action for item in history])
if __name__ == "__main__":
unittest.main()
+375
View File
@@ -0,0 +1,375 @@
from __future__ import annotations
from dataclasses import replace
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 FunctionAssignmentGovernanceDecision
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,
IdmOrganizationFunctionAssignment,
IdmTenantSettings,
)
from govoplan_idm.backend.function_assignment_changes import (
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")),
"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,
)
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__,
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)
if __name__ == "__main__":
unittest.main()