Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7586f6d9 | ||
|
|
b2a641cac2 | ||
|
|
e9f8e0a1f8 | ||
|
|
3dd7766b08 | ||
|
|
21e8f0bc39 |
@@ -53,8 +53,13 @@ creates them:
|
||||
assignment or represented account.
|
||||
|
||||
The Organizations function must permit the requested mode. The source and
|
||||
derived assignments must belong to the same tenant and function, and the
|
||||
source must be current and active. An actor also needs the assignment-write
|
||||
derived assignments must belong to the same tenant, function, and unit scope.
|
||||
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
|
||||
request/grant or an administrator must use the recorded emergency-override
|
||||
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`,
|
||||
`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.
|
||||
|
||||
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`,
|
||||
`recipient_acceptance_required`, `evidence_required`,
|
||||
`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
|
||||
`/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,
|
||||
support review change requests and responses, and apply an assignment exactly
|
||||
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
|
||||
a function has an enabled governance profile, however, direct create or update
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# SCIM 2.0 provisioning foundation
|
||||
|
||||
IDM uses SCIM 2.0 as the first provisioning boundary. OIDC remains the authentication boundary: a successful login is not provisioning evidence, and a SCIM resource does not grant application authority.
|
||||
|
||||
## Reconciliation model
|
||||
|
||||
The connector reads RFC 7643 User and Group resources using RFC 7644 one-based pagination. A snapshot is complete only after every advertised page for both collections has been read without totals changing. An outage, malformed page, pagination stall, or configured item limit fails the snapshot; it never implies that an external object was deleted.
|
||||
|
||||
Each binding must select a provider-owned immutable match attribute. User name, display name, and email are deliberately rejected as defaults because they are mutable and collision-prone. The SCIM provider `id` is retained after linking, `externalId` remains provider/client correlation when supplied, and the source representation is digest-bound.
|
||||
|
||||
The dry-run planner emits create, link, update, deactivate, or quarantine operations with expected local revisions. Duplicate provider IDs, multiple immutable matches, and changes to a bound immutable value are quarantined. Deactivation is possible only from a complete snapshot and only under a reviewed provider policy; review is the default.
|
||||
|
||||
## Authority boundary
|
||||
|
||||
SCIM Users can become candidates for Identity-owned people and accounts. SCIM Groups and memberships are projected only as business membership facts into IDM. They never become Access roles, permissions, or authorization decisions automatically. Organizations continues to own organization structures and functions, and Access continues to own application authority.
|
||||
|
||||
This slice performs discovery and deterministic planning only. Applying a plan requires a later governed execution slice with persisted provider configuration, operator review, audit evidence, idempotency, conflict checks, and recovery.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-idm"
|
||||
version = "0.1.19"
|
||||
version = "0.1.24"
|
||||
description = "GovOPlaN identity management bridge module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-core>=0.1.29",
|
||||
"govoplan-identity>=0.1.18",
|
||||
"govoplan-organizations>=0.1.18",
|
||||
]
|
||||
|
||||
@@ -244,6 +244,10 @@ def _change_item(
|
||||
workflow_current_step_id=change.workflow_current_step_id,
|
||||
resulting_assignment_id=change.resulting_assignment_id,
|
||||
expires_at=change.expires_at,
|
||||
review_deadline_at=change.review_deadline_at,
|
||||
escalated_at=change.escalated_at,
|
||||
escalation_from_state=change.escalation_from_state,
|
||||
escalation_target_function_id=change.escalation_target_function_id,
|
||||
outcome_reason=change.outcome_reason,
|
||||
resource_revision=change.resource_revision,
|
||||
etag=change.strong_etag,
|
||||
@@ -297,6 +301,18 @@ def _record_change_audit(
|
||||
"workflow_instance_id": change.workflow_instance_id,
|
||||
"evidence": list(change.evidence),
|
||||
"resulting_assignment_id": change.resulting_assignment_id,
|
||||
"review_deadline_at": (
|
||||
change.review_deadline_at.isoformat()
|
||||
if change.review_deadline_at
|
||||
else None
|
||||
),
|
||||
"escalated_at": (
|
||||
change.escalated_at.isoformat() if change.escalated_at else None
|
||||
),
|
||||
"escalation_from_state": change.escalation_from_state,
|
||||
"escalation_target_function_id": (
|
||||
change.escalation_target_function_id
|
||||
),
|
||||
"resource_revision": change.resource_revision,
|
||||
},
|
||||
commit=False,
|
||||
@@ -370,6 +386,18 @@ def get_function_assignment_capability(
|
||||
decision and decision.recipient_acceptance_required
|
||||
),
|
||||
maximum_validity_days=(decision.maximum_validity_days if decision else None),
|
||||
delegation_allowed=bool(decision and decision.delegation_allowed),
|
||||
maximum_delegation_depth=(
|
||||
decision.maximum_delegation_depth if decision else 0
|
||||
),
|
||||
maximum_delegated_validity_days=(
|
||||
decision.maximum_delegated_validity_days if decision else None
|
||||
),
|
||||
escalation_rules=(
|
||||
[rule.to_dict() for rule in decision.escalation_rules]
|
||||
if decision
|
||||
else []
|
||||
),
|
||||
workflow_available=registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION),
|
||||
policy_available=registry.has_capability(
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE
|
||||
|
||||
@@ -516,6 +516,50 @@ def get_idm_settings(
|
||||
return _settings_item(item) if item is not None else _default_settings(tenant_id)
|
||||
|
||||
|
||||
def _validate_function_governance_defaults(settings: dict[str, Any]) -> None:
|
||||
raw = settings.get("function_assignment_governance_defaults")
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, dict):
|
||||
raise _invalid("Function assignment governance defaults must be an object.")
|
||||
if "delegation_allowed" in raw and not isinstance(
|
||||
raw.get("delegation_allowed"), bool
|
||||
):
|
||||
raise _invalid("delegation_allowed must be true or false.")
|
||||
for key, minimum, maximum in (
|
||||
("maximum_delegation_depth", 1, 20),
|
||||
("maximum_delegated_validity_days", 1, 3650),
|
||||
):
|
||||
value = raw.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, int) or isinstance(value, bool) or not minimum <= value <= maximum:
|
||||
raise _invalid(f"{key} must be between {minimum} and {maximum}.")
|
||||
escalation = raw.get("escalation")
|
||||
if escalation is None:
|
||||
return
|
||||
if not isinstance(escalation, dict):
|
||||
raise _invalid("Escalation defaults must be an object.")
|
||||
unsupported = set(escalation) - {"holder", "authority", "recipient"}
|
||||
if unsupported:
|
||||
raise _invalid(
|
||||
"Unsupported escalation review step: " + ", ".join(sorted(unsupported))
|
||||
)
|
||||
for step, value in escalation.items():
|
||||
if not isinstance(value, dict):
|
||||
raise _invalid(f"The {step} escalation rule must be an object.")
|
||||
target = value.get("target_function_id")
|
||||
timeout = value.get("timeout_hours")
|
||||
if not isinstance(target, str) or not target.strip():
|
||||
raise _invalid(f"The {step} escalation target function is required.")
|
||||
if (
|
||||
not isinstance(timeout, int)
|
||||
or isinstance(timeout, bool)
|
||||
or not 1 <= timeout <= 8760
|
||||
):
|
||||
raise _invalid(f"The {step} escalation timeout must be 1 to 8760 hours.")
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=IdmSettingsItem)
|
||||
def update_idm_settings(
|
||||
payload: IdmSettingsUpdateRequest,
|
||||
@@ -544,6 +588,16 @@ def update_idm_settings(
|
||||
if "settings" in fields:
|
||||
if payload.settings is None:
|
||||
raise _invalid("Settings cannot be empty.")
|
||||
_validate_function_governance_defaults(payload.settings)
|
||||
defaults = payload.settings.get("function_assignment_governance_defaults")
|
||||
escalation = defaults.get("escalation") if isinstance(defaults, dict) else None
|
||||
if isinstance(escalation, dict):
|
||||
for rule in escalation.values():
|
||||
if isinstance(rule, dict):
|
||||
_organization_function(
|
||||
str(rule.get("target_function_id")),
|
||||
tenant_id,
|
||||
)
|
||||
item.settings = payload.settings
|
||||
session.flush()
|
||||
result = _settings_item(item)
|
||||
|
||||
@@ -324,6 +324,10 @@ class FunctionAssignmentChangeItem(BaseModel):
|
||||
workflow_current_step_id: str | None = None
|
||||
resulting_assignment_id: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
review_deadline_at: datetime | None = None
|
||||
escalated_at: datetime | None = None
|
||||
escalation_from_state: str | None = None
|
||||
escalation_target_function_id: str | None = None
|
||||
outcome_reason: str | None = None
|
||||
resource_revision: int
|
||||
etag: str
|
||||
@@ -355,5 +359,9 @@ class FunctionAssignmentCapabilityItem(BaseModel):
|
||||
evidence_required: bool = False
|
||||
recipient_acceptance_required: bool = False
|
||||
maximum_validity_days: int | None = None
|
||||
delegation_allowed: bool = False
|
||||
maximum_delegation_depth: int = 0
|
||||
maximum_delegated_validity_days: int | None = None
|
||||
escalation_rules: list[dict[str, Any]] = Field(default_factory=list)
|
||||
workflow_available: bool = False
|
||||
policy_available: bool = False
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import (
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.core.events import (
|
||||
PlatformEvent,
|
||||
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.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
@@ -104,6 +105,12 @@ class SqlIdmAssignmentLifecycle:
|
||||
resource_type="organization_function_assignment_expiry",
|
||||
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(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -122,10 +129,159 @@ class SqlIdmAssignmentLifecycle:
|
||||
"assignment_ids": expired_ids,
|
||||
"expired_changes": len(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),
|
||||
"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
|
||||
def _expire_relationships(
|
||||
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"]
|
||||
|
||||
@@ -294,6 +294,18 @@ class IdmFunctionAssignmentChange(Base, TimestampMixin):
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
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)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
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 collections.abc import Mapping, Sequence
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
@@ -49,6 +49,12 @@ from govoplan_idm.backend.db.models import (
|
||||
IdmTenantSettings,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_idm.backend.delegation_routes import (
|
||||
DelegationRoute,
|
||||
resolve_actor_function_route,
|
||||
resolve_function_route_availability,
|
||||
validate_delegation_chain,
|
||||
)
|
||||
|
||||
|
||||
OPEN_STATES = {
|
||||
@@ -59,6 +65,7 @@ OPEN_STATES = {
|
||||
"changes_requested",
|
||||
"blocked",
|
||||
"failed_manual_review",
|
||||
"escalated",
|
||||
}
|
||||
TERMINAL_STATES = {
|
||||
"applied",
|
||||
@@ -116,17 +123,39 @@ def resolve_submission_capability(
|
||||
tenant_id=principal.tenant_id,
|
||||
function=function,
|
||||
)
|
||||
authority_function_id = _authority_function_id(settings)
|
||||
context = _actor_context(
|
||||
session,
|
||||
base_context = _base_actor_context(
|
||||
principal=principal,
|
||||
function_id=function.id,
|
||||
authority_function_id=authority_function_id,
|
||||
candidate_identity_id=candidate_identity_id,
|
||||
candidate_account_id=candidate_account_id,
|
||||
initiator_account_id=principal.account_id,
|
||||
has_evidence=has_evidence,
|
||||
)
|
||||
preliminary = policy.resolve_function_assignment_action(
|
||||
session,
|
||||
request=FunctionAssignmentGovernanceRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
action="submit",
|
||||
function_id=function.id,
|
||||
actor=principal.to_platform_principal(),
|
||||
candidate_identity_id=candidate_identity_id,
|
||||
candidate_account_id=candidate_account_id,
|
||||
function_settings=settings,
|
||||
context=base_context,
|
||||
),
|
||||
)
|
||||
context = _actor_context(
|
||||
session,
|
||||
principal=principal,
|
||||
function_id=function.id,
|
||||
authority_function_id=preliminary.authority_function_id,
|
||||
escalation_target_function_id=None,
|
||||
candidate_identity_id=candidate_identity_id,
|
||||
candidate_account_id=candidate_account_id,
|
||||
initiator_account_id=principal.account_id,
|
||||
has_evidence=has_evidence,
|
||||
decision=preliminary,
|
||||
)
|
||||
decision = policy.resolve_function_assignment_action(
|
||||
session,
|
||||
request=FunctionAssignmentGovernanceRequest(
|
||||
@@ -192,15 +221,25 @@ def create_function_assignment_change(
|
||||
decision.reason or "The function assignment change is not allowed."
|
||||
)
|
||||
_validate_requested_validity(payload, decision)
|
||||
assignment_source = str(getattr(payload, "assignment_source", "governance"))
|
||||
represented_assignment_id = _text(
|
||||
getattr(payload, "represented_assignment_id", None)
|
||||
)
|
||||
if assignment_source == "delegated" and represented_assignment_id is None:
|
||||
raise FunctionAssignmentChangeUnavailable(
|
||||
"A delegated assignment requires the represented source assignment."
|
||||
)
|
||||
if assignment_source != "delegated" and represented_assignment_id is not None:
|
||||
raise FunctionAssignmentChangeUnavailable(
|
||||
"Only a delegated assignment may reference a represented source assignment."
|
||||
)
|
||||
if represented_assignment_id is not None:
|
||||
_require_actor_assignment(
|
||||
session,
|
||||
principal=principal,
|
||||
assignment_id=represented_assignment_id,
|
||||
function_id=function.id,
|
||||
decision=decision,
|
||||
)
|
||||
now = utc_now()
|
||||
change = IdmFunctionAssignmentChange(
|
||||
@@ -221,7 +260,7 @@ def create_function_assignment_change(
|
||||
requested_valid_from=getattr(payload, "requested_valid_from", None),
|
||||
requested_valid_until=getattr(payload, "requested_valid_until", None),
|
||||
applies_to_subunits=bool(getattr(payload, "applies_to_subunits", False)),
|
||||
assignment_source=str(getattr(payload, "assignment_source", "governance")),
|
||||
assignment_source=assignment_source,
|
||||
required_steps=list(decision.required_steps),
|
||||
completed_steps=[],
|
||||
policy_decision=decision.to_dict(),
|
||||
@@ -268,19 +307,22 @@ def create_function_assignment_change(
|
||||
raise FunctionAssignmentChangeUnavailable(str(exc)) from exc
|
||||
_pin_workflow(change, workflow_ref)
|
||||
next_step = _next_required_step(change)
|
||||
blocked_reason = _missing_reviewer_reason(session, change)
|
||||
blocked_reason = _missing_reviewer_reason(session, change, decision=decision)
|
||||
if blocked_reason is not None:
|
||||
change.state = "blocked"
|
||||
change.outcome_reason = blocked_reason
|
||||
_clear_review_route(change)
|
||||
elif next_step is None:
|
||||
_apply_assignment(
|
||||
session,
|
||||
change=change,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
function=function,
|
||||
)
|
||||
else:
|
||||
change.state = STEP_STATE[next_step]
|
||||
_set_review_route(change, decision=decision, step=next_step, now=now)
|
||||
session.add(change)
|
||||
session.flush()
|
||||
_append_event(
|
||||
@@ -332,6 +374,7 @@ def transition_function_assignment_change(
|
||||
raise FunctionAssignmentChangeUnavailable(
|
||||
decision.reason or "This transition is not allowed."
|
||||
)
|
||||
change.policy_decision = decision.to_dict()
|
||||
if (
|
||||
decision.separation_of_duties
|
||||
and action in {"approve", "accept"}
|
||||
@@ -372,7 +415,8 @@ def transition_function_assignment_change(
|
||||
_record_step_approval(
|
||||
change,
|
||||
step=_current_required_step(change),
|
||||
actor_id=principal.account_id,
|
||||
principal=principal,
|
||||
decision=decision,
|
||||
)
|
||||
if _step_approval_count(change, _current_required_step(change)) >= (
|
||||
1 if action == "accept" else decision.quorum
|
||||
@@ -382,11 +426,11 @@ def transition_function_assignment_change(
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
change=change,
|
||||
function=function,
|
||||
decision=decision,
|
||||
comment=comment,
|
||||
evidence=evidence,
|
||||
)
|
||||
else:
|
||||
change.policy_decision = decision.to_dict()
|
||||
elif action == "request_changes":
|
||||
_request_changes(
|
||||
session,
|
||||
@@ -412,6 +456,12 @@ def transition_function_assignment_change(
|
||||
**dict(change.metadata_),
|
||||
"last_response": comment.strip(),
|
||||
}
|
||||
_set_review_route(
|
||||
change,
|
||||
decision=decision,
|
||||
step=_current_required_step(change),
|
||||
now=utc_now(),
|
||||
)
|
||||
elif action == "recover":
|
||||
change.outcome_reason = None
|
||||
_resume_change(
|
||||
@@ -419,6 +469,8 @@ def transition_function_assignment_change(
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
change=change,
|
||||
function=function,
|
||||
decision=decision,
|
||||
)
|
||||
else:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
@@ -457,7 +509,7 @@ def available_change_actions(
|
||||
return [], f"The change is {change.state}."
|
||||
candidates = (
|
||||
["approve", "request_changes", "reject", "withdraw"]
|
||||
if change.state in {"awaiting_holder", "awaiting_authority"}
|
||||
if change.state in {"awaiting_holder", "awaiting_authority", "escalated"}
|
||||
else ["accept", "request_changes", "reject", "withdraw"]
|
||||
if change.state == "awaiting_recipient"
|
||||
else ["respond", "withdraw"]
|
||||
@@ -520,6 +572,9 @@ def visible_change_filter(
|
||||
IdmFunctionAssignmentChange.policy_decision["authority_function_id"]
|
||||
.as_string()
|
||||
.in_(actor_function_ids),
|
||||
IdmFunctionAssignmentChange.escalation_target_function_id.in_(
|
||||
actor_function_ids
|
||||
),
|
||||
)
|
||||
)
|
||||
return or_(*clauses)
|
||||
@@ -545,17 +600,40 @@ def _resolve_transition_decision(
|
||||
tenant_id=change.tenant_id,
|
||||
function=function,
|
||||
)
|
||||
authority_function_id = _authority_function_id(settings)
|
||||
context = _actor_context(
|
||||
session,
|
||||
base_context = _base_actor_context(
|
||||
principal=principal,
|
||||
function_id=change.function_id,
|
||||
authority_function_id=authority_function_id,
|
||||
candidate_identity_id=change.candidate_identity_id,
|
||||
candidate_account_id=change.candidate_account_id,
|
||||
initiator_account_id=change.initiator_account_id,
|
||||
has_evidence=has_evidence,
|
||||
)
|
||||
preliminary = policy.resolve_function_assignment_action(
|
||||
session,
|
||||
request=FunctionAssignmentGovernanceRequest(
|
||||
tenant_id=change.tenant_id,
|
||||
kind=change.kind, # type: ignore[arg-type]
|
||||
action=action,
|
||||
function_id=change.function_id,
|
||||
actor=principal.to_platform_principal(),
|
||||
candidate_identity_id=change.candidate_identity_id,
|
||||
candidate_account_id=change.candidate_account_id,
|
||||
current_state=change.state,
|
||||
function_settings=settings,
|
||||
context=base_context,
|
||||
),
|
||||
)
|
||||
context = _actor_context(
|
||||
session,
|
||||
principal=principal,
|
||||
function_id=change.function_id,
|
||||
authority_function_id=preliminary.authority_function_id,
|
||||
escalation_target_function_id=change.escalation_target_function_id,
|
||||
candidate_identity_id=change.candidate_identity_id,
|
||||
candidate_account_id=change.candidate_account_id,
|
||||
initiator_account_id=change.initiator_account_id,
|
||||
has_evidence=has_evidence,
|
||||
decision=preliminary,
|
||||
)
|
||||
context["approvals_complete"] = _next_required_step(change) is None
|
||||
return policy.resolve_function_assignment_action(
|
||||
session,
|
||||
@@ -580,6 +658,8 @@ def _complete_current_step(
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
function: OrganizationFunctionRef,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
comment: str | None,
|
||||
evidence: Sequence[str],
|
||||
) -> None:
|
||||
@@ -620,18 +700,30 @@ def _complete_current_step(
|
||||
change=change,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
function=function,
|
||||
)
|
||||
except FunctionAssignmentChangeConflict as exc:
|
||||
change.state = "failed_manual_review"
|
||||
change.outcome_reason = str(exc)
|
||||
else:
|
||||
blocked_reason = _missing_reviewer_reason(session, change)
|
||||
blocked_reason = _missing_reviewer_reason(
|
||||
session,
|
||||
change,
|
||||
decision=decision,
|
||||
)
|
||||
if blocked_reason is not None:
|
||||
change.state = "blocked"
|
||||
change.outcome_reason = blocked_reason
|
||||
_clear_review_route(change)
|
||||
else:
|
||||
change.state = STEP_STATE[next_step]
|
||||
change.outcome_reason = None
|
||||
_set_review_route(
|
||||
change,
|
||||
decision=decision,
|
||||
step=next_step,
|
||||
now=utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def _finish_negative_transition(
|
||||
@@ -667,6 +759,7 @@ def _finish_negative_transition(
|
||||
_pin_workflow(change, reference)
|
||||
change.state = "rejected" if action == "reject" else "withdrawn"
|
||||
change.outcome_reason = comment
|
||||
_clear_review_route(change)
|
||||
|
||||
|
||||
def _request_changes(
|
||||
@@ -685,7 +778,11 @@ def _request_changes(
|
||||
raise FunctionAssignmentChangeUnavailable(
|
||||
"The pinned Workflow instance is unavailable."
|
||||
)
|
||||
previous_state = change.state
|
||||
previous_state = (
|
||||
change.escalation_from_state
|
||||
if change.state == "escalated" and change.escalation_from_state
|
||||
else change.state
|
||||
)
|
||||
reference = workflow.resolve_current_step(
|
||||
session,
|
||||
principal,
|
||||
@@ -707,6 +804,7 @@ def _request_changes(
|
||||
**dict(change.metadata_),
|
||||
"resume_state": previous_state,
|
||||
}
|
||||
_clear_review_route(change)
|
||||
|
||||
|
||||
def _resume_change(
|
||||
@@ -715,6 +813,8 @@ def _resume_change(
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
function: OrganizationFunctionRef,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
) -> None:
|
||||
workflow = workflow_orchestration_provider(registry)
|
||||
if workflow is None or change.workflow_instance_id is None:
|
||||
@@ -735,13 +835,24 @@ def _resume_change(
|
||||
)
|
||||
_pin_workflow(change, reference)
|
||||
step = _next_required_step(change)
|
||||
blocked_reason = _missing_reviewer_reason(session, change)
|
||||
blocked_reason = _missing_reviewer_reason(
|
||||
session,
|
||||
change,
|
||||
decision=decision,
|
||||
)
|
||||
if blocked_reason is not None:
|
||||
change.state = "blocked"
|
||||
change.outcome_reason = blocked_reason
|
||||
_clear_review_route(change)
|
||||
elif step is not None:
|
||||
change.state = STEP_STATE[step]
|
||||
change.outcome_reason = None
|
||||
_set_review_route(
|
||||
change,
|
||||
decision=decision,
|
||||
step=step,
|
||||
now=utc_now(),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
_apply_assignment(
|
||||
@@ -749,6 +860,7 @@ def _resume_change(
|
||||
change=change,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
function=function,
|
||||
)
|
||||
except FunctionAssignmentChangeConflict as exc:
|
||||
change.state = "failed_manual_review"
|
||||
@@ -788,16 +900,259 @@ def _align_workflow_to_required_step(
|
||||
)
|
||||
|
||||
|
||||
def _recheck_application(
|
||||
session: Session,
|
||||
*,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
function: OrganizationFunctionRef,
|
||||
) -> FunctionAssignmentGovernanceDecision:
|
||||
policy = function_assignment_governance_policy(registry)
|
||||
if policy is None:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"Function assignment governance Policy is unavailable at application."
|
||||
)
|
||||
settings = _effective_function_settings(
|
||||
session,
|
||||
tenant_id=change.tenant_id,
|
||||
function=function,
|
||||
)
|
||||
context = _base_actor_context(
|
||||
principal=principal,
|
||||
candidate_identity_id=change.candidate_identity_id,
|
||||
candidate_account_id=change.candidate_account_id,
|
||||
initiator_account_id=change.initiator_account_id,
|
||||
has_evidence=bool(change.evidence),
|
||||
)
|
||||
preliminary = policy.resolve_function_assignment_action(
|
||||
session,
|
||||
request=FunctionAssignmentGovernanceRequest(
|
||||
tenant_id=change.tenant_id,
|
||||
kind=change.kind, # type: ignore[arg-type]
|
||||
action="apply",
|
||||
function_id=change.function_id,
|
||||
actor=principal.to_platform_principal(),
|
||||
candidate_identity_id=change.candidate_identity_id,
|
||||
candidate_account_id=change.candidate_account_id,
|
||||
current_state=change.state,
|
||||
function_settings=settings,
|
||||
context=context,
|
||||
),
|
||||
)
|
||||
if tuple(change.required_steps) != preliminary.required_steps:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"The effective Policy review steps changed after submission; "
|
||||
"administrative recovery must re-plan the pinned workflow."
|
||||
)
|
||||
_recheck_completed_approvals(
|
||||
session,
|
||||
change=change,
|
||||
decision=preliminary,
|
||||
)
|
||||
_recheck_delegated_assignment_source(
|
||||
session,
|
||||
change=change,
|
||||
function=function,
|
||||
decision=preliminary,
|
||||
)
|
||||
context["approvals_complete"] = True
|
||||
decision = policy.resolve_function_assignment_action(
|
||||
session,
|
||||
request=FunctionAssignmentGovernanceRequest(
|
||||
tenant_id=change.tenant_id,
|
||||
kind=change.kind, # type: ignore[arg-type]
|
||||
action="apply",
|
||||
function_id=change.function_id,
|
||||
actor=principal.to_platform_principal(),
|
||||
candidate_identity_id=change.candidate_identity_id,
|
||||
candidate_account_id=change.candidate_account_id,
|
||||
current_state=change.state,
|
||||
function_settings=settings,
|
||||
context=context,
|
||||
),
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
decision.reason or "The current Policy blocks application."
|
||||
)
|
||||
change.policy_decision = decision.to_dict()
|
||||
return decision
|
||||
|
||||
|
||||
def _recheck_completed_approvals(
|
||||
session: Session,
|
||||
*,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
) -> None:
|
||||
approvals = change.metadata_.get("step_approvals")
|
||||
approval_map = approvals if isinstance(approvals, Mapping) else {}
|
||||
for step in decision.required_steps:
|
||||
records = list(approval_map.get(step, ()))
|
||||
required = 1 if step == "recipient" else decision.quorum
|
||||
valid = 0
|
||||
failure_reason: str | None = None
|
||||
for raw in records:
|
||||
record = raw if isinstance(raw, Mapping) else {"actor_account_id": raw}
|
||||
account_id = _text(record.get("actor_account_id"))
|
||||
identity_id = _text(record.get("actor_identity_id"))
|
||||
if step == "recipient":
|
||||
if (
|
||||
(account_id and account_id == change.candidate_account_id)
|
||||
or (identity_id and identity_id == change.candidate_identity_id)
|
||||
):
|
||||
valid += 1
|
||||
else:
|
||||
failure_reason = "Recipient acceptance no longer resolves to the candidate."
|
||||
continue
|
||||
|
||||
route_kind = _text(record.get("route_kind")) or step
|
||||
expected_function_id = (
|
||||
change.function_id
|
||||
if step == "holder" and route_kind != "escalation"
|
||||
else decision.authority_function_id
|
||||
if step == "authority" and route_kind != "escalation"
|
||||
else _text(record.get("expected_function_id"))
|
||||
)
|
||||
if route_kind == "escalation":
|
||||
current_rule = decision.escalation_rule(step) # type: ignore[arg-type]
|
||||
if (
|
||||
current_rule is None
|
||||
or expected_function_id != current_rule.target_function_id
|
||||
):
|
||||
failure_reason = (
|
||||
"The effective Policy no longer permits the recorded "
|
||||
f"{step} escalation route."
|
||||
)
|
||||
continue
|
||||
elif step == "authority" and expected_function_id != _text(
|
||||
record.get("expected_function_id")
|
||||
):
|
||||
failure_reason = (
|
||||
"The designated authority changed after its approval was recorded."
|
||||
)
|
||||
continue
|
||||
if expected_function_id is None:
|
||||
failure_reason = f"The {step} approval route is unavailable."
|
||||
continue
|
||||
route = resolve_actor_function_route(
|
||||
session,
|
||||
tenant_id=change.tenant_id,
|
||||
function_id=expected_function_id,
|
||||
account_id=account_id,
|
||||
identity_id=identity_id,
|
||||
decision=decision,
|
||||
)
|
||||
if route.effective:
|
||||
valid += 1
|
||||
else:
|
||||
failure_reason = route.reason
|
||||
if valid < required:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
failure_reason
|
||||
or f"The current {step} approval quorum is no longer effective."
|
||||
)
|
||||
|
||||
|
||||
def _recheck_delegated_assignment_source(
|
||||
session: Session,
|
||||
*,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
function: OrganizationFunctionRef,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
) -> None:
|
||||
if change.assignment_source != "delegated":
|
||||
if change.represented_assignment_id is not None:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"A non-delegated change cannot retain a represented assignment."
|
||||
)
|
||||
return
|
||||
if not function.delegable or not decision.delegation_allowed:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"The organization function or current Policy no longer permits delegation."
|
||||
)
|
||||
source = (
|
||||
session.get(
|
||||
IdmOrganizationFunctionAssignment,
|
||||
change.represented_assignment_id,
|
||||
)
|
||||
if change.represented_assignment_id
|
||||
else None
|
||||
)
|
||||
if source is None:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"The represented source assignment is unavailable."
|
||||
)
|
||||
if (
|
||||
source.account_id != change.initiator_account_id
|
||||
and source.identity_id != change.initiator_identity_id
|
||||
):
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"The represented source assignment no longer belongs to the initiator."
|
||||
)
|
||||
route = validate_delegation_chain(
|
||||
session,
|
||||
assignment=source,
|
||||
tenant_id=change.tenant_id,
|
||||
function_id=change.function_id,
|
||||
decision=decision,
|
||||
)
|
||||
if not route.effective:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
route.reason or "The represented delegation route is no longer effective."
|
||||
)
|
||||
if (
|
||||
source.valid_from is not None
|
||||
and (
|
||||
change.requested_valid_from is None
|
||||
or change.requested_valid_from < source.valid_from
|
||||
)
|
||||
):
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"The delegated assignment would start before its source assignment."
|
||||
)
|
||||
if (
|
||||
source.valid_until is not None
|
||||
and (
|
||||
change.requested_valid_until is None
|
||||
or change.requested_valid_until > source.valid_until
|
||||
)
|
||||
):
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"The delegated assignment would outlive its source assignment."
|
||||
)
|
||||
if (
|
||||
decision.maximum_delegated_validity_days is not None
|
||||
and change.requested_valid_until is not None
|
||||
):
|
||||
start = change.requested_valid_from or utc_now()
|
||||
if change.requested_valid_until > start + timedelta(
|
||||
days=decision.maximum_delegated_validity_days
|
||||
):
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"The delegated validity window exceeds the current Policy ceiling."
|
||||
)
|
||||
|
||||
|
||||
def _apply_assignment(
|
||||
session: Session,
|
||||
*,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
function: OrganizationFunctionRef,
|
||||
) -> None:
|
||||
if change.resulting_assignment_id:
|
||||
change.state = "applied"
|
||||
return
|
||||
_recheck_application(
|
||||
session,
|
||||
change=change,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
function=function,
|
||||
)
|
||||
existing = session.scalar(
|
||||
select(IdmOrganizationFunctionAssignment).where(
|
||||
IdmOrganizationFunctionAssignment.tenant_id == change.tenant_id,
|
||||
@@ -845,6 +1200,7 @@ def _apply_assignment(
|
||||
change.resulting_assignment_id = assignment.id
|
||||
change.state = "applied"
|
||||
change.outcome_reason = None
|
||||
_clear_review_route(change)
|
||||
emit_assignment_event(
|
||||
session,
|
||||
assignment,
|
||||
@@ -874,20 +1230,84 @@ def _actor_context(
|
||||
principal: ApiPrincipal,
|
||||
function_id: str,
|
||||
authority_function_id: str | None,
|
||||
escalation_target_function_id: str | None,
|
||||
candidate_identity_id: str,
|
||||
candidate_account_id: str | None,
|
||||
initiator_account_id: str,
|
||||
has_evidence: bool,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
) -> dict[str, object]:
|
||||
holder_route = resolve_actor_function_route(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
function_id=function_id,
|
||||
account_id=principal.account_id,
|
||||
identity_id=principal.identity_id,
|
||||
decision=decision,
|
||||
)
|
||||
authority_route = (
|
||||
resolve_actor_function_route(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
function_id=authority_function_id,
|
||||
account_id=principal.account_id,
|
||||
identity_id=principal.identity_id,
|
||||
decision=decision,
|
||||
)
|
||||
if authority_function_id
|
||||
else DelegationRoute(
|
||||
False,
|
||||
"unavailable",
|
||||
"The effective Policy does not designate an authority function.",
|
||||
)
|
||||
)
|
||||
escalation_route = (
|
||||
resolve_actor_function_route(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
function_id=escalation_target_function_id,
|
||||
account_id=principal.account_id,
|
||||
identity_id=principal.identity_id,
|
||||
decision=decision,
|
||||
)
|
||||
if escalation_target_function_id
|
||||
else DelegationRoute(
|
||||
False,
|
||||
"unavailable",
|
||||
"This review is not currently escalated to a target function.",
|
||||
)
|
||||
)
|
||||
return {
|
||||
**_base_actor_context(
|
||||
principal=principal,
|
||||
candidate_identity_id=candidate_identity_id,
|
||||
candidate_account_id=candidate_account_id,
|
||||
initiator_account_id=initiator_account_id,
|
||||
has_evidence=has_evidence,
|
||||
),
|
||||
"actor_is_holder": holder_route.effective,
|
||||
"actor_is_authority": authority_route.effective,
|
||||
"actor_is_escalation_target": escalation_route.effective,
|
||||
"actor_routes": {
|
||||
"holder": holder_route.to_dict(),
|
||||
"authority": authority_route.to_dict(),
|
||||
"escalation": escalation_route.to_dict(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _base_actor_context(
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
candidate_identity_id: str,
|
||||
candidate_account_id: str | None,
|
||||
initiator_account_id: str,
|
||||
has_evidence: bool,
|
||||
) -> dict[str, object]:
|
||||
actor_assignments = _actor_assignments(session, principal)
|
||||
return {
|
||||
"actor_is_holder": any(
|
||||
item.function_id == function_id for item in actor_assignments
|
||||
),
|
||||
"actor_is_authority": bool(authority_function_id)
|
||||
and any(
|
||||
item.function_id == authority_function_id for item in actor_assignments
|
||||
),
|
||||
"actor_is_holder": False,
|
||||
"actor_is_authority": False,
|
||||
"actor_is_escalation_target": False,
|
||||
"candidate_is_actor": (
|
||||
principal.identity_id == candidate_identity_id
|
||||
or (
|
||||
@@ -897,6 +1317,7 @@ def _actor_context(
|
||||
),
|
||||
"actor_is_initiator": principal.account_id == initiator_account_id,
|
||||
"has_evidence": has_evidence,
|
||||
"actor_routes": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -953,12 +1374,6 @@ def _effective_function_settings(
|
||||
}
|
||||
|
||||
|
||||
def _authority_function_id(settings: Mapping[str, object]) -> str | None:
|
||||
raw = settings.get("assignment_governance")
|
||||
policy = raw if isinstance(raw, Mapping) else {}
|
||||
return _text(policy.get("authority_function_id"))
|
||||
|
||||
|
||||
def _validate_requested_validity(
|
||||
payload: object,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
@@ -976,6 +1391,24 @@ def _validate_requested_validity(
|
||||
f"Requested validity exceeds the Policy limit of "
|
||||
f"{decision.maximum_validity_days} days."
|
||||
)
|
||||
if str(getattr(payload, "assignment_source", "governance")) != "delegated":
|
||||
return
|
||||
if not decision.delegation_allowed:
|
||||
raise FunctionAssignmentChangeUnavailable(
|
||||
"The current Policy does not permit delegated assignments."
|
||||
)
|
||||
if (
|
||||
decision.maximum_delegated_validity_days is not None
|
||||
and valid_until is not None
|
||||
):
|
||||
start = valid_from or utc_now()
|
||||
if valid_until > start + timedelta(
|
||||
days=decision.maximum_delegated_validity_days
|
||||
):
|
||||
raise FunctionAssignmentChangeUnavailable(
|
||||
"Requested delegated validity exceeds the Policy limit of "
|
||||
f"{decision.maximum_delegated_validity_days} days."
|
||||
)
|
||||
|
||||
|
||||
def _require_actor_assignment(
|
||||
@@ -984,13 +1417,13 @@ def _require_actor_assignment(
|
||||
principal: ApiPrincipal,
|
||||
assignment_id: str,
|
||||
function_id: str,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
) -> None:
|
||||
assignment = session.get(IdmOrganizationFunctionAssignment, assignment_id)
|
||||
if (
|
||||
assignment is None
|
||||
or assignment.tenant_id != principal.tenant_id
|
||||
or assignment.function_id != function_id
|
||||
or not assignment.is_active
|
||||
or (
|
||||
assignment.account_id != principal.account_id
|
||||
and assignment.identity_id != principal.identity_id
|
||||
@@ -999,6 +1432,18 @@ def _require_actor_assignment(
|
||||
raise FunctionAssignmentChangeUnavailable(
|
||||
"The represented function assignment is not an effective assignment of the actor."
|
||||
)
|
||||
route = validate_delegation_chain(
|
||||
session,
|
||||
assignment=assignment,
|
||||
tenant_id=principal.tenant_id,
|
||||
function_id=function_id,
|
||||
decision=decision,
|
||||
)
|
||||
if not route.effective:
|
||||
raise FunctionAssignmentChangeUnavailable(
|
||||
route.reason
|
||||
or "The represented function assignment route is not effective."
|
||||
)
|
||||
|
||||
|
||||
def _next_required_step(change: IdmFunctionAssignmentChange) -> str | None:
|
||||
@@ -1018,11 +1463,38 @@ def _current_required_step(change: IdmFunctionAssignmentChange) -> str:
|
||||
return step
|
||||
|
||||
|
||||
def _set_review_route(
|
||||
change: IdmFunctionAssignmentChange,
|
||||
*,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
step: str,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
rule = decision.escalation_rule(step) # type: ignore[arg-type]
|
||||
change.review_deadline_at = (
|
||||
now + timedelta(hours=rule.timeout_hours) if rule is not None else None
|
||||
)
|
||||
change.escalation_target_function_id = (
|
||||
rule.target_function_id if rule is not None else None
|
||||
)
|
||||
change.escalated_at = None
|
||||
change.escalation_from_state = None
|
||||
|
||||
|
||||
def _clear_review_route(change: IdmFunctionAssignmentChange) -> None:
|
||||
change.review_deadline_at = None
|
||||
change.escalated_at = None
|
||||
change.escalation_from_state = None
|
||||
change.escalation_target_function_id = None
|
||||
|
||||
|
||||
def _governance_action(
|
||||
change: IdmFunctionAssignmentChange,
|
||||
action: str,
|
||||
) -> FunctionAssignmentGovernanceAction:
|
||||
if action == "approve":
|
||||
if change.state == "escalated":
|
||||
return "approve_escalation"
|
||||
step = _current_required_step(change)
|
||||
if step == "holder":
|
||||
return "approve_holder"
|
||||
@@ -1054,15 +1526,48 @@ def _record_step_approval(
|
||||
change: IdmFunctionAssignmentChange,
|
||||
*,
|
||||
step: str,
|
||||
actor_id: str,
|
||||
principal: ApiPrincipal,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
) -> None:
|
||||
approvals = dict(change.metadata_.get("step_approvals") or {})
|
||||
actors = [str(item) for item in approvals.get(step, ())]
|
||||
if actor_id in actors:
|
||||
records = list(approvals.get(step, ()))
|
||||
actors = [
|
||||
str(item.get("actor_account_id"))
|
||||
if isinstance(item, Mapping)
|
||||
else str(item)
|
||||
for item in records
|
||||
]
|
||||
if principal.account_id in actors:
|
||||
raise FunctionAssignmentChangeConflict(
|
||||
"This actor already approved the current governance step."
|
||||
)
|
||||
approvals[step] = [*actors, actor_id]
|
||||
route_key = "escalation" if change.state == "escalated" else step
|
||||
routes = decision.details.get("actor_routes")
|
||||
route = routes.get(route_key) if isinstance(routes, Mapping) else None
|
||||
route_data = dict(route) if isinstance(route, Mapping) else {}
|
||||
approvals[step] = [
|
||||
*records,
|
||||
{
|
||||
"actor_account_id": principal.account_id,
|
||||
"actor_identity_id": principal.identity_id,
|
||||
"approved_at": utc_now().isoformat(),
|
||||
"route_kind": route_key,
|
||||
"expected_function_id": (
|
||||
change.escalation_target_function_id
|
||||
if route_key == "escalation"
|
||||
else change.function_id
|
||||
if step == "holder"
|
||||
else decision.authority_function_id
|
||||
if step == "authority"
|
||||
else None
|
||||
),
|
||||
"assignment_id": route_data.get("assignment_id"),
|
||||
"chain_assignment_ids": list(
|
||||
route_data.get("chain_assignment_ids") or ()
|
||||
),
|
||||
"delegation_depth": route_data.get("delegation_depth", 0),
|
||||
},
|
||||
]
|
||||
change.metadata_ = {**dict(change.metadata_), "step_approvals": approvals}
|
||||
|
||||
|
||||
@@ -1228,6 +1733,14 @@ def _notification_recipients(
|
||||
function_id=str(authority_id),
|
||||
)
|
||||
)
|
||||
elif change.state == "escalated" and change.escalation_target_function_id:
|
||||
recipients.update(
|
||||
_function_holder_accounts(
|
||||
session,
|
||||
tenant_id=change.tenant_id,
|
||||
function_id=change.escalation_target_function_id,
|
||||
)
|
||||
)
|
||||
return tuple(sorted(item for item in recipients if item))
|
||||
|
||||
|
||||
@@ -1263,24 +1776,31 @@ def _function_holder_accounts(
|
||||
def _missing_reviewer_reason(
|
||||
session: Session,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
*,
|
||||
decision: FunctionAssignmentGovernanceDecision,
|
||||
) -> str | None:
|
||||
step = _next_required_step(change)
|
||||
if step == "holder" and not _function_has_incumbent(
|
||||
session,
|
||||
tenant_id=change.tenant_id,
|
||||
function_id=change.function_id,
|
||||
):
|
||||
return "The function is vacant; no effective holder can review this change."
|
||||
if step == "holder":
|
||||
route = resolve_function_route_availability(
|
||||
session,
|
||||
tenant_id=change.tenant_id,
|
||||
function_id=change.function_id,
|
||||
decision=decision,
|
||||
)
|
||||
if not route.effective:
|
||||
return route.reason or "No effective holder can review this change."
|
||||
if step == "authority":
|
||||
authority_id = _text(change.policy_decision.get("authority_function_id"))
|
||||
authority_id = decision.authority_function_id
|
||||
if authority_id is None:
|
||||
return "The effective Policy does not designate an authority function."
|
||||
if not _function_has_incumbent(
|
||||
route = resolve_function_route_availability(
|
||||
session,
|
||||
tenant_id=change.tenant_id,
|
||||
function_id=authority_id,
|
||||
):
|
||||
return "The designated authority function is vacant."
|
||||
decision=decision,
|
||||
)
|
||||
if not route.effective:
|
||||
return route.reason or "The designated authority function is unavailable."
|
||||
if step == "recipient" and not (
|
||||
change.candidate_account_id or change.candidate_identity_id
|
||||
):
|
||||
@@ -1288,32 +1808,6 @@ def _missing_reviewer_reason(
|
||||
return None
|
||||
|
||||
|
||||
def _function_has_incumbent(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
function_id: str,
|
||||
) -> bool:
|
||||
now = utc_now()
|
||||
return bool(
|
||||
session.scalar(
|
||||
select(func.count(IdmOrganizationFunctionAssignment.id)).where(
|
||||
IdmOrganizationFunctionAssignment.tenant_id == tenant_id,
|
||||
IdmOrganizationFunctionAssignment.function_id == function_id,
|
||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||
or_(
|
||||
IdmOrganizationFunctionAssignment.valid_from.is_(None),
|
||||
IdmOrganizationFunctionAssignment.valid_from <= now,
|
||||
),
|
||||
or_(
|
||||
IdmOrganizationFunctionAssignment.valid_until.is_(None),
|
||||
IdmOrganizationFunctionAssignment.valid_until > now,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _request_fingerprint(payload: object) -> str:
|
||||
if hasattr(payload, "model_dump"):
|
||||
value = payload.model_dump(mode="json", exclude={"idempotency_key"})
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'idm.reference.assignment-governance': {'consequence_classes': {'emergency_override': 'Umgeht den '
|
||||
'normalen '
|
||||
'geregelten '
|
||||
'Antrags- '
|
||||
'oder '
|
||||
'Gewährungspfad '
|
||||
'und '
|
||||
'erfordert '
|
||||
'beibehaltene '
|
||||
'Gründe und '
|
||||
'Nachweise.',
|
||||
'function_decision': 'Erweitert '
|
||||
'oder '
|
||||
'beendet '
|
||||
'eine '
|
||||
'geregelte '
|
||||
'Änderung '
|
||||
'und behält '
|
||||
'handelnde '
|
||||
'Person, '
|
||||
'Kommentar, '
|
||||
'Politik und '
|
||||
'Workflow-Nachweise.',
|
||||
'governance_settings': 'Ändert, '
|
||||
'ob '
|
||||
'direkte '
|
||||
'Zuordnungsmutationen '
|
||||
'genehmigte '
|
||||
'Änderungsnachweise '
|
||||
'erfordern.',
|
||||
'timed_escalation': 'Zeichnet '
|
||||
'eine '
|
||||
'überfällige '
|
||||
'Überprüfung '
|
||||
'und genaue '
|
||||
'Zielfunktion '
|
||||
'auf, ohne '
|
||||
'eine '
|
||||
'Genehmigung '
|
||||
'zu ersetzen '
|
||||
'oder '
|
||||
'abzuschließen.'}},
|
||||
'idm.reference.fields-and-consequences': {'consequence_classes': {'acting_for': 'Ermöglicht es '
|
||||
'einem gebundenen '
|
||||
'Konto, anstelle '
|
||||
'einer '
|
||||
'Quellzuweisung '
|
||||
'zu handeln, wenn '
|
||||
'Organisationen '
|
||||
'dies zulassen.',
|
||||
'assignment': 'Ändert die '
|
||||
'effektive '
|
||||
'institutionelle '
|
||||
'Funktion, die '
|
||||
'von optionalen '
|
||||
'nachgelagerten '
|
||||
'Fähigkeiten '
|
||||
'verbraucht wird.',
|
||||
'deactivate_or_expire': 'Entfernt '
|
||||
'die '
|
||||
'Tatsache '
|
||||
'aus '
|
||||
'der '
|
||||
'effektiven '
|
||||
'Auflösung, '
|
||||
'während '
|
||||
'Provenienz '
|
||||
'und '
|
||||
'Lebenszyklus '
|
||||
'Nachweise '
|
||||
'beibehalten.',
|
||||
'delegation': 'Erstellt eine '
|
||||
'begrenzte '
|
||||
'abgeleitete '
|
||||
'Zuweisung, die '
|
||||
'an die '
|
||||
'Quellzuweisung '
|
||||
'gebunden bleibt.',
|
||||
'escalation': 'Leitet eine '
|
||||
'überfällige '
|
||||
'Überprüfung '
|
||||
'sichtbar zu '
|
||||
'einer genau '
|
||||
'konfigurierten '
|
||||
'Funktion, ohne '
|
||||
'die Entscheidung '
|
||||
'abzuschließen.',
|
||||
'retention': 'Ändert, wie lange '
|
||||
'detaillierte '
|
||||
'Zuordnungsänderungsnachweise '
|
||||
'verfügbar '
|
||||
'bleiben.'}},
|
||||
'idm.reference.typed-relationships': {'consequences': ['Ein zukünftiger Start verzögert die '
|
||||
'Mitgliedschaft bis zum ausgewählten '
|
||||
'Zeitpunkt.',
|
||||
'Expiry entfernt die Beziehung von der '
|
||||
'effektiven Auflösung, während Nachweise '
|
||||
'aufbewahrt werden.',
|
||||
'Der Widerruf entfernt die Beziehung '
|
||||
'sofort von der effektiven Auflösung und '
|
||||
'kann nicht rückgängig gemacht werden.',
|
||||
'Das Ändern einer extern beschafften '
|
||||
'Tatsache ohne übereinstimmende Provenienz '
|
||||
'kann die Verantwortlichkeit für die '
|
||||
'Abstimmung unterbrechen.'],
|
||||
'limitations': ['Mitgliedschaftsbeschlüsse sind '
|
||||
'mieterspezifisch und lehnen '
|
||||
'mieterübergreifende Gruppenreferenzen ab.',
|
||||
'Eine widerrufene Beziehung ist '
|
||||
'unveränderlich und erfordert einen Ersatz '
|
||||
'für eine spätere Wiederverwendung.',
|
||||
'Die Mitgliedschaft allein aktiviert '
|
||||
'niemals eine Identität oder erteilt eine '
|
||||
'Antragsberechtigung.'],
|
||||
'outcome': 'Der Mandant hat erklärbare, effektiv datierte '
|
||||
'Geschäftsmitgliedschaftsfakten, die '
|
||||
'nachgelagerte Verbraucher lösen können, ohne '
|
||||
'IDM-Interna zu importieren oder Zugriffsrechte '
|
||||
'abzuleiten.',
|
||||
'prerequisites': ['Die Identitäten existieren im '
|
||||
'Mandanten-Identitätsverzeichnis.',
|
||||
'Die handelnde Person hat die Berechtigung '
|
||||
'zum Lesen von Beziehungen und die '
|
||||
'Berechtigung zum Schreiben von '
|
||||
'Mutationen.',
|
||||
'Die verantwortliche Quelle, das '
|
||||
'effektive Fenster, die Art der Beziehung '
|
||||
'und der Geschäftszweck sind bekannt.'],
|
||||
'steps': ['Erstellen oder wählen Sie eine typisierte Gruppe '
|
||||
'mit einem stabilen Schlüssel, Typ und Herkunft '
|
||||
'aus.',
|
||||
'Erstellen Sie eine Beziehung zu durchsuchbaren '
|
||||
'Betreff- und Zielreferenzen und dem '
|
||||
'beabsichtigten Gültigkeitsfenster.',
|
||||
'Überprüfen Sie effektive Mitgliedschaften zum '
|
||||
'jeweiligen Zeitpunkt und überprüfen Sie jede '
|
||||
'eingeschlossene oder ausgeschlossene '
|
||||
'Entscheidung.',
|
||||
'Widerrufen Sie eine Beziehung mit einem '
|
||||
'vorgehaltenen Grund, wenn die Tatsache vor ihrem '
|
||||
'geplanten Ende aufhören muss.'],
|
||||
'verification': 'Laden Sie beide Verzeichnisse neu, '
|
||||
'bestätigen Sie die Datensatzrevision und '
|
||||
'die Quellfelder und lösen Sie dann die '
|
||||
'Mitgliedschaften der Zielgruppe zu Zeiten '
|
||||
'vor, während und nach dem '
|
||||
'Gültigkeitsfenster auf. Stellen Sie '
|
||||
'sicher, dass Zugriffsberechtigungen '
|
||||
'unverändert bleiben.'},
|
||||
'idm.scim-provisioning': {'consequences': ['Ein unvollständiger oder fehlgeschlagener Snapshot '
|
||||
'kann eine lokale Identität nicht deaktivieren.',
|
||||
'Ein geänderter unveränderlicher Wert oder eine '
|
||||
'Kollision blockiert die automatische Verknüpfung.',
|
||||
'Ein vollständiger Snapshot kann nur dann eine '
|
||||
'Deaktivierung vorschlagen, wenn die Mandantrichtlinie '
|
||||
'ihn explizit auswählt.'],
|
||||
'limitations': ['Diese Version zeigt eine Vorschau, wendet jedoch keine '
|
||||
'SCIM-Bereitstellungspläne an.',
|
||||
'Cursor-Paginierung wird nicht verwendet, bis '
|
||||
'angekündigt und durch einen Provider-Zieltest '
|
||||
'abgedeckt.',
|
||||
'Eine SCIM-Gruppenmitgliedschaft wird niemals '
|
||||
'automatisch zur Zugriffsberechtigung.'],
|
||||
'prerequisites': ['Der Anbieter stellt RFC 7643 Benutzer- und '
|
||||
'Gruppenressourcen über SCIM 2.0 zur Verfügung.',
|
||||
'Ein anbietereigenes unveränderliches '
|
||||
'Übereinstimmungsattribut wurde ausgewählt und '
|
||||
'kollisionsgetestet.',
|
||||
'Die Authentifizierung erfolgt in einem Scoped Access '
|
||||
'Credential-Umschlag.'],
|
||||
'steps': ['Lesen Sie jede Benutzer- und Gruppenseite in einem '
|
||||
'vollständigen Snapshot.',
|
||||
'Überprüfung von Schema, Paginierung, Kollision und Diagnose '
|
||||
'von unveränderlichen Werten.',
|
||||
'Überprüfen Sie jede Erstellung, Verknüpfung, Aktualisierung, '
|
||||
'Deaktivierung oder Quarantäne und die erwartete lokale '
|
||||
'Überarbeitung.',
|
||||
'Entsorgen und neu erstellen Sie den Plan nach jeder '
|
||||
'Anbieter, Mapping oder lokale Revision Änderung.'],
|
||||
'verification': 'Vergleichen Sie Seitensummen, Quellen- und '
|
||||
'Planverdauungen, erwartete lokale Überarbeitungen, '
|
||||
'Kollisionsdiagnosen und die Abwesenheitsrichtlinien, '
|
||||
'bevor Sie eine spätere Ausführung genehmigen.'}}
|
||||
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_idm.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
@@ -46,7 +50,15 @@ from govoplan_core.core.modules import (
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
ExternalProviderRuntimeState,
|
||||
ExternalProviderStateContext,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
ProviderBehaviorDeclaration,
|
||||
ProviderObjectDeclaration,
|
||||
declared_module_architecture,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata
|
||||
from govoplan_idm.backend.dsar_provider import IDM_DSAR_CAPABILITY
|
||||
@@ -54,9 +66,10 @@ from govoplan_idm.backend.workflow_definitions import (
|
||||
function_assignment_workflow_definitions,
|
||||
)
|
||||
from govoplan_idm.backend.search_source import create_idm_search_source
|
||||
from govoplan_idm.backend.scim import SCIM_EXTERNAL_PROVIDER_ID
|
||||
|
||||
|
||||
MODULE_VERSION = "0.1.19"
|
||||
MODULE_VERSION = "0.1.24"
|
||||
|
||||
IDM_READ_SCOPES = (
|
||||
"idm:organization_assignment:read",
|
||||
@@ -194,7 +207,9 @@ def _idm_directory(context: ModuleContext) -> object:
|
||||
from govoplan_idm.backend.directory import SqlIdmDirectory
|
||||
|
||||
identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
organizations = context.registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
organizations = context.registry.require_capability(
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
)
|
||||
if not isinstance(identities, IdentityDirectory):
|
||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||
if not isinstance(organizations, OrganizationDirectory):
|
||||
@@ -226,6 +241,82 @@ def _idm_dsar_provider(context: ModuleContext) -> object:
|
||||
return IdmDsarProvider()
|
||||
|
||||
|
||||
SCIM_PROVIDER = ExternalProviderDeclaration(
|
||||
id=SCIM_EXTERNAL_PROVIDER_ID,
|
||||
module_id="idm",
|
||||
label="SCIM 2.0 identity provisioning",
|
||||
maturity="read",
|
||||
operations=("discover", "read", "preview", "dry_run"),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="user",
|
||||
field_groups=(
|
||||
"provider_identity",
|
||||
"immutable_match",
|
||||
"lifecycle",
|
||||
"source_revision",
|
||||
),
|
||||
authority_modes=("external_authoritative", "external_mirror"),
|
||||
default_authority_mode="external_authoritative",
|
||||
),
|
||||
ProviderObjectDeclaration(
|
||||
object_type="group",
|
||||
field_groups=(
|
||||
"provider_identity",
|
||||
"immutable_match",
|
||||
"business_membership",
|
||||
"source_revision",
|
||||
),
|
||||
authority_modes=("external_authoritative", "external_mirror"),
|
||||
default_authority_mode="external_authoritative",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
revision_tokens="SCIM meta.version and a canonical source digest are retained for every resource.",
|
||||
concurrency="Plans carry the expected local revision and must be rebuilt after local or remote change.",
|
||||
freshness="A snapshot is current only after every advertised User and Group page has completed.",
|
||||
health="Transport, schema, pagination, mapping, collision, and completeness failures are reported separately.",
|
||||
max_read_items=10000,
|
||||
idempotency="Provider resource id, configured immutable match, source digest, and plan digest prevent duplicate projection effects.",
|
||||
retry="Failed reads are retried as a new complete snapshot; an incomplete attempt has no absence effects.",
|
||||
timeout_seconds=30,
|
||||
conflicts="Duplicate provider ids, ambiguous matches, and changed immutable values are quarantined.",
|
||||
outcome_unknown="Read outages and partial snapshots never infer deletion or deactivation.",
|
||||
outcome_unknown_supported=True,
|
||||
evidence="Provider id, resource id, version, source digest, plan digest, expected local revision, and review outcome are retained.",
|
||||
correction="Correct the provider data or mapping and produce a new complete snapshot and plan.",
|
||||
rollback="Applied identity lifecycle changes require governed compensating revisions, not history rewriting.",
|
||||
compensation="A later reviewed plan can reactivate or correct a projection while preserving prior evidence.",
|
||||
reconciliation="Read complete one-based pages, match existing bindings by provider id, then use the configured immutable attribute.",
|
||||
outage="Existing identities and memberships remain available with stale provider status; no absence action runs.",
|
||||
classifications=("personal", "confidential", "restricted"),
|
||||
purposes=("identity provisioning", "business membership reconciliation"),
|
||||
retention="Identity, IDM, audit, and provider-evidence policies govern their respective retained facts.",
|
||||
secret_handling="Only an Access credential-envelope reference is configured; bearer values are never retained in snapshots or plans.",
|
||||
),
|
||||
documentation_topic_ids=("idm.scim-provisioning",),
|
||||
)
|
||||
|
||||
|
||||
def _scim_provider_states(
|
||||
context: ExternalProviderStateContext,
|
||||
) -> tuple[ExternalProviderRuntimeState, ...]:
|
||||
del context
|
||||
return (
|
||||
ExternalProviderRuntimeState(
|
||||
provider_id=SCIM_EXTERNAL_PROVIDER_ID,
|
||||
observed_at=datetime.now(UTC),
|
||||
configured=False,
|
||||
active=False,
|
||||
health="inactive",
|
||||
freshness="not_applicable",
|
||||
conflict="not_applicable",
|
||||
recovery="unknown",
|
||||
detail="No persisted SCIM provider binding is available in the preview-only slice.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="idm",
|
||||
name="IDM",
|
||||
@@ -290,12 +381,32 @@ manifest = ModuleManifest(
|
||||
factory=create_idm_search_source,
|
||||
),
|
||||
),
|
||||
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/idm",
|
||||
label="IDM",
|
||||
icon="users",
|
||||
required_any=IDM_READ_SCOPES,
|
||||
order=72,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="idm",
|
||||
package_name="@govoplan/idm-webui",
|
||||
routes=(FrontendRoute(path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72),),
|
||||
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/idm",
|
||||
label="IDM",
|
||||
icon="users",
|
||||
required_any=IDM_READ_SCOPES,
|
||||
order=72,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="people-responsibility",
|
||||
@@ -356,7 +467,72 @@ manifest = ModuleManifest(
|
||||
workflow_definitions=function_assignment_workflow_definitions(
|
||||
module_version=MODULE_VERSION,
|
||||
),
|
||||
external_providers=(SCIM_PROVIDER,),
|
||||
external_provider_state_providers=(
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="idm",
|
||||
provider_id=SCIM_EXTERNAL_PROVIDER_ID,
|
||||
provider=_scim_provider_states,
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="idm.scim-provisioning",
|
||||
title="Preview SCIM 2.0 identity provisioning",
|
||||
summary="Read a complete provider snapshot and review deterministic User and Group projection changes before any local effect.",
|
||||
body=(
|
||||
"SCIM 2.0 is the first provisioning boundary; OIDC remains responsible for authentication. Configure a provider URL, reusable Access credential envelope, explicit immutable matching attribute, case policy, page size, and missing-user policy. IDM reads one-based RFC 7644 pages and accepts absence as evidence only after every User and Group page completes with stable totals. The dry-run links an existing provider id first, then the configured immutable value; collisions and changed immutable values are quarantined. Missing users default to review and can be configured for deactivation only from a complete snapshot. Groups remain business membership facts and never grant Access roles or permissions. Applying plans is not available in this slice."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "operator", "module_admin", "auditor"),
|
||||
related_modules=("identity", "organizations", "access", "audit"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
any_scopes=("idm:settings:read", "idm:settings:write"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="SCIM provisioning boundary",
|
||||
href="docs/SCIM_PROVISIONING.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "SCIM-2.0-Identitätsbereitstellung vorab prüfen",
|
||||
"summary": "Einen vollständigen Anbieterstand lesen und deterministische Änderungen an Benutzer- und Gruppenprojektionen vor jeder lokalen Wirkung prüfen.",
|
||||
"body": "SCIM 2.0 ist die erste Bereitstellungsgrenze; OIDC bleibt für die Authentifizierung zuständig. Konfigurieren Sie Anbieter-URL, wiederverwendbaren Access-Berechtigungsnachweis, ein ausdrücklich unveränderliches Abgleichsattribut, Groß-/Kleinschreibungsregel, Seitengröße und Richtlinie für fehlende Benutzer. IDM liest einsbasierte RFC-7644-Seiten und wertet Abwesenheit erst dann als Nachweis, wenn alle Benutzer- und Gruppenseiten mit stabiler Gesamtzahl vollständig sind. Der Vorabplan ordnet zuerst eine bestehende Anbieter-ID und danach den konfigurierten unveränderlichen Wert zu; Mehrdeutigkeiten und geänderte unveränderliche Werte werden unter Quarantäne gestellt. Fehlende Benutzer erfordern standardmäßig Prüfung und dürfen nur nach einem vollständigen Stand richtliniengesteuert deaktiviert werden. Gruppen bleiben fachliche Mitgliedschaftstatsachen und erteilen niemals Access-Rollen oder -Rechte. Die Ausführung der Pläne ist in diesem Abschnitt nicht verfügbar.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"prerequisites": [
|
||||
"The provider exposes RFC 7643 User and Group resources through SCIM 2.0.",
|
||||
"A provider-owned immutable match attribute has been selected and collision-tested.",
|
||||
"Authentication is held in a scoped Access credential envelope.",
|
||||
],
|
||||
"steps": [
|
||||
"Read every User and Group page into one complete snapshot.",
|
||||
"Review schema, pagination, collision, and immutable-value diagnostics.",
|
||||
"Review each create, link, update, deactivate, or quarantine operation and its expected local revision.",
|
||||
"Discard and rebuild the plan after any provider, mapping, or local revision change.",
|
||||
],
|
||||
"limitations": [
|
||||
"This release previews but does not apply SCIM provisioning plans.",
|
||||
"Cursor pagination is not used until advertised and covered by a provider target test.",
|
||||
"SCIM group membership never becomes Access authority automatically.",
|
||||
],
|
||||
"consequences": [
|
||||
"An incomplete or failed snapshot cannot deactivate a local identity.",
|
||||
"A changed immutable value or collision blocks automatic linking.",
|
||||
"A complete snapshot can propose deactivation only when the tenant policy explicitly selects it.",
|
||||
],
|
||||
"verification": "Compare page totals, source and plan digests, expected local revisions, collision diagnostics, and the absence policy before approving a later execution.",
|
||||
},
|
||||
order=25,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="idm.privacy.data-subject-requests",
|
||||
title="Review IDM data in a data-subject request",
|
||||
@@ -397,9 +573,7 @@ manifest = ModuleManifest(
|
||||
"organizations",
|
||||
"records",
|
||||
),
|
||||
conditions=(
|
||||
DocumentationCondition(required_modules=("idm", "access")),
|
||||
),
|
||||
conditions=(DocumentationCondition(required_modules=("idm", "access")),),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Data-subject requests",
|
||||
@@ -412,6 +586,28 @@ manifest = ModuleManifest(
|
||||
kind="runtime",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "IDM-Daten in einer Betroffenenanfrage prüfen",
|
||||
"summary": (
|
||||
"Mandantenbezogene institutionelle Funktions- und Beziehungsmerkmale erfassen und dabei gesteuerte "
|
||||
"Entscheidungsnachweise bewahren."
|
||||
),
|
||||
"body": (
|
||||
"IDM durchsucht bestätigte Konto- und Identitätsselektoren sowie namensraumgebundene Zuweisungs-, Beziehungs- und "
|
||||
"Zuweisungsänderungsverweise. Ergebnisse umfassen zeitlich wirksame Organisationsfunktionszuweisungen, typisierte "
|
||||
"Identitätsbeziehungen mit minimiertem Gruppenkontext, gesteuerte Zuweisungsanträge oder -erteilungen und zugehörige "
|
||||
"Lebenszyklusereignisse. Betrifft ein Datensatz eine andere kandidierende oder handelnde Person, werden deren Identitäts- "
|
||||
"und Kontokennungen aus dem automatischen Export entfernt. Einstellungen, Gruppeneigenschaften, externe Quellverweise, "
|
||||
"Herkunft, Begründungen, Nachweislisten, Richtlinienentscheidungen, Workflow-Interna, Idempotenzschlüssel, Anfrage-Digests, "
|
||||
"undurchsichtige Metadaten, Ereigniskommentare und -details, unbeteiligte Datensätze und andere Mandanten sind ausgeschlossen. "
|
||||
"Zuweisungen und Beziehungen sind wirksame institutionelle Tatsachen; Korrektur, Widerruf, Deaktivierung oder Ablauf "
|
||||
"erfordern daher eine berechtigte IDM-Lebenszyklusprüfung. Gesteuerte Änderungs- und Ereignisdatensätze bewahren "
|
||||
"ausdrückliche Gründe für Entscheidungsnachweise. Identity besitzt den Personendatensatz, Organizations Funktionen und "
|
||||
"Einheiten und Access die aus bestätigten IDM-Tatsachen abgeleitete Befugnis."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"help_contexts": [
|
||||
@@ -436,6 +632,21 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
related_modules=("search", "identity", "organizations"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Berechtigte IDM-Datensätze durchsuchen",
|
||||
"summary": (
|
||||
"Typisierte Gruppen, zeitlich wirksame Beziehungen und Organisationsfunktionszuweisungen für die "
|
||||
"berechtigungsbewusste Plattform-Suche bereitstellen."
|
||||
),
|
||||
"body": (
|
||||
"Ist Search installiert, liefert IDM begrenzte Verzeichnis- und Zuweisungsmetadaten, ohne uneingeschränkte "
|
||||
"Herkunftsdaten zu kopieren. Jedes Ergebnis bleibt mandantengebunden und prüft die aktuelle Leseberechtigung für "
|
||||
"Zuweisung oder Beziehung erneut. Festgeschriebene IDM-Lebenszyklusereignisse aktualisieren den abgeleiteten Index; ein "
|
||||
"betrieblicher Neuaufbau gleicht Datensätze ab, die vor der Aktivierung von Search angelegt wurden."
|
||||
),
|
||||
}
|
||||
},
|
||||
order=25,
|
||||
),
|
||||
DocumentationTopic(
|
||||
@@ -451,6 +662,20 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
related_modules=("identity", "organizations", "access"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Brücke zwischen Identität und Organisation",
|
||||
"summary": (
|
||||
"IDM löst auf, welche Identitäten und Konten Organisationsfunktionszuweisungen zugeordnet werden können."
|
||||
),
|
||||
"body": (
|
||||
"Identity besitzt normalisierte Identitäten und Kontoverknüpfungen. Organizations besitzt Einheiten und Funktionen. "
|
||||
"IDM besitzt die Zuweisungsverknüpfungen zwischen Identitäten und Organisationsfunktionen, einschließlich Identitätssuche "
|
||||
"für Organisationszuweisungen und zukünftiger Synchronisations-/Zuordnungsabläufe. Access darf diese Verknüpfungen nutzen, "
|
||||
"wenn IDM installiert ist; IDM benötigt Access jedoch nicht, um Rechte auszuwerten."
|
||||
),
|
||||
}
|
||||
},
|
||||
order=26,
|
||||
),
|
||||
DocumentationTopic(
|
||||
@@ -460,6 +685,7 @@ manifest = ModuleManifest(
|
||||
body=(
|
||||
"Assignment links are high-impact because they can later feed access decisions. "
|
||||
"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. "
|
||||
"The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write."
|
||||
),
|
||||
@@ -479,6 +705,26 @@ manifest = ModuleManifest(
|
||||
kind="api",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Governance von IDM-Zuweisungen",
|
||||
"summary": (
|
||||
"Mandantenbezogene IDM-Einstellungen können freigegebene Änderungsanträge verlangen, bevor "
|
||||
"Identitäts-Funktionszuweisungen angewendet werden."
|
||||
),
|
||||
"body": (
|
||||
"Zuweisungsverknüpfungen haben hohe Auswirkung, weil sie später Zugriffsentscheidungen speisen können. Mandanten können "
|
||||
"aufgezeichnete Änderungsanträge für Anlage und Aktualisierung von Zuweisungen verlangen. Zusätzlich lassen sich "
|
||||
"Obergrenzen für Tiefe und Gültigkeit von Delegationsketten sowie ausdrückliche Eskalationsziele und Fristen für Prüfungen "
|
||||
"durch Inhabende, verantwortliche Stelle oder Empfangende konfigurieren. IDM prüft vollständige Wege bei jeder "
|
||||
"Entscheidung und der endgültigen Anwendung gegen die aktuelle Policy. Abgelaufene Fristen werden als sichtbarer "
|
||||
"eskalierter Zustand mit Notifications- und Auditnachweis erfasst; eine Freigabe wird nicht unterstellt. Ein periodischer "
|
||||
"Worker sendet genau ein Ablaufereignis, wenn eine zukünftige Zuweisung endet. Kennzeichnung und Ereignis werden gemeinsam "
|
||||
"festgeschrieben, damit Wiederholungen idempotent bleiben. Der alte Bereich organizations:function:assign bleibt während "
|
||||
"des Übergangs gültig; neue Rollenvorlagen sollten idm:organization_assignment:write gewähren."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
@@ -488,11 +734,14 @@ manifest = ModuleManifest(
|
||||
"idm.function-change.request",
|
||||
"idm.function-change.grant",
|
||||
"idm.function-change.decision",
|
||||
"idm.field.delegation-ceilings",
|
||||
"idm.field.escalation",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"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.",
|
||||
"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,
|
||||
@@ -517,10 +766,24 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="IDM relationship administration", href="/idm", kind="runtime"),
|
||||
DocumentationLink(label="Typed groups API", href="/api/v1/idm/typed-groups", kind="api"),
|
||||
DocumentationLink(label="Identity relationships API", href="/api/v1/idm/relationships", kind="api"),
|
||||
DocumentationLink(label="Typed relationship contract", href="docs/TYPED_RELATIONSHIPS.md", kind="repository"),
|
||||
DocumentationLink(
|
||||
label="IDM relationship administration", href="/idm", kind="runtime"
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Typed groups API",
|
||||
href="/api/v1/idm/typed-groups",
|
||||
kind="api",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Identity relationships API",
|
||||
href="/api/v1/idm/relationships",
|
||||
kind="api",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Typed relationship contract",
|
||||
href="docs/TYPED_RELATIONSHIPS.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
@@ -624,9 +887,7 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||
related_modules=("identity", "organizations", "access"),
|
||||
conditions=(
|
||||
DocumentationCondition(any_scopes=IDM_READ_SCOPES),
|
||||
),
|
||||
conditions=(DocumentationCondition(any_scopes=IDM_READ_SCOPES),),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="IDM assignments",
|
||||
@@ -639,6 +900,24 @@ manifest = ModuleManifest(
|
||||
kind="api",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Einer Identität eine Organisationsfunktion zuweisen",
|
||||
"summary": (
|
||||
"IDM verknüpft Identitäten oder Konten mit Organisationsfunktionen; Access kann bestätigte Verknüpfungen bei "
|
||||
"vorhandener Funktions-Rollenzuordnung verwenden."
|
||||
),
|
||||
"body": (
|
||||
"Legen Sie zuerst Einheit und Funktion in Organizations an und stellen Sie sicher, dass Person und Konto in Identity "
|
||||
"bestehen. Erstellen Sie anschließend die Zuweisung in IDM. Direkte Zuweisungen halten fest, wer die Funktion innehat. "
|
||||
"Delegierte Zuweisungen benötigen eine Quellzuweisung und eine delegierbare Funktion. Stellvertretungszuweisungen benötigen "
|
||||
"Quellzuweisung, handelndes Konto und eine Funktion, die Handeln an Stelle zulässt. Access bildet bestätigte "
|
||||
"Funktionsmerkmale auf Rollen und Rechte ab; ohne eine solche Zuordnung wird die Zuweisung gespeichert, gewährt aber keine "
|
||||
"Anwendungsberechtigungen. Der Zuweisungsarbeitsbereich nutzt die verfügbare Anwendungsbreite, sodass Governance-Steuerungen "
|
||||
"und Zuweisungsdaten gemeinsam sichtbar bleiben."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
@@ -670,14 +949,24 @@ manifest = ModuleManifest(
|
||||
"organizational reach. Deactivation and expiry preserve provenance while "
|
||||
"removing the assignment from effective resolution. Governed request 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 "
|
||||
"permissions; Access requires an explicit mapping."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||
related_modules=("identity", "organizations", "access", "policy", "audit", "workflow_engine"),
|
||||
related_modules=(
|
||||
"identity",
|
||||
"organizations",
|
||||
"access",
|
||||
"policy",
|
||||
"audit",
|
||||
"workflow_engine",
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Function assignment workflows",
|
||||
@@ -685,6 +974,30 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Felder und Folgen von IDM-Zuweisungen",
|
||||
"summary": (
|
||||
"Referenz für direkte Zuweisungen, Delegation, Stellvertretung, Wirksamkeitsdaten, gesteuerte Änderungen, Nachweise und "
|
||||
"Aufbewahrung."
|
||||
),
|
||||
"body": (
|
||||
"Identität und Konto wählen, wer die institutionelle Tatsache erhält; Funktion und Einheit gehören Organizations. Die "
|
||||
"Quelle unterscheidet direkte, delegierte, stellvertretende, Verzeichnis-, Governance- und Systemtatsachen. Delegation und "
|
||||
"Stellvertretung verlangen eine gültige Quellzuweisung und die entsprechende Organizations-Funktionsberechtigung. Eine "
|
||||
"delegierte Person handelt als sie selbst; bei Stellvertretung muss Access zusätzlich den exakten Repräsentationskontext "
|
||||
"auswählen, bevor daraus Befugnis entsteht. Quell- und abgeleitete Zuweisungen müssen aktuell, aktiv, mandantenlokal und "
|
||||
"funktionskompatibel bleiben. Der Untereinheitenbereich erweitert die organisatorische Reichweite. Deaktivierung und Ablauf "
|
||||
"bewahren die Herkunft, entfernen die Zuweisung aber aus der wirksamen Auflösung. Entscheidungen zu gesteuerten Anträgen "
|
||||
"und Erteilungen bewahren handelnde Person, Richtlinie, Workflow-Revision, Kommentare und Nachweise. Delegierte Befugnis "
|
||||
"wird über die vollständige Quellkette gegen aktuelle Tiefen- und Gültigkeitsgrenzen geprüft. Eine konfigurierte Frist pro "
|
||||
"Schritt erzeugt einen sichtbaren eskalierten Zustand und eine exakte Zielfunktionsroute; sie ersetzt oder protokolliert "
|
||||
"niemals automatisch eine freigebende Person. Eine Notfallüberschreibung ist nicht der Normalweg und benötigt einen "
|
||||
"ausdrücklichen Grund. Eine IDM-Zuweisung allein gewährt niemals Anwendungsberechtigungen; Access verlangt eine "
|
||||
"ausdrückliche Zuordnung."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
@@ -699,11 +1012,14 @@ manifest = ModuleManifest(
|
||||
"idm.field.justification",
|
||||
"idm.field.evidence",
|
||||
"idm.field.retention",
|
||||
"idm.field.delegation-ceilings",
|
||||
"idm.field.escalation",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"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.",
|
||||
"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.",
|
||||
"retention": "Changes how long detailed assignment-change evidence remains available.",
|
||||
},
|
||||
@@ -717,15 +1033,43 @@ manifest = ModuleManifest(
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||
test_ref="tests/test_assignment_workflow.py",
|
||||
known_limits=("External directory provisioning and all authority-specific grant workflows are not reference-ready.",),
|
||||
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"),
|
||||
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
|
||||
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
|
||||
operations_docs=("README.md",),
|
||||
known_limits=(
|
||||
"SCIM provisioning is a deterministic preview; governed plan execution is not implemented yet.",
|
||||
),
|
||||
supported_authority_modes=("external_authoritative", "external_mirror"),
|
||||
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",
|
||||
),
|
||||
recovery_docs=(
|
||||
"docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||
"docs/TYPED_RELATIONSHIPS.md",
|
||||
"docs/SCIM_PROVISIONING.md",
|
||||
),
|
||||
security_docs=(
|
||||
"docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||
"docs/TYPED_RELATIONSHIPS.md",
|
||||
"docs/SCIM_PROVISIONING.md",
|
||||
),
|
||||
operations_docs=("README.md", "docs/SCIM_PROVISIONING.md"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
+69
@@ -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")
|
||||
@@ -0,0 +1,553 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Literal, Protocol
|
||||
from urllib.parse import urlencode, urljoin, urlsplit
|
||||
|
||||
from govoplan_core.security.http_fetch import HttpFetchResponse, fetch_http, validate_http_url
|
||||
|
||||
|
||||
SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
||||
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
||||
SCIM_EXTERNAL_PROVIDER_ID = "idm.scim2"
|
||||
MAX_SCIM_PAGE_SIZE = 500
|
||||
MAX_SCIM_RESULTS = 10_000
|
||||
MAX_SCIM_RESPONSE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
ScimResourceType = Literal["User", "Group"]
|
||||
ScimPlanAction = Literal["create", "link", "update", "deactivate", "quarantine"]
|
||||
|
||||
|
||||
class ScimError(RuntimeError):
|
||||
"""Stable, sanitized SCIM discovery and planning error."""
|
||||
|
||||
|
||||
class ScimTransport(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
method: str,
|
||||
headers: Mapping[str, str],
|
||||
body: bytes | None,
|
||||
) -> HttpFetchResponse: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimProfile:
|
||||
"""Non-secret provider-neutral SCIM 2.0 reconciliation policy."""
|
||||
|
||||
provider_id: str
|
||||
base_url: str
|
||||
credential_ref: str
|
||||
immutable_match_attribute: str
|
||||
immutable_match_case_exact: bool = True
|
||||
absent_user_action: Literal["review", "deactivate"] = "review"
|
||||
group_projection_mode: Literal["business_membership_only"] = "business_membership_only"
|
||||
page_size: int = 200
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("provider_id", "credential_ref", "immutable_match_attribute"):
|
||||
value = str(getattr(self, name) or "").strip()
|
||||
if not value or len(value) > 255:
|
||||
raise ValueError(f"SCIM {name.replace('_', ' ')} is required and limited to 255 characters.")
|
||||
object.__setattr__(self, name, value)
|
||||
if self.immutable_match_attribute in {"id", "userName", "emails", "displayName"}:
|
||||
raise ValueError(
|
||||
"SCIM matching requires an explicitly governed immutable attribute, not a mutable login, email, or display field."
|
||||
)
|
||||
if not 1 <= self.page_size <= MAX_SCIM_PAGE_SIZE:
|
||||
raise ValueError(f"SCIM page_size must be between 1 and {MAX_SCIM_PAGE_SIZE}.")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"base_url",
|
||||
validate_http_url(self.base_url, label="SCIM base URL").rstrip("/"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimResource:
|
||||
resource_type: ScimResourceType
|
||||
resource_id: str
|
||||
external_id: str | None
|
||||
version: str | None
|
||||
active: bool
|
||||
display_name: str
|
||||
attributes: Mapping[str, object]
|
||||
source_sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimSnapshot:
|
||||
provider_id: str
|
||||
observed_at: datetime
|
||||
users: tuple[ScimResource, ...] = ()
|
||||
groups: tuple[ScimResource, ...] = ()
|
||||
complete: bool = False
|
||||
page_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimLocalProjection:
|
||||
local_id: str
|
||||
resource_type: ScimResourceType
|
||||
immutable_match_value: str
|
||||
revision: int
|
||||
active: bool = True
|
||||
provider_resource_id: str | None = None
|
||||
source_sha256: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.local_id.strip() or not self.immutable_match_value.strip():
|
||||
raise ValueError("SCIM local projections require local and immutable-match identity.")
|
||||
if self.revision < 1:
|
||||
raise ValueError("SCIM local projection revisions must be positive.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimPlanOperation:
|
||||
action: ScimPlanAction
|
||||
resource_type: ScimResourceType
|
||||
provider_resource_id: str | None
|
||||
local_id: str | None
|
||||
immutable_match_value: str | None
|
||||
source_sha256: str | None
|
||||
expected_local_revision: int | None
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScimProvisioningPlan:
|
||||
provider_id: str
|
||||
observed_at: datetime
|
||||
snapshot_complete: bool
|
||||
operations: tuple[ScimPlanOperation, ...]
|
||||
plan_sha256: str
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ScimPage:
|
||||
resources: tuple[ScimResource, ...]
|
||||
total_results: int
|
||||
start_index: int
|
||||
items_per_page: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScimClient:
|
||||
profile: ScimProfile
|
||||
bearer_token: str | None = field(default=None, repr=False)
|
||||
transport: ScimTransport | None = field(default=None, repr=False)
|
||||
timeout_seconds: int = 30
|
||||
|
||||
def fetch_snapshot(self) -> ScimSnapshot:
|
||||
observed_at = datetime.now(UTC)
|
||||
users, user_pages = self._fetch_collection("Users", "User")
|
||||
groups, group_pages = self._fetch_collection("Groups", "Group")
|
||||
return ScimSnapshot(
|
||||
provider_id=self.profile.provider_id,
|
||||
observed_at=observed_at,
|
||||
users=users,
|
||||
groups=groups,
|
||||
complete=True,
|
||||
page_count=user_pages + group_pages,
|
||||
)
|
||||
|
||||
def _fetch_collection(
|
||||
self,
|
||||
path: str,
|
||||
resource_type: ScimResourceType,
|
||||
) -> tuple[tuple[ScimResource, ...], int]:
|
||||
resources: list[ScimResource] = []
|
||||
start_index = 1
|
||||
page_count = 0
|
||||
expected_total: int | None = None
|
||||
while True:
|
||||
page = self._fetch_page(path, resource_type, start_index=start_index)
|
||||
page_count += 1
|
||||
if page.start_index != start_index:
|
||||
raise ScimError("SCIM provider returned a non-matching startIndex.")
|
||||
if expected_total is None:
|
||||
expected_total = page.total_results
|
||||
elif page.total_results != expected_total:
|
||||
raise ScimError("SCIM totalResults changed during the snapshot.")
|
||||
resources.extend(page.resources)
|
||||
if len(resources) > MAX_SCIM_RESULTS:
|
||||
raise ScimError(f"SCIM snapshot exceeds the governed limit of {MAX_SCIM_RESULTS} resources.")
|
||||
if len(resources) >= page.total_results:
|
||||
if len(resources) != page.total_results:
|
||||
raise ScimError("SCIM pagination returned more resources than totalResults.")
|
||||
return tuple(resources), page_count
|
||||
if page.items_per_page < 1 or not page.resources:
|
||||
raise ScimError("SCIM pagination did not make progress.")
|
||||
start_index += len(page.resources)
|
||||
|
||||
def _fetch_page(
|
||||
self,
|
||||
path: str,
|
||||
resource_type: ScimResourceType,
|
||||
*,
|
||||
start_index: int,
|
||||
) -> _ScimPage:
|
||||
query = urlencode({"startIndex": start_index, "count": self.profile.page_size})
|
||||
url = self._url(f"{path}?{query}")
|
||||
headers = {"Accept": "application/scim+json, application/json"}
|
||||
if self.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||
if self.transport is not None:
|
||||
response = self.transport(url, method="GET", headers=headers, body=None)
|
||||
else:
|
||||
if not self.bearer_token:
|
||||
raise ScimError(
|
||||
"SCIM authentication is unavailable; resolve the configured credential envelope first."
|
||||
)
|
||||
response = fetch_http(
|
||||
url,
|
||||
method="GET",
|
||||
headers=headers,
|
||||
timeout=self.timeout_seconds,
|
||||
max_bytes=MAX_SCIM_RESPONSE_BYTES,
|
||||
label="SCIM 2.0 provider",
|
||||
redirect_sensitive_headers=("Authorization",),
|
||||
)
|
||||
if response.status != 200:
|
||||
raise ScimError(f"SCIM collection read returned HTTP {response.status}.")
|
||||
if len(response.body) > MAX_SCIM_RESPONSE_BYTES:
|
||||
raise ScimError("SCIM response exceeded the safety limit.")
|
||||
try:
|
||||
payload = json.loads(response.body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ScimError("SCIM provider returned malformed JSON.") from exc
|
||||
return parse_scim_list_response(payload, resource_type=resource_type)
|
||||
|
||||
def _url(self, relative_path: str) -> str:
|
||||
url = urljoin(f"{self.profile.base_url}/", relative_path)
|
||||
if _origin(urlsplit(url)) != _origin(urlsplit(self.profile.base_url)):
|
||||
raise ScimError("SCIM endpoint escaped the configured provider origin.")
|
||||
return url
|
||||
|
||||
|
||||
class ScimProvisioningPlanner:
|
||||
"""Build a deterministic dry-run; it never mutates Identity, IDM, or Access."""
|
||||
|
||||
def __init__(self, profile: ScimProfile) -> None:
|
||||
self.profile = profile
|
||||
|
||||
def plan(
|
||||
self,
|
||||
snapshot: ScimSnapshot,
|
||||
local_projections: Sequence[ScimLocalProjection],
|
||||
) -> ScimProvisioningPlan:
|
||||
if snapshot.provider_id != self.profile.provider_id:
|
||||
raise ScimError("SCIM snapshot belongs to another provider binding.")
|
||||
operations: list[ScimPlanOperation] = []
|
||||
warnings: list[str] = []
|
||||
locals_by_type = {
|
||||
resource_type: [item for item in local_projections if item.resource_type == resource_type]
|
||||
for resource_type in ("User", "Group")
|
||||
}
|
||||
for resource_type, resources in (("User", snapshot.users), ("Group", snapshot.groups)):
|
||||
operations.extend(
|
||||
self._plan_resource_type(
|
||||
resource_type,
|
||||
resources,
|
||||
locals_by_type[resource_type],
|
||||
snapshot_complete=snapshot.complete,
|
||||
)
|
||||
)
|
||||
if not snapshot.complete:
|
||||
warnings.append(
|
||||
"The snapshot is incomplete; absence-based deactivation is suppressed."
|
||||
)
|
||||
if self.profile.absent_user_action == "review":
|
||||
warnings.append(
|
||||
"Missing SCIM users are quarantined for review instead of being deactivated automatically."
|
||||
)
|
||||
payload = {
|
||||
"provider_id": snapshot.provider_id,
|
||||
"observed_at": snapshot.observed_at.isoformat(),
|
||||
"snapshot_complete": snapshot.complete,
|
||||
"operations": [_operation_dict(item) for item in operations],
|
||||
"warnings": warnings,
|
||||
}
|
||||
return ScimProvisioningPlan(
|
||||
provider_id=snapshot.provider_id,
|
||||
observed_at=snapshot.observed_at,
|
||||
snapshot_complete=snapshot.complete,
|
||||
operations=tuple(operations),
|
||||
plan_sha256=hashlib.sha256(_canonical_json(payload)).hexdigest(),
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
def _plan_resource_type(
|
||||
self,
|
||||
resource_type: ScimResourceType,
|
||||
resources: Sequence[ScimResource],
|
||||
local: Sequence[ScimLocalProjection],
|
||||
*,
|
||||
snapshot_complete: bool,
|
||||
) -> list[ScimPlanOperation]:
|
||||
by_provider_id: dict[str, list[ScimLocalProjection]] = {}
|
||||
by_match: dict[str, list[ScimLocalProjection]] = {}
|
||||
for item in local:
|
||||
if item.provider_resource_id:
|
||||
by_provider_id.setdefault(item.provider_resource_id, []).append(item)
|
||||
by_match.setdefault(self._match_key(item.immutable_match_value), []).append(item)
|
||||
seen_remote_ids: set[str] = set()
|
||||
matched_local_ids: set[str] = set()
|
||||
operations: list[ScimPlanOperation] = []
|
||||
for resource in resources:
|
||||
if resource.resource_type != resource_type:
|
||||
raise ScimError("SCIM snapshot resource type is inconsistent.")
|
||||
if resource.resource_id in seen_remote_ids:
|
||||
raise ScimError("SCIM snapshot contains duplicate provider resource ids.")
|
||||
seen_remote_ids.add(resource.resource_id)
|
||||
match_value = _required_match_value(
|
||||
resource.attributes,
|
||||
self.profile.immutable_match_attribute,
|
||||
)
|
||||
bound = by_provider_id.get(resource.resource_id, [])
|
||||
if len(bound) > 1:
|
||||
operations.append(
|
||||
_quarantine(resource, match_value, "Multiple local objects are bound to the same SCIM resource id.")
|
||||
)
|
||||
continue
|
||||
if bound:
|
||||
item = bound[0]
|
||||
matched_local_ids.add(item.local_id)
|
||||
if self._match_key(item.immutable_match_value) != self._match_key(match_value):
|
||||
operations.append(
|
||||
_quarantine(resource, match_value, "The immutable match value changed for an existing binding.", item)
|
||||
)
|
||||
elif item.source_sha256 != resource.source_sha256 or item.active != resource.active:
|
||||
operations.append(
|
||||
_operation("update", resource, match_value, "The bound SCIM source revision changed.", item)
|
||||
)
|
||||
continue
|
||||
candidates = [
|
||||
item
|
||||
for item in by_match.get(self._match_key(match_value), [])
|
||||
if item.provider_resource_id is None
|
||||
]
|
||||
if len(candidates) > 1:
|
||||
operations.append(
|
||||
_quarantine(resource, match_value, "The immutable match value resolves to multiple local candidates.")
|
||||
)
|
||||
elif candidates:
|
||||
item = candidates[0]
|
||||
matched_local_ids.add(item.local_id)
|
||||
operations.append(
|
||||
_operation("link", resource, match_value, "One unbound local object matched the configured immutable attribute.", item)
|
||||
)
|
||||
else:
|
||||
operations.append(
|
||||
_operation("create", resource, match_value, "No local object matched the configured immutable attribute.")
|
||||
)
|
||||
if snapshot_complete:
|
||||
for item in local:
|
||||
if not item.provider_resource_id or item.local_id in matched_local_ids:
|
||||
continue
|
||||
if item.provider_resource_id in seen_remote_ids:
|
||||
continue
|
||||
action: ScimPlanAction = "quarantine"
|
||||
reason = "The bound SCIM object is absent from a complete snapshot and requires review."
|
||||
if resource_type == "User" and self.profile.absent_user_action == "deactivate":
|
||||
action = "deactivate"
|
||||
reason = "The bound SCIM user is absent from a complete snapshot under the reviewed deactivation policy."
|
||||
operations.append(
|
||||
ScimPlanOperation(
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
provider_resource_id=item.provider_resource_id,
|
||||
local_id=item.local_id,
|
||||
immutable_match_value=item.immutable_match_value,
|
||||
source_sha256=None,
|
||||
expected_local_revision=item.revision,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
return operations
|
||||
|
||||
def _match_key(self, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
return normalized if self.profile.immutable_match_case_exact else normalized.casefold()
|
||||
|
||||
|
||||
def parse_scim_list_response(
|
||||
payload: object,
|
||||
*,
|
||||
resource_type: ScimResourceType,
|
||||
) -> _ScimPage:
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ScimError("SCIM ListResponse must be a JSON object.")
|
||||
schemas = payload.get("schemas")
|
||||
if not isinstance(schemas, list) or SCIM_LIST_SCHEMA not in schemas:
|
||||
raise ScimError("SCIM response is missing the ListResponse schema.")
|
||||
total_results = _nonnegative_int(payload.get("totalResults"), "totalResults")
|
||||
start_index = _positive_int(payload.get("startIndex", 1), "startIndex")
|
||||
items = payload.get("Resources", [])
|
||||
if not isinstance(items, list):
|
||||
raise ScimError("SCIM Resources must be an array.")
|
||||
items_per_page = _nonnegative_int(payload.get("itemsPerPage", len(items)), "itemsPerPage")
|
||||
if items_per_page != len(items):
|
||||
raise ScimError("SCIM itemsPerPage does not match the returned resource count.")
|
||||
expected_schema = SCIM_USER_SCHEMA if resource_type == "User" else SCIM_GROUP_SCHEMA
|
||||
parsed: list[ScimResource] = []
|
||||
for item in items:
|
||||
if not isinstance(item, Mapping):
|
||||
raise ScimError("SCIM resources must be JSON objects.")
|
||||
resource_schemas = item.get("schemas")
|
||||
if not isinstance(resource_schemas, list) or expected_schema not in resource_schemas:
|
||||
raise ScimError(f"SCIM {resource_type} is missing its core schema.")
|
||||
resource_id = _required_text(item.get("id"), f"SCIM {resource_type} id")
|
||||
if resource_type == "User":
|
||||
display_name = _required_text(item.get("userName"), "SCIM User userName")
|
||||
active_value = item.get("active", True)
|
||||
if not isinstance(active_value, bool):
|
||||
raise ScimError("SCIM User active must be boolean.")
|
||||
active = active_value
|
||||
else:
|
||||
display_name = _required_text(item.get("displayName"), "SCIM Group displayName")
|
||||
active = True
|
||||
meta = item.get("meta")
|
||||
version = None
|
||||
if meta is not None:
|
||||
if not isinstance(meta, Mapping):
|
||||
raise ScimError("SCIM resource meta must be an object.")
|
||||
version = _optional_text(meta.get("version"))
|
||||
external_id = _optional_text(item.get("externalId"))
|
||||
normalized = dict(item)
|
||||
parsed.append(
|
||||
ScimResource(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
external_id=external_id,
|
||||
version=version,
|
||||
active=active,
|
||||
display_name=display_name,
|
||||
attributes=normalized,
|
||||
source_sha256=hashlib.sha256(_canonical_json(normalized)).hexdigest(),
|
||||
)
|
||||
)
|
||||
return _ScimPage(
|
||||
resources=tuple(parsed),
|
||||
total_results=total_results,
|
||||
start_index=start_index,
|
||||
items_per_page=items_per_page,
|
||||
)
|
||||
|
||||
|
||||
def _required_match_value(attributes: Mapping[str, object], attribute: str) -> str:
|
||||
value: object = attributes.get(attribute)
|
||||
if value is None and "." in attribute:
|
||||
value = attributes
|
||||
for part in attribute.split("."):
|
||||
if not isinstance(value, Mapping):
|
||||
value = None
|
||||
break
|
||||
value = value.get(part)
|
||||
return _required_text(value, f"SCIM immutable attribute {attribute}")
|
||||
|
||||
|
||||
def _operation(
|
||||
action: ScimPlanAction,
|
||||
resource: ScimResource,
|
||||
match_value: str,
|
||||
reason: str,
|
||||
local: ScimLocalProjection | None = None,
|
||||
) -> ScimPlanOperation:
|
||||
return ScimPlanOperation(
|
||||
action=action,
|
||||
resource_type=resource.resource_type,
|
||||
provider_resource_id=resource.resource_id,
|
||||
local_id=local.local_id if local else None,
|
||||
immutable_match_value=match_value,
|
||||
source_sha256=resource.source_sha256,
|
||||
expected_local_revision=local.revision if local else None,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _quarantine(
|
||||
resource: ScimResource,
|
||||
match_value: str,
|
||||
reason: str,
|
||||
local: ScimLocalProjection | None = None,
|
||||
) -> ScimPlanOperation:
|
||||
return _operation("quarantine", resource, match_value, reason, local)
|
||||
|
||||
|
||||
def _operation_dict(value: ScimPlanOperation) -> dict[str, object]:
|
||||
return {
|
||||
"action": value.action,
|
||||
"resource_type": value.resource_type,
|
||||
"provider_resource_id": value.provider_resource_id,
|
||||
"local_id": value.local_id,
|
||||
"immutable_match_value": value.immutable_match_value,
|
||||
"source_sha256": value.source_sha256,
|
||||
"expected_local_revision": value.expected_local_revision,
|
||||
"reason": value.reason,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _origin(parts) -> tuple[str, str, int | None]:
|
||||
return (
|
||||
parts.scheme.casefold(),
|
||||
(parts.hostname or "").casefold(),
|
||||
parts.port or (443 if parts.scheme.casefold() == "https" else 80),
|
||||
)
|
||||
|
||||
|
||||
def _required_text(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip() or len(value.strip()) > 500:
|
||||
raise ScimError(f"{label} is required and limited to 500 characters.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _optional_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value.strip()) > 500:
|
||||
raise ScimError("SCIM optional text values must be non-empty strings limited to 500 characters.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _nonnegative_int(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ScimError(f"SCIM {label} must be a non-negative integer.")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_int(value: object, label: str) -> int:
|
||||
parsed = _nonnegative_int(value, label)
|
||||
if parsed < 1:
|
||||
raise ScimError(f"SCIM {label} must be positive.")
|
||||
return parsed
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SCIM_EXTERNAL_PROVIDER_ID",
|
||||
"SCIM_GROUP_SCHEMA",
|
||||
"SCIM_LIST_SCHEMA",
|
||||
"SCIM_USER_SCHEMA",
|
||||
"ScimClient",
|
||||
"ScimError",
|
||||
"ScimLocalProjection",
|
||||
"ScimProfile",
|
||||
"ScimProvisioningPlan",
|
||||
"ScimProvisioningPlanner",
|
||||
"ScimResource",
|
||||
"ScimSnapshot",
|
||||
"parse_scim_list_response",
|
||||
]
|
||||
|
||||
@@ -208,6 +208,82 @@ class AssignmentExpiryTests(unittest.TestCase):
|
||||
history = session.query(IdmFunctionAssignmentChangeEvent).all()
|
||||
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:
|
||||
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
|
||||
@@ -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()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
import unittest
|
||||
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.concurrency import RevisionConflictError
|
||||
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.db.base import Base
|
||||
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 (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmIdentityRelationship,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
IdmTenantSettings,
|
||||
IdmTypedGroup,
|
||||
)
|
||||
from govoplan_idm.backend.function_assignment_changes import (
|
||||
FunctionAssignmentChangeConflict,
|
||||
create_function_assignment_change,
|
||||
transition_function_assignment_change,
|
||||
)
|
||||
@@ -38,6 +47,9 @@ class _Policy:
|
||||
"submit": bool(context.get("candidate_is_actor")),
|
||||
"approve_holder": bool(context.get("actor_is_holder")),
|
||||
"approve_authority": bool(context.get("actor_is_authority")),
|
||||
"approve_escalation": bool(
|
||||
context.get("actor_is_escalation_target")
|
||||
),
|
||||
"accept_recipient": bool(context.get("candidate_is_actor")),
|
||||
"request_changes": bool(
|
||||
context.get("actor_is_holder") or context.get("actor_is_authority")
|
||||
@@ -60,6 +72,13 @@ class _Policy:
|
||||
authority_function_id="authority-function",
|
||||
separation_of_duties=False,
|
||||
request_expiry_hours=24,
|
||||
escalation_rules=(
|
||||
FunctionAssignmentEscalationRule(
|
||||
step="holder",
|
||||
target_function_id="escalation-function",
|
||||
timeout_hours=1,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -205,6 +224,8 @@ class FunctionAssignmentChangeTests(unittest.TestCase):
|
||||
IdmFunctionAssignmentChange.__table__,
|
||||
IdmFunctionAssignmentChangeEvent.__table__,
|
||||
IdmTenantSettings.__table__,
|
||||
IdmTypedGroup.__table__,
|
||||
IdmIdentityRelationship.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
@@ -370,6 +391,112 @@ class FunctionAssignmentChangeTests(unittest.TestCase):
|
||||
self.assertEqual([], item.available_actions)
|
||||
self.assertIn("no longer available", item.availability_reason)
|
||||
|
||||
def test_escalated_approval_rechecks_changed_routes_and_applies_exactly_once(
|
||||
self,
|
||||
) -> None:
|
||||
with self.database.session() as session:
|
||||
self._add_reviewer_assignments(session)
|
||||
escalation_assignment = IdmOrganizationFunctionAssignment(
|
||||
id="escalation-assignment",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="escalation-identity",
|
||||
account_id="escalation",
|
||||
function_id="escalation-function",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
session.add(escalation_assignment)
|
||||
change, _ = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
change.review_deadline_at = utc_now() - timedelta(seconds=1)
|
||||
session.commit()
|
||||
|
||||
lifecycle = SqlIdmAssignmentLifecycle()
|
||||
result = lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=utc_now(),
|
||||
)
|
||||
session.flush()
|
||||
self.assertEqual([change.id], result["escalated_change_ids"])
|
||||
self.assertEqual("escalated", change.state)
|
||||
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("escalation", "escalation-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=2,
|
||||
comment="Explicit escalated holder decision",
|
||||
evidence=(),
|
||||
)
|
||||
self.assertEqual("awaiting_authority", change.state)
|
||||
|
||||
escalation_assignment.is_active = False
|
||||
session.flush()
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("authority", "authority-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=3,
|
||||
comment="Authority approval",
|
||||
evidence=(),
|
||||
)
|
||||
self.assertEqual("failed_manual_review", change.state)
|
||||
self.assertIn("no longer active", change.outcome_reason)
|
||||
self.assertIsNone(change.resulting_assignment_id)
|
||||
|
||||
escalation_assignment.is_active = True
|
||||
session.flush()
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("admin", "admin-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="recover",
|
||||
base_revision=4,
|
||||
comment="Current routes rechecked",
|
||||
evidence=(),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual("applied", change.state)
|
||||
self.assertIsNotNone(change.resulting_assignment_id)
|
||||
self.assertEqual(
|
||||
1,
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.filter(
|
||||
IdmOrganizationFunctionAssignment.identity_id
|
||||
== "candidate-identity"
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
with self.assertRaises(FunctionAssignmentChangeConflict):
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("admin", "admin-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="recover",
|
||||
base_revision=5,
|
||||
comment=None,
|
||||
evidence=(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
@@ -7,6 +7,14 @@ from govoplan_idm.backend.api.v1.routes import ORGANIZATION_IDENTITY_READ_SCOPES
|
||||
|
||||
|
||||
class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
def test_route_and_contributed_action_remain_declared(self) -> None:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
@@ -33,7 +41,9 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
self.assertIn("function_decision", governance.metadata["consequence_classes"])
|
||||
reference = topics["idm.reference.fields-and-consequences"]
|
||||
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("escalation", reference.metadata["consequence_classes"])
|
||||
|
||||
relationships = topics["idm.reference.typed-relationships"]
|
||||
self.assertEqual(
|
||||
@@ -52,8 +62,13 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
"idm.typed-groups.action.resolve-memberships",
|
||||
relationships.metadata["help_contexts"],
|
||||
)
|
||||
self.assertIn("Revocation immediately", relationships.metadata["consequences"][2])
|
||||
self.assertIn("Access permissions remain unchanged", relationships.metadata["verification"])
|
||||
self.assertIn(
|
||||
"Revocation immediately", relationships.metadata["consequences"][2]
|
||||
)
|
||||
self.assertIn(
|
||||
"Access permissions remain unchanged",
|
||||
relationships.metadata["verification"],
|
||||
)
|
||||
|
||||
def test_relationship_writers_may_use_identity_search_selectors(self) -> None:
|
||||
self.assertIn("idm:relationship:write", ORGANIZATION_IDENTITY_READ_SCOPES)
|
||||
|
||||
@@ -32,7 +32,7 @@ class IdmMigrationTests(unittest.TestCase):
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"b1c2d3e4f5a6",
|
||||
"c2d3e4f5a6b7",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
self.assertEqual(
|
||||
@@ -50,6 +50,20 @@ class IdmMigrationTests(unittest.TestCase):
|
||||
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:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.security.http_fetch import HttpFetchResponse
|
||||
from govoplan_idm.backend.scim import (
|
||||
SCIM_GROUP_SCHEMA,
|
||||
SCIM_LIST_SCHEMA,
|
||||
SCIM_USER_SCHEMA,
|
||||
ScimClient,
|
||||
ScimError,
|
||||
ScimLocalProjection,
|
||||
ScimProfile,
|
||||
ScimProvisioningPlanner,
|
||||
ScimSnapshot,
|
||||
parse_scim_list_response,
|
||||
)
|
||||
|
||||
|
||||
def _profile(*, absent_user_action="review") -> ScimProfile:
|
||||
return ScimProfile(
|
||||
provider_id="institutional-idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="core-credential:scim",
|
||||
immutable_match_attribute="urn:example:params:scim:schemas:extension:staff:2.0:User:personnelNumber",
|
||||
absent_user_action=absent_user_action,
|
||||
page_size=2,
|
||||
)
|
||||
|
||||
|
||||
def _user(resource_id: str, number: str, *, active: bool = True) -> dict[str, object]:
|
||||
return {
|
||||
"schemas": [
|
||||
SCIM_USER_SCHEMA,
|
||||
"urn:example:params:scim:schemas:extension:staff:2.0:User",
|
||||
],
|
||||
"id": resource_id,
|
||||
"userName": f"user-{number}",
|
||||
"active": active,
|
||||
"urn:example:params:scim:schemas:extension:staff:2.0:User:personnelNumber": number,
|
||||
"meta": {"version": f'W/"{resource_id}"'},
|
||||
}
|
||||
|
||||
|
||||
def _list(resources: list[dict[str, object]], *, total: int, start: int) -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"schemas": [SCIM_LIST_SCHEMA],
|
||||
"totalResults": total,
|
||||
"startIndex": start,
|
||||
"itemsPerPage": len(resources),
|
||||
"Resources": resources,
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def test_scim_client_reads_complete_one_based_paginated_snapshot() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def transport(url, *, method, headers, body):
|
||||
calls.append(url)
|
||||
assert method == "GET" and body is None
|
||||
assert "Authorization" not in headers
|
||||
if "/Users?" in url and "startIndex=1" in url:
|
||||
payload = _list([_user("u-1", "100"), _user("u-2", "200")], total=3, start=1)
|
||||
elif "/Users?" in url:
|
||||
payload = _list([_user("u-3", "300")], total=3, start=3)
|
||||
else:
|
||||
payload = _list([], total=0, start=1)
|
||||
return HttpFetchResponse(200, {"Content-Type": "application/scim+json"}, payload)
|
||||
|
||||
snapshot = ScimClient(_profile(), transport=transport).fetch_snapshot()
|
||||
|
||||
assert snapshot.complete is True
|
||||
assert [item.resource_id for item in snapshot.users] == ["u-1", "u-2", "u-3"]
|
||||
assert snapshot.groups == ()
|
||||
assert snapshot.page_count == 3
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
def test_planner_links_by_explicit_immutable_attribute_and_quarantines_collision() -> None:
|
||||
page = parse_scim_list_response(
|
||||
json.loads(_list([_user("u-1", "100"), _user("u-2", "200")], total=2, start=1)),
|
||||
resource_type="User",
|
||||
)
|
||||
snapshot = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
users=page.resources,
|
||||
complete=True,
|
||||
page_count=1,
|
||||
)
|
||||
local = (
|
||||
ScimLocalProjection("local-100", "User", "100", revision=2),
|
||||
ScimLocalProjection("local-200-a", "User", "200", revision=1),
|
||||
ScimLocalProjection("local-200-b", "User", "200", revision=1),
|
||||
)
|
||||
|
||||
plan = ScimProvisioningPlanner(_profile()).plan(snapshot, local)
|
||||
|
||||
assert [(item.action, item.local_id) for item in plan.operations] == [
|
||||
("link", "local-100"),
|
||||
("quarantine", None),
|
||||
]
|
||||
assert len(plan.plan_sha256) == 64
|
||||
|
||||
|
||||
def test_absence_never_deactivates_from_incomplete_snapshot() -> None:
|
||||
local = (
|
||||
ScimLocalProjection(
|
||||
"local-100",
|
||||
"User",
|
||||
"100",
|
||||
revision=3,
|
||||
provider_resource_id="u-1",
|
||||
),
|
||||
)
|
||||
incomplete = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
complete=False,
|
||||
)
|
||||
complete = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
complete=True,
|
||||
)
|
||||
|
||||
first = ScimProvisioningPlanner(_profile(absent_user_action="deactivate")).plan(incomplete, local)
|
||||
second = ScimProvisioningPlanner(_profile(absent_user_action="deactivate")).plan(complete, local)
|
||||
|
||||
assert first.operations == ()
|
||||
assert first.warnings
|
||||
assert second.operations[0].action == "deactivate"
|
||||
assert second.operations[0].expected_local_revision == 3
|
||||
|
||||
|
||||
def test_groups_are_business_projections_and_not_access_grants() -> None:
|
||||
group = {
|
||||
"schemas": [SCIM_GROUP_SCHEMA],
|
||||
"id": "g-1",
|
||||
"displayName": "Payroll reviewers",
|
||||
"externalId": "group-100",
|
||||
"members": [{"value": "u-1"}],
|
||||
}
|
||||
profile = ScimProfile(
|
||||
provider_id="institutional-idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="core-credential:scim",
|
||||
immutable_match_attribute="externalIdImmutable",
|
||||
)
|
||||
group["externalIdImmutable"] = "group-stable-100"
|
||||
page = parse_scim_list_response(
|
||||
{
|
||||
"schemas": [SCIM_LIST_SCHEMA],
|
||||
"totalResults": 1,
|
||||
"startIndex": 1,
|
||||
"itemsPerPage": 1,
|
||||
"Resources": [group],
|
||||
},
|
||||
resource_type="Group",
|
||||
)
|
||||
plan = ScimProvisioningPlanner(profile).plan(
|
||||
ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
groups=page.resources,
|
||||
complete=True,
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
assert plan.operations[0].resource_type == "Group"
|
||||
assert plan.operations[0].action == "create"
|
||||
assert profile.group_projection_mode == "business_membership_only"
|
||||
|
||||
|
||||
def test_parser_and_profile_reject_unsafe_identity_assumptions() -> None:
|
||||
with pytest.raises(ValueError, match="immutable"):
|
||||
ScimProfile(
|
||||
provider_id="idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="credential",
|
||||
immutable_match_attribute="userName",
|
||||
)
|
||||
with pytest.raises(ScimError, match="ListResponse"):
|
||||
parse_scim_list_response({"Resources": []}, resource_type="User")
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -259,6 +259,10 @@ export type FunctionAssignmentChange = {
|
||||
workflow_instance_id?: string | null;
|
||||
resulting_assignment_id?: 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;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
|
||||
@@ -94,7 +94,7 @@ function dateTimeValue(value: string): string | null {
|
||||
function statusTone(state: string): string {
|
||||
if (state === "applied") return "success";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@ const DOMAIN_LABELS: Record<string, string> = {
|
||||
cancelled: "i18n:govoplan-idm.state_cancelled",
|
||||
blocked: "i18n:govoplan-idm.state_blocked",
|
||||
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_authority: "i18n:govoplan-idm.step_approve_authority",
|
||||
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>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>
|
||||
{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>
|
||||
{selected.outcome_reason && <div className="wide"><dt>Explanation</dt><dd>{selected.outcome_reason}</dd></div>}
|
||||
</dl>
|
||||
|
||||
@@ -72,6 +72,15 @@ type SettingsDraft = {
|
||||
require_assignment_change_requests: boolean;
|
||||
audit_detail_level: "summary" | "standard" | "full";
|
||||
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 = {
|
||||
@@ -152,20 +161,66 @@ function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): Assignme
|
||||
|
||||
function settingsDraftFrom(item: IdmSettings | null): SettingsDraft {
|
||||
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 {
|
||||
require_assignment_change_requests: source.require_assignment_change_requests,
|
||||
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"> {
|
||||
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 {
|
||||
require_assignment_change_requests: draft.require_assignment_change_requests,
|
||||
audit_detail_level: draft.audit_detail_level,
|
||||
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 (
|
||||
draft.require_assignment_change_requests !== baseline.require_assignment_change_requests ||
|
||||
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> {
|
||||
return new Map(items.map((item) => [item.id, item]));
|
||||
}
|
||||
@@ -701,7 +772,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3" documentation={IDM_FIELD_DOCUMENTATION} helpContextId="idm.field.retention" helpModuleId="idm">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -710,12 +781,41 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
|
||||
/>
|
||||
</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">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canManageSettings || busy || !hasDirtySettingsDraft}
|
||||
disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (!hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)}
|
||||
disabled={!canManageSettings || busy || !hasDirtySettingsDraft || settingsDraftInvalid(settingsDraft)}
|
||||
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
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user