Implement governed function assignment workflows
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user