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
+23 -5
View File
@@ -53,8 +53,13 @@ creates them:
assignment or represented account. assignment or represented account.
The Organizations function must permit the requested mode. The source and The Organizations function must permit the requested mode. The source and
derived assignments must belong to the same tenant and function, and the derived assignments must belong to the same tenant, function, and unit scope.
source must be current and active. An actor also needs the assignment-write IDM walks the complete source chain at submission, every decision, recovery,
and final application. Cycles, missing or inactive sources, expired windows,
child windows outside their source, and chains beyond the current Policy depth
ceiling fail closed with a specific explanation. Tightening Policy therefore
invalidates a formerly acceptable route; captured submission authority is
evidence, not a future permission grant. An actor also needs the assignment-write
scope; where a governance profile is enabled, Policy must authorize the scope; where a governance profile is enabled, Policy must authorize the
request/grant or an administrator must use the recorded emergency-override request/grant or an administrator must use the recorded emergency-override
path. Validity windows make substitutions expire automatically. Deactivation path. Validity windows make substitutions expire automatically. Deactivation
@@ -96,7 +101,7 @@ IDM persists one function-assignment change aggregate for both journeys:
Candidate states are `draft`, `submitted`, `awaiting_holder`, Candidate states are `draft`, `submitted`, `awaiting_holder`,
`awaiting_authority`, `awaiting_recipient`, `changes_requested`, `blocked`, `awaiting_authority`, `awaiting_recipient`, `changes_requested`, `blocked`,
`approved`, `accepted`, `applied`, `rejected`, `withdrawn`, `expired`, `escalated`, `approved`, `accepted`, `applied`, `rejected`, `withdrawn`, `expired`,
`cancelled`, and `failed_manual_review`. Not every profile uses every state. `cancelled`, and `failed_manual_review`. Not every profile uses every state.
The workflow instance coordinates the process, but the IDM change record is the The workflow instance coordinates the process, but the IDM change record is the
@@ -133,7 +138,14 @@ override only the corresponding defaults. Supported keys include
`request_profile`, `grant_profile`, `authority_function_id`, `request_profile`, `grant_profile`, `authority_function_id`,
`recipient_acceptance_required`, `evidence_required`, `recipient_acceptance_required`, `evidence_required`,
`separation_of_duties`, `quorum`, `maximum_validity_days`, and `separation_of_duties`, `quorum`, `maximum_validity_days`, and
`request_expiry_hours`. Missing or malformed profiles fail closed. `request_expiry_hours`. Delegation uses `delegation_allowed`,
`maximum_delegation_depth`, and `maximum_delegated_validity_days`. The optional
`escalation` object has `holder`, `authority`, or `recipient` entries; each entry
requires an exact `target_function_id` and a bounded `timeout_hours`. Missing or
malformed profiles and half-configured escalation rules fail closed. Tenant
administrators can edit these defaults in the IDM governance panel, while a
function-specific Organizations setting may only tighten or deliberately
override the corresponding default with visible Policy provenance.
IDM exposes governed changes at IDM exposes governed changes at
`/api/v1/idm/function-assignment-changes`. Mutations require a strong `If-Match` `/api/v1/idm/function-assignment-changes`. Mutations require a strong `If-Match`
@@ -141,7 +153,13 @@ precondition and the aggregate revision. Requests and grants pin the exact
Workflow definition revision and hash, retain append-only transition evidence, Workflow definition revision and hash, retain append-only transition evidence,
support review change requests and responses, and apply an assignment exactly support review change requests and responses, and apply an assignment exactly
once. Vacant holder or authority functions create a visible `blocked` state; once. Vacant holder or authority functions create a visible `blocked` state;
the lifecycle worker expires overdue open changes durably. the lifecycle worker expires overdue open changes durably. When a configured
review deadline elapses, the worker atomically changes the aggregate to
`escalated`, retains the original review state and exact target function,
notifies the participants and target holders, and records change, Platform
Event, and Audit evidence. It never marks the review complete. A current holder
of that explicit target must make a normal revision-checked decision. Recovery
rechecks the current route and cannot manufacture an approver.
Direct administration remains available for independently deployed IDM. When Direct administration remains available for independently deployed IDM. When
a function has an enabled governance profile, however, direct create or update a function has an enabled governance profile, however, direct create or update
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-idm" name = "govoplan-idm"
version = "0.1.19" version = "0.1.20"
description = "GovOPlaN identity management bridge module." description = "GovOPlaN identity management bridge module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.29",
"govoplan-identity>=0.1.18", "govoplan-identity>=0.1.18",
"govoplan-organizations>=0.1.18", "govoplan-organizations>=0.1.18",
] ]
@@ -244,6 +244,10 @@ def _change_item(
workflow_current_step_id=change.workflow_current_step_id, workflow_current_step_id=change.workflow_current_step_id,
resulting_assignment_id=change.resulting_assignment_id, resulting_assignment_id=change.resulting_assignment_id,
expires_at=change.expires_at, 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, outcome_reason=change.outcome_reason,
resource_revision=change.resource_revision, resource_revision=change.resource_revision,
etag=change.strong_etag, etag=change.strong_etag,
@@ -297,6 +301,18 @@ def _record_change_audit(
"workflow_instance_id": change.workflow_instance_id, "workflow_instance_id": change.workflow_instance_id,
"evidence": list(change.evidence), "evidence": list(change.evidence),
"resulting_assignment_id": change.resulting_assignment_id, "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, "resource_revision": change.resource_revision,
}, },
commit=False, commit=False,
@@ -370,6 +386,18 @@ def get_function_assignment_capability(
decision and decision.recipient_acceptance_required decision and decision.recipient_acceptance_required
), ),
maximum_validity_days=(decision.maximum_validity_days if decision else None), 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), workflow_available=registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION),
policy_available=registry.has_capability( policy_available=registry.has_capability(
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE 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) 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) @router.patch("/settings", response_model=IdmSettingsItem)
def update_idm_settings( def update_idm_settings(
payload: IdmSettingsUpdateRequest, payload: IdmSettingsUpdateRequest,
@@ -544,6 +588,16 @@ def update_idm_settings(
if "settings" in fields: if "settings" in fields:
if payload.settings is None: if payload.settings is None:
raise _invalid("Settings cannot be empty.") 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 item.settings = payload.settings
session.flush() session.flush()
result = _settings_item(item) result = _settings_item(item)
@@ -324,6 +324,10 @@ class FunctionAssignmentChangeItem(BaseModel):
workflow_current_step_id: str | None = None workflow_current_step_id: str | None = None
resulting_assignment_id: str | None = None resulting_assignment_id: str | None = None
expires_at: datetime | 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 outcome_reason: str | None = None
resource_revision: int resource_revision: int
etag: str etag: str
@@ -355,5 +359,9 @@ class FunctionAssignmentCapabilityItem(BaseModel):
evidence_required: bool = False evidence_required: bool = False
recipient_acceptance_required: bool = False recipient_acceptance_required: bool = False
maximum_validity_days: int | None = None 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 workflow_available: bool = False
policy_available: bool = False policy_available: bool = False
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import func from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.events import ( from govoplan_core.core.events import (
@@ -12,6 +12,7 @@ from govoplan_core.core.events import (
PlatformEvent, PlatformEvent,
emit_platform_event, emit_platform_event,
) )
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.principal_cache import invalidate_auth_principals from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.core.notifications import ( from govoplan_core.core.notifications import (
NotificationDispatchRequest, NotificationDispatchRequest,
@@ -104,6 +105,12 @@ class SqlIdmAssignmentLifecycle:
resource_type="organization_function_assignment_expiry", resource_type="organization_function_assignment_expiry",
resource_id=touched_tenant_id, resource_id=touched_tenant_id,
) )
escalated_change_ids = self._escalate_due_changes(
session,
tenant_id=tenant_id,
effective_at=now,
limit=limit,
)
expired_change_ids = self._expire_open_changes( expired_change_ids = self._expire_open_changes(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -122,10 +129,159 @@ class SqlIdmAssignmentLifecycle:
"assignment_ids": expired_ids, "assignment_ids": expired_ids,
"expired_changes": len(expired_change_ids), "expired_changes": len(expired_change_ids),
"change_ids": expired_change_ids, "change_ids": expired_change_ids,
"escalated_changes": len(escalated_change_ids),
"escalated_change_ids": escalated_change_ids,
"expired_relationships": len(expired_relationship_ids), "expired_relationships": len(expired_relationship_ids),
"relationship_ids": expired_relationship_ids, "relationship_ids": expired_relationship_ids,
} }
def _escalate_due_changes(
self,
session: Session,
*,
tenant_id: str | None,
effective_at: datetime,
limit: int,
) -> list[str]:
review_states = (
"awaiting_holder",
"awaiting_authority",
"awaiting_recipient",
)
query = session.query(IdmFunctionAssignmentChange).filter(
IdmFunctionAssignmentChange.state.in_(review_states),
IdmFunctionAssignmentChange.review_deadline_at.is_not(None),
IdmFunctionAssignmentChange.review_deadline_at <= effective_at,
IdmFunctionAssignmentChange.escalated_at.is_(None),
IdmFunctionAssignmentChange.escalation_target_function_id.is_not(None),
)
if tenant_id is not None:
query = query.filter(IdmFunctionAssignmentChange.tenant_id == tenant_id)
candidates = (
query.order_by(
IdmFunctionAssignmentChange.review_deadline_at.asc(),
IdmFunctionAssignmentChange.id.asc(),
)
.limit(limit)
.all()
)
escalated_ids: list[str] = []
for change in candidates:
previous_state = change.state
deadline = change.review_deadline_at
target_function_id = change.escalation_target_function_id
claimed = (
session.query(IdmFunctionAssignmentChange)
.filter(
IdmFunctionAssignmentChange.id == change.id,
IdmFunctionAssignmentChange.state == previous_state,
IdmFunctionAssignmentChange.review_deadline_at == deadline,
IdmFunctionAssignmentChange.review_deadline_at <= effective_at,
IdmFunctionAssignmentChange.escalated_at.is_(None),
IdmFunctionAssignmentChange.escalation_target_function_id
== target_function_id,
)
.update(
{
IdmFunctionAssignmentChange.state: "escalated",
IdmFunctionAssignmentChange.escalated_at: effective_at,
IdmFunctionAssignmentChange.escalation_from_state: previous_state,
IdmFunctionAssignmentChange.review_deadline_at: None,
IdmFunctionAssignmentChange.outcome_reason: (
"The review deadline elapsed; the change is explicitly "
"escalated to the configured target function."
),
IdmFunctionAssignmentChange.resource_revision: (
IdmFunctionAssignmentChange.resource_revision + 1
),
},
synchronize_session=False,
)
)
if claimed != 1:
continue
session.refresh(change)
sequence = (
int(
session.scalar(
select(func.max(IdmFunctionAssignmentChangeEvent.sequence)).where(
IdmFunctionAssignmentChangeEvent.change_id == change.id
)
)
or 0
)
+ 1
)
session.add(
IdmFunctionAssignmentChangeEvent(
tenant_id=change.tenant_id,
change_id=change.id,
sequence=sequence,
action="escalated",
from_state=previous_state,
to_state="escalated",
policy_decision=dict(change.policy_decision),
workflow_step_id=change.workflow_current_step_id,
details={
"review_deadline_at": (
deadline.isoformat() if deadline is not None else None
),
"effective_at": effective_at.isoformat(),
"target_function_id": target_function_id,
"automatic_approver_substitution": False,
},
created_at=effective_at,
)
)
emit_platform_event(
session,
PlatformEvent(
type="idm.function_change.escalated.v1",
module_id="idm",
payload={
"kind": change.kind,
"state": change.state,
"from_state": previous_state,
"function_id": change.function_id,
"target_function_id": target_function_id,
"resource_revision": change.resource_revision,
},
actor=EventActorRef(type="system"),
tenant=EventTenantRef(id=change.tenant_id),
subject=EventObjectRef(
type="organization_function",
id=change.function_id,
),
resource=EventObjectRef(
type="function_assignment_change",
id=change.id,
),
classification="internal",
),
)
audit_event(
session,
tenant_id=change.tenant_id,
action="idm.function_assignment_change.escalated",
object_type="function_assignment_change",
object_id=change.id,
details={
"from_state": previous_state,
"to_state": "escalated",
"review_deadline_at": (
deadline.isoformat() if deadline is not None else None
),
"target_function_id": target_function_id,
"resource_revision": change.resource_revision,
"automatic_approver_substitution": False,
},
correlation_id=change.id,
commit=False,
)
self._notify_escalation(session, change)
escalated_ids.append(change.id)
return escalated_ids
@staticmethod @staticmethod
def _expire_relationships( def _expire_relationships(
session: Session, session: Session,
@@ -344,5 +500,68 @@ class SqlIdmAssignmentLifecycle:
), ),
) )
def _notify_escalation(
self,
session: Session,
change: IdmFunctionAssignmentChange,
) -> None:
provider = notification_dispatch_provider(self._registry)
if provider is None:
return
recipients = {
change.initiator_account_id,
change.candidate_account_id,
}
target = change.escalation_target_function_id
if target:
effective_at = change.escalated_at or utc_now()
recipients.update(
item
for item in session.scalars(
select(IdmOrganizationFunctionAssignment.account_id).where(
IdmOrganizationFunctionAssignment.tenant_id
== change.tenant_id,
IdmOrganizationFunctionAssignment.function_id == target,
IdmOrganizationFunctionAssignment.account_id.is_not(None),
IdmOrganizationFunctionAssignment.is_active.is_(True),
or_(
IdmOrganizationFunctionAssignment.valid_from.is_(None),
IdmOrganizationFunctionAssignment.valid_from
<= effective_at,
),
or_(
IdmOrganizationFunctionAssignment.valid_until.is_(None),
IdmOrganizationFunctionAssignment.valid_until
> effective_at,
),
)
)
if item
)
for account_id in sorted(item for item in recipients if item):
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=change.tenant_id,
source_module="idm",
source_resource_type="function_assignment_change",
source_resource_id=change.id,
event_kind="function_assignment_change.escalated",
recipient_type="account",
recipient_id=account_id,
subject="Function assignment review escalated",
body_text=(
"The configured review deadline elapsed. The change is "
"visibly escalated and still requires an explicit decision."
),
action_url=f"/idm?change={change.id}",
payload={
"change_id": change.id,
"state": "escalated",
"target_function_id": target,
},
),
)
__all__ = ["SqlIdmAssignmentLifecycle"] __all__ = ["SqlIdmAssignmentLifecycle"]
+12
View File
@@ -294,6 +294,18 @@ class IdmFunctionAssignmentChange(Base, TimestampMixin):
expires_at: Mapped[datetime | None] = mapped_column( expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
review_deadline_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
escalated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
escalation_from_state: Mapped[str | None] = mapped_column(
String(40), nullable=True
)
escalation_target_function_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
outcome_reason: Mapped[str | None] = mapped_column(Text, nullable=True) outcome_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column( metadata_: Mapped[dict[str, Any]] = mapped_column(
@@ -0,0 +1,352 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
from govoplan_core.security.time import utc_now
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
@dataclass(frozen=True, slots=True)
class DelegationRoute:
effective: bool
code: str
reason: str | None = None
assignment_id: str | None = None
chain_assignment_ids: tuple[str, ...] = ()
delegation_depth: int = 0
def to_dict(self) -> dict[str, object]:
return {
"effective": self.effective,
"code": self.code,
"reason": self.reason,
"assignment_id": self.assignment_id,
"chain_assignment_ids": list(self.chain_assignment_ids),
"delegation_depth": self.delegation_depth,
}
def resolve_actor_function_route(
session: Session,
*,
tenant_id: str,
function_id: str,
account_id: str | None,
identity_id: str | None,
decision: FunctionAssignmentGovernanceDecision,
effective_at: datetime | None = None,
) -> DelegationRoute:
clauses = []
if account_id:
clauses.append(IdmOrganizationFunctionAssignment.account_id == account_id)
if identity_id:
clauses.append(IdmOrganizationFunctionAssignment.identity_id == identity_id)
if not clauses:
return DelegationRoute(
False,
"identity_unavailable",
"The actor has no resolvable account or identity for this route.",
)
candidates = list(
session.scalars(
select(IdmOrganizationFunctionAssignment)
.where(
IdmOrganizationFunctionAssignment.tenant_id == tenant_id,
IdmOrganizationFunctionAssignment.function_id == function_id,
or_(*clauses),
)
.order_by(
IdmOrganizationFunctionAssignment.is_active.desc(),
IdmOrganizationFunctionAssignment.updated_at.desc(),
IdmOrganizationFunctionAssignment.id.asc(),
)
)
)
if not candidates:
return DelegationRoute(
False,
"vacant",
"No function assignment connects the actor to this review route.",
)
failures: list[DelegationRoute] = []
for candidate in candidates:
route = validate_delegation_chain(
session,
assignment=candidate,
tenant_id=tenant_id,
function_id=function_id,
decision=decision,
effective_at=effective_at,
)
if route.effective:
return route
failures.append(route)
return _preferred_failure(failures)
def resolve_function_route_availability(
session: Session,
*,
tenant_id: str,
function_id: str,
decision: FunctionAssignmentGovernanceDecision,
effective_at: datetime | None = None,
) -> DelegationRoute:
candidates = list(
session.scalars(
select(IdmOrganizationFunctionAssignment)
.where(
IdmOrganizationFunctionAssignment.tenant_id == tenant_id,
IdmOrganizationFunctionAssignment.function_id == function_id,
)
.order_by(
IdmOrganizationFunctionAssignment.is_active.desc(),
IdmOrganizationFunctionAssignment.updated_at.desc(),
IdmOrganizationFunctionAssignment.id.asc(),
)
)
)
if not candidates:
return DelegationRoute(
False,
"vacant",
"The designated function is vacant.",
)
failures: list[DelegationRoute] = []
for candidate in candidates:
route = validate_delegation_chain(
session,
assignment=candidate,
tenant_id=tenant_id,
function_id=function_id,
decision=decision,
effective_at=effective_at,
)
if route.effective:
return route
failures.append(route)
return _preferred_failure(failures)
def validate_delegation_chain(
session: Session,
*,
assignment: IdmOrganizationFunctionAssignment,
tenant_id: str,
function_id: str,
decision: FunctionAssignmentGovernanceDecision,
effective_at: datetime | None = None,
) -> DelegationRoute:
now = _aware(effective_at or utc_now())
current = assignment
visited: set[str] = set()
chain: list[str] = []
delegation_depth = 0
while True:
if current.id in visited:
return _failure(
"cyclic",
"The effective delegation route is cyclic and cannot authorize this action.",
assignment,
chain,
delegation_depth,
)
visited.add(current.id)
chain.append(current.id)
if current.tenant_id != tenant_id:
return _failure(
"tenant_mismatch",
"The delegation route crosses a tenant boundary.",
assignment,
chain,
delegation_depth,
)
if current.function_id != function_id:
return _failure(
"function_mismatch",
"The delegation route changes organization function.",
assignment,
chain,
delegation_depth,
)
if not current.is_active:
return _failure(
"unavailable",
"A function assignment in the delegation route is no longer active.",
assignment,
chain,
delegation_depth,
)
if current.valid_from is not None and _aware(current.valid_from) > now:
return _failure(
"not_yet_effective",
"A function assignment in the delegation route is not yet effective.",
assignment,
chain,
delegation_depth,
)
if current.valid_until is not None and _aware(current.valid_until) <= now:
return _failure(
"expired",
"A function assignment in the delegation route has expired.",
assignment,
chain,
delegation_depth,
)
source_id = current.delegated_from_assignment_id
if source_id is None:
if current.source in {"delegated", "acting_for"}:
return _failure(
"source_unavailable",
"A derived function assignment has no available source assignment.",
assignment,
chain,
delegation_depth,
)
return DelegationRoute(
True,
"effective",
assignment_id=assignment.id,
chain_assignment_ids=tuple(chain),
delegation_depth=delegation_depth,
)
if current.source not in {"delegated", "acting_for"}:
return _failure(
"source_mismatch",
"Only delegated or acting-for assignments may extend an assignment route.",
assignment,
chain,
delegation_depth,
)
if current.source == "delegated":
delegation_depth += 1
if not decision.delegation_allowed:
return _failure(
"policy_tightened",
"The current Policy no longer permits delegated authority.",
assignment,
chain,
delegation_depth,
)
if delegation_depth > decision.maximum_delegation_depth:
return _failure(
"over_depth",
"The delegation route exceeds the current Policy depth ceiling.",
assignment,
chain,
delegation_depth,
)
if (
decision.maximum_delegated_validity_days is not None
and current.valid_until is not None
):
start = _aware(current.valid_from) if current.valid_from else now
ceiling = start + timedelta(
days=decision.maximum_delegated_validity_days
)
if _aware(current.valid_until) > ceiling:
return _failure(
"policy_tightened",
"The delegated validity window exceeds the current Policy ceiling.",
assignment,
chain,
delegation_depth,
)
parent = session.get(IdmOrganizationFunctionAssignment, source_id)
if parent is None:
return _failure(
"source_unavailable",
"A source assignment in the delegation route is unavailable.",
assignment,
chain,
delegation_depth,
)
if parent.organization_unit_id != current.organization_unit_id:
return _failure(
"scope_mismatch",
"The delegation route changes organization-unit scope.",
assignment,
chain,
delegation_depth,
)
if (
parent.valid_from is not None
and (
current.valid_from is None
or _aware(current.valid_from) < _aware(parent.valid_from)
)
):
return _failure(
"validity_outside_source",
"A derived assignment starts before its source assignment.",
assignment,
chain,
delegation_depth,
)
if (
parent.valid_until is not None
and (
current.valid_until is None
or _aware(current.valid_until) > _aware(parent.valid_until)
)
):
return _failure(
"validity_outside_source",
"A derived assignment outlives its source assignment.",
assignment,
chain,
delegation_depth,
)
current = parent
def _preferred_failure(failures: list[DelegationRoute]) -> DelegationRoute:
priority = {
"cyclic": 0,
"over_depth": 1,
"policy_tightened": 2,
"validity_outside_source": 3,
"expired": 4,
"unavailable": 5,
"not_yet_effective": 6,
}
return min(failures, key=lambda item: priority.get(item.code, 20))
def _failure(
code: str,
reason: str,
assignment: IdmOrganizationFunctionAssignment,
chain: list[str],
depth: int,
) -> DelegationRoute:
return DelegationRoute(
False,
code,
reason,
assignment.id,
tuple(chain),
depth,
)
def _aware(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = [
"DelegationRoute",
"resolve_actor_function_route",
"resolve_function_route_availability",
"validate_delegation_chain",
]
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from datetime import timedelta from datetime import datetime, timedelta
import hashlib import hashlib
import json import json
@@ -49,6 +49,12 @@ from govoplan_idm.backend.db.models import (
IdmTenantSettings, IdmTenantSettings,
new_uuid, new_uuid,
) )
from govoplan_idm.backend.delegation_routes import (
DelegationRoute,
resolve_actor_function_route,
resolve_function_route_availability,
validate_delegation_chain,
)
OPEN_STATES = { OPEN_STATES = {
@@ -59,6 +65,7 @@ OPEN_STATES = {
"changes_requested", "changes_requested",
"blocked", "blocked",
"failed_manual_review", "failed_manual_review",
"escalated",
} }
TERMINAL_STATES = { TERMINAL_STATES = {
"applied", "applied",
@@ -116,17 +123,39 @@ def resolve_submission_capability(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
function=function, function=function,
) )
authority_function_id = _authority_function_id(settings) base_context = _base_actor_context(
context = _actor_context(
session,
principal=principal, principal=principal,
function_id=function.id,
authority_function_id=authority_function_id,
candidate_identity_id=candidate_identity_id, candidate_identity_id=candidate_identity_id,
candidate_account_id=candidate_account_id, candidate_account_id=candidate_account_id,
initiator_account_id=principal.account_id, initiator_account_id=principal.account_id,
has_evidence=has_evidence, 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( decision = policy.resolve_function_assignment_action(
session, session,
request=FunctionAssignmentGovernanceRequest( request=FunctionAssignmentGovernanceRequest(
@@ -192,15 +221,25 @@ def create_function_assignment_change(
decision.reason or "The function assignment change is not allowed." decision.reason or "The function assignment change is not allowed."
) )
_validate_requested_validity(payload, decision) _validate_requested_validity(payload, decision)
assignment_source = str(getattr(payload, "assignment_source", "governance"))
represented_assignment_id = _text( represented_assignment_id = _text(
getattr(payload, "represented_assignment_id", None) 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: if represented_assignment_id is not None:
_require_actor_assignment( _require_actor_assignment(
session, session,
principal=principal, principal=principal,
assignment_id=represented_assignment_id, assignment_id=represented_assignment_id,
function_id=function.id, function_id=function.id,
decision=decision,
) )
now = utc_now() now = utc_now()
change = IdmFunctionAssignmentChange( change = IdmFunctionAssignmentChange(
@@ -221,7 +260,7 @@ def create_function_assignment_change(
requested_valid_from=getattr(payload, "requested_valid_from", None), requested_valid_from=getattr(payload, "requested_valid_from", None),
requested_valid_until=getattr(payload, "requested_valid_until", None), requested_valid_until=getattr(payload, "requested_valid_until", None),
applies_to_subunits=bool(getattr(payload, "applies_to_subunits", False)), 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), required_steps=list(decision.required_steps),
completed_steps=[], completed_steps=[],
policy_decision=decision.to_dict(), policy_decision=decision.to_dict(),
@@ -268,19 +307,22 @@ def create_function_assignment_change(
raise FunctionAssignmentChangeUnavailable(str(exc)) from exc raise FunctionAssignmentChangeUnavailable(str(exc)) from exc
_pin_workflow(change, workflow_ref) _pin_workflow(change, workflow_ref)
next_step = _next_required_step(change) 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: if blocked_reason is not None:
change.state = "blocked" change.state = "blocked"
change.outcome_reason = blocked_reason change.outcome_reason = blocked_reason
_clear_review_route(change)
elif next_step is None: elif next_step is None:
_apply_assignment( _apply_assignment(
session, session,
change=change, change=change,
principal=principal, principal=principal,
registry=registry, registry=registry,
function=function,
) )
else: else:
change.state = STEP_STATE[next_step] change.state = STEP_STATE[next_step]
_set_review_route(change, decision=decision, step=next_step, now=now)
session.add(change) session.add(change)
session.flush() session.flush()
_append_event( _append_event(
@@ -332,6 +374,7 @@ def transition_function_assignment_change(
raise FunctionAssignmentChangeUnavailable( raise FunctionAssignmentChangeUnavailable(
decision.reason or "This transition is not allowed." decision.reason or "This transition is not allowed."
) )
change.policy_decision = decision.to_dict()
if ( if (
decision.separation_of_duties decision.separation_of_duties
and action in {"approve", "accept"} and action in {"approve", "accept"}
@@ -372,7 +415,8 @@ def transition_function_assignment_change(
_record_step_approval( _record_step_approval(
change, change,
step=_current_required_step(change), step=_current_required_step(change),
actor_id=principal.account_id, principal=principal,
decision=decision,
) )
if _step_approval_count(change, _current_required_step(change)) >= ( if _step_approval_count(change, _current_required_step(change)) >= (
1 if action == "accept" else decision.quorum 1 if action == "accept" else decision.quorum
@@ -382,11 +426,11 @@ def transition_function_assignment_change(
principal=principal, principal=principal,
registry=registry, registry=registry,
change=change, change=change,
function=function,
decision=decision,
comment=comment, comment=comment,
evidence=evidence, evidence=evidence,
) )
else:
change.policy_decision = decision.to_dict()
elif action == "request_changes": elif action == "request_changes":
_request_changes( _request_changes(
session, session,
@@ -412,6 +456,12 @@ def transition_function_assignment_change(
**dict(change.metadata_), **dict(change.metadata_),
"last_response": comment.strip(), "last_response": comment.strip(),
} }
_set_review_route(
change,
decision=decision,
step=_current_required_step(change),
now=utc_now(),
)
elif action == "recover": elif action == "recover":
change.outcome_reason = None change.outcome_reason = None
_resume_change( _resume_change(
@@ -419,6 +469,8 @@ def transition_function_assignment_change(
principal=principal, principal=principal,
registry=registry, registry=registry,
change=change, change=change,
function=function,
decision=decision,
) )
else: else:
raise FunctionAssignmentChangeConflict( raise FunctionAssignmentChangeConflict(
@@ -457,7 +509,7 @@ def available_change_actions(
return [], f"The change is {change.state}." return [], f"The change is {change.state}."
candidates = ( candidates = (
["approve", "request_changes", "reject", "withdraw"] ["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"] else ["accept", "request_changes", "reject", "withdraw"]
if change.state == "awaiting_recipient" if change.state == "awaiting_recipient"
else ["respond", "withdraw"] else ["respond", "withdraw"]
@@ -520,6 +572,9 @@ def visible_change_filter(
IdmFunctionAssignmentChange.policy_decision["authority_function_id"] IdmFunctionAssignmentChange.policy_decision["authority_function_id"]
.as_string() .as_string()
.in_(actor_function_ids), .in_(actor_function_ids),
IdmFunctionAssignmentChange.escalation_target_function_id.in_(
actor_function_ids
),
) )
) )
return or_(*clauses) return or_(*clauses)
@@ -545,17 +600,40 @@ def _resolve_transition_decision(
tenant_id=change.tenant_id, tenant_id=change.tenant_id,
function=function, function=function,
) )
authority_function_id = _authority_function_id(settings) base_context = _base_actor_context(
context = _actor_context(
session,
principal=principal, principal=principal,
function_id=change.function_id,
authority_function_id=authority_function_id,
candidate_identity_id=change.candidate_identity_id, candidate_identity_id=change.candidate_identity_id,
candidate_account_id=change.candidate_account_id, candidate_account_id=change.candidate_account_id,
initiator_account_id=change.initiator_account_id, initiator_account_id=change.initiator_account_id,
has_evidence=has_evidence, 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 context["approvals_complete"] = _next_required_step(change) is None
return policy.resolve_function_assignment_action( return policy.resolve_function_assignment_action(
session, session,
@@ -580,6 +658,8 @@ def _complete_current_step(
principal: ApiPrincipal, principal: ApiPrincipal,
registry: object | None, registry: object | None,
change: IdmFunctionAssignmentChange, change: IdmFunctionAssignmentChange,
function: OrganizationFunctionRef,
decision: FunctionAssignmentGovernanceDecision,
comment: str | None, comment: str | None,
evidence: Sequence[str], evidence: Sequence[str],
) -> None: ) -> None:
@@ -620,18 +700,30 @@ def _complete_current_step(
change=change, change=change,
principal=principal, principal=principal,
registry=registry, registry=registry,
function=function,
) )
except FunctionAssignmentChangeConflict as exc: except FunctionAssignmentChangeConflict as exc:
change.state = "failed_manual_review" change.state = "failed_manual_review"
change.outcome_reason = str(exc) change.outcome_reason = str(exc)
else: else:
blocked_reason = _missing_reviewer_reason(session, change) blocked_reason = _missing_reviewer_reason(
session,
change,
decision=decision,
)
if blocked_reason is not None: if blocked_reason is not None:
change.state = "blocked" change.state = "blocked"
change.outcome_reason = blocked_reason change.outcome_reason = blocked_reason
_clear_review_route(change)
else: else:
change.state = STEP_STATE[next_step] change.state = STEP_STATE[next_step]
change.outcome_reason = None change.outcome_reason = None
_set_review_route(
change,
decision=decision,
step=next_step,
now=utc_now(),
)
def _finish_negative_transition( def _finish_negative_transition(
@@ -667,6 +759,7 @@ def _finish_negative_transition(
_pin_workflow(change, reference) _pin_workflow(change, reference)
change.state = "rejected" if action == "reject" else "withdrawn" change.state = "rejected" if action == "reject" else "withdrawn"
change.outcome_reason = comment change.outcome_reason = comment
_clear_review_route(change)
def _request_changes( def _request_changes(
@@ -685,7 +778,11 @@ def _request_changes(
raise FunctionAssignmentChangeUnavailable( raise FunctionAssignmentChangeUnavailable(
"The pinned Workflow instance is unavailable." "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( reference = workflow.resolve_current_step(
session, session,
principal, principal,
@@ -707,6 +804,7 @@ def _request_changes(
**dict(change.metadata_), **dict(change.metadata_),
"resume_state": previous_state, "resume_state": previous_state,
} }
_clear_review_route(change)
def _resume_change( def _resume_change(
@@ -715,6 +813,8 @@ def _resume_change(
principal: ApiPrincipal, principal: ApiPrincipal,
registry: object | None, registry: object | None,
change: IdmFunctionAssignmentChange, change: IdmFunctionAssignmentChange,
function: OrganizationFunctionRef,
decision: FunctionAssignmentGovernanceDecision,
) -> None: ) -> None:
workflow = workflow_orchestration_provider(registry) workflow = workflow_orchestration_provider(registry)
if workflow is None or change.workflow_instance_id is None: if workflow is None or change.workflow_instance_id is None:
@@ -735,13 +835,24 @@ def _resume_change(
) )
_pin_workflow(change, reference) _pin_workflow(change, reference)
step = _next_required_step(change) 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: if blocked_reason is not None:
change.state = "blocked" change.state = "blocked"
change.outcome_reason = blocked_reason change.outcome_reason = blocked_reason
_clear_review_route(change)
elif step is not None: elif step is not None:
change.state = STEP_STATE[step] change.state = STEP_STATE[step]
change.outcome_reason = None change.outcome_reason = None
_set_review_route(
change,
decision=decision,
step=step,
now=utc_now(),
)
else: else:
try: try:
_apply_assignment( _apply_assignment(
@@ -749,6 +860,7 @@ def _resume_change(
change=change, change=change,
principal=principal, principal=principal,
registry=registry, registry=registry,
function=function,
) )
except FunctionAssignmentChangeConflict as exc: except FunctionAssignmentChangeConflict as exc:
change.state = "failed_manual_review" 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( def _apply_assignment(
session: Session, session: Session,
*, *,
change: IdmFunctionAssignmentChange, change: IdmFunctionAssignmentChange,
principal: ApiPrincipal, principal: ApiPrincipal,
registry: object | None, registry: object | None,
function: OrganizationFunctionRef,
) -> None: ) -> None:
if change.resulting_assignment_id: if change.resulting_assignment_id:
change.state = "applied" change.state = "applied"
return return
_recheck_application(
session,
change=change,
principal=principal,
registry=registry,
function=function,
)
existing = session.scalar( existing = session.scalar(
select(IdmOrganizationFunctionAssignment).where( select(IdmOrganizationFunctionAssignment).where(
IdmOrganizationFunctionAssignment.tenant_id == change.tenant_id, IdmOrganizationFunctionAssignment.tenant_id == change.tenant_id,
@@ -845,6 +1200,7 @@ def _apply_assignment(
change.resulting_assignment_id = assignment.id change.resulting_assignment_id = assignment.id
change.state = "applied" change.state = "applied"
change.outcome_reason = None change.outcome_reason = None
_clear_review_route(change)
emit_assignment_event( emit_assignment_event(
session, session,
assignment, assignment,
@@ -874,20 +1230,84 @@ def _actor_context(
principal: ApiPrincipal, principal: ApiPrincipal,
function_id: str, function_id: str,
authority_function_id: str | None, 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_identity_id: str,
candidate_account_id: str | None, candidate_account_id: str | None,
initiator_account_id: str, initiator_account_id: str,
has_evidence: bool, has_evidence: bool,
) -> dict[str, object]: ) -> dict[str, object]:
actor_assignments = _actor_assignments(session, principal)
return { return {
"actor_is_holder": any( "actor_is_holder": False,
item.function_id == function_id for item in actor_assignments "actor_is_authority": False,
), "actor_is_escalation_target": False,
"actor_is_authority": bool(authority_function_id)
and any(
item.function_id == authority_function_id for item in actor_assignments
),
"candidate_is_actor": ( "candidate_is_actor": (
principal.identity_id == candidate_identity_id principal.identity_id == candidate_identity_id
or ( or (
@@ -897,6 +1317,7 @@ def _actor_context(
), ),
"actor_is_initiator": principal.account_id == initiator_account_id, "actor_is_initiator": principal.account_id == initiator_account_id,
"has_evidence": has_evidence, "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( def _validate_requested_validity(
payload: object, payload: object,
decision: FunctionAssignmentGovernanceDecision, decision: FunctionAssignmentGovernanceDecision,
@@ -976,6 +1391,24 @@ def _validate_requested_validity(
f"Requested validity exceeds the Policy limit of " f"Requested validity exceeds the Policy limit of "
f"{decision.maximum_validity_days} days." 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( def _require_actor_assignment(
@@ -984,13 +1417,13 @@ def _require_actor_assignment(
principal: ApiPrincipal, principal: ApiPrincipal,
assignment_id: str, assignment_id: str,
function_id: str, function_id: str,
decision: FunctionAssignmentGovernanceDecision,
) -> None: ) -> None:
assignment = session.get(IdmOrganizationFunctionAssignment, assignment_id) assignment = session.get(IdmOrganizationFunctionAssignment, assignment_id)
if ( if (
assignment is None assignment is None
or assignment.tenant_id != principal.tenant_id or assignment.tenant_id != principal.tenant_id
or assignment.function_id != function_id or assignment.function_id != function_id
or not assignment.is_active
or ( or (
assignment.account_id != principal.account_id assignment.account_id != principal.account_id
and assignment.identity_id != principal.identity_id and assignment.identity_id != principal.identity_id
@@ -999,6 +1432,18 @@ def _require_actor_assignment(
raise FunctionAssignmentChangeUnavailable( raise FunctionAssignmentChangeUnavailable(
"The represented function assignment is not an effective assignment of the actor." "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: def _next_required_step(change: IdmFunctionAssignmentChange) -> str | None:
@@ -1018,11 +1463,38 @@ def _current_required_step(change: IdmFunctionAssignmentChange) -> str:
return step 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( def _governance_action(
change: IdmFunctionAssignmentChange, change: IdmFunctionAssignmentChange,
action: str, action: str,
) -> FunctionAssignmentGovernanceAction: ) -> FunctionAssignmentGovernanceAction:
if action == "approve": if action == "approve":
if change.state == "escalated":
return "approve_escalation"
step = _current_required_step(change) step = _current_required_step(change)
if step == "holder": if step == "holder":
return "approve_holder" return "approve_holder"
@@ -1054,15 +1526,48 @@ def _record_step_approval(
change: IdmFunctionAssignmentChange, change: IdmFunctionAssignmentChange,
*, *,
step: str, step: str,
actor_id: str, principal: ApiPrincipal,
decision: FunctionAssignmentGovernanceDecision,
) -> None: ) -> None:
approvals = dict(change.metadata_.get("step_approvals") or {}) approvals = dict(change.metadata_.get("step_approvals") or {})
actors = [str(item) for item in approvals.get(step, ())] records = list(approvals.get(step, ()))
if actor_id in actors: 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( raise FunctionAssignmentChangeConflict(
"This actor already approved the current governance step." "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} change.metadata_ = {**dict(change.metadata_), "step_approvals": approvals}
@@ -1228,6 +1733,14 @@ def _notification_recipients(
function_id=str(authority_id), 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)) return tuple(sorted(item for item in recipients if item))
@@ -1263,24 +1776,31 @@ def _function_holder_accounts(
def _missing_reviewer_reason( def _missing_reviewer_reason(
session: Session, session: Session,
change: IdmFunctionAssignmentChange, change: IdmFunctionAssignmentChange,
*,
decision: FunctionAssignmentGovernanceDecision,
) -> str | None: ) -> str | None:
step = _next_required_step(change) step = _next_required_step(change)
if step == "holder" and not _function_has_incumbent( if step == "holder":
route = resolve_function_route_availability(
session, session,
tenant_id=change.tenant_id, tenant_id=change.tenant_id,
function_id=change.function_id, function_id=change.function_id,
): decision=decision,
return "The function is vacant; no effective holder can review this change." )
if not route.effective:
return route.reason or "No effective holder can review this change."
if step == "authority": if step == "authority":
authority_id = _text(change.policy_decision.get("authority_function_id")) authority_id = decision.authority_function_id
if authority_id is None: if authority_id is None:
return "The effective Policy does not designate an authority function." return "The effective Policy does not designate an authority function."
if not _function_has_incumbent( route = resolve_function_route_availability(
session, session,
tenant_id=change.tenant_id, tenant_id=change.tenant_id,
function_id=authority_id, function_id=authority_id,
): decision=decision,
return "The designated authority function is vacant." )
if not route.effective:
return route.reason or "The designated authority function is unavailable."
if step == "recipient" and not ( if step == "recipient" and not (
change.candidate_account_id or change.candidate_identity_id change.candidate_account_id or change.candidate_identity_id
): ):
@@ -1288,32 +1808,6 @@ def _missing_reviewer_reason(
return None 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: def _request_fingerprint(payload: object) -> str:
if hasattr(payload, "model_dump"): if hasattr(payload, "model_dump"):
value = payload.model_dump(mode="json", exclude={"idempotency_key"}) value = payload.model_dump(mode="json", exclude={"idempotency_key"})
+13 -3
View File
@@ -56,7 +56,7 @@ from govoplan_idm.backend.workflow_definitions import (
from govoplan_idm.backend.search_source import create_idm_search_source from govoplan_idm.backend.search_source import create_idm_search_source
MODULE_VERSION = "0.1.19" MODULE_VERSION = "0.1.20"
IDM_READ_SCOPES = ( IDM_READ_SCOPES = (
"idm:organization_assignment:read", "idm:organization_assignment:read",
@@ -460,6 +460,7 @@ manifest = ModuleManifest(
body=( body=(
"Assignment links are high-impact because they can later feed access decisions. " "Assignment links are high-impact because they can later feed access decisions. "
"Tenants can enable recorded change requests for assignment create and update operations. " "Tenants can enable recorded change requests for assignment create and update operations. "
"They can also configure delegation-chain depth and validity ceilings plus explicit holder, authority, and recipient review escalation targets and deadlines. IDM rechecks complete routes against current Policy at each decision and final application. Elapsed deadlines become a visible escalated state with Notifications and Audit evidence; no approval is inferred. "
"A periodic worker emits one expiry event when a future-dated assignment elapses; the marker and event are committed together so retries remain idempotent. " "A periodic worker emits one expiry event when a future-dated assignment elapses; the marker and event are committed together so retries remain idempotent. "
"The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write." "The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write."
), ),
@@ -488,11 +489,14 @@ manifest = ModuleManifest(
"idm.function-change.request", "idm.function-change.request",
"idm.function-change.grant", "idm.function-change.grant",
"idm.function-change.decision", "idm.function-change.decision",
"idm.field.delegation-ceilings",
"idm.field.escalation",
], ],
"consequence_classes": { "consequence_classes": {
"governance_settings": "Changes whether direct assignment mutations require approved change evidence.", "governance_settings": "Changes whether direct assignment mutations require approved change evidence.",
"emergency_override": "Bypasses the normal governed request or grant path and requires retained reason and evidence.", "emergency_override": "Bypasses the normal governed request or grant path and requires retained reason and evidence.",
"function_decision": "Advances or terminates a governed change and retains actor, comment, policy, and workflow evidence.", "function_decision": "Advances or terminates a governed change and retains actor, comment, policy, and workflow evidence.",
"timed_escalation": "Records an overdue review and exact target function without substituting or completing an approval.",
}, },
}, },
order=27, order=27,
@@ -670,7 +674,10 @@ manifest = ModuleManifest(
"organizational reach. Deactivation and expiry preserve provenance while " "organizational reach. Deactivation and expiry preserve provenance while "
"removing the assignment from effective resolution. Governed request and " "removing the assignment from effective resolution. Governed request and "
"grant decisions retain actor, policy, workflow revision, comments, and " "grant decisions retain actor, policy, workflow revision, comments, and "
"evidence. An emergency override is not the normal process and must carry " "evidence. Delegated authority is rechecked across the complete source chain "
"against current depth and validity ceilings. A configured per-step timeout "
"creates a visible escalated state and exact target-function route; it never "
"substitutes or records an approver automatically. An emergency override is not the normal process and must carry "
"an explicit reason. An IDM assignment alone never grants application " "an explicit reason. An IDM assignment alone never grants application "
"permissions; Access requires an explicit mapping." "permissions; Access requires an explicit mapping."
), ),
@@ -699,11 +706,14 @@ manifest = ModuleManifest(
"idm.field.justification", "idm.field.justification",
"idm.field.evidence", "idm.field.evidence",
"idm.field.retention", "idm.field.retention",
"idm.field.delegation-ceilings",
"idm.field.escalation",
], ],
"consequence_classes": { "consequence_classes": {
"assignment": "Changes the effective institutional function fact consumed by optional downstream capabilities.", "assignment": "Changes the effective institutional function fact consumed by optional downstream capabilities.",
"deactivate_or_expire": "Removes the fact from effective resolution while retaining provenance and lifecycle evidence.", "deactivate_or_expire": "Removes the fact from effective resolution while retaining provenance and lifecycle evidence.",
"delegation": "Creates a bounded derived assignment that remains tied to its source assignment.", "delegation": "Creates a bounded derived assignment that remains tied to its source assignment.",
"escalation": "Routes an overdue review visibly to an exact configured function without completing the decision.",
"acting_for": "Allows a bounded account to act in place of a source assignment when Organizations permits it.", "acting_for": "Allows a bounded account to act in place of a source assignment when Organizations permits it.",
"retention": "Changes how long detailed assignment-change evidence remains available.", "retention": "Changes how long detailed assignment-change evidence remains available.",
}, },
@@ -717,7 +727,7 @@ manifest = ModuleManifest(
maturity="vertical_slice", maturity="vertical_slice",
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
test_ref="tests/test_assignment_workflow.py", test_ref="tests/test_assignment_workflow.py",
known_limits=("External directory provisioning and all authority-specific grant workflows are not reference-ready.",), known_limits=("External directory provisioning remains outside the reference workflow.",),
owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request", "typed group", "identity relationship"), owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request", "typed group", "identity relationship"),
non_owned_concepts=("identity", "organization function", "application role", "workflow runtime"), non_owned_concepts=("identity", "organization function", "application role", "workflow runtime"),
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"), recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
@@ -0,0 +1,69 @@
"""Add durable function-assignment review escalation state.
Revision ID: c2d3e4f5a6b7
Revises: b1c2d3e4f5a6
Create Date: 2026-08-22 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c2d3e4f5a6b7"
down_revision = "b1c2d3e4f5a6"
branch_labels = None
depends_on = None
def upgrade() -> None:
table = "idm_function_assignment_changes"
op.add_column(
table,
sa.Column("review_deadline_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
table,
sa.Column("escalated_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
table,
sa.Column("escalation_from_state", sa.String(length=40), nullable=True),
)
op.add_column(
table,
sa.Column(
"escalation_target_function_id",
sa.String(length=36),
nullable=True,
),
)
op.create_index(
op.f("ix_idm_function_assignment_changes_review_deadline_at"),
table,
["review_deadline_at"],
unique=False,
)
op.create_index(
op.f("ix_idm_function_assignment_changes_escalation_target_function_id"),
table,
["escalation_target_function_id"],
unique=False,
)
def downgrade() -> None:
table = "idm_function_assignment_changes"
op.drop_index(
op.f("ix_idm_function_assignment_changes_escalation_target_function_id"),
table_name=table,
)
op.drop_index(
op.f("ix_idm_function_assignment_changes_review_deadline_at"),
table_name=table,
)
op.drop_column(table, "escalation_target_function_id")
op.drop_column(table, "escalation_from_state")
op.drop_column(table, "escalated_at")
op.drop_column(table, "review_deadline_at")
+76
View File
@@ -208,6 +208,82 @@ class AssignmentExpiryTests(unittest.TestCase):
history = session.query(IdmFunctionAssignmentChangeEvent).all() history = session.query(IdmFunctionAssignmentChangeEvent).all()
self.assertEqual(["expired"], [item.action for item in history]) self.assertEqual(["expired"], [item.action for item in history])
def test_sweep_escalates_due_review_once_without_substituting_approval(self) -> None:
boundary = datetime(2026, 8, 22, 12, tzinfo=timezone.utc)
with self.database.session() as session:
session.add(
IdmFunctionAssignmentChange(
id="change-escalate",
tenant_id="tenant-1",
kind="grant",
state="awaiting_holder",
profile="holder_grant",
function_id="function-1",
organization_unit_id="unit-1",
candidate_identity_id="identity-1",
initiator_account_id="account-1",
justification="Timed governed review",
evidence=[],
assignment_source="governance",
required_steps=["holder"],
completed_steps=[],
policy_decision={
"escalation_rules": [
{
"step": "holder",
"target_function_id": "function-escalation",
"timeout_hours": 4,
}
]
},
idempotency_key="grant-escalate-1",
expires_at=boundary + timedelta(days=2),
review_deadline_at=boundary - timedelta(seconds=1),
escalation_target_function_id="function-escalation",
metadata_={"step_approvals": {}},
)
)
session.commit()
events: list[PlatformEvent] = []
audit_events: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("idm.function_change.escalated.v1", events.append)
bus.subscribe(
"idm.function_assignment_change.escalated",
audit_events.append,
)
with self.database.SessionLocal() as session, event_bus_context(bus):
result = self.lifecycle.process_expired(
session,
tenant_id="tenant-1",
effective_at=boundary,
)
session.commit()
repeated = self.lifecycle.process_expired(
session,
tenant_id="tenant-1",
effective_at=boundary,
)
session.commit()
self.assertEqual(["change-escalate"], result["escalated_change_ids"])
self.assertEqual(0, repeated["escalated_changes"])
self.assertEqual(1, len(events))
self.assertEqual(1, len(audit_events))
with self.database.session() as session:
change = session.get(IdmFunctionAssignmentChange, "change-escalate")
self.assertEqual("escalated", change.state)
self.assertEqual("awaiting_holder", change.escalation_from_state)
self.assertEqual("function-escalation", change.escalation_target_function_id)
self.assertEqual([], change.completed_steps)
self.assertIsNone(change.resulting_assignment_id)
history = session.query(IdmFunctionAssignmentChangeEvent).all()
self.assertEqual(["escalated"], [item.action for item in history])
self.assertFalse(
history[0].details["automatic_approver_substitution"]
)
def test_sweep_emits_relationship_expiry_once(self) -> None: def test_sweep_emits_relationship_expiry_once(self) -> None:
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc) boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
with self.database.session() as session: with self.database.session() as session:
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import unittest
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
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.db.models import IdmOrganizationFunctionAssignment
from govoplan_idm.backend.delegation_routes import validate_delegation_chain
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
class DelegationRouteTests(unittest.TestCase):
def setUp(self) -> None:
self.database = configure_database("sqlite:///:memory:")
Base.metadata.create_all(
self.database.engine,
tables=[IdmOrganizationFunctionAssignment.__table__],
)
self.now = datetime(2026, 8, 22, 12, tzinfo=timezone.utc)
def tearDown(self) -> None:
reset_database(dispose=True)
def assignment(
self,
assignment_id: str,
*,
source: str = "direct",
parent: str | None = None,
active: bool = True,
valid_from: datetime | None = None,
valid_until: datetime | None = None,
) -> IdmOrganizationFunctionAssignment:
return IdmOrganizationFunctionAssignment(
id=assignment_id,
tenant_id="tenant-1",
identity_id=f"identity-{assignment_id}",
account_id=f"account-{assignment_id}",
function_id="function-1",
organization_unit_id="unit-1",
source=source,
delegated_from_assignment_id=parent,
is_active=active,
valid_from=valid_from,
valid_until=valid_until,
settings={},
)
@staticmethod
def decision(
*,
allowed: bool = True,
depth: int = 3,
validity_days: int | None = None,
) -> FunctionAssignmentGovernanceDecision:
return FunctionAssignmentGovernanceDecision(
allowed=True,
delegation_allowed=allowed,
maximum_delegation_depth=depth if allowed else 0,
maximum_delegated_validity_days=validity_days,
)
def test_complete_effective_chain_is_accepted(self) -> None:
with self.database.session() as session:
root = self.assignment(
"root",
valid_from=self.now - timedelta(days=30),
valid_until=self.now + timedelta(days=30),
)
first = self.assignment(
"first",
source="delegated",
parent="root",
valid_from=self.now - timedelta(days=10),
valid_until=self.now + timedelta(days=20),
)
second = self.assignment(
"second",
source="delegated",
parent="first",
valid_from=self.now - timedelta(days=1),
valid_until=self.now + timedelta(days=5),
)
session.add_all((root, first, second))
session.flush()
route = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(depth=2),
effective_at=self.now,
)
self.assertTrue(route.effective)
self.assertEqual(("second", "first", "root"), route.chain_assignment_ids)
self.assertEqual(2, route.delegation_depth)
def test_policy_tightening_and_over_depth_fail_closed(self) -> None:
with self.database.session() as session:
root = self.assignment("root")
first = self.assignment("first", source="delegated", parent="root")
second = self.assignment("second", source="delegated", parent="first")
session.add_all((root, first, second))
session.flush()
disabled = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(allowed=False),
effective_at=self.now,
)
shallow = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(depth=1),
effective_at=self.now,
)
self.assertEqual("policy_tightened", disabled.code)
self.assertEqual("over_depth", shallow.code)
def test_expired_cyclic_and_overlong_routes_are_explained(self) -> None:
with self.database.session() as session:
expired = self.assignment(
"expired",
valid_until=self.now - timedelta(seconds=1),
)
cycle_a = self.assignment("cycle-a", source="delegated", parent="cycle-b")
cycle_b = self.assignment("cycle-b", source="delegated", parent="cycle-a")
overlong = self.assignment(
"overlong",
source="delegated",
parent="root",
valid_from=self.now,
valid_until=self.now + timedelta(days=31),
)
root = self.assignment("root")
session.add_all(
(expired, cycle_a, cycle_b, root, overlong)
)
session.flush()
expired_route = validate_delegation_chain(
session,
assignment=expired,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(),
effective_at=self.now,
)
cyclic_route = validate_delegation_chain(
session,
assignment=cycle_a,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(),
effective_at=self.now,
)
overlong_route = validate_delegation_chain(
session,
assignment=overlong,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(validity_days=30),
effective_at=self.now,
)
self.assertEqual("expired", expired_route.code)
self.assertEqual("cyclic", cyclic_route.code)
self.assertEqual("policy_tightened", overlong_route.code)
if __name__ == "__main__":
unittest.main()
+128 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace from dataclasses import replace
from datetime import timedelta
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -9,7 +10,12 @@ from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.concurrency import RevisionConflictError from govoplan_core.core.concurrency import RevisionConflictError
from govoplan_core.core.organizations import OrganizationFunctionRef from govoplan_core.core.organizations import OrganizationFunctionRef
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision 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.core.workflows import WorkflowInstanceRef
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database from govoplan_core.db.session import configure_database, reset_database
@@ -19,10 +25,13 @@ from govoplan_idm.backend.api.v1.function_changes import _change_item
from govoplan_idm.backend.db.models import ( from govoplan_idm.backend.db.models import (
IdmFunctionAssignmentChange, IdmFunctionAssignmentChange,
IdmFunctionAssignmentChangeEvent, IdmFunctionAssignmentChangeEvent,
IdmIdentityRelationship,
IdmOrganizationFunctionAssignment, IdmOrganizationFunctionAssignment,
IdmTenantSettings, IdmTenantSettings,
IdmTypedGroup,
) )
from govoplan_idm.backend.function_assignment_changes import ( from govoplan_idm.backend.function_assignment_changes import (
FunctionAssignmentChangeConflict,
create_function_assignment_change, create_function_assignment_change,
transition_function_assignment_change, transition_function_assignment_change,
) )
@@ -38,6 +47,9 @@ class _Policy:
"submit": bool(context.get("candidate_is_actor")), "submit": bool(context.get("candidate_is_actor")),
"approve_holder": bool(context.get("actor_is_holder")), "approve_holder": bool(context.get("actor_is_holder")),
"approve_authority": bool(context.get("actor_is_authority")), "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")), "accept_recipient": bool(context.get("candidate_is_actor")),
"request_changes": bool( "request_changes": bool(
context.get("actor_is_holder") or context.get("actor_is_authority") context.get("actor_is_holder") or context.get("actor_is_authority")
@@ -60,6 +72,13 @@ class _Policy:
authority_function_id="authority-function", authority_function_id="authority-function",
separation_of_duties=False, separation_of_duties=False,
request_expiry_hours=24, request_expiry_hours=24,
escalation_rules=(
FunctionAssignmentEscalationRule(
step="holder",
target_function_id="escalation-function",
timeout_hours=1,
),
),
) )
@@ -205,6 +224,8 @@ class FunctionAssignmentChangeTests(unittest.TestCase):
IdmFunctionAssignmentChange.__table__, IdmFunctionAssignmentChange.__table__,
IdmFunctionAssignmentChangeEvent.__table__, IdmFunctionAssignmentChangeEvent.__table__,
IdmTenantSettings.__table__, IdmTenantSettings.__table__,
IdmTypedGroup.__table__,
IdmIdentityRelationship.__table__,
ChangeSequenceEntry.__table__, ChangeSequenceEntry.__table__,
], ],
) )
@@ -370,6 +391,112 @@ class FunctionAssignmentChangeTests(unittest.TestCase):
self.assertEqual([], item.available_actions) self.assertEqual([], item.available_actions)
self.assertIn("no longer available", item.availability_reason) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import unittest
from fastapi import HTTPException
from govoplan_idm.backend.api.v1.routes import (
_validate_function_governance_defaults,
)
class FunctionGovernanceSettingsTests(unittest.TestCase):
def test_bounded_delegation_and_escalation_defaults_are_accepted(self) -> None:
_validate_function_governance_defaults(
{
"function_assignment_governance_defaults": {
"delegation_allowed": True,
"maximum_delegation_depth": 2,
"maximum_delegated_validity_days": 30,
"escalation": {
"holder": {
"target_function_id": "function-escalation",
"timeout_hours": 24,
}
},
}
}
)
def test_incomplete_or_unbounded_rules_are_rejected(self) -> None:
invalid = (
{"maximum_delegation_depth": 0},
{"maximum_delegated_validity_days": 3651},
{"escalation": {"holder": {"timeout_hours": 24}}},
{
"escalation": {
"authority": {
"target_function_id": "function-escalation",
"timeout_hours": 0,
}
}
},
{
"escalation": {
"unknown": {
"target_function_id": "function-escalation",
"timeout_hours": 24,
}
}
},
)
for defaults in invalid:
with self.subTest(defaults=defaults), self.assertRaises(HTTPException):
_validate_function_governance_defaults(
{"function_assignment_governance_defaults": defaults}
)
if __name__ == "__main__":
unittest.main()
@@ -33,7 +33,9 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("function_decision", governance.metadata["consequence_classes"]) self.assertIn("function_decision", governance.metadata["consequence_classes"])
reference = topics["idm.reference.fields-and-consequences"] reference = topics["idm.reference.fields-and-consequences"]
self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"]) self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"])
self.assertIn("idm.field.escalation", reference.metadata["help_contexts"])
self.assertIn("deactivate_or_expire", reference.metadata["consequence_classes"]) self.assertIn("deactivate_or_expire", reference.metadata["consequence_classes"])
self.assertIn("escalation", reference.metadata["consequence_classes"])
relationships = topics["idm.reference.typed-relationships"] relationships = topics["idm.reference.typed-relationships"]
self.assertEqual( self.assertEqual(
+15 -1
View File
@@ -32,7 +32,7 @@ class IdmMigrationTests(unittest.TestCase):
try: try:
with engine.connect() as connection: with engine.connect() as connection:
self.assertIn( self.assertIn(
"b1c2d3e4f5a6", "c2d3e4f5a6b7",
set(MigrationContext.configure(connection).get_current_heads()), set(MigrationContext.configure(connection).get_current_heads()),
) )
self.assertEqual( self.assertEqual(
@@ -50,6 +50,20 @@ class IdmMigrationTests(unittest.TestCase):
if name.startswith("idm_") if name.startswith("idm_")
}, },
) )
change_columns = {
item["name"]
for item in inspect(connection).get_columns(
"idm_function_assignment_changes"
)
}
self.assertTrue(
{
"review_deadline_at",
"escalated_at",
"escalation_from_state",
"escalation_target_function_id",
}.issubset(change_columns)
)
finally: finally:
engine.dispose() engine.dispose()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.19", "version": "0.1.20",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+4
View File
@@ -259,6 +259,10 @@ export type FunctionAssignmentChange = {
workflow_instance_id?: string | null; workflow_instance_id?: string | null;
resulting_assignment_id?: string | null; resulting_assignment_id?: string | null;
expires_at?: string | null; expires_at?: string | null;
review_deadline_at?: string | null;
escalated_at?: string | null;
escalation_from_state?: string | null;
escalation_target_function_id?: string | null;
outcome_reason?: string | null; outcome_reason?: string | null;
resource_revision: number; resource_revision: number;
etag: string; etag: string;
@@ -94,7 +94,7 @@ function dateTimeValue(value: string): string | null {
function statusTone(state: string): string { function statusTone(state: string): string {
if (state === "applied") return "success"; if (state === "applied") return "success";
if (["rejected", "expired", "withdrawn", "cancelled"].includes(state)) return "inactive"; if (["rejected", "expired", "withdrawn", "cancelled"].includes(state)) return "inactive";
if (["blocked", "failed_manual_review"].includes(state)) return "danger"; if (["blocked", "failed_manual_review", "escalated"].includes(state)) return "danger";
return "warning"; return "warning";
} }
@@ -113,6 +113,8 @@ const DOMAIN_LABELS: Record<string, string> = {
cancelled: "i18n:govoplan-idm.state_cancelled", cancelled: "i18n:govoplan-idm.state_cancelled",
blocked: "i18n:govoplan-idm.state_blocked", blocked: "i18n:govoplan-idm.state_blocked",
failed_manual_review: "i18n:govoplan-idm.state_failed_manual_review", failed_manual_review: "i18n:govoplan-idm.state_failed_manual_review",
escalated: "Escalated",
escalated_review: "Escalated review",
approve_holder: "i18n:govoplan-idm.step_approve_holder", approve_holder: "i18n:govoplan-idm.step_approve_holder",
approve_authority: "i18n:govoplan-idm.step_approve_authority", approve_authority: "i18n:govoplan-idm.step_approve_authority",
accept_recipient: "i18n:govoplan-idm.step_accept_recipient", accept_recipient: "i18n:govoplan-idm.step_accept_recipient",
@@ -441,6 +443,10 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
<div><dt>Workflow revision</dt><dd>{selected.workflow_definition_revision ?? "-"}</dd></div> <div><dt>Workflow revision</dt><dd>{selected.workflow_definition_revision ?? "-"}</dd></div>
<div><dt>Required decisions</dt><dd>{selected.required_steps.map(domainLabel).join(", ") || "None"}</dd></div> <div><dt>Required decisions</dt><dd>{selected.required_steps.map(domainLabel).join(", ") || "None"}</dd></div>
<div><dt>Completed decisions</dt><dd>{selected.completed_steps.map(domainLabel).join(", ") || "None"}</dd></div> <div><dt>Completed decisions</dt><dd>{selected.completed_steps.map(domainLabel).join(", ") || "None"}</dd></div>
{selected.review_deadline_at && <div><dt>Review deadline</dt><dd>{new Date(selected.review_deadline_at).toLocaleString(language)}</dd></div>}
{selected.escalated_at && <div><dt>Escalated</dt><dd>{new Date(selected.escalated_at).toLocaleString(language)}</dd></div>}
{selected.escalation_from_state && <div><dt>Escalated from</dt><dd>{domainLabel(selected.escalation_from_state)}</dd></div>}
{selected.escalation_target_function_id && <div><dt>Escalation target</dt><dd>{functionById.get(selected.escalation_target_function_id)?.name ?? selected.escalation_target_function_id}</dd></div>}
<div className="wide"><dt>Justification</dt><dd>{selected.justification}</dd></div> <div className="wide"><dt>Justification</dt><dd>{selected.justification}</dd></div>
{selected.outcome_reason && <div className="wide"><dt>Explanation</dt><dd>{selected.outcome_reason}</dd></div>} {selected.outcome_reason && <div className="wide"><dt>Explanation</dt><dd>{selected.outcome_reason}</dd></div>}
</dl> </dl>
+105 -5
View File
@@ -72,6 +72,15 @@ type SettingsDraft = {
require_assignment_change_requests: boolean; require_assignment_change_requests: boolean;
audit_detail_level: "summary" | "standard" | "full"; audit_detail_level: "summary" | "standard" | "full";
change_retention_days: string; change_retention_days: string;
delegation_allowed: boolean;
maximum_delegation_depth: string;
maximum_delegated_validity_days: string;
holder_escalation_target: string;
holder_escalation_hours: string;
authority_escalation_target: string;
authority_escalation_hours: string;
recipient_escalation_target: string;
recipient_escalation_hours: string;
}; };
const EMPTY_MODEL: OrganizationModel = { const EMPTY_MODEL: OrganizationModel = {
@@ -152,20 +161,66 @@ function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): Assignme
function settingsDraftFrom(item: IdmSettings | null): SettingsDraft { function settingsDraftFrom(item: IdmSettings | null): SettingsDraft {
const source = item ?? DEFAULT_IDM_SETTINGS; const source = item ?? DEFAULT_IDM_SETTINGS;
const defaults = source.settings.function_assignment_governance_defaults;
const governance = defaults && typeof defaults === "object" && !Array.isArray(defaults)
? defaults as Record<string, unknown>
: {};
const escalationValue = governance.escalation;
const escalation = escalationValue && typeof escalationValue === "object" && !Array.isArray(escalationValue)
? escalationValue as Record<string, unknown>
: {};
const escalationRule = (step: string): Record<string, unknown> => {
const value = escalation[step];
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
};
const holder = escalationRule("holder");
const authority = escalationRule("authority");
const recipient = escalationRule("recipient");
return { return {
require_assignment_change_requests: source.require_assignment_change_requests, require_assignment_change_requests: source.require_assignment_change_requests,
audit_detail_level: source.audit_detail_level, audit_detail_level: source.audit_detail_level,
change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days) change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days),
delegation_allowed: governance.delegation_allowed === true,
maximum_delegation_depth: governance.maximum_delegation_depth == null ? "1" : String(governance.maximum_delegation_depth),
maximum_delegated_validity_days: governance.maximum_delegated_validity_days == null ? "" : String(governance.maximum_delegated_validity_days),
holder_escalation_target: String(holder.target_function_id ?? ""),
holder_escalation_hours: holder.timeout_hours == null ? "" : String(holder.timeout_hours),
authority_escalation_target: String(authority.target_function_id ?? ""),
authority_escalation_hours: authority.timeout_hours == null ? "" : String(authority.timeout_hours),
recipient_escalation_target: String(recipient.target_function_id ?? ""),
recipient_escalation_hours: recipient.timeout_hours == null ? "" : String(recipient.timeout_hours)
}; };
} }
function settingsPayload(draft: SettingsDraft, item: IdmSettings | null): Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings"> { function settingsPayload(draft: SettingsDraft, item: IdmSettings | null): Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings"> {
const trimmedDays = draft.change_retention_days.trim(); const trimmedDays = draft.change_retention_days.trim();
const sourceSettings = item?.settings ?? {};
const sourceDefaults = sourceSettings.function_assignment_governance_defaults;
const existingDefaults = sourceDefaults && typeof sourceDefaults === "object" && !Array.isArray(sourceDefaults)
? sourceDefaults as Record<string, unknown>
: {};
const escalation: Record<string, { target_function_id: string; timeout_hours: number }> = {};
for (const [step, target, hours] of [
["holder", draft.holder_escalation_target, draft.holder_escalation_hours],
["authority", draft.authority_escalation_target, draft.authority_escalation_hours],
["recipient", draft.recipient_escalation_target, draft.recipient_escalation_hours]
] as const) {
if (target && hours) escalation[step] = { target_function_id: target, timeout_hours: Number(hours) };
}
return { return {
require_assignment_change_requests: draft.require_assignment_change_requests, require_assignment_change_requests: draft.require_assignment_change_requests,
audit_detail_level: draft.audit_detail_level, audit_detail_level: draft.audit_detail_level,
change_retention_days: trimmedDays ? Number(trimmedDays) : null, change_retention_days: trimmedDays ? Number(trimmedDays) : null,
settings: item?.settings ?? {} settings: {
...sourceSettings,
function_assignment_governance_defaults: {
...existingDefaults,
delegation_allowed: draft.delegation_allowed,
maximum_delegation_depth: Number(draft.maximum_delegation_depth || "1"),
maximum_delegated_validity_days: draft.maximum_delegated_validity_days ? Number(draft.maximum_delegated_validity_days) : null,
escalation
}
}
}; };
} }
@@ -174,10 +229,26 @@ function isSettingsDirty(draft: SettingsDraft, item: IdmSettings | null): boolea
return ( return (
draft.require_assignment_change_requests !== baseline.require_assignment_change_requests || draft.require_assignment_change_requests !== baseline.require_assignment_change_requests ||
draft.audit_detail_level !== baseline.audit_detail_level || draft.audit_detail_level !== baseline.audit_detail_level ||
draft.change_retention_days.trim() !== baseline.change_retention_days.trim() draft.change_retention_days.trim() !== baseline.change_retention_days.trim() ||
draft.delegation_allowed !== baseline.delegation_allowed ||
draft.maximum_delegation_depth !== baseline.maximum_delegation_depth ||
draft.maximum_delegated_validity_days !== baseline.maximum_delegated_validity_days ||
draft.holder_escalation_target !== baseline.holder_escalation_target ||
draft.holder_escalation_hours !== baseline.holder_escalation_hours ||
draft.authority_escalation_target !== baseline.authority_escalation_target ||
draft.authority_escalation_hours !== baseline.authority_escalation_hours ||
draft.recipient_escalation_target !== baseline.recipient_escalation_target ||
draft.recipient_escalation_hours !== baseline.recipient_escalation_hours
); );
} }
function settingsDraftInvalid(draft: SettingsDraft): boolean {
const incompleteRule = (target: string, hours: string) => Boolean(target) !== Boolean(hours);
return incompleteRule(draft.holder_escalation_target, draft.holder_escalation_hours)
|| incompleteRule(draft.authority_escalation_target, draft.authority_escalation_hours)
|| incompleteRule(draft.recipient_escalation_target, draft.recipient_escalation_hours);
}
function mapById<T extends { id: string }>(items: T[]): Map<string, T> { function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item])); return new Map(items.map((item) => [item.id, item]));
} }
@@ -710,12 +781,41 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })} onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
/> />
</FormField> </FormField>
<div className="wide"><h3>Delegation and timed escalation defaults</h3><p>Function-specific policy may tighten these tenant defaults. A timeout changes the review to a visible escalated state; it never approves automatically.</p></div>
<div className="idm-check-list wide">
<ToggleSwitch label="Allow governed delegation" checked={settingsDraft.delegation_allowed} disabled={!canManageSettings || busy} help={idmDisabledReason(false, busy, canManageSettings)} onChange={(delegation_allowed) => setSettingsDraft({ ...settingsDraft, delegation_allowed })} />
</div>
<FormField label="Maximum delegation-chain depth" documentation={IDM_FIELD_DOCUMENTATION}>
<input type="number" min="1" max="20" value={settingsDraft.maximum_delegation_depth} disabled={!canManageSettings || busy || !settingsDraft.delegation_allowed} onChange={(event) => setSettingsDraft({ ...settingsDraft, maximum_delegation_depth: event.target.value })} />
</FormField>
<FormField label="Maximum delegated validity (days)" documentation={IDM_FIELD_DOCUMENTATION}>
<input type="number" min="1" max="3650" value={settingsDraft.maximum_delegated_validity_days} placeholder="No additional ceiling" disabled={!canManageSettings || busy || !settingsDraft.delegation_allowed} onChange={(event) => setSettingsDraft({ ...settingsDraft, maximum_delegated_validity_days: event.target.value })} />
</FormField>
{([
["Holder review", "holder_escalation_target", "holder_escalation_hours"],
["Authority review", "authority_escalation_target", "authority_escalation_hours"],
["Recipient review", "recipient_escalation_target", "recipient_escalation_hours"]
] as const).map(([label, targetKey, hoursKey]) => (
<div className="wide" key={targetKey}>
<FormLayout columns={2} gap="small" collapseAt="workspace" className="">
<FormField label={`${label} escalation target`} documentation={IDM_FIELD_DOCUMENTATION}>
<select value={settingsDraft[targetKey]} disabled={!canManageSettings || busy} onChange={(event) => setSettingsDraft({ ...settingsDraft, [targetKey]: event.target.value })}>
<option value="">No timed escalation</option>
{model.functions.filter((item) => item.is_active).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label={`${label} timeout (hours)`} documentation={IDM_FIELD_DOCUMENTATION}>
<input type="number" min="1" max="8760" value={settingsDraft[hoursKey]} disabled={!canManageSettings || busy || !settingsDraft[targetKey]} onChange={(event) => setSettingsDraft({ ...settingsDraft, [hoursKey]: event.target.value })} />
</FormField>
</FormLayout>
</div>
))}
<div className="button-row compact-actions wide"> <div className="button-row compact-actions wide">
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={!canManageSettings || busy || !hasDirtySettingsDraft} disabled={!canManageSettings || busy || !hasDirtySettingsDraft || settingsDraftInvalid(settingsDraft)}
disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (!hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)} disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (settingsDraftInvalid(settingsDraft) ? "Each escalation rule needs both a target and timeout." : !hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)}
> >
i18n:govoplan-idm.save_settings.4602c430 i18n:govoplan-idm.save_settings.4602c430
</Button> </Button>