feat(idm): govern delegation chains and timed escalation
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-08-22 03:12:30 +02:00
parent b0eda35195
commit 21e8f0bc39
21 changed files with 1931 additions and 95 deletions
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import timedelta
from datetime import datetime, timedelta
import hashlib
import json
@@ -49,6 +49,12 @@ from govoplan_idm.backend.db.models import (
IdmTenantSettings,
new_uuid,
)
from govoplan_idm.backend.delegation_routes import (
DelegationRoute,
resolve_actor_function_route,
resolve_function_route_availability,
validate_delegation_chain,
)
OPEN_STATES = {
@@ -59,6 +65,7 @@ OPEN_STATES = {
"changes_requested",
"blocked",
"failed_manual_review",
"escalated",
}
TERMINAL_STATES = {
"applied",
@@ -116,17 +123,39 @@ def resolve_submission_capability(
tenant_id=principal.tenant_id,
function=function,
)
authority_function_id = _authority_function_id(settings)
context = _actor_context(
session,
base_context = _base_actor_context(
principal=principal,
function_id=function.id,
authority_function_id=authority_function_id,
candidate_identity_id=candidate_identity_id,
candidate_account_id=candidate_account_id,
initiator_account_id=principal.account_id,
has_evidence=has_evidence,
)
preliminary = policy.resolve_function_assignment_action(
session,
request=FunctionAssignmentGovernanceRequest(
tenant_id=principal.tenant_id,
kind=kind, # type: ignore[arg-type]
action="submit",
function_id=function.id,
actor=principal.to_platform_principal(),
candidate_identity_id=candidate_identity_id,
candidate_account_id=candidate_account_id,
function_settings=settings,
context=base_context,
),
)
context = _actor_context(
session,
principal=principal,
function_id=function.id,
authority_function_id=preliminary.authority_function_id,
escalation_target_function_id=None,
candidate_identity_id=candidate_identity_id,
candidate_account_id=candidate_account_id,
initiator_account_id=principal.account_id,
has_evidence=has_evidence,
decision=preliminary,
)
decision = policy.resolve_function_assignment_action(
session,
request=FunctionAssignmentGovernanceRequest(
@@ -192,15 +221,25 @@ def create_function_assignment_change(
decision.reason or "The function assignment change is not allowed."
)
_validate_requested_validity(payload, decision)
assignment_source = str(getattr(payload, "assignment_source", "governance"))
represented_assignment_id = _text(
getattr(payload, "represented_assignment_id", None)
)
if assignment_source == "delegated" and represented_assignment_id is None:
raise FunctionAssignmentChangeUnavailable(
"A delegated assignment requires the represented source assignment."
)
if assignment_source != "delegated" and represented_assignment_id is not None:
raise FunctionAssignmentChangeUnavailable(
"Only a delegated assignment may reference a represented source assignment."
)
if represented_assignment_id is not None:
_require_actor_assignment(
session,
principal=principal,
assignment_id=represented_assignment_id,
function_id=function.id,
decision=decision,
)
now = utc_now()
change = IdmFunctionAssignmentChange(
@@ -221,7 +260,7 @@ def create_function_assignment_change(
requested_valid_from=getattr(payload, "requested_valid_from", None),
requested_valid_until=getattr(payload, "requested_valid_until", None),
applies_to_subunits=bool(getattr(payload, "applies_to_subunits", False)),
assignment_source=str(getattr(payload, "assignment_source", "governance")),
assignment_source=assignment_source,
required_steps=list(decision.required_steps),
completed_steps=[],
policy_decision=decision.to_dict(),
@@ -268,19 +307,22 @@ def create_function_assignment_change(
raise FunctionAssignmentChangeUnavailable(str(exc)) from exc
_pin_workflow(change, workflow_ref)
next_step = _next_required_step(change)
blocked_reason = _missing_reviewer_reason(session, change)
blocked_reason = _missing_reviewer_reason(session, change, decision=decision)
if blocked_reason is not None:
change.state = "blocked"
change.outcome_reason = blocked_reason
_clear_review_route(change)
elif next_step is None:
_apply_assignment(
session,
change=change,
principal=principal,
registry=registry,
function=function,
)
else:
change.state = STEP_STATE[next_step]
_set_review_route(change, decision=decision, step=next_step, now=now)
session.add(change)
session.flush()
_append_event(
@@ -332,6 +374,7 @@ def transition_function_assignment_change(
raise FunctionAssignmentChangeUnavailable(
decision.reason or "This transition is not allowed."
)
change.policy_decision = decision.to_dict()
if (
decision.separation_of_duties
and action in {"approve", "accept"}
@@ -372,7 +415,8 @@ def transition_function_assignment_change(
_record_step_approval(
change,
step=_current_required_step(change),
actor_id=principal.account_id,
principal=principal,
decision=decision,
)
if _step_approval_count(change, _current_required_step(change)) >= (
1 if action == "accept" else decision.quorum
@@ -382,11 +426,11 @@ def transition_function_assignment_change(
principal=principal,
registry=registry,
change=change,
function=function,
decision=decision,
comment=comment,
evidence=evidence,
)
else:
change.policy_decision = decision.to_dict()
elif action == "request_changes":
_request_changes(
session,
@@ -412,6 +456,12 @@ def transition_function_assignment_change(
**dict(change.metadata_),
"last_response": comment.strip(),
}
_set_review_route(
change,
decision=decision,
step=_current_required_step(change),
now=utc_now(),
)
elif action == "recover":
change.outcome_reason = None
_resume_change(
@@ -419,6 +469,8 @@ def transition_function_assignment_change(
principal=principal,
registry=registry,
change=change,
function=function,
decision=decision,
)
else:
raise FunctionAssignmentChangeConflict(
@@ -457,7 +509,7 @@ def available_change_actions(
return [], f"The change is {change.state}."
candidates = (
["approve", "request_changes", "reject", "withdraw"]
if change.state in {"awaiting_holder", "awaiting_authority"}
if change.state in {"awaiting_holder", "awaiting_authority", "escalated"}
else ["accept", "request_changes", "reject", "withdraw"]
if change.state == "awaiting_recipient"
else ["respond", "withdraw"]
@@ -520,6 +572,9 @@ def visible_change_filter(
IdmFunctionAssignmentChange.policy_decision["authority_function_id"]
.as_string()
.in_(actor_function_ids),
IdmFunctionAssignmentChange.escalation_target_function_id.in_(
actor_function_ids
),
)
)
return or_(*clauses)
@@ -545,17 +600,40 @@ def _resolve_transition_decision(
tenant_id=change.tenant_id,
function=function,
)
authority_function_id = _authority_function_id(settings)
context = _actor_context(
session,
base_context = _base_actor_context(
principal=principal,
function_id=change.function_id,
authority_function_id=authority_function_id,
candidate_identity_id=change.candidate_identity_id,
candidate_account_id=change.candidate_account_id,
initiator_account_id=change.initiator_account_id,
has_evidence=has_evidence,
)
preliminary = policy.resolve_function_assignment_action(
session,
request=FunctionAssignmentGovernanceRequest(
tenant_id=change.tenant_id,
kind=change.kind, # type: ignore[arg-type]
action=action,
function_id=change.function_id,
actor=principal.to_platform_principal(),
candidate_identity_id=change.candidate_identity_id,
candidate_account_id=change.candidate_account_id,
current_state=change.state,
function_settings=settings,
context=base_context,
),
)
context = _actor_context(
session,
principal=principal,
function_id=change.function_id,
authority_function_id=preliminary.authority_function_id,
escalation_target_function_id=change.escalation_target_function_id,
candidate_identity_id=change.candidate_identity_id,
candidate_account_id=change.candidate_account_id,
initiator_account_id=change.initiator_account_id,
has_evidence=has_evidence,
decision=preliminary,
)
context["approvals_complete"] = _next_required_step(change) is None
return policy.resolve_function_assignment_action(
session,
@@ -580,6 +658,8 @@ def _complete_current_step(
principal: ApiPrincipal,
registry: object | None,
change: IdmFunctionAssignmentChange,
function: OrganizationFunctionRef,
decision: FunctionAssignmentGovernanceDecision,
comment: str | None,
evidence: Sequence[str],
) -> None:
@@ -620,18 +700,30 @@ def _complete_current_step(
change=change,
principal=principal,
registry=registry,
function=function,
)
except FunctionAssignmentChangeConflict as exc:
change.state = "failed_manual_review"
change.outcome_reason = str(exc)
else:
blocked_reason = _missing_reviewer_reason(session, change)
blocked_reason = _missing_reviewer_reason(
session,
change,
decision=decision,
)
if blocked_reason is not None:
change.state = "blocked"
change.outcome_reason = blocked_reason
_clear_review_route(change)
else:
change.state = STEP_STATE[next_step]
change.outcome_reason = None
_set_review_route(
change,
decision=decision,
step=next_step,
now=utc_now(),
)
def _finish_negative_transition(
@@ -667,6 +759,7 @@ def _finish_negative_transition(
_pin_workflow(change, reference)
change.state = "rejected" if action == "reject" else "withdrawn"
change.outcome_reason = comment
_clear_review_route(change)
def _request_changes(
@@ -685,7 +778,11 @@ def _request_changes(
raise FunctionAssignmentChangeUnavailable(
"The pinned Workflow instance is unavailable."
)
previous_state = change.state
previous_state = (
change.escalation_from_state
if change.state == "escalated" and change.escalation_from_state
else change.state
)
reference = workflow.resolve_current_step(
session,
principal,
@@ -707,6 +804,7 @@ def _request_changes(
**dict(change.metadata_),
"resume_state": previous_state,
}
_clear_review_route(change)
def _resume_change(
@@ -715,6 +813,8 @@ def _resume_change(
principal: ApiPrincipal,
registry: object | None,
change: IdmFunctionAssignmentChange,
function: OrganizationFunctionRef,
decision: FunctionAssignmentGovernanceDecision,
) -> None:
workflow = workflow_orchestration_provider(registry)
if workflow is None or change.workflow_instance_id is None:
@@ -735,13 +835,24 @@ def _resume_change(
)
_pin_workflow(change, reference)
step = _next_required_step(change)
blocked_reason = _missing_reviewer_reason(session, change)
blocked_reason = _missing_reviewer_reason(
session,
change,
decision=decision,
)
if blocked_reason is not None:
change.state = "blocked"
change.outcome_reason = blocked_reason
_clear_review_route(change)
elif step is not None:
change.state = STEP_STATE[step]
change.outcome_reason = None
_set_review_route(
change,
decision=decision,
step=step,
now=utc_now(),
)
else:
try:
_apply_assignment(
@@ -749,6 +860,7 @@ def _resume_change(
change=change,
principal=principal,
registry=registry,
function=function,
)
except FunctionAssignmentChangeConflict as exc:
change.state = "failed_manual_review"
@@ -788,16 +900,259 @@ def _align_workflow_to_required_step(
)
def _recheck_application(
session: Session,
*,
change: IdmFunctionAssignmentChange,
principal: ApiPrincipal,
registry: object | None,
function: OrganizationFunctionRef,
) -> FunctionAssignmentGovernanceDecision:
policy = function_assignment_governance_policy(registry)
if policy is None:
raise FunctionAssignmentChangeConflict(
"Function assignment governance Policy is unavailable at application."
)
settings = _effective_function_settings(
session,
tenant_id=change.tenant_id,
function=function,
)
context = _base_actor_context(
principal=principal,
candidate_identity_id=change.candidate_identity_id,
candidate_account_id=change.candidate_account_id,
initiator_account_id=change.initiator_account_id,
has_evidence=bool(change.evidence),
)
preliminary = policy.resolve_function_assignment_action(
session,
request=FunctionAssignmentGovernanceRequest(
tenant_id=change.tenant_id,
kind=change.kind, # type: ignore[arg-type]
action="apply",
function_id=change.function_id,
actor=principal.to_platform_principal(),
candidate_identity_id=change.candidate_identity_id,
candidate_account_id=change.candidate_account_id,
current_state=change.state,
function_settings=settings,
context=context,
),
)
if tuple(change.required_steps) != preliminary.required_steps:
raise FunctionAssignmentChangeConflict(
"The effective Policy review steps changed after submission; "
"administrative recovery must re-plan the pinned workflow."
)
_recheck_completed_approvals(
session,
change=change,
decision=preliminary,
)
_recheck_delegated_assignment_source(
session,
change=change,
function=function,
decision=preliminary,
)
context["approvals_complete"] = True
decision = policy.resolve_function_assignment_action(
session,
request=FunctionAssignmentGovernanceRequest(
tenant_id=change.tenant_id,
kind=change.kind, # type: ignore[arg-type]
action="apply",
function_id=change.function_id,
actor=principal.to_platform_principal(),
candidate_identity_id=change.candidate_identity_id,
candidate_account_id=change.candidate_account_id,
current_state=change.state,
function_settings=settings,
context=context,
),
)
if not decision.allowed:
raise FunctionAssignmentChangeConflict(
decision.reason or "The current Policy blocks application."
)
change.policy_decision = decision.to_dict()
return decision
def _recheck_completed_approvals(
session: Session,
*,
change: IdmFunctionAssignmentChange,
decision: FunctionAssignmentGovernanceDecision,
) -> None:
approvals = change.metadata_.get("step_approvals")
approval_map = approvals if isinstance(approvals, Mapping) else {}
for step in decision.required_steps:
records = list(approval_map.get(step, ()))
required = 1 if step == "recipient" else decision.quorum
valid = 0
failure_reason: str | None = None
for raw in records:
record = raw if isinstance(raw, Mapping) else {"actor_account_id": raw}
account_id = _text(record.get("actor_account_id"))
identity_id = _text(record.get("actor_identity_id"))
if step == "recipient":
if (
(account_id and account_id == change.candidate_account_id)
or (identity_id and identity_id == change.candidate_identity_id)
):
valid += 1
else:
failure_reason = "Recipient acceptance no longer resolves to the candidate."
continue
route_kind = _text(record.get("route_kind")) or step
expected_function_id = (
change.function_id
if step == "holder" and route_kind != "escalation"
else decision.authority_function_id
if step == "authority" and route_kind != "escalation"
else _text(record.get("expected_function_id"))
)
if route_kind == "escalation":
current_rule = decision.escalation_rule(step) # type: ignore[arg-type]
if (
current_rule is None
or expected_function_id != current_rule.target_function_id
):
failure_reason = (
"The effective Policy no longer permits the recorded "
f"{step} escalation route."
)
continue
elif step == "authority" and expected_function_id != _text(
record.get("expected_function_id")
):
failure_reason = (
"The designated authority changed after its approval was recorded."
)
continue
if expected_function_id is None:
failure_reason = f"The {step} approval route is unavailable."
continue
route = resolve_actor_function_route(
session,
tenant_id=change.tenant_id,
function_id=expected_function_id,
account_id=account_id,
identity_id=identity_id,
decision=decision,
)
if route.effective:
valid += 1
else:
failure_reason = route.reason
if valid < required:
raise FunctionAssignmentChangeConflict(
failure_reason
or f"The current {step} approval quorum is no longer effective."
)
def _recheck_delegated_assignment_source(
session: Session,
*,
change: IdmFunctionAssignmentChange,
function: OrganizationFunctionRef,
decision: FunctionAssignmentGovernanceDecision,
) -> None:
if change.assignment_source != "delegated":
if change.represented_assignment_id is not None:
raise FunctionAssignmentChangeConflict(
"A non-delegated change cannot retain a represented assignment."
)
return
if not function.delegable or not decision.delegation_allowed:
raise FunctionAssignmentChangeConflict(
"The organization function or current Policy no longer permits delegation."
)
source = (
session.get(
IdmOrganizationFunctionAssignment,
change.represented_assignment_id,
)
if change.represented_assignment_id
else None
)
if source is None:
raise FunctionAssignmentChangeConflict(
"The represented source assignment is unavailable."
)
if (
source.account_id != change.initiator_account_id
and source.identity_id != change.initiator_identity_id
):
raise FunctionAssignmentChangeConflict(
"The represented source assignment no longer belongs to the initiator."
)
route = validate_delegation_chain(
session,
assignment=source,
tenant_id=change.tenant_id,
function_id=change.function_id,
decision=decision,
)
if not route.effective:
raise FunctionAssignmentChangeConflict(
route.reason or "The represented delegation route is no longer effective."
)
if (
source.valid_from is not None
and (
change.requested_valid_from is None
or change.requested_valid_from < source.valid_from
)
):
raise FunctionAssignmentChangeConflict(
"The delegated assignment would start before its source assignment."
)
if (
source.valid_until is not None
and (
change.requested_valid_until is None
or change.requested_valid_until > source.valid_until
)
):
raise FunctionAssignmentChangeConflict(
"The delegated assignment would outlive its source assignment."
)
if (
decision.maximum_delegated_validity_days is not None
and change.requested_valid_until is not None
):
start = change.requested_valid_from or utc_now()
if change.requested_valid_until > start + timedelta(
days=decision.maximum_delegated_validity_days
):
raise FunctionAssignmentChangeConflict(
"The delegated validity window exceeds the current Policy ceiling."
)
def _apply_assignment(
session: Session,
*,
change: IdmFunctionAssignmentChange,
principal: ApiPrincipal,
registry: object | None,
function: OrganizationFunctionRef,
) -> None:
if change.resulting_assignment_id:
change.state = "applied"
return
_recheck_application(
session,
change=change,
principal=principal,
registry=registry,
function=function,
)
existing = session.scalar(
select(IdmOrganizationFunctionAssignment).where(
IdmOrganizationFunctionAssignment.tenant_id == change.tenant_id,
@@ -845,6 +1200,7 @@ def _apply_assignment(
change.resulting_assignment_id = assignment.id
change.state = "applied"
change.outcome_reason = None
_clear_review_route(change)
emit_assignment_event(
session,
assignment,
@@ -874,20 +1230,84 @@ def _actor_context(
principal: ApiPrincipal,
function_id: str,
authority_function_id: str | None,
escalation_target_function_id: str | None,
candidate_identity_id: str,
candidate_account_id: str | None,
initiator_account_id: str,
has_evidence: bool,
decision: FunctionAssignmentGovernanceDecision,
) -> dict[str, object]:
holder_route = resolve_actor_function_route(
session,
tenant_id=principal.tenant_id,
function_id=function_id,
account_id=principal.account_id,
identity_id=principal.identity_id,
decision=decision,
)
authority_route = (
resolve_actor_function_route(
session,
tenant_id=principal.tenant_id,
function_id=authority_function_id,
account_id=principal.account_id,
identity_id=principal.identity_id,
decision=decision,
)
if authority_function_id
else DelegationRoute(
False,
"unavailable",
"The effective Policy does not designate an authority function.",
)
)
escalation_route = (
resolve_actor_function_route(
session,
tenant_id=principal.tenant_id,
function_id=escalation_target_function_id,
account_id=principal.account_id,
identity_id=principal.identity_id,
decision=decision,
)
if escalation_target_function_id
else DelegationRoute(
False,
"unavailable",
"This review is not currently escalated to a target function.",
)
)
return {
**_base_actor_context(
principal=principal,
candidate_identity_id=candidate_identity_id,
candidate_account_id=candidate_account_id,
initiator_account_id=initiator_account_id,
has_evidence=has_evidence,
),
"actor_is_holder": holder_route.effective,
"actor_is_authority": authority_route.effective,
"actor_is_escalation_target": escalation_route.effective,
"actor_routes": {
"holder": holder_route.to_dict(),
"authority": authority_route.to_dict(),
"escalation": escalation_route.to_dict(),
},
}
def _base_actor_context(
*,
principal: ApiPrincipal,
candidate_identity_id: str,
candidate_account_id: str | None,
initiator_account_id: str,
has_evidence: bool,
) -> dict[str, object]:
actor_assignments = _actor_assignments(session, principal)
return {
"actor_is_holder": any(
item.function_id == function_id for item in actor_assignments
),
"actor_is_authority": bool(authority_function_id)
and any(
item.function_id == authority_function_id for item in actor_assignments
),
"actor_is_holder": False,
"actor_is_authority": False,
"actor_is_escalation_target": False,
"candidate_is_actor": (
principal.identity_id == candidate_identity_id
or (
@@ -897,6 +1317,7 @@ def _actor_context(
),
"actor_is_initiator": principal.account_id == initiator_account_id,
"has_evidence": has_evidence,
"actor_routes": {},
}
@@ -953,12 +1374,6 @@ def _effective_function_settings(
}
def _authority_function_id(settings: Mapping[str, object]) -> str | None:
raw = settings.get("assignment_governance")
policy = raw if isinstance(raw, Mapping) else {}
return _text(policy.get("authority_function_id"))
def _validate_requested_validity(
payload: object,
decision: FunctionAssignmentGovernanceDecision,
@@ -976,6 +1391,24 @@ def _validate_requested_validity(
f"Requested validity exceeds the Policy limit of "
f"{decision.maximum_validity_days} days."
)
if str(getattr(payload, "assignment_source", "governance")) != "delegated":
return
if not decision.delegation_allowed:
raise FunctionAssignmentChangeUnavailable(
"The current Policy does not permit delegated assignments."
)
if (
decision.maximum_delegated_validity_days is not None
and valid_until is not None
):
start = valid_from or utc_now()
if valid_until > start + timedelta(
days=decision.maximum_delegated_validity_days
):
raise FunctionAssignmentChangeUnavailable(
"Requested delegated validity exceeds the Policy limit of "
f"{decision.maximum_delegated_validity_days} days."
)
def _require_actor_assignment(
@@ -984,13 +1417,13 @@ def _require_actor_assignment(
principal: ApiPrincipal,
assignment_id: str,
function_id: str,
decision: FunctionAssignmentGovernanceDecision,
) -> None:
assignment = session.get(IdmOrganizationFunctionAssignment, assignment_id)
if (
assignment is None
or assignment.tenant_id != principal.tenant_id
or assignment.function_id != function_id
or not assignment.is_active
or (
assignment.account_id != principal.account_id
and assignment.identity_id != principal.identity_id
@@ -999,6 +1432,18 @@ def _require_actor_assignment(
raise FunctionAssignmentChangeUnavailable(
"The represented function assignment is not an effective assignment of the actor."
)
route = validate_delegation_chain(
session,
assignment=assignment,
tenant_id=principal.tenant_id,
function_id=function_id,
decision=decision,
)
if not route.effective:
raise FunctionAssignmentChangeUnavailable(
route.reason
or "The represented function assignment route is not effective."
)
def _next_required_step(change: IdmFunctionAssignmentChange) -> str | None:
@@ -1018,11 +1463,38 @@ def _current_required_step(change: IdmFunctionAssignmentChange) -> str:
return step
def _set_review_route(
change: IdmFunctionAssignmentChange,
*,
decision: FunctionAssignmentGovernanceDecision,
step: str,
now: datetime,
) -> None:
rule = decision.escalation_rule(step) # type: ignore[arg-type]
change.review_deadline_at = (
now + timedelta(hours=rule.timeout_hours) if rule is not None else None
)
change.escalation_target_function_id = (
rule.target_function_id if rule is not None else None
)
change.escalated_at = None
change.escalation_from_state = None
def _clear_review_route(change: IdmFunctionAssignmentChange) -> None:
change.review_deadline_at = None
change.escalated_at = None
change.escalation_from_state = None
change.escalation_target_function_id = None
def _governance_action(
change: IdmFunctionAssignmentChange,
action: str,
) -> FunctionAssignmentGovernanceAction:
if action == "approve":
if change.state == "escalated":
return "approve_escalation"
step = _current_required_step(change)
if step == "holder":
return "approve_holder"
@@ -1054,15 +1526,48 @@ def _record_step_approval(
change: IdmFunctionAssignmentChange,
*,
step: str,
actor_id: str,
principal: ApiPrincipal,
decision: FunctionAssignmentGovernanceDecision,
) -> None:
approvals = dict(change.metadata_.get("step_approvals") or {})
actors = [str(item) for item in approvals.get(step, ())]
if actor_id in actors:
records = list(approvals.get(step, ()))
actors = [
str(item.get("actor_account_id"))
if isinstance(item, Mapping)
else str(item)
for item in records
]
if principal.account_id in actors:
raise FunctionAssignmentChangeConflict(
"This actor already approved the current governance step."
)
approvals[step] = [*actors, actor_id]
route_key = "escalation" if change.state == "escalated" else step
routes = decision.details.get("actor_routes")
route = routes.get(route_key) if isinstance(routes, Mapping) else None
route_data = dict(route) if isinstance(route, Mapping) else {}
approvals[step] = [
*records,
{
"actor_account_id": principal.account_id,
"actor_identity_id": principal.identity_id,
"approved_at": utc_now().isoformat(),
"route_kind": route_key,
"expected_function_id": (
change.escalation_target_function_id
if route_key == "escalation"
else change.function_id
if step == "holder"
else decision.authority_function_id
if step == "authority"
else None
),
"assignment_id": route_data.get("assignment_id"),
"chain_assignment_ids": list(
route_data.get("chain_assignment_ids") or ()
),
"delegation_depth": route_data.get("delegation_depth", 0),
},
]
change.metadata_ = {**dict(change.metadata_), "step_approvals": approvals}
@@ -1228,6 +1733,14 @@ def _notification_recipients(
function_id=str(authority_id),
)
)
elif change.state == "escalated" and change.escalation_target_function_id:
recipients.update(
_function_holder_accounts(
session,
tenant_id=change.tenant_id,
function_id=change.escalation_target_function_id,
)
)
return tuple(sorted(item for item in recipients if item))
@@ -1263,24 +1776,31 @@ def _function_holder_accounts(
def _missing_reviewer_reason(
session: Session,
change: IdmFunctionAssignmentChange,
*,
decision: FunctionAssignmentGovernanceDecision,
) -> str | None:
step = _next_required_step(change)
if step == "holder" and not _function_has_incumbent(
session,
tenant_id=change.tenant_id,
function_id=change.function_id,
):
return "The function is vacant; no effective holder can review this change."
if step == "holder":
route = resolve_function_route_availability(
session,
tenant_id=change.tenant_id,
function_id=change.function_id,
decision=decision,
)
if not route.effective:
return route.reason or "No effective holder can review this change."
if step == "authority":
authority_id = _text(change.policy_decision.get("authority_function_id"))
authority_id = decision.authority_function_id
if authority_id is None:
return "The effective Policy does not designate an authority function."
if not _function_has_incumbent(
route = resolve_function_route_availability(
session,
tenant_id=change.tenant_id,
function_id=authority_id,
):
return "The designated authority function is vacant."
decision=decision,
)
if not route.effective:
return route.reason or "The designated authority function is unavailable."
if step == "recipient" and not (
change.candidate_account_id or change.candidate_identity_id
):
@@ -1288,32 +1808,6 @@ def _missing_reviewer_reason(
return None
def _function_has_incumbent(
session: Session,
*,
tenant_id: str,
function_id: str,
) -> bool:
now = utc_now()
return bool(
session.scalar(
select(func.count(IdmOrganizationFunctionAssignment.id)).where(
IdmOrganizationFunctionAssignment.tenant_id == tenant_id,
IdmOrganizationFunctionAssignment.function_id == function_id,
IdmOrganizationFunctionAssignment.is_active.is_(True),
or_(
IdmOrganizationFunctionAssignment.valid_from.is_(None),
IdmOrganizationFunctionAssignment.valid_from <= now,
),
or_(
IdmOrganizationFunctionAssignment.valid_until.is_(None),
IdmOrganizationFunctionAssignment.valid_until > now,
),
)
)
)
def _request_fingerprint(payload: object) -> str:
if hasattr(payload, "model_dump"):
value = payload.model_dump(mode="json", exclude={"idempotency_key"})