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
@@ -244,6 +244,10 @@ def _change_item(
workflow_current_step_id=change.workflow_current_step_id,
resulting_assignment_id=change.resulting_assignment_id,
expires_at=change.expires_at,
review_deadline_at=change.review_deadline_at,
escalated_at=change.escalated_at,
escalation_from_state=change.escalation_from_state,
escalation_target_function_id=change.escalation_target_function_id,
outcome_reason=change.outcome_reason,
resource_revision=change.resource_revision,
etag=change.strong_etag,
@@ -297,6 +301,18 @@ def _record_change_audit(
"workflow_instance_id": change.workflow_instance_id,
"evidence": list(change.evidence),
"resulting_assignment_id": change.resulting_assignment_id,
"review_deadline_at": (
change.review_deadline_at.isoformat()
if change.review_deadline_at
else None
),
"escalated_at": (
change.escalated_at.isoformat() if change.escalated_at else None
),
"escalation_from_state": change.escalation_from_state,
"escalation_target_function_id": (
change.escalation_target_function_id
),
"resource_revision": change.resource_revision,
},
commit=False,
@@ -370,6 +386,18 @@ def get_function_assignment_capability(
decision and decision.recipient_acceptance_required
),
maximum_validity_days=(decision.maximum_validity_days if decision else None),
delegation_allowed=bool(decision and decision.delegation_allowed),
maximum_delegation_depth=(
decision.maximum_delegation_depth if decision else 0
),
maximum_delegated_validity_days=(
decision.maximum_delegated_validity_days if decision else None
),
escalation_rules=(
[rule.to_dict() for rule in decision.escalation_rules]
if decision
else []
),
workflow_available=registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION),
policy_available=registry.has_capability(
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE
+54
View File
@@ -516,6 +516,50 @@ def get_idm_settings(
return _settings_item(item) if item is not None else _default_settings(tenant_id)
def _validate_function_governance_defaults(settings: dict[str, Any]) -> None:
raw = settings.get("function_assignment_governance_defaults")
if raw is None:
return
if not isinstance(raw, dict):
raise _invalid("Function assignment governance defaults must be an object.")
if "delegation_allowed" in raw and not isinstance(
raw.get("delegation_allowed"), bool
):
raise _invalid("delegation_allowed must be true or false.")
for key, minimum, maximum in (
("maximum_delegation_depth", 1, 20),
("maximum_delegated_validity_days", 1, 3650),
):
value = raw.get(key)
if value is None:
continue
if not isinstance(value, int) or isinstance(value, bool) or not minimum <= value <= maximum:
raise _invalid(f"{key} must be between {minimum} and {maximum}.")
escalation = raw.get("escalation")
if escalation is None:
return
if not isinstance(escalation, dict):
raise _invalid("Escalation defaults must be an object.")
unsupported = set(escalation) - {"holder", "authority", "recipient"}
if unsupported:
raise _invalid(
"Unsupported escalation review step: " + ", ".join(sorted(unsupported))
)
for step, value in escalation.items():
if not isinstance(value, dict):
raise _invalid(f"The {step} escalation rule must be an object.")
target = value.get("target_function_id")
timeout = value.get("timeout_hours")
if not isinstance(target, str) or not target.strip():
raise _invalid(f"The {step} escalation target function is required.")
if (
not isinstance(timeout, int)
or isinstance(timeout, bool)
or not 1 <= timeout <= 8760
):
raise _invalid(f"The {step} escalation timeout must be 1 to 8760 hours.")
@router.patch("/settings", response_model=IdmSettingsItem)
def update_idm_settings(
payload: IdmSettingsUpdateRequest,
@@ -544,6 +588,16 @@ def update_idm_settings(
if "settings" in fields:
if payload.settings is None:
raise _invalid("Settings cannot be empty.")
_validate_function_governance_defaults(payload.settings)
defaults = payload.settings.get("function_assignment_governance_defaults")
escalation = defaults.get("escalation") if isinstance(defaults, dict) else None
if isinstance(escalation, dict):
for rule in escalation.values():
if isinstance(rule, dict):
_organization_function(
str(rule.get("target_function_id")),
tenant_id,
)
item.settings = payload.settings
session.flush()
result = _settings_item(item)
@@ -324,6 +324,10 @@ class FunctionAssignmentChangeItem(BaseModel):
workflow_current_step_id: str | None = None
resulting_assignment_id: str | None = None
expires_at: datetime | None = None
review_deadline_at: datetime | None = None
escalated_at: datetime | None = None
escalation_from_state: str | None = None
escalation_target_function_id: str | None = None
outcome_reason: str | None = None
resource_revision: int
etag: str
@@ -355,5 +359,9 @@ class FunctionAssignmentCapabilityItem(BaseModel):
evidence_required: bool = False
recipient_acceptance_required: bool = False
maximum_validity_days: int | None = None
delegation_allowed: bool = False
maximum_delegation_depth: int = 0
maximum_delegated_validity_days: int | None = None
escalation_rules: list[dict[str, Any]] = Field(default_factory=list)
workflow_available: bool = False
policy_available: bool = False