feat(idm): govern delegation chains and timed escalation
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user