503 lines
18 KiB
Python
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()
|