17 Commits
Author SHA1 Message Date
zemion e2a39d7c4f fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:47 +02:00
zemion ba04593e29 refactor(idm): share directory dependency resolution
Module Package Release / publish-packages (push) Successful in 18s
Release v0.1.26. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:39 +02:00
zemion 46e09d0c68 Release govoplan-idm v0.1.25: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 01:32:43 +02:00
zemion 5c7586f6d9 fix(webui): bind identity retention to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:40 +02:00
zemion b2a641cac2 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:34 +02:00
zemion e9f8e0a1f8 docs(idm): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 20:19:15 +02:00
zemion 3dd7766b08 feat(idm): add SCIM provisioning preview
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 11:20:42 +02:00
zemion 21e8f0bc39 feat(idm): govern delegation chains and timed escalation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 03:12:30 +02:00
zemion b0eda35195 feat(idm): administer typed identity relationships
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 22:04:06 +02:00
zemion 65ff14a613 feat(idm): add governed DSAR coverage 2026-08-21 01:32:50 +02:00
zemion dc99a40384 docs(idm): complete delegation lifecycle guidance 2026-08-19 22:16:18 +02:00
zemion 2c6ee041b9 feat: classify identity management product area 2026-08-18 21:32:50 +02:00
zemion 091ad975aa Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion e9e8783cbd Adopt shared WebUI layout primitives 2026-08-18 10:42:52 +02:00
zemion 9bcd2de587 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:45 +02:00
zemion 85b5f80c59 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 20:34:04 +02:00
zemion 6e9393c9c7 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:11 +02:00
41 changed files with 6470 additions and 208 deletions
+9
View File
@@ -91,6 +91,15 @@ planning.
The WebUI exposed by this repository is a normal module UI at `/idm`. It is the
editing surface for identity-to-organization-function assignment links.
The module also publishes `privacy.dsar.idm`. The provider finds tenant-scoped
function assignments, typed relationships, governed assignment changes, and
lifecycle events using corroborated identity/account selectors. Automated
exports minimize other candidates and actors and exclude opaque settings,
properties, provenance, external source references, policy/workflow internals,
idempotency material, evidence payloads, comments, and event details. Assignment
or relationship changes require the normal governed IDM lifecycle; immutable
change and event evidence is retained with an explicit reason.
Its interface archetypes, consequence classes, contextual-help contract, and
accessibility evidence are recorded in
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
+49 -3
View File
@@ -35,6 +35,39 @@ authority must clear it, or whether only the authority can initiate and grant
it. Recipient acceptance can be required or waived only by an explicit policy
with recorded provenance.
## Delegation, Substitution, And Acting In Place
These modes are explicit assignment sources; ordinary group membership never
creates them:
- A **delegated assignment** is a bounded substitution. For example, a registry
lead may delegate the same delegable function to a deputy until Friday. The
deputy acts as themself, and Access receives both the derived assignment ID
and its source assignment ID.
- An **acting-for assignment** is a bounded representation context. For example,
an assistant may select an active acting context for the represented function
holder. Access records the real account, the selected assignment, and the
represented account on the session and in audit evidence. No acting-for
authority is effective until that exact context is selected.
- A **direct assignment** is the holder's own function fact and has no source
assignment or represented account.
The Organizations function must permit the requested mode. The source and
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
revokes an assignment without deleting its provenance. IDM emits changed,
revoked, and expired lifecycle events and writes the normal assignment audit
record; effective-directory reads immediately exclude inactive, future,
expired, or source-invalid derived assignments.
## Grant Profiles
The first policy profiles are:
@@ -68,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
@@ -105,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`
@@ -113,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
+17 -6
View File
@@ -14,6 +14,9 @@ IDM owns effective identity-to-function facts and their governed lifecycle.
| Function request/grant list | Governed work queue | Start or inspect a function change | Shared grid/loading/status/action slot, localized state and workflow vocabulary |
| Request/grant editor | Guided consequential editor | Submit a governed assignment change | Shared segmented control/dialog/forms, guarded draft, effective dates, justification and evidence help |
| Function-change detail | Decision and provenance record | Approve, reject, accept, request changes, withdraw, respond, or recover | Shared confirmation, available-action contract, retained actor/policy/workflow/history evidence |
| Typed-group directory | Repeated administration | Create, edit, activate, or deactivate a tenant business group | Shared grid/card/dialog/action bar, optimistic revision, source and provenance fields, exact contextual help |
| Effective relationship directory | Effective-dated administration | Create, change, expire, or irreversibly revoke a business relationship | Searchable identity/group selectors, four distinct lifecycle states, dirty guard, reasoned destructive confirmation |
| Membership inspector | Point-in-time evidence reader | Resolve included and excluded identities for a group, time, and relationship kind | Shared resolver endpoint, localized time, decision codes, identity lifecycle explanation |
| `idm.action.view-function-assignments` | Contextual cross-module action | Navigate with function context | Declared capability surface, permission guard, no Organizations-private import |
## Consequence And Availability Rules
@@ -32,6 +35,14 @@ IDM owns effective identity-to-function facts and their governed lifecycle.
the governed record.
- Deactivation and expiry remove a fact from effective resolution while
retaining provenance and lifecycle evidence.
- Future, active, expired, and revoked relationships remain visually distinct.
Revocation requires a reason, acts immediately, and leaves the record
immutable; later reuse requires a new relationship.
- Typed relationship managers use searchable Identity and group references.
External source, revision, properties, and provenance remain inspectable and
editable under optimistic concurrency.
- Membership inspection uses the production resolution capability and shows
excluded decisions instead of presenting only a flattened member list.
- Missing permission, identity search, and organization functions identify the
required action, responsible administrator, and destination.
@@ -43,9 +54,9 @@ dialogs retain focus containment and return behavior; stable grid actions remain
keyboard reachable. Existing responsive CSS collapses summaries and histories
to one column at narrow widths.
English and German catalogues cover route metadata, assignment fields, governed
states, workflow steps, decisions, confirmations, and accessible labels. Dates
follow the selected platform locale. Manifest topics provide stable route,
field, blocker, workflow, and consequence references without importing optional
Policy, Audit, Notifications, Access, or Workflow Engine implementations.
English and German catalogues cover route metadata, assignment and relationship
fields, governed states, workflow steps, membership decisions, confirmations,
and accessible labels. Dates follow the selected platform locale. Manifest
topics provide stable route, field, blocker, workflow, lifecycle, provenance,
and consequence references without importing optional Policy, Audit,
Notifications, Access, or Workflow Engine implementations.
+18
View File
@@ -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.
+20 -1
View File
@@ -41,6 +41,26 @@ The existing IDM lifecycle worker claims an elapsed relationship and records its
event marker in the same transaction. Repeated or concurrent sweeps therefore do
not publish duplicate expiry events.
## Administration workspace
The `/idm` workspace exposes typed groups and effective relationships to users
with `idm:relationship:read`. Mutations require `idm:relationship:write`; the
write permission also permits the identity search used by the subject and
related-identity selectors without broadening read-only relationship access.
Group and relationship editors retain external provider, resource, revision,
property, and provenance values. Updates carry the loaded optimistic revision,
so a stale editor receives a conflict instead of overwriting another
administrator's change. The relationship directory distinguishes future,
active, expired, and revoked states from the validity window and lifecycle
record. Revocation requires a reason, takes effect immediately, and leaves the
record immutable as evidence.
The membership inspector accepts an effective time and one or more relationship
kinds. It shows both included and excluded decisions with stable reason codes
and identity lifecycle state. This is the same resolution contract used by
downstream consumers; it is not a preview with different semantics.
## Distribution Lists
When Distribution Lists is enabled, an `idm_group` entry resolves through this
@@ -48,4 +68,3 @@ capability. Every effective identity becomes an internal-mail candidate when an
active linked account exists. Every rejected relationship remains visible in the
expansion evidence with a stable reason code. Distribution Lists stores only the
provider reference and frozen expansion evidence, not IDM records.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.15",
"version": "0.1.26",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.45",
"@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
+4 -4
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-idm"
version = "0.1.15"
version = "0.1.26"
description = "GovOPlaN identity management bridge module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.15",
"govoplan-identity>=0.1.15",
"govoplan-organizations>=0.1.15",
"govoplan-core>=0.1.45",
"govoplan-identity>=0.1.18",
"govoplan-organizations>=0.1.18",
]
[tool.setuptools.packages.find]
+20
View File
@@ -0,0 +1,20 @@
"""IDM route dependencies; resolve the current optional Core capability per call."""
from fastapi import HTTPException, status
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
def require_identity_directory(registry: object | None) -> IdentityDirectory:
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
)
return capability
@@ -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
@@ -15,7 +15,6 @@ from govoplan_core.core.events import (
emit_platform_event,
)
from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
IdentityDirectory,
)
from govoplan_core.core.idm import (
@@ -34,6 +33,7 @@ from govoplan_idm.backend.db.models import (
IdmTypedGroup,
)
from .directory_dependencies import require_identity_directory
from .schemas import (
IdentityRelationshipCreateRequest,
IdentityRelationshipDecisionItem,
@@ -154,19 +154,7 @@ def _tenant_row(session: Session, model, item_id: str, tenant_id: str, label: st
def _identity_directory() -> IdentityDirectory:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
)
return capability
return require_identity_directory(get_registry())
def _relationship_directory() -> IdmRelationshipDirectory:
+57 -14
View File
@@ -18,7 +18,6 @@ from govoplan_core.core.configuration_control import (
)
from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
CAPABILITY_IDENTITY_SEARCH,
IdentityDirectory,
IdentityRef,
@@ -44,6 +43,7 @@ from govoplan_idm.backend.assignment_transitions import (
from govoplan_idm.backend.assignment_events import emit_assignment_event
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from .directory_dependencies import require_identity_directory
from .schemas import (
IdmSettingsItem,
IdmSettingsUpdateRequest,
@@ -61,6 +61,7 @@ router = APIRouter(prefix="/idm", tags=["idm"])
ORGANIZATION_IDENTITY_READ_SCOPES = (
"idm:organization_identity:read",
"idm:organization_assignment:write",
"idm:relationship:write",
"organizations:function:assign",
"admin:users:read",
)
@@ -186,19 +187,7 @@ def _default_settings(tenant_id: str) -> IdmSettingsItem:
def _identity_directory() -> IdentityDirectory:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
)
return capability
return require_identity_directory(get_registry())
def _identity_search() -> IdentitySearchProvider:
@@ -515,6 +504,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,
@@ -543,6 +576,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"]
+12
View File
@@ -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",
]
+749
View File
@@ -0,0 +1,749 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_idm.backend.db.models import (
IdmFunctionAssignmentChange,
IdmFunctionAssignmentChangeEvent,
IdmIdentityRelationship,
IdmOrganizationFunctionAssignment,
IdmTypedGroup,
)
IDM_DSAR_CAPABILITY = dsar_capability_name("idm")
_MAX_RECORDS = 5_000
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str | None
identity_id: str | None
references: Mapping[str, str]
@property
def has_canonical_selector(self) -> bool:
return bool(self.account_id or self.identity_id)
class IdmDsarProvider:
provider_id = "idm"
module_id = "idm"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
assignments = _matching_assignments(
db,
tenant_id=tenant_id,
selectors=selectors,
)
relationships = _matching_relationships(
db,
tenant_id=tenant_id,
selectors=selectors,
)
changes = _matching_changes(
db,
tenant_id=tenant_id,
selectors=selectors,
)
if _direct_reference_conflicts(
selectors,
assignments=assignments,
relationships=relationships,
changes=changes,
):
return ()
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
def append(record: DsarRecordRef) -> None:
key = (record.resource_type, record.resource_id)
if key in seen:
return
if len(records) >= _MAX_RECORDS:
raise ValueError(
"IDM DSAR result limit exceeded; narrow the subject selectors."
)
seen.add(key)
records.append(record)
for assignment in assignments:
match_fields = _assignment_match_fields(assignment, selectors)
append(
_record(
"idm_function_assignment",
assignment.id,
"institutional_function_fact",
"IDM organization-function assignment",
{
"match_fields": match_fields,
"identity_id": (
assignment.identity_id
if "identity_id" in match_fields
else None
),
"account_id": (
assignment.account_id
if "account_id" in match_fields
else None
),
"function_id": assignment.function_id,
"organization_unit_id": assignment.organization_unit_id,
"applies_to_subunits": assignment.applies_to_subunits,
"source": assignment.source,
"has_delegated_source": bool(
assignment.delegated_from_assignment_id
),
"acting_for_account_id": (
assignment.acting_for_account_id
if "acting_for_account_id" in match_fields
else None
),
"valid_from": _iso(assignment.valid_from),
"valid_until": _iso(assignment.valid_until),
"expired_event_at": _iso(assignment.expired_event_at),
"is_active": assignment.is_active,
},
observed_at=assignment.updated_at,
)
)
group_ids = {
row.target_group_id
for row in relationships
if row.target_group_id is not None
}
groups = {
row.id: row
for row in _rows_by_ids(
db,
IdmTypedGroup,
tenant_id=tenant_id,
ids=group_ids,
)
}
for group in groups.values():
append(
_record(
"idm_typed_group_context",
group.id,
"typed_relationship_context",
"IDM typed-group relationship context",
{
"key": group.key,
"name": _bounded_text(group.name, 255),
"group_type": group.group_type,
"status": group.status,
"source_provider": group.source_provider,
"revision": group.revision,
},
observed_at=group.updated_at,
immutable=True,
retention_reason=(
"The minimized typed-group definition is retained as context "
"for the subject's effective-dated relationship evidence."
),
)
)
for relationship in relationships:
match_fields = _relationship_match_fields(relationship, selectors)
group = groups.get(relationship.target_group_id or "")
append(
_record(
"idm_identity_relationship",
relationship.id,
"typed_identity_relationship",
"IDM typed identity relationship",
{
"match_fields": match_fields,
"relationship_kind": relationship.relationship_kind,
"subject_identity_id": (
relationship.subject_identity_id
if "subject_identity_id" in match_fields
else None
),
"target_group_id": relationship.target_group_id,
"target_group_key": group.key if group else None,
"target_group_name": _bounded_text(
group.name if group else None,
255,
),
"target_group_type": group.group_type if group else None,
"related_identity_id": (
relationship.related_identity_id
if "related_identity_id" in match_fields
else None
),
"role": _bounded_text(relationship.role, 120),
"valid_from": _iso(relationship.valid_from),
"valid_until": _iso(relationship.valid_until),
"status": relationship.status,
"revoked_at": _iso(relationship.revoked_at),
"revoked_by": (
relationship.revoked_by
if relationship.revoked_by == selectors.account_id
else None
),
"revocation_reason": _bounded_text(
relationship.revocation_reason,
2_000,
),
"expired_event_at": _iso(relationship.expired_event_at),
"source_provider": relationship.source_provider,
"revision": relationship.revision,
},
observed_at=relationship.updated_at,
)
)
change_ids: set[str] = set()
for change in changes:
change_ids.add(change.id)
match_fields = _change_match_fields(change, selectors)
append(
_record(
"idm_function_assignment_change",
change.id,
"function_assignment_governance_evidence",
"IDM governed function-assignment change",
{
"match_fields": match_fields,
"kind": change.kind,
"state": change.state,
"profile": change.profile,
"function_id": change.function_id,
"organization_unit_id": change.organization_unit_id,
"candidate_identity_id": (
change.candidate_identity_id
if "candidate_identity_id" in match_fields
else None
),
"candidate_account_id": (
change.candidate_account_id
if "candidate_account_id" in match_fields
else None
),
"initiator_account_id": (
change.initiator_account_id
if "initiator_account_id" in match_fields
else None
),
"initiator_identity_id": (
change.initiator_identity_id
if "initiator_identity_id" in match_fields
else None
),
"has_represented_assignment": bool(
change.represented_assignment_id
),
"requested_valid_from": _iso(change.requested_valid_from),
"requested_valid_until": _iso(change.requested_valid_until),
"applies_to_subunits": change.applies_to_subunits,
"assignment_source": change.assignment_source,
"resulting_assignment_id": change.resulting_assignment_id,
"expires_at": _iso(change.expires_at),
"resource_revision": change.resource_revision,
},
observed_at=change.updated_at,
immutable=True,
retention_reason=(
"Governed assignment requests and grants retain their state, "
"subject linkage, and outcome as institutional decision evidence."
),
)
)
for event in _matching_change_events(
db,
tenant_id=tenant_id,
selectors=selectors,
change_ids=change_ids,
):
match_fields = []
if event.change_id in change_ids:
match_fields.append("change_id")
if event.actor_account_id == selectors.account_id:
match_fields.append("actor_account_id")
if event.actor_identity_id == selectors.identity_id:
match_fields.append("actor_identity_id")
append(
_record(
"idm_function_assignment_change_event",
event.id,
"function_assignment_governance_evidence",
"IDM function-assignment lifecycle event",
{
"match_fields": match_fields,
"change_id": event.change_id,
"sequence": event.sequence,
"action": event.action,
"from_state": event.from_state,
"to_state": event.to_state,
"actor_account_id": (
event.actor_account_id
if "actor_account_id" in match_fields
else None
),
"actor_identity_id": (
event.actor_identity_id
if "actor_identity_id" in match_fields
else None
),
"actor_assignment_id": (
event.actor_assignment_id
if (
"actor_account_id" in match_fields
or "actor_identity_id" in match_fields
)
else None
),
"created_at": _iso(event.created_at),
},
observed_at=event.created_at,
immutable=True,
retention_reason=(
"Assignment lifecycle events are immutable decision and "
"accountability evidence."
),
)
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("IDM DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.immutable_evidence:
kind = "retain"
rationale = record.retention_reason or (
"IDM governance evidence must be retained."
)
title = f"Retain {record.title}"
else:
kind = "manual_review"
rationale = (
"Function assignments and typed relationships are effective-dated "
"institutional facts. An authorized IDM operator must correct, "
"revoke, deactivate, or expire them through the governed lifecycle "
"after reviewing organizational and third-party consequences."
)
title = f"Review {record.title}"
actions.append(
DsarErasureActionRef(
action_id=f"idm:{kind}:{record.resource_type}:{record.resource_id}",
provider_id=self.provider_id,
module_id=self.module_id,
kind=kind,
resource_type=record.resource_type,
resource_id=record.resource_id,
title=title,
rationale=rationale,
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("IDM DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable:
raise ValueError(
"IDM DSAR does not publish executable erasure actions."
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Use the governed IDM assignment or relationship lifecycle "
"after organizational, evidence, and third-party review."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _matching_assignments(
session: Session,
*,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[IdmOrganizationFunctionAssignment]:
conditions = []
if selectors.identity_id:
conditions.append(
IdmOrganizationFunctionAssignment.identity_id == selectors.identity_id
)
if selectors.account_id:
conditions.extend(
(
IdmOrganizationFunctionAssignment.account_id == selectors.account_id,
IdmOrganizationFunctionAssignment.acting_for_account_id
== selectors.account_id,
)
)
if reference := selectors.references.get("assignment"):
conditions.append(IdmOrganizationFunctionAssignment.id == reference)
return _query_conditions(
session,
IdmOrganizationFunctionAssignment,
tenant_id=tenant_id,
conditions=conditions,
)
def _matching_relationships(
session: Session,
*,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[IdmIdentityRelationship]:
conditions = []
if selectors.identity_id:
conditions.extend(
(
IdmIdentityRelationship.subject_identity_id == selectors.identity_id,
IdmIdentityRelationship.related_identity_id == selectors.identity_id,
)
)
if reference := selectors.references.get("relationship"):
conditions.append(IdmIdentityRelationship.id == reference)
return _query_conditions(
session,
IdmIdentityRelationship,
tenant_id=tenant_id,
conditions=conditions,
)
def _matching_changes(
session: Session,
*,
tenant_id: str,
selectors: _SubjectSelectors,
) -> list[IdmFunctionAssignmentChange]:
conditions = []
if selectors.identity_id:
conditions.extend(
(
IdmFunctionAssignmentChange.candidate_identity_id
== selectors.identity_id,
IdmFunctionAssignmentChange.initiator_identity_id
== selectors.identity_id,
)
)
if selectors.account_id:
conditions.extend(
(
IdmFunctionAssignmentChange.candidate_account_id
== selectors.account_id,
IdmFunctionAssignmentChange.initiator_account_id
== selectors.account_id,
)
)
if reference := selectors.references.get("assignment_change"):
conditions.append(IdmFunctionAssignmentChange.id == reference)
return _query_conditions(
session,
IdmFunctionAssignmentChange,
tenant_id=tenant_id,
conditions=conditions,
)
def _matching_change_events(
session: Session,
*,
tenant_id: str,
selectors: _SubjectSelectors,
change_ids: set[str],
) -> list[IdmFunctionAssignmentChangeEvent]:
conditions = []
if change_ids:
conditions.append(IdmFunctionAssignmentChangeEvent.change_id.in_(change_ids))
if selectors.account_id:
conditions.append(
IdmFunctionAssignmentChangeEvent.actor_account_id == selectors.account_id
)
if selectors.identity_id:
conditions.append(
IdmFunctionAssignmentChangeEvent.actor_identity_id == selectors.identity_id
)
return _query_conditions(
session,
IdmFunctionAssignmentChangeEvent,
tenant_id=tenant_id,
conditions=conditions,
)
def _direct_reference_conflicts(
selectors: _SubjectSelectors,
*,
assignments: Sequence[IdmOrganizationFunctionAssignment],
relationships: Sequence[IdmIdentityRelationship],
changes: Sequence[IdmFunctionAssignmentChange],
) -> bool:
if not selectors.has_canonical_selector:
return False
checks = (
(
"assignment",
assignments,
lambda row: _assignment_match_fields(row, selectors),
),
(
"relationship",
relationships,
lambda row: _relationship_match_fields(row, selectors),
),
(
"assignment_change",
changes,
lambda row: _change_match_fields(row, selectors),
),
)
for kind, rows, match in checks:
reference = selectors.references.get(kind)
if not reference:
continue
row = next((item for item in rows if item.id == reference), None)
if row is None or not [field for field in match(row) if field != "reference"]:
return True
return False
def _assignment_match_fields(
row: IdmOrganizationFunctionAssignment,
selectors: _SubjectSelectors,
) -> list[str]:
fields = []
if row.identity_id == selectors.identity_id:
fields.append("identity_id")
if selectors.account_id and row.account_id == selectors.account_id:
fields.append("account_id")
if selectors.account_id and row.acting_for_account_id == selectors.account_id:
fields.append("acting_for_account_id")
if row.id == selectors.references.get("assignment"):
fields.append("reference")
return fields
def _relationship_match_fields(
row: IdmIdentityRelationship,
selectors: _SubjectSelectors,
) -> list[str]:
fields = []
if row.subject_identity_id == selectors.identity_id:
fields.append("subject_identity_id")
if selectors.identity_id and row.related_identity_id == selectors.identity_id:
fields.append("related_identity_id")
if row.revoked_by == selectors.account_id:
fields.append("revoked_by")
if row.id == selectors.references.get("relationship"):
fields.append("reference")
return fields
def _change_match_fields(
row: IdmFunctionAssignmentChange,
selectors: _SubjectSelectors,
) -> list[str]:
fields = []
for field, expected in (
("candidate_identity_id", selectors.identity_id),
("candidate_account_id", selectors.account_id),
("initiator_identity_id", selectors.identity_id),
("initiator_account_id", selectors.account_id),
):
if expected and getattr(row, field) == expected:
fields.append(field)
if row.id == selectors.references.get("assignment_change"):
fields.append("reference")
return fields
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
groups = {
"account_id": (
subject.account_id,
subject.external_references.get("idm.account"),
subject.external_references.get("access.account"),
),
"identity_id": (
subject.identity_id,
subject.external_references.get("idm.identity"),
subject.external_references.get("identity.id"),
),
}
normalized: dict[str, str | None] = {}
for key, values in groups.items():
distinct = {value for item in values if (value := _normalized_id(item))}
if len(distinct) > 1:
return None
normalized[key] = next(iter(distinct), None)
aliases = {
"idm.assignment": "assignment",
"idm.relationship": "relationship",
"idm.assignment_change": "assignment_change",
}
references = {
target: value
for source, target in aliases.items()
if (value := _normalized_id(subject.external_references.get(source)))
}
return _SubjectSelectors(references=references, **normalized)
def _rows_by_ids(
session: Session,
model: type,
*,
tenant_id: str,
ids: set[str],
) -> list[object]:
if not ids:
return []
return _bounded_rows(
session.query(model)
.filter(model.tenant_id == tenant_id, model.id.in_(ids))
.order_by(model.id)
)
def _query_conditions(
session: Session,
model: type,
*,
tenant_id: str,
conditions: Sequence[object],
) -> list[object]:
if not conditions:
return []
return _bounded_rows(
session.query(model)
.filter(model.tenant_id == tenant_id, or_(*conditions))
.order_by(model.id)
)
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "idm" or record.module_id != "idm":
raise ValueError("IDM DSAR received a foreign provider record.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "idm" or action.module_id != "idm":
raise ValueError("IDM DSAR received a foreign provider action.")
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None,
immutable: bool = False,
retention_reason: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="idm",
module_id="idm",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=observed_at,
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path="/idm",
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("IDM DSAR provider requires a SQLAlchemy session.")
return value
def _bounded_rows(query: object) -> list[object]:
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
if len(rows) > _MAX_RECORDS:
raise ValueError("IDM DSAR match limit exceeded; narrow the subject selectors.")
return rows
def _bounded_text(value: str | None, limit: int) -> str | None:
return value[:limit] if value else None
def _normalized_id(value: object) -> str | None:
if value is None:
return None
value = str(value).strip()
return value or None
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
__all__ = ["IDM_DSAR_CAPABILITY", "IdmDsarProvider"]
@@ -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(
if step == "holder":
route = resolve_function_route_availability(
session,
tenant_id=change.tenant_id,
function_id=change.function_id,
):
return "The function is vacant; no effective holder can review this change."
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.'}}
+592 -23
View File
@@ -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 (
@@ -29,6 +33,7 @@ from govoplan_core.core.organizations import (
from govoplan_core.core.views import ViewSurface
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
@@ -41,19 +46,30 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
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
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.15"
MODULE_VERSION = "0.1.26"
IDM_READ_SCOPES = (
"idm:organization_assignment:read",
@@ -191,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):
@@ -216,6 +234,89 @@ def _relationship_directory(context: ModuleContext) -> object:
return SqlIdmRelationshipDirectory(identities=identities)
def _idm_dsar_provider(context: ModuleContext) -> object:
del context
from govoplan_idm.backend.dsar_provider import IdmDsarProvider
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",
@@ -258,6 +359,10 @@ manifest = ModuleManifest(
name="idm.function_assignment_changes",
version="1.0.0",
),
ModuleInterfaceProvider(
name=IDM_DSAR_CAPABILITY,
version="0.1.0",
),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -276,12 +381,43 @@ 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",
module_id="idm",
label="i18n:govoplan-core.product_area.people_responsibility",
icon="users",
description="i18n:govoplan-core.product_area.people_responsibility_description",
surface_ids=("idm.nav.idm", "idm.route.idm"),
order=70,
),
),
view_surfaces=(
ViewSurface(
id="idm.action.view-function-assignments",
@@ -313,11 +449,196 @@ manifest = ModuleManifest(
CAPABILITY_IDM_DIRECTORY: _idm_directory,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory,
IDM_DSAR_CAPABILITY: _idm_dsar_provider,
},
capability_documentation={
IDM_DSAR_CAPABILITY: CapabilityDocumentation(
label="IDM data-subject request provider",
summary=(
"Finds tenant-scoped function assignments, typed relationships, "
"governed changes, and lifecycle evidence with third-party and opaque "
"payload minimization."
),
contract_version="0.1.0",
documentation_types=("admin",),
audience=("privacy_officer", "idm_admin", "records_manager"),
),
},
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.workspace-layout",
title="IDM workspace layout",
summary="Find workspace actions and read consistently arranged content.",
body="Documentation books sit beside IDM and the relevant governance, request, or relationship "
"heading. Emergency override guidance is attached to that phrase, and field help stays with "
"its label. "
"Function requests and grants, typed groups, effective identity relationships, and function assignments use full-width table cards with consistent spacing. Card headings and actions remain above each table; explanatory taglines are kept out of the table surface. Relationship help still explains the essential boundary: institutional membership does not grant application permissions; Access evaluates authority separately. Administrators retain the existing read, write, request, grant, and decision permissions. Shared Core card and grid layouts replace per-section width or gap workarounds.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Identitätsmanagement: Aufbau des Arbeitsbereichs",
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
"body": "Dokumentationsbücher stehen neben IDM und der jeweiligen Überschrift zu Governance, Anfragen "
"oder Beziehungen. Hinweise zu Notfallübersteuerungen stehen direkt an diesem Begriff, und "
"Feldhilfe bleibt bei der Feldbezeichnung. "
"Funktionsanträge und -vergaben, typisierte Gruppen, wirksame Identitätsbeziehungen und Funktionszuordnungen verwenden Tabellenkarten über die gesamte Breite mit einheitlichen Abständen. Überschrift und Aktionen bleiben über der jeweiligen Tabelle; erläuternde Unterzeilen entfallen in der Tabellenfläche. Die Beziehungshilfe erklärt weiterhin die wesentliche Grenze: Institutionelle Mitgliedschaft erteilt keine Anwendungsrechte; Access bewertet Berechtigungen getrennt. Administratoren behalten die vorhandenen Lese-, Schreib-, Antrags-, Vergabe- und Entscheidungsrechte. Gemeinsame Core-Karten- und Rasterlayouts ersetzen lokale Breiten- oder Abstandsbehelfe.",
}},
),
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",
summary=(
"Collect tenant-scoped institutional function and relationship facts "
"while preserving governed decision evidence."
),
body=(
"IDM searches corroborated account and identity selectors plus "
"namespaced assignment, relationship, and assignment-change references. "
"Results include effective-dated organization-function assignments, typed "
"identity relationships with minimized group context, governed assignment "
"requests or grants, and related lifecycle events. When a record concerns "
"another candidate or actor, their identity and account identifiers are "
"removed from the automated export. Settings, group properties, external "
"source references, provenance, justifications, evidence arrays, policy "
"decisions, workflow internals, idempotency keys, request digests, opaque "
"metadata, event comments and details, unrelated records, and other tenants "
"are excluded. Assignments and relationships are effective institutional "
"facts, so correction, revocation, deactivation, or expiry requires an "
"authorized IDM lifecycle review. Governed change and event records retain "
"explicit decision-evidence reasons. Identity owns the person record, "
"Organizations owns functions and units, and Access owns the authority "
"derived from accepted IDM facts."
),
layer="static",
documentation_types=("admin",),
audience=(
"privacy_officer",
"idm_admin",
"records_manager",
"operator",
),
related_modules=(
"access",
"audit",
"identity",
"organizations",
"records",
),
conditions=(DocumentationCondition(required_modules=("idm", "access")),),
links=(
DocumentationLink(
label="Data-subject requests",
href="/admin?section=tenant-data-subject-requests",
kind="runtime",
),
DocumentationLink(
label="IDM assignments",
href="/idm",
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": [
"idm.route.assignments",
"idm.action.view-function-assignments",
"idm.function-change.request",
],
},
order=24,
),
DocumentationTopic(
id="idm.search.directory",
title="Search authorized IDM records",
@@ -332,6 +653,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(
@@ -347,6 +683,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(
@@ -356,6 +706,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."
),
@@ -375,6 +726,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": [
@@ -384,11 +755,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,
@@ -396,17 +770,127 @@ manifest = ModuleManifest(
DocumentationTopic(
id="idm.reference.typed-relationships",
title="Typed groups and effective relationships",
summary="IDM keeps business group membership separate from identity lifecycle status.",
summary="Create tenant-scoped business groups, maintain effective-dated identity links, inspect membership decisions, and retain source provenance without turning membership into access authority.",
body=(
"Typed groups and identity relationships are tenant-scoped, effective-dated facts. "
"Current, future, expired, and revoked links remain explainable, including external "
"directory source revisions and provenance. Consumers such as Distribution Lists use "
"the IDM relationship capability and never infer application permissions from membership."
"Typed groups and identity relationships are tenant-scoped institutional facts. Administrators use stable group keys and types, searchable identity and group selectors, effective dates, source references, typed properties, and provenance to record why a relationship exists. "
"A future relationship is scheduled but not yet effective. An expired relationship no longer contributes to membership, and revocation stops membership immediately while retaining the actor, time, reason, source, and revision as evidence. Revoked relationships cannot be edited or reactivated; create a replacement when the fact becomes valid again. "
"The membership inspector evaluates a selected group, time, and relationship kind through the same Core capability used by downstream consumers. Included and excluded decisions remain visible with reason codes and identity lifecycle state. Business membership never grants application permissions; Access evaluates roles and rights separately."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("identity", "organizations", "dist_lists"),
conditions=(
DocumentationCondition(
required_modules=("idm",),
any_scopes=("idm:relationship:read", "idm:relationship:write"),
),
),
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",
),
),
translations={
"de": {
"title": "Typisierte Gruppen und wirksame Beziehungen",
"summary": "Erstellen Sie mandantenbezogene Fachgruppen, pflegen Sie zeitlich wirksame Identitätsbeziehungen, prüfen Sie Mitgliedschaftsentscheidungen und bewahren Sie die Herkunft auf, ohne Mitgliedschaft mit Zugriffsrechten gleichzusetzen.",
"body": "Typisierte Gruppen und Identitätsbeziehungen sind mandantenbezogene institutionelle Tatsachen. Administrierende erfassen mit stabilen Gruppenschlüsseln und -typen, durchsuchbaren Identitäts- und Gruppenauswahlen, Wirksamkeitsdaten, Quellreferenzen, typisierten Eigenschaften und Herkunftsnachweisen, warum eine Beziehung besteht. Eine zukünftige Beziehung ist geplant, aber noch nicht wirksam. Eine abgelaufene Beziehung trägt nicht mehr zur Mitgliedschaft bei. Ein Widerruf beendet die Mitgliedschaft sofort, bewahrt jedoch handelnde Person, Zeitpunkt, Grund, Quelle und Revision als Nachweis. Widerrufene Beziehungen können weder bearbeitet noch reaktiviert werden; wird die Tatsache erneut gültig, ist eine neue Beziehung anzulegen. Die Mitgliedschaftsprüfung wertet eine gewählte Gruppe, einen Zeitpunkt und eine Beziehungsart über dieselbe Core-Fähigkeit aus, die nachgelagerte Verbraucher verwenden. Einbezogene und ausgeschlossene Entscheidungen bleiben mit Begründungscode und Identitätsstatus sichtbar. Eine fachliche Mitgliedschaft erteilt niemals Anwendungsberechtigungen; Access bewertet Rollen und Rechte getrennt.",
}
},
metadata={
"kind": "reference",
"route": "/idm",
"screen": "Typed groups and identity relationships",
"help_contexts": [
"idm.relationships.page",
"idm.typed-groups.action.reload",
"idm.typed-groups.action.create",
"idm.typed-groups.action.edit",
"idm.typed-groups.action.save",
"idm.typed-groups.action.inspect-memberships",
"idm.typed-groups.action.resolve-memberships",
"idm.typed-groups.editor",
"idm.typed-groups.membership-resolution",
"idm.typed-groups.field.show-inactive",
"idm.typed-groups.field.key",
"idm.typed-groups.field.name",
"idm.typed-groups.field.type",
"idm.typed-groups.field.status",
"idm.typed-groups.field.description",
"idm.typed-groups.field.source-provider",
"idm.typed-groups.field.source-resource-type",
"idm.typed-groups.field.source-resource-id",
"idm.typed-groups.field.source-revision",
"idm.typed-groups.field.properties",
"idm.typed-groups.field.provenance",
"idm.typed-groups.field.membership-effective-at",
"idm.typed-groups.field.membership-kinds",
"idm.relationships.action.reload",
"idm.relationships.action.create",
"idm.relationships.action.edit",
"idm.relationships.action.save",
"idm.relationships.action.revoke",
"idm.relationships.action.confirm-revoke",
"idm.relationships.editor",
"idm.relationships.confirm-revoke",
"idm.relationships.field.show-revoked",
"idm.relationships.field.kind",
"idm.relationships.field.role",
"idm.relationships.field.subject-identity",
"idm.relationships.field.target-type",
"idm.relationships.field.target-group",
"idm.relationships.field.related-identity",
"idm.relationships.field.valid-from",
"idm.relationships.field.valid-until",
"idm.relationships.field.source-provider",
"idm.relationships.field.source-resource-type",
"idm.relationships.field.source-resource-id",
"idm.relationships.field.source-revision",
"idm.relationships.field.properties",
"idm.relationships.field.provenance",
"idm.relationships.field.revocation-reason",
],
"prerequisites": [
"The identities exist in the tenant identity directory.",
"The actor has relationship read permission and write permission for mutations.",
"The accountable source, effective window, relationship kind, and business purpose are known.",
],
"steps": [
"Create or select a typed group with a stable key, type, and source provenance.",
"Create a relationship with searchable subject and target references and the intended validity window.",
"Inspect effective memberships at the relevant time and review every included or excluded decision.",
"Revoke a relationship with a retained reason when the fact must stop before its scheduled end.",
],
"outcome": "The tenant has explainable, effective-dated business membership facts that downstream consumers can resolve without importing IDM internals or inferring Access rights.",
"limitations": [
"Membership resolution is tenant-scoped and rejects cross-tenant group references.",
"A revoked relationship is immutable and requires a replacement for later reuse.",
"Membership alone never activates an identity or grants an application permission.",
],
"consequences": [
"A future start delays membership until the selected instant.",
"Expiry removes the relationship from effective resolution while retaining evidence.",
"Revocation immediately removes the relationship from effective resolution and cannot be undone.",
"Changing an externally sourced fact without matching provenance can break reconciliation accountability.",
],
"verification": "Reload both directories, confirm the record revision and source fields, then resolve the target group's memberships at times before, during, and after the validity window. Verify that Access permissions remain unchanged.",
},
order=28,
),
DocumentationTopic(
@@ -418,14 +902,13 @@ manifest = ModuleManifest(
"Then create the assignment in IDM. Direct assignments state who holds the function. Delegated assignments require a source assignment and a delegable function. "
"Acting-for assignments require a source assignment, an acting account, and a function that allows acting in place. "
"Access maps accepted function facts to roles and rights; without such a mapping, the assignment is recorded but does not grant application permissions. "
"The assignment workspace uses the available application width so governance controls and assignment data remain visible together."
),
layer="configured",
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",
@@ -438,6 +921,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": [
@@ -462,18 +963,31 @@ manifest = ModuleManifest(
"function and unit are owned by Organizations. Source distinguishes "
"direct, delegated, acting-for, directory, governance, and system facts. "
"Delegation and acting-for require a valid source assignment and the "
"corresponding function permission. Subunit scope broadens the fact's "
"corresponding Organizations function permission. A delegate acts as themself; "
"acting-for additionally requires Access to select the exact representation "
"context before it contributes authority. Source and derived assignments must "
"remain current, active, tenant-local, and function-compatible. Subunit scope broadens the fact's "
"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",
@@ -481,6 +995,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": [
@@ -495,11 +1033,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.",
},
@@ -513,13 +1054,41 @@ 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
)
@@ -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")
+553
View File
@@ -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",
]
+76
View File
@@ -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:
+41
View File
@@ -99,6 +99,26 @@ class AssignmentWorkflowTests(unittest.TestCase):
),
)
def test_delegated_assignment_rejects_function_that_forbids_delegation(self) -> None:
base = assignment(id="source-1")
item = assignment(
id="assignment-2",
identity_id="identity-2",
account_id="account-2",
source="delegated",
delegated_from_assignment_id="source-1",
)
self.assert_invalid(
"This organization function does not allow delegation.",
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(delegable=False),
base=base,
account_linked_to_identity=lambda _identity_id, _account_id: False,
),
)
def test_acting_for_assignment_accepts_identity_linked_account(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
item = assignment(
@@ -136,6 +156,27 @@ class AssignmentWorkflowTests(unittest.TestCase):
),
)
def test_acting_for_assignment_rejects_function_that_forbids_representation(self) -> None:
base = assignment(id="source-1")
item = assignment(
id="assignment-2",
identity_id="identity-2",
account_id="acting-account",
source="acting_for",
delegated_from_assignment_id="source-1",
acting_for_account_id="account-1",
)
self.assert_invalid(
"This organization function does not allow acting in place.",
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(act_in_place_allowed=False),
base=base,
account_linked_to_identity=lambda _identity_id, _account_id: False,
),
)
def test_update_plan_does_not_mutate_until_applied(self) -> None:
item = assignment()
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import unittest
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
from govoplan_identity.backend.db import models as identity_models # noqa: F401
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_idm.backend.delegation_routes import validate_delegation_chain
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
class DelegationRouteTests(unittest.TestCase):
def setUp(self) -> None:
self.database = configure_database("sqlite:///:memory:")
Base.metadata.create_all(
self.database.engine,
tables=[IdmOrganizationFunctionAssignment.__table__],
)
self.now = datetime(2026, 8, 22, 12, tzinfo=timezone.utc)
def tearDown(self) -> None:
reset_database(dispose=True)
def assignment(
self,
assignment_id: str,
*,
source: str = "direct",
parent: str | None = None,
active: bool = True,
valid_from: datetime | None = None,
valid_until: datetime | None = None,
) -> IdmOrganizationFunctionAssignment:
return IdmOrganizationFunctionAssignment(
id=assignment_id,
tenant_id="tenant-1",
identity_id=f"identity-{assignment_id}",
account_id=f"account-{assignment_id}",
function_id="function-1",
organization_unit_id="unit-1",
source=source,
delegated_from_assignment_id=parent,
is_active=active,
valid_from=valid_from,
valid_until=valid_until,
settings={},
)
@staticmethod
def decision(
*,
allowed: bool = True,
depth: int = 3,
validity_days: int | None = None,
) -> FunctionAssignmentGovernanceDecision:
return FunctionAssignmentGovernanceDecision(
allowed=True,
delegation_allowed=allowed,
maximum_delegation_depth=depth if allowed else 0,
maximum_delegated_validity_days=validity_days,
)
def test_complete_effective_chain_is_accepted(self) -> None:
with self.database.session() as session:
root = self.assignment(
"root",
valid_from=self.now - timedelta(days=30),
valid_until=self.now + timedelta(days=30),
)
first = self.assignment(
"first",
source="delegated",
parent="root",
valid_from=self.now - timedelta(days=10),
valid_until=self.now + timedelta(days=20),
)
second = self.assignment(
"second",
source="delegated",
parent="first",
valid_from=self.now - timedelta(days=1),
valid_until=self.now + timedelta(days=5),
)
session.add_all((root, first, second))
session.flush()
route = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(depth=2),
effective_at=self.now,
)
self.assertTrue(route.effective)
self.assertEqual(("second", "first", "root"), route.chain_assignment_ids)
self.assertEqual(2, route.delegation_depth)
def test_policy_tightening_and_over_depth_fail_closed(self) -> None:
with self.database.session() as session:
root = self.assignment("root")
first = self.assignment("first", source="delegated", parent="root")
second = self.assignment("second", source="delegated", parent="first")
session.add_all((root, first, second))
session.flush()
disabled = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(allowed=False),
effective_at=self.now,
)
shallow = validate_delegation_chain(
session,
assignment=second,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(depth=1),
effective_at=self.now,
)
self.assertEqual("policy_tightened", disabled.code)
self.assertEqual("over_depth", shallow.code)
def test_expired_cyclic_and_overlong_routes_are_explained(self) -> None:
with self.database.session() as session:
expired = self.assignment(
"expired",
valid_until=self.now - timedelta(seconds=1),
)
cycle_a = self.assignment("cycle-a", source="delegated", parent="cycle-b")
cycle_b = self.assignment("cycle-b", source="delegated", parent="cycle-a")
overlong = self.assignment(
"overlong",
source="delegated",
parent="root",
valid_from=self.now,
valid_until=self.now + timedelta(days=31),
)
root = self.assignment("root")
session.add_all(
(expired, cycle_a, cycle_b, root, overlong)
)
session.flush()
expired_route = validate_delegation_chain(
session,
assignment=expired,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(),
effective_at=self.now,
)
cyclic_route = validate_delegation_chain(
session,
assignment=cycle_a,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(),
effective_at=self.now,
)
overlong_route = validate_delegation_chain(
session,
assignment=overlong,
tenant_id="tenant-1",
function_id="function-1",
decision=self.decision(validity_days=30),
effective_at=self.now,
)
self.assertEqual("expired", expired_route.code)
self.assertEqual("cyclic", cyclic_route.code)
self.assertEqual("policy_tightened", overlong_route.code)
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from fastapi import HTTPException
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_idm.backend.api.v1 import function_changes, relationships, routes
class DirectoryDependencyTests(unittest.TestCase):
def test_identical_routes_preserve_missing_invalid_and_success_contracts(self) -> None:
valid = Mock(spec=IdentityDirectory)
for route in (relationships, routes):
for registry, expected_status, expected_detail in (
(None, 503, "Identity directory is unavailable"),
(Mock(has_capability=Mock(return_value=False)), 503, "Identity directory is unavailable"),
(Mock(has_capability=Mock(return_value=True), require_capability=Mock(return_value=object())), 500,
f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}"),
):
with self.subTest(route=route.__name__, status=expected_status), patch.object(route, "get_registry", return_value=registry):
with self.assertRaises(HTTPException) as caught:
route._identity_directory()
self.assertEqual(expected_status, caught.exception.status_code)
self.assertEqual(expected_detail, caught.exception.detail)
registry = Mock(has_capability=Mock(return_value=True), require_capability=Mock(return_value=valid))
with patch.object(route, "get_registry", return_value=registry):
self.assertIs(valid, route._identity_directory())
registry.has_capability.assert_called_once_with(CAPABILITY_IDENTITY_DIRECTORY)
registry.require_capability.assert_called_once_with(CAPABILITY_IDENTITY_DIRECTORY)
def test_each_call_resolves_current_registry_without_caching_authority(self) -> None:
for route in (relationships, routes):
valid = Mock(spec=IdentityDirectory)
registry = Mock(has_capability=Mock(return_value=True), require_capability=Mock(return_value=valid))
with patch.object(route, "get_registry", side_effect=[registry, None]) as get_registry:
self.assertIs(valid, route._identity_directory())
with self.assertRaises(HTTPException) as caught:
route._identity_directory()
self.assertEqual(503, caught.exception.status_code)
self.assertEqual(2, get_registry.call_count)
def test_lookup_failure_is_not_silently_replaced_or_retried(self) -> None:
failure = RuntimeError("registry changed during lookup")
for route in (relationships, routes):
registry = Mock(has_capability=Mock(return_value=True), require_capability=Mock(side_effect=failure))
with patch.object(route, "get_registry", return_value=registry), self.assertRaises(RuntimeError) as caught:
route._identity_directory()
self.assertIs(failure, caught.exception)
self.assertEqual(1, registry.require_capability.call_count)
def test_function_changes_keeps_its_distinct_unavailable_contract(self) -> None:
registry = SimpleNamespace(capability=lambda _name: object())
with patch.object(function_changes, "get_registry", return_value=registry), self.assertRaises(HTTPException) as caught:
function_changes._identity_directory()
self.assertEqual(503, caught.exception.status_code)
self.assertEqual("The Identity directory is unavailable.", caught.exception.detail)
if __name__ == "__main__":
unittest.main()
+560
View File
@@ -0,0 +1,560 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_identity.backend.db.models import CanonicalIdentity
from govoplan_idm.backend.db.models import (
IdmFunctionAssignmentChange,
IdmFunctionAssignmentChangeEvent,
IdmIdentityRelationship,
IdmOrganizationFunctionAssignment,
IdmTypedGroup,
)
from govoplan_idm.backend.dsar_provider import IDM_DSAR_CAPABILITY, IdmDsarProvider
from govoplan_idm.backend.manifest import manifest
from govoplan_organizations.backend.db.models import (
OrganizationFunction,
OrganizationUnit,
)
class _Registry:
def __init__(self, provider: IdmDsarProvider, *, idm_active: bool = True) -> None:
self.provider = provider
self.idm_active = idm_active
def capability_names(self):
return (IDM_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "idm"
def tenant_entitlement_resolver(self):
idm_active = self.idm_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("idm",) if idm_active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "idm"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != IDM_DSAR_CAPABILITY:
raise KeyError(name)
class IdmDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=self.engine)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
self.identity = CanonicalIdentity(
id="identity-1",
display_name="Subject",
settings={"secret": "identity-settings-do-not-export"},
)
other_identity = CanonicalIdentity(
id="identity-other",
display_name="Unrelated Person",
settings={"secret": "other-identity-settings-do-not-export"},
)
unit = OrganizationUnit(
id="unit-1",
tenant_id="tenant-1",
slug="residents",
name="Residents Office",
)
function = OrganizationFunction(
id="function-1",
tenant_id="tenant-1",
organization_unit_id=unit.id,
slug="case-worker",
name="Case worker",
)
acting_function = OrganizationFunction(
id="function-2",
tenant_id="tenant-1",
organization_unit_id=unit.id,
slug="acting-case-worker",
name="Acting case worker",
)
self.assignment = IdmOrganizationFunctionAssignment(
id="assignment-1",
tenant_id="tenant-1",
identity_id=self.identity.id,
account_id="account-1",
function_id=function.id,
organization_unit_id=unit.id,
source="direct",
valid_from=now,
settings={"secret": "assignment-settings-do-not-export"},
)
acting_assignment = IdmOrganizationFunctionAssignment(
id="assignment-acting",
tenant_id="tenant-1",
identity_id=other_identity.id,
account_id="account-other",
function_id=acting_function.id,
organization_unit_id=unit.id,
source="acting_for",
delegated_from_assignment_id=self.assignment.id,
acting_for_account_id="account-1",
valid_from=now,
settings={"secret": "acting-settings-do-not-export"},
)
self.unrelated_assignment = IdmOrganizationFunctionAssignment(
id="assignment-unrelated",
tenant_id="tenant-1",
identity_id=other_identity.id,
account_id="account-other",
function_id=function.id,
organization_unit_id=unit.id,
source="direct",
settings={"secret": "unrelated-assignment-do-not-export"},
)
tenant_two_assignment = IdmOrganizationFunctionAssignment(
id="assignment-tenant-2",
tenant_id="tenant-2",
identity_id=self.identity.id,
account_id="account-1",
function_id=function.id,
organization_unit_id=unit.id,
source="directory",
settings={"secret": "other-tenant-assignment-do-not-export"},
)
self.group = IdmTypedGroup(
id="group-1",
tenant_id="tenant-1",
key="residents",
name="Residents",
group_type="business_group",
source_provider="ldap",
source_resource_id="private-group-ref-do-not-export",
properties={"secret": "group-properties-do-not-export"},
provenance={"secret": "group-provenance-do-not-export"},
)
self.relationship = IdmIdentityRelationship(
id="relationship-1",
tenant_id="tenant-1",
relationship_kind="member_of",
subject_identity_id=self.identity.id,
target_group_id=self.group.id,
role="member",
valid_from=now,
source_provider="ldap",
source_resource_id="private-relationship-ref-do-not-export",
source_revision="private-source-revision-do-not-export",
properties={"secret": "relationship-properties-do-not-export"},
provenance={"secret": "relationship-provenance-do-not-export"},
)
related_relationship = IdmIdentityRelationship(
id="relationship-related",
tenant_id="tenant-1",
relationship_kind="representative_for",
subject_identity_id=other_identity.id,
related_identity_id=self.identity.id,
role="representative",
properties={"secret": "related-properties-do-not-export"},
provenance={"secret": "related-provenance-do-not-export"},
)
unrelated_relationship = IdmIdentityRelationship(
id="relationship-unrelated",
tenant_id="tenant-1",
relationship_kind="member_of",
subject_identity_id=other_identity.id,
target_group_id=self.group.id,
role="member",
)
tenant_two_relationship = IdmIdentityRelationship(
id="relationship-tenant-2",
tenant_id="tenant-2",
relationship_kind="member_of",
subject_identity_id=self.identity.id,
target_group_id=self.group.id,
role="member",
properties={"secret": "other-tenant-relationship-do-not-export"},
)
self.change = IdmFunctionAssignmentChange(
id="change-1",
tenant_id="tenant-1",
kind="request",
state="approved",
profile="self_request",
function_id=function.id,
organization_unit_id=unit.id,
candidate_identity_id=self.identity.id,
candidate_account_id="account-1",
initiator_account_id="account-other",
initiator_identity_id=other_identity.id,
justification="private-justification-do-not-export",
evidence=["private-evidence-do-not-export"],
requested_valid_from=now,
required_steps=["approval-secret-do-not-export"],
completed_steps=["approval-secret-do-not-export"],
policy_decision={"secret": "policy-decision-do-not-export"},
workflow_definition_id="workflow-secret-do-not-export",
workflow_instance_id="workflow-instance-do-not-export",
idempotency_key="idempotency-key-do-not-export",
outcome_reason="outcome-reason-do-not-export",
metadata_={"secret": "change-metadata-do-not-export"},
)
initiated_change = IdmFunctionAssignmentChange(
id="change-initiated",
tenant_id="tenant-1",
kind="grant",
state="pending",
profile="authority_grant",
function_id=acting_function.id,
organization_unit_id=unit.id,
candidate_identity_id=other_identity.id,
candidate_account_id="account-other",
initiator_account_id="account-1",
initiator_identity_id=self.identity.id,
justification="third-party-justification-do-not-export",
evidence=["third-party-evidence-do-not-export"],
idempotency_key="initiated-change-key-do-not-export",
metadata_={"secret": "initiated-metadata-do-not-export"},
)
unrelated_change = IdmFunctionAssignmentChange(
id="change-unrelated",
tenant_id="tenant-1",
kind="grant",
state="pending",
profile="authority_grant",
function_id=function.id,
organization_unit_id=unit.id,
candidate_identity_id=other_identity.id,
candidate_account_id="account-other",
initiator_account_id="account-other",
initiator_identity_id=other_identity.id,
justification="unrelated-change-do-not-export",
idempotency_key="unrelated-change-key",
)
tenant_two_change = IdmFunctionAssignmentChange(
id="change-tenant-2",
tenant_id="tenant-2",
kind="request",
state="pending",
profile="self_request",
function_id=function.id,
organization_unit_id=unit.id,
candidate_identity_id=self.identity.id,
candidate_account_id="account-1",
initiator_account_id="account-1",
initiator_identity_id=self.identity.id,
justification="other-tenant-change-do-not-export",
idempotency_key="tenant-two-key",
)
self.event = IdmFunctionAssignmentChangeEvent(
id="event-1",
tenant_id="tenant-1",
change_id=self.change.id,
sequence=1,
action="approved",
from_state="pending",
to_state="approved",
actor_account_id="account-other",
actor_identity_id=other_identity.id,
actor_assignment_id=self.unrelated_assignment.id,
comment="private-event-comment-do-not-export",
evidence=["private-event-evidence-do-not-export"],
policy_decision={"secret": "event-policy-do-not-export"},
workflow_step_id="workflow-step-do-not-export",
details={"secret": "event-details-do-not-export"},
created_at=now,
)
actor_event = IdmFunctionAssignmentChangeEvent(
id="event-actor",
tenant_id="tenant-1",
change_id=unrelated_change.id,
sequence=1,
action="reviewed",
from_state="pending",
to_state="pending",
actor_account_id="account-1",
actor_identity_id=self.identity.id,
actor_assignment_id=self.assignment.id,
comment="actor-comment-do-not-export",
evidence=["actor-evidence-do-not-export"],
policy_decision={"secret": "actor-policy-do-not-export"},
workflow_step_id="actor-workflow-step-do-not-export",
details={"secret": "actor-details-do-not-export"},
created_at=now,
)
tenant_two_event = IdmFunctionAssignmentChangeEvent(
id="event-tenant-2",
tenant_id="tenant-2",
change_id=tenant_two_change.id,
sequence=1,
action="requested",
to_state="pending",
actor_account_id="account-1",
actor_identity_id=self.identity.id,
details={"secret": "other-tenant-event-do-not-export"},
created_at=now,
)
self.session.add_all(
[
self.identity,
other_identity,
unit,
function,
acting_function,
self.assignment,
acting_assignment,
self.unrelated_assignment,
tenant_two_assignment,
self.group,
self.relationship,
related_relationship,
unrelated_relationship,
tenant_two_relationship,
self.change,
initiated_change,
unrelated_change,
tenant_two_change,
self.event,
actor_event,
tenant_two_event,
]
)
self.session.commit()
self.provider = IdmDsarProvider()
self.subject = DsarSubjectRef(
account_id="account-1",
identity_id=self.identity.id,
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
self.assertIn(
IDM_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
provider = manifest.capability_factories[IDM_DSAR_CAPABILITY](None)
self.assertIsInstance(provider, DsarProvider)
def test_search_is_tenant_scoped_third_party_safe_and_minimized(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
self.assertTrue(
{
"idm_function_assignment",
"idm_identity_relationship",
"idm_typed_group_context",
"idm_function_assignment_change",
"idm_function_assignment_change_event",
}.issubset({record.resource_type for record in records})
)
serialized = repr([record.to_dict() for record in records])
self.assertIn("assignment-acting", serialized)
self.assertIn("relationship-related", serialized)
self.assertIn("change-initiated", serialized)
self.assertIn("event-actor", serialized)
excluded = (
"identity-other",
"account-other",
"assignment-settings-do-not-export",
"private-group-ref-do-not-export",
"group-properties-do-not-export",
"group-provenance-do-not-export",
"private-relationship-ref-do-not-export",
"private-source-revision-do-not-export",
"relationship-properties-do-not-export",
"relationship-provenance-do-not-export",
"private-justification-do-not-export",
"private-evidence-do-not-export",
"approval-secret-do-not-export",
"policy-decision-do-not-export",
"workflow-secret-do-not-export",
"workflow-instance-do-not-export",
"idempotency-key-do-not-export",
"outcome-reason-do-not-export",
"change-metadata-do-not-export",
"private-event-comment-do-not-export",
"private-event-evidence-do-not-export",
"event-policy-do-not-export",
"workflow-step-do-not-export",
"event-details-do-not-export",
"unrelated-change-do-not-export",
"other-tenant-assignment-do-not-export",
"other-tenant-relationship-do-not-export",
"other-tenant-change-do-not-export",
"other-tenant-event-do-not-export",
)
for value in excluded:
self.assertNotIn(value, serialized)
def test_conflicting_and_uncorroborated_direct_selectors_fail_closed(self) -> None:
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
identity_id=self.identity.id,
external_references={"idm.identity": "identity-other"},
),
)
direct_conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
identity_id=self.identity.id,
external_references={
"idm.assignment": self.unrelated_assignment.id,
},
),
)
self.assertEqual((), conflict)
self.assertEqual((), direct_conflict)
def test_plan_retains_evidence_and_routes_facts_to_manual_review(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
self.assertEqual(
{"manual_review", "retain"},
{action.kind for action in actions},
)
self.assertFalse(any(action.executable for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=actions,
request_id="dsar-idm-1",
)
self.assertEqual({"blocked"}, {result.status for result in results})
self.assertIsNotNone(
self.session.get(IdmOrganizationFunctionAssignment, self.assignment.id)
)
def test_execution_rejects_foreign_and_forged_executable_actions(self) -> None:
actions = (
DsarErasureActionRef(
action_id="identity:delete:assignment:assignment-1",
provider_id="identity",
module_id="identity",
kind="delete",
resource_type="idm_function_assignment",
resource_id=self.assignment.id,
title="Foreign action",
rationale="Must be rejected",
executable=True,
),
DsarErasureActionRef(
action_id="idm:delete:assignment:assignment-1",
provider_id="idm",
module_id="idm",
kind="delete",
resource_type="idm_function_assignment",
resource_id=self.assignment.id,
title="Forged action",
rationale="Must be rejected",
executable=True,
),
)
for action in actions:
with self.assertRaises(ValueError):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(action,),
request_id="dsar-idm-2",
)
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-IDM-1",
request_kind="access",
subject=self.subject,
purpose="Respond to an authorized privacy request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=1,
)
self.assertEqual(["idm"], request.coverage["covered_modules"])
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-IDM-DISABLED",
request_kind="access",
subject=self.subject,
purpose="Verify disabled-module coverage.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, idm_active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(0, disabled.search_result["record_count"])
self.assertEqual(
[IDM_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
if __name__ == "__main__":
unittest.main()
+128 -1
View File
@@ -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()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import unittest
from fastapi import HTTPException
from govoplan_idm.backend.api.v1.routes import (
_validate_function_governance_defaults,
)
class FunctionGovernanceSettingsTests(unittest.TestCase):
def test_bounded_delegation_and_escalation_defaults_are_accepted(self) -> None:
_validate_function_governance_defaults(
{
"function_assignment_governance_defaults": {
"delegation_allowed": True,
"maximum_delegation_depth": 2,
"maximum_delegated_validity_days": 30,
"escalation": {
"holder": {
"target_function_id": "function-escalation",
"timeout_hours": 24,
}
},
}
}
)
def test_incomplete_or_unbounded_rules_are_rejected(self) -> None:
invalid = (
{"maximum_delegation_depth": 0},
{"maximum_delegated_validity_days": 3651},
{"escalation": {"holder": {"timeout_hours": 24}}},
{
"escalation": {
"authority": {
"target_function_id": "function-escalation",
"timeout_hours": 0,
}
}
},
{
"escalation": {
"unknown": {
"target_function_id": "function-escalation",
"timeout_hours": 24,
}
}
},
)
for defaults in invalid:
with self.subTest(defaults=defaults), self.assertRaises(HTTPException):
_validate_function_governance_defaults(
{"function_assignment_governance_defaults": defaults}
)
if __name__ == "__main__":
unittest.main()
+41 -1
View File
@@ -3,9 +3,18 @@ from __future__ import annotations
import unittest
from govoplan_idm.backend.manifest import manifest
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)
@@ -24,6 +33,7 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("idm.workflow.assign-function-to-identity", topics)
self.assertIn("idm.reference.assignment-governance", topics)
self.assertIn("idm.reference.fields-and-consequences", topics)
self.assertIn("idm.reference.typed-relationships", topics)
workflow = topics["idm.workflow.assign-function-to-identity"]
self.assertIn("idm.blocker.permission", workflow.metadata["help_contexts"])
@@ -31,9 +41,39 @@ 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(
{
"title",
"summary",
"body",
},
set(relationships.translations["de"]),
)
self.assertIn(
"idm.relationships.field.revocation-reason",
relationships.metadata["help_contexts"],
)
self.assertIn(
"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"],
)
def test_relationship_writers_may_use_identity_search_selectors(self) -> None:
self.assertIn("idm:relationship:write", ORGANIZATION_IDENTITY_READ_SCOPES)
self.assertNotIn("idm:relationship:read", ORGANIZATION_IDENTITY_READ_SCOPES)
if __name__ == "__main__":
unittest.main()
+15 -1
View File
@@ -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()
+191
View File
@@ -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")
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.15",
"version": "0.1.26",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -17,7 +17,7 @@
"./styles/idm.css": "./src/styles/idm.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.45",
"@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
@@ -10,9 +10,12 @@ function assert(condition, message) {
const page = source("../src/features/IdmPage.tsx");
const changes = source("../src/features/FunctionAssignmentChangesPanel.tsx");
const relationships = source("../src/features/TypedRelationshipsPanel.tsx");
const api = source("../src/api/idm.ts");
const patterns = source("../src/features/interfacePatterns.ts");
const moduleSource = source("../src/module.ts");
const translations = source("../src/i18n/generatedTranslations.ts");
const styles = source("../src/styles/idm.css");
assert(page.includes("ActionBlockerHint") && page.includes("DocumentationHelpLink"), "IDM permissions and prerequisites expose actionable help");
assert(page.includes("assignmentBaseline") && page.includes("draftKey(assignmentDraft) !== draftKey(assignmentBaseline)"), "Existing assignments compare against their loaded draft baseline");
@@ -24,6 +27,18 @@ assert(patterns.includes('topicId: "idm.reference.fields-and-consequences"'), "I
assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-idm.view_assignments.2d40d6a5"'), "WebUI metadata matches the module release and localizes its action surface");
assert(translations.includes('"i18n:govoplan-idm.state_awaiting_authority"'), "Governed states and decisions are in the translation catalogue");
assert(!page.includes("window.confirm") && !changes.includes("window.confirm"), "IDM does not use browser-native consequential confirmation");
assert(styles.includes(".idm-page") && !/\.idm-page\s*\{[^}]*max-width/s.test(styles), "The IDM workspace uses the full shared application width");
assert(page.includes("TypedRelationshipsPanel") && page.includes("<TypedRelationshipsPanel settings={settings} auth={auth} />"), "The IDM workspace exposes typed-group and relationship administration");
assert(relationships.includes("SearchableSelect") && relationships.includes('aria-label="Subject identity"') && relationships.includes('aria-label="Target typed group"') && relationships.includes('aria-label="Related identity"'), "Identity and group references use searchable selectors");
assert(relationships.includes('"future" | "active" | "expired" | "revoked"') && relationships.includes("relationshipState(row)"), "Relationship lifecycle states remain visually distinct");
assert(relationships.includes("revocationReason.trim()") && relationships.includes('helpContextId="idm.relationships.confirm-revoke"'), "Relationship revocation requires a reason and explicit governed confirmation");
assert(relationships.includes("resolveTypedGroupMemberships") && relationships.includes('id="idm-typed-group-membership-resolution"'), "Effective group membership is inspectable through the shared resolver");
assert(relationships.includes("sourceResourceId") && relationships.includes("sourceRevision") && relationships.includes("provenance"), "Relationship administration retains source and provenance evidence");
assert(api.includes("/api/v1/idm/typed-groups") && api.includes("/api/v1/idm/relationships") && api.includes("/memberships?"), "The WebUI API uses the implemented relationship lifecycle endpoints");
assert(moduleSource.includes('"idm:relationship:read"') && moduleSource.includes('"idm:relationship:write"'), "Relationship-only administrators can enter the IDM product surface");
assert(translations.includes('"Typed groups and identity relationships": "Typisierte Gruppen und Identitätsbeziehungen"') && translations.includes('"Revoked": "Widerrufen"'), "The relationship administration vocabulary has German reference translations");
assert(!relationships.includes("window.confirm"), "Relationship administration does not use browser-native consequential confirmation");
assert(relationships.includes('<ContentGrid columns={1}>') && (relationships.match(/bodyLayout="table"/g) ?? []).length === 2, "Typed groups and effective relationships use spaced full-width table cards");
assert(!relationships.includes('className="idm-muted idm-card-note"') && changes.includes('bodyLayout="table"') && page.includes('bodyLayout="table"'), "All IDM collection tables share the table-card treatment without a repeated tagline");
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
+198 -2
View File
@@ -49,6 +49,117 @@ export type IdentityListResponse = {
identities: IdentityOption[];
};
export type TypedGroupItem = {
id: string;
tenant_id: string;
key: string;
name: string;
group_type: string;
description?: string | null;
status: "active" | "inactive";
source_provider: string;
source_resource_type?: string | null;
source_resource_id?: string | null;
source_revision?: string | null;
properties: Record<string, unknown>;
provenance: Record<string, unknown>;
revision: number;
created_at?: string | null;
updated_at?: string | null;
};
export type TypedGroupList = {
groups: TypedGroupItem[];
total: number;
};
export type TypedGroupPayload = {
key: string;
name: string;
group_type: string;
description?: string | null;
source_provider?: string;
source_resource_type?: string | null;
source_resource_id?: string | null;
source_revision?: string | null;
properties?: Record<string, unknown>;
provenance?: Record<string, unknown>;
};
export type TypedGroupUpdatePayload = Partial<TypedGroupPayload> & {
base_revision: number;
status?: "active" | "inactive";
};
export type IdentityRelationshipItem = {
id: string;
tenant_id: string;
relationship_kind: string;
subject_identity_id: string;
target_group_id?: string | null;
related_identity_id?: string | null;
role?: string | null;
valid_from?: string | null;
valid_until?: string | null;
status: "active" | "revoked";
revoked_at?: string | null;
revoked_by?: string | null;
revocation_reason?: string | null;
expired_event_at?: string | null;
source_provider: string;
source_resource_type?: string | null;
source_resource_id?: string | null;
source_revision?: string | null;
properties: Record<string, unknown>;
provenance: Record<string, unknown>;
revision: number;
created_at?: string | null;
updated_at?: string | null;
};
export type IdentityRelationshipList = {
relationships: IdentityRelationshipItem[];
total: number;
};
export type IdentityRelationshipPayload = {
relationship_kind: string;
subject_identity_id: string;
target_group_id?: string | null;
related_identity_id?: string | null;
role?: string | null;
valid_from?: string | null;
valid_until?: string | null;
source_provider?: string;
source_resource_type?: string | null;
source_resource_id?: string | null;
source_revision?: string | null;
properties?: Record<string, unknown>;
provenance?: Record<string, unknown>;
};
export type IdentityRelationshipUpdatePayload = Omit<
Partial<IdentityRelationshipPayload>,
"subject_identity_id"
> & {
base_revision: number;
};
export type IdentityRelationshipDecisionItem = {
relationship: IdentityRelationshipItem;
included: boolean;
code: string;
explanation: string;
identity_status?: string | null;
};
export type TypedGroupMembershipResolution = {
group: TypedGroupItem;
effective_at: string;
decisions: IdentityRelationshipDecisionItem[];
identity_ids: string[];
};
export type OrganizationFunctionAssignmentItem = {
id: string;
tenant_id: string;
@@ -148,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;
@@ -218,12 +333,93 @@ export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayl
return apiPatchJson(settings, "/api/v1/idm/settings", payload);
}
export function searchOrganizationIdentityOptions(settings: ApiSettings, query = "", limit = 50): Promise<IdentityListResponse> {
export function searchOrganizationIdentityOptions(
settings: ApiSettings,
query = "",
limit = 50,
signal?: AbortSignal
): Promise<IdentityListResponse> {
const params = new URLSearchParams();
const trimmed = query.trim();
if (trimmed) params.set("query", trimmed);
params.set("limit", String(limit));
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`);
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`, { signal });
}
export function getTypedGroups(
settings: ApiSettings,
options: { query?: string; includeInactive?: boolean; limit?: number; signal?: AbortSignal } = {}
): Promise<TypedGroupList> {
const params = new URLSearchParams();
if (options.query?.trim()) params.set("query", options.query.trim());
if (options.includeInactive) params.set("include_inactive", "true");
params.set("limit", String(options.limit ?? 1000));
return apiFetch<TypedGroupList>(settings, `/api/v1/idm/typed-groups?${params.toString()}`, { signal: options.signal });
}
export function createTypedGroup(settings: ApiSettings, payload: TypedGroupPayload): Promise<TypedGroupItem> {
return apiPostJson(settings, "/api/v1/idm/typed-groups", payload);
}
export function patchTypedGroup(
settings: ApiSettings,
groupId: string,
payload: TypedGroupUpdatePayload
): Promise<TypedGroupItem> {
return apiPatchJson(settings, `/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}`, payload);
}
export function getIdentityRelationships(
settings: ApiSettings,
options: { includeRevoked?: boolean; identityId?: string; groupId?: string; relationshipKind?: string; limit?: number } = {}
): Promise<IdentityRelationshipList> {
const params = new URLSearchParams();
if (options.includeRevoked) params.set("include_revoked", "true");
if (options.identityId) params.set("identity_id", options.identityId);
if (options.groupId) params.set("group_id", options.groupId);
if (options.relationshipKind) params.set("relationship_kind", options.relationshipKind);
params.set("limit", String(options.limit ?? 1000));
return apiFetch<IdentityRelationshipList>(settings, `/api/v1/idm/relationships?${params.toString()}`);
}
export function createIdentityRelationship(
settings: ApiSettings,
payload: IdentityRelationshipPayload
): Promise<IdentityRelationshipItem> {
return apiPostJson(settings, "/api/v1/idm/relationships", payload);
}
export function patchIdentityRelationship(
settings: ApiSettings,
relationshipId: string,
payload: IdentityRelationshipUpdatePayload
): Promise<IdentityRelationshipItem> {
return apiPatchJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationshipId)}`, payload);
}
export function revokeIdentityRelationship(
settings: ApiSettings,
relationship: Pick<IdentityRelationshipItem, "id" | "revision">,
reason: string
): Promise<IdentityRelationshipItem> {
return apiPostJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationship.id)}/revoke`, {
base_revision: relationship.revision,
reason
});
}
export function resolveTypedGroupMemberships(
settings: ApiSettings,
groupId: string,
options: { effectiveAt?: string; relationshipKinds?: string[] } = {}
): Promise<TypedGroupMembershipResolution> {
const params = new URLSearchParams();
if (options.effectiveAt) params.set("effective_at", options.effectiveAt);
for (const kind of options.relationshipKinds ?? ["member"]) params.append("relationship_kind", kind);
return apiFetch<TypedGroupMembershipResolution>(
settings,
`/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}/memberships?${params.toString()}`
);
}
export function createOrganizationFunctionAssignment(
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { Check, Eye, Plus, RotateCcw, Undo2, X } from "lucide-react";
import {
import { FormLayout,
AdminIconButton,
ApiError,
Button,
@@ -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",
@@ -345,12 +347,13 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
<>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<Card
bodyLayout="table"
title="Function requests and grants"
titleHelp={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}
collapsible
collapseKey="idm.function-assignment-changes"
actions={(
<div className="button-row compact-actions">
<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />
{(canRequest || canGrant) ? <AdminIconButton label="Start governed change" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={busy} disabledReason={idmDisabledReason(false, busy)} onClick={openCreate} /> : null}
</div>
)}
@@ -360,15 +363,15 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
</LoadingFrame>
</Card>
<Dialog
<Dialog variant="administration" size="wide"
open={createOpen}
title="Start governed function change"
className="admin-dialog admin-dialog-wide idm-change-dialog"
className="idm-change-dialog"
onClose={closeCreate}
closeDisabled={busy}
footer={<><Button type="button" onClick={closeCreate} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Cancel</Button><Button type="submit" form="idm-change-create" variant="primary" disabled={busy || !draft.functionId || !draft.identityId || !draft.justification.trim()} disabledReason={idmDisabledReason(false, busy) ?? ((!draft.functionId || !draft.identityId || !draft.justification.trim()) ? IDM_INTERFACE_I18N.incomplete : undefined)}>Submit</Button></>}
>
<form id="idm-change-create" className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
<FormLayout columns={2} gap="small" collapseAt="workspace" id="idm-change-create" className="" onSubmit={(event) => void submit(event)}>
<div className="wide">
<SegmentedControl
ariaLabel="Function change kind"
@@ -414,10 +417,10 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
<textarea rows={3} value={draft.evidence} onChange={(event) => setDraft((current) => ({ ...current, evidence: event.target.value }))} disabled={busy} />
</FormField>
</div>
</form>
</FormLayout>
</Dialog>
<Dialog
<Dialog variant="administration" size="wide"
open={detailOpen && selected !== null}
title={selected
? i18nMessage(
@@ -427,7 +430,7 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
{ function: functionById.get(selected.function_id)?.name ?? selected.function_id }
)
: "Function change"}
className="admin-dialog admin-dialog-wide idm-change-dialog"
className="idm-change-dialog"
onClose={() => !busy && setDetailOpen(false)}
closeDisabled={busy}
footer={<Button type="button" onClick={() => setDetailOpen(false)} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Close</Button>}
@@ -441,6 +444,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>
+134 -27
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { Edit3, Plus, RefreshCw } from "lucide-react";
import {
import { FormLayout, ActionToolbar,
ActionBlockerHint,
AdminIconButton,
ApiError,
@@ -10,6 +10,7 @@ import {
Dialog,
DismissibleAlert,
DocumentationHelpLink,
TextWithHelp,
FormField,
LoadingFrame,
PageScrollViewport,
@@ -41,6 +42,7 @@ import {
type OrganizationUnitItem
} from "../api/idm";
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
import TypedRelationshipsPanel from "./TypedRelationshipsPanel";
import {
IDM_DOCUMENTATION,
IDM_FIELD_DOCUMENTATION,
@@ -71,6 +73,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 = {
@@ -151,20 +162,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
}
}
};
}
@@ -173,10 +230,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]));
}
@@ -277,7 +350,10 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
const appliedInitialQueryRef = useRef(false);
const { requestDiscard } = useUnsavedChanges();
const canReadAssignments = hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
const canUseFunctionChanges = hasScope(auth, "idm:function_change:read") || hasScope(auth, "idm:function_request:create") || hasScope(auth, "idm:function_grant:create") || hasScope(auth, "idm:function_change:decide") || hasScope(auth, "idm:function_change:admin");
const canUseAssignmentWorkspace = canReadAssignments || canUseFunctionChanges;
const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
const canReadSettings = hasScope(auth, "idm:settings:read") || hasScope(auth, "idm:settings:write") || hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write");
const canManageSettings = hasScope(auth, "idm:settings:write");
@@ -336,8 +412,8 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
setError("");
try {
const [nextModel, nextAssignments, nextSettings] = await Promise.all([
getOrganizationModel(settings),
getOrganizationFunctionAssignments(settings),
canUseAssignmentWorkspace ? getOrganizationModel(settings) : Promise.resolve(EMPTY_MODEL),
canReadAssignments ? getOrganizationFunctionAssignments(settings) : Promise.resolve({ assignments: [], total: 0, page: 1, page_size: 0, pages: 1 }),
canReadSettings ? getIdmSettings(settings).catch(() => null) : Promise.resolve(null)
]);
setModel(nextModel);
@@ -371,7 +447,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
} finally {
setLoading(false);
}
}, [canReadSettings, canSearchIdentities, initialFunctionFilter, initialQuery.assignmentId, settings]);
}, [canReadAssignments, canReadSettings, canSearchIdentities, canUseAssignmentWorkspace, initialQuery.assignmentId, settings]);
useEffect(() => {
void loadData();
@@ -621,11 +697,10 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
<div className="content-pad idm-page">
<div className="page-heading split idm-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-idm.idm.61f4a7a2</PageTitle>
<PageTitle loading={loading} titleHelp={<DocumentationHelpLink reference={IDM_DOCUMENTATION} />}>i18n:govoplan-idm.idm.61f4a7a2</PageTitle>
<p>i18n:govoplan-idm.identity_links_intro.45fed9dd</p>
</div>
<div className="idm-toolbar">
<DocumentationHelpLink reference={IDM_DOCUMENTATION} />
<ActionToolbar justify="end" className="idm-toolbar">
<Button
type="button"
onClick={() => requestDiscard(() => void loadData())}
@@ -635,12 +710,12 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
</Button>
</div>
</ActionToolbar>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{!canManage && (
{canReadAssignments && !canManage && (
<ActionBlockerHint
reason={{
summary: IDM_INTERFACE_I18N.writeReason,
@@ -656,7 +731,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
documentation={IDM_DOCUMENTATION}
/>
)}
{!model.functions.length && !loading && (
{canUseAssignmentWorkspace && !model.functions.length && !loading && (
<ActionBlockerHint
reason={{
summary: IDM_INTERFACE_I18N.noFunctions,
@@ -678,11 +753,11 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
{canReadSettings && (
<Card
title="i18n:govoplan-idm.idm_governance.6e4f3251"
titleHelp={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}
collapsible
collapseKey="idm.governance"
actions={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}
>
<form className="admin-form-grid two-columns" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
<FormLayout columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
<div className="idm-check-list wide">
<ToggleSwitch label="i18n:govoplan-idm.require_assignment_change_requests.697718a1" checked={settingsDraft.require_assignment_change_requests} disabled={!canManageSettings || busy} help={idmDisabledReason(false, busy, canManageSettings)} onChange={(require_assignment_change_requests) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests })} />
</div>
@@ -697,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"
@@ -706,28 +781,61 @@ 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>
</div>
</form>
</FormLayout>
</Card>
)}
{canUseFunctionChanges && (
<FunctionAssignmentChangesPanel
settings={settings}
auth={auth}
model={model}
identities={identityOptions}
/>
)}
<Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy || !model.functions.length} disabledReason={idmDisabledReason(false, busy, canManage) ?? (!model.functions.length ? IDM_INTERFACE_I18N.noFunctions : undefined)} onClick={openCreateAssignment} />}>
<TypedRelationshipsPanel settings={settings} auth={auth} />
{canReadAssignments && <Card bodyLayout="table" title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy || !model.functions.length} disabledReason={idmDisabledReason(false, busy, canManage) ?? (!model.functions.length ? IDM_INTERFACE_I18N.noFunctions : undefined)} onClick={openCreateAssignment} />}>
<DataGrid
id="idm-organization-function-assignments"
rows={assignments}
@@ -737,7 +845,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
initialFit="container"
/>
</Card>
</Card>}
</div>
</LoadingFrame>
{renderAssignmentDialog()}
@@ -748,12 +856,12 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
function renderAssignmentDialog() {
const formId = "idm-assignment-editor";
return (
<Dialog
<Dialog variant="administration" size="wide"
open={assignmentEditorOpen}
title={editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
onClose={closeAssignmentEditor}
closeDisabled={busy}
className="admin-dialog admin-dialog-wide idm-editor-dialog"
className="idm-editor-dialog"
footer={(
<>
<Button type="button" onClick={closeAssignmentEditor} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
@@ -769,7 +877,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
</>
)}
>
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submitAssignment(event)}>
<FormLayout columns={2} gap="small" collapseAt="workspace" id={formId} className="" onSubmit={(event) => void submitAssignment(event)}>
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf" documentation={IDM_FIELD_DOCUMENTATION}>
<input
value={identitySearch}
@@ -838,8 +946,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
{selectedFunctionIsGoverned && (
<div className="wide idm-governance-override">
<DismissibleAlert tone="warning" dismissible={false}>
Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.
<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />
Direct changes to this governed function are <TextWithHelp help={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}>emergency overrides</TextWithHelp>. Use a request or grant above for the normal process.
</DismissibleAlert>
<FormField label="Emergency override reason" documentation={IDM_FIELD_DOCUMENTATION}>
<textarea
@@ -886,7 +993,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
{identityLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_identities.f3b84693</p>}
{actingForLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e</p>}
{!model.functions.length && <p className="idm-muted wide">i18n:govoplan-idm.no_functions_available.51ba08eb</p>}
</form>
</FormLayout>
</Dialog>
);
}
@@ -0,0 +1,976 @@
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
ActionBlockerHint,
ActionToolbar,
AdminIconButton,
ApiError,
Button,
Card,
ContentGrid,
DataGrid,
DateTimeField,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
FormLayout,
LoadingFrame,
SearchableSelect,
StatusBadge,
TableActionGroup,
ToggleSwitch,
hasScope,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo,
type DataGridColumn,
type SearchableSelectOption
} from "@govoplan/core-webui";
import {
createIdentityRelationship,
createTypedGroup,
getIdentityRelationships,
getTypedGroups,
patchIdentityRelationship,
patchTypedGroup,
resolveTypedGroupMemberships,
revokeIdentityRelationship,
searchOrganizationIdentityOptions,
type IdentityOption,
type IdentityRelationshipDecisionItem,
type IdentityRelationshipItem,
type IdentityRelationshipPayload,
type TypedGroupItem,
type TypedGroupMembershipResolution,
type TypedGroupPayload
} from "../api/idm";
import { IDM_RELATIONSHIP_DOCUMENTATION } from "./interfacePatterns";
type Props = {
settings: ApiSettings;
auth: AuthInfo;
};
type GroupDraft = {
key: string;
name: string;
groupType: string;
description: string;
status: "active" | "inactive";
sourceProvider: string;
sourceResourceType: string;
sourceResourceId: string;
sourceRevision: string;
properties: string;
provenance: string;
};
type RelationshipDraft = {
relationshipKind: string;
subjectIdentityId: string;
targetType: "group" | "identity";
targetGroupId: string;
relatedIdentityId: string;
role: string;
validFrom: string;
validUntil: string;
sourceProvider: string;
sourceResourceType: string;
sourceResourceId: string;
sourceRevision: string;
properties: string;
provenance: string;
};
const EMPTY_GROUP_DRAFT: GroupDraft = {
key: "",
name: "",
groupType: "business_group",
description: "",
status: "active",
sourceProvider: "local",
sourceResourceType: "",
sourceResourceId: "",
sourceRevision: "",
properties: "{}",
provenance: "{}"
};
const EMPTY_RELATIONSHIP_DRAFT: RelationshipDraft = {
relationshipKind: "member",
subjectIdentityId: "",
targetType: "group",
targetGroupId: "",
relatedIdentityId: "",
role: "",
validFrom: "",
validUntil: "",
sourceProvider: "local",
sourceResourceType: "",
sourceResourceId: "",
sourceRevision: "",
properties: "{}",
provenance: "{}"
};
export default function TypedRelationshipsPanel({ settings, auth }: Props) {
const [groups, setGroups] = useState<TypedGroupItem[]>([]);
const [relationships, setRelationships] = useState<IdentityRelationshipItem[]>([]);
const [identities, setIdentities] = useState<IdentityOption[]>([]);
const [showInactiveGroups, setShowInactiveGroups] = useState(false);
const [showRevokedRelationships, setShowRevokedRelationships] = useState(false);
const [groupEditor, setGroupEditor] = useState<TypedGroupItem | "create" | null>(null);
const [groupDraft, setGroupDraft] = useState<GroupDraft>({ ...EMPTY_GROUP_DRAFT });
const [groupBaseline, setGroupBaseline] = useState<GroupDraft>({ ...EMPTY_GROUP_DRAFT });
const [relationshipEditor, setRelationshipEditor] = useState<IdentityRelationshipItem | "create" | null>(null);
const [relationshipDraft, setRelationshipDraft] = useState<RelationshipDraft>({ ...EMPTY_RELATIONSHIP_DRAFT });
const [relationshipBaseline, setRelationshipBaseline] = useState<RelationshipDraft>({ ...EMPTY_RELATIONSHIP_DRAFT });
const [revokeTarget, setRevokeTarget] = useState<IdentityRelationshipItem | null>(null);
const [revocationReason, setRevocationReason] = useState("");
const [membershipGroup, setMembershipGroup] = useState<TypedGroupItem | null>(null);
const [membershipEffectiveAt, setMembershipEffectiveAt] = useState("");
const [membershipKinds, setMembershipKinds] = useState("member");
const [membershipResolution, setMembershipResolution] = useState<TypedGroupMembershipResolution | null>(null);
const [loading, setLoading] = useState(true);
const [membershipLoading, setMembershipLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const appliedDeepLink = useRef(false);
const { language } = usePlatformLanguage();
const { requestDiscard } = useUnsavedChanges();
const canRead = hasScope(auth, "idm:relationship:read") || hasScope(auth, "idm:relationship:write");
const canWrite = hasScope(auth, "idm:relationship:write");
const canSearchIdentities = canWrite
|| hasScope(auth, "idm:organization_identity:read")
|| hasScope(auth, "idm:organization_assignment:write")
|| hasScope(auth, "admin:users:read");
const groupById = useMemo(() => new Map(groups.map((item) => [item.id, item])), [groups]);
const visibleGroups = useMemo(
() => showInactiveGroups ? groups : groups.filter((item) => item.status === "active"),
[groups, showInactiveGroups]
);
const identityById = useMemo(() => new Map(identities.map((item) => [item.id, item])), [identities]);
const groupOptions = useMemo<SearchableSelectOption[]>(
() => groups.filter((item) => item.status === "active").map(groupOption),
[groups]
);
const dirty = groupEditor
? draftKey(groupDraft) !== draftKey(groupBaseline)
: relationshipEditor
? draftKey(relationshipDraft) !== draftKey(relationshipBaseline)
: false;
const load = useCallback(async () => {
if (!canRead) {
setLoading(false);
return;
}
setLoading(true);
setError("");
try {
const [groupResponse, relationshipResponse, identityResponse] = await Promise.all([
getTypedGroups(settings, { includeInactive: true }),
getIdentityRelationships(settings, { includeRevoked: showRevokedRelationships }),
canSearchIdentities
? searchOrganizationIdentityOptions(settings, "", 100).catch(() => ({ identities: [] }))
: Promise.resolve({ identities: [] })
]);
setGroups(groupResponse.groups);
setRelationships(relationshipResponse.relationships);
setIdentities(identityResponse.identities);
if (!appliedDeepLink.current && typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
const groupId = params.get("groupId");
const relationshipId = params.get("relationshipId");
const linkedGroup = groupResponse.groups.find((item) => item.id === groupId);
const linkedRelationship = relationshipResponse.relationships.find((item) => item.id === relationshipId);
if (linkedGroup) {
appliedDeepLink.current = true;
openMembership(linkedGroup);
} else if (linkedRelationship) {
appliedDeepLink.current = true;
openRelationshipEditor(linkedRelationship);
}
}
} catch (caught) {
setError(apiErrorMessage(caught));
} finally {
setLoading(false);
}
}, [canRead, canSearchIdentities, settings, showRevokedRelationships]);
useEffect(() => {
void load();
}, [load]);
const loadIdentityOptions = useCallback(async (
query: string,
options: { limit: number; signal: AbortSignal }
): Promise<SearchableSelectOption[]> => {
const response = await searchOrganizationIdentityOptions(settings, query, options.limit, options.signal);
if (!options.signal.aborted) {
setIdentities((current) => mergeIdentities(current, response.identities));
}
return response.identities.map(identityOption);
}, [settings]);
useUnsavedDraftGuard({
dirty,
onSave: async () => groupEditor ? saveGroup() : saveRelationship(),
onDiscard: closeEditors,
title: "Unsaved relationship administration",
message: "Save or discard the typed-group or relationship draft before leaving this surface."
});
const groupColumns = useMemo<DataGridColumn<TypedGroupItem>[]>(() => [
{
id: "name",
header: "Name",
minWidth: 210,
sortable: true,
filterable: true,
value: (row) => row.name,
render: (row) => <div><strong>{row.name}</strong><div className="idm-id">{row.key}</div></div>
},
{ id: "type", header: "Group type", minWidth: 170, sortable: true, filterable: true, value: (row) => row.group_type },
{ id: "source", header: "Source", minWidth: 160, sortable: true, value: (row) => row.source_provider, render: (row) => sourceSummary(row) },
{ id: "status", header: "Status", width: 120, sortable: true, value: (row) => row.status, render: (row) => <StatusBadge status={row.status} label={groupStatusLabel(row.status)} /> },
{ id: "revision", header: "Revision", width: 100, sortable: true, value: (row) => row.revision },
{
id: "actions",
header: "Actions",
width: 108,
sticky: "end",
render: (row) => <TableActionGroup actions={[
{
id: "memberships",
label: i18nMessage("i18n:govoplan-idm.inspect_memberships_value", { value0: row.name }),
icon: <Eye size={16} aria-hidden="true" />,
helpContextId: "idm.typed-groups.action.inspect-memberships",
helpModuleId: "idm",
onClick: () => openMembership(row)
},
{
id: "edit",
label: i18nMessage("i18n:govoplan-idm.edit_group_value", { value0: row.name }),
icon: <Pencil size={16} aria-hidden="true" />,
helpContextId: "idm.typed-groups.action.edit",
helpModuleId: "idm",
disabled: !canWrite || busy,
disabledReason: !canWrite ? "Typed-group write permission is required." : undefined,
onClick: () => openGroupEditor(row)
}
]} />
}
], [busy, canWrite]);
const relationshipColumns = useMemo<DataGridColumn<IdentityRelationshipItem>[]>(() => [
{
id: "subject",
header: "Subject identity",
minWidth: 220,
sortable: true,
filterable: true,
filterValue: (row) => identityLabel(identityById.get(row.subject_identity_id), row.subject_identity_id),
render: (row) => identityDisplay(identityById.get(row.subject_identity_id), row.subject_identity_id)
},
{ id: "kind", header: "Relationship", minWidth: 170, sortable: true, filterable: true, value: (row) => row.relationship_kind, render: (row) => row.role ? `${row.relationship_kind} · ${row.role}` : row.relationship_kind },
{
id: "target",
header: "Target",
minWidth: 220,
filterable: true,
filterValue: (row) => relationshipTargetLabel(row, groupById, identityById),
render: (row) => relationshipTarget(row, groupById, identityById)
},
{
id: "effective_status",
header: "Effective state",
width: 145,
sortable: true,
value: (row) => relationshipState(row),
render: (row) => {
const state = relationshipState(row);
return <StatusBadge status={stateStatus(state)} label={relationshipStateLabel(state)} />;
}
},
{ id: "window", header: "Effective window", minWidth: 220, value: (row) => `${row.valid_from ?? ""} ${row.valid_until ?? ""}`, render: (row) => effectiveWindow(row, language) },
{ id: "source", header: "Source", minWidth: 150, value: (row) => row.source_provider, render: (row) => sourceSummary(row) },
{
id: "actions",
header: "Actions",
width: 108,
sticky: "end",
render: (row) => <TableActionGroup actions={[
{
id: "edit",
label: "Edit relationship",
icon: <Pencil size={16} aria-hidden="true" />,
helpContextId: "idm.relationships.action.edit",
helpModuleId: "idm",
disabled: !canWrite || busy || row.status === "revoked",
disabledReason: !canWrite ? "Relationship write permission is required." : row.status === "revoked" ? "Revoked relationships are retained as immutable evidence." : undefined,
onClick: () => openRelationshipEditor(row)
},
{
id: "revoke",
label: "Revoke relationship",
icon: <Trash2 size={16} aria-hidden="true" />,
variant: "danger",
helpContextId: "idm.relationships.action.revoke",
helpModuleId: "idm",
applicable: row.status !== "revoked",
disabled: !canWrite || busy,
disabledReason: !canWrite ? "Relationship write permission is required." : undefined,
onClick: () => {
setRevokeTarget(row);
setRevocationReason("");
}
}
]} />
}
], [busy, canWrite, groupById, identityById, language]);
const membershipColumns = useMemo<DataGridColumn<IdentityRelationshipDecisionItem>[]>(() => [
{
id: "identity",
header: "Identity",
minWidth: 220,
value: (row) => row.relationship.subject_identity_id,
render: (row) => identityDisplay(identityById.get(row.relationship.subject_identity_id), row.relationship.subject_identity_id)
},
{ id: "kind", header: "Relationship", minWidth: 150, value: (row) => row.relationship.relationship_kind },
{ id: "decision", header: "Resolution", minWidth: 150, value: (row) => row.code, render: (row) => <StatusBadge status={row.included ? "success" : "inactive"} label={row.code} /> },
{ id: "identity_status", header: "Identity state", minWidth: 130, value: (row) => row.identity_status ?? "", render: (row) => row.identity_status ?? "Not available" },
{ id: "explanation", header: "Explanation", minWidth: 280, fill: true, value: (row) => row.explanation }
], [identityById]);
if (!canRead) {
return (
<Card title="Typed groups and identity relationships" collapsible collapseKey="idm.typed-relationships">
<ActionBlockerHint
reason={{
summary: "You do not have permission to view typed identity relationships.",
requiredAction: "Ask for typed-relationship read permission.",
actor: "An Access or tenant administrator",
target: "Access role assignments"
}}
labels={{ requiredAction: "Required action", actor: "Responsible actor", target: "Destination" }}
documentation={IDM_RELATIONSHIP_DOCUMENTATION}
/>
</Card>
);
}
return (
<div className="idm-relationship-stack" data-help-context-id="idm.relationships.page" data-help-module-id="idm">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && !error && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
{!canWrite && (
<ActionBlockerHint
reason={{
summary: "You may inspect relationship evidence but not change it.",
requiredAction: "Ask for typed-relationship write permission before creating, editing, or revoking records.",
actor: "An Access or tenant administrator",
target: "Access role assignments"
}}
labels={{ requiredAction: "Required action", actor: "Responsible actor", target: "Destination" }}
documentation={IDM_RELATIONSHIP_DOCUMENTATION}
/>
)}
<LoadingFrame loading={loading} label="Loading typed groups and relationships">
<ContentGrid columns={1}>
<Card
bodyLayout="table"
title="Typed groups"
titleHelp={<DocumentationHelpLink reference={IDM_RELATIONSHIP_DOCUMENTATION} />}
collapsible
collapseKey="idm.typed-groups"
actions={(
<ActionToolbar justify="end">
<ToggleSwitch
label="Show inactive groups"
checked={showInactiveGroups}
helpContextId="idm.typed-groups.field.show-inactive"
helpModuleId="idm"
onChange={setShowInactiveGroups}
/>
<Button helpContextId="idm.typed-groups.action.reload" helpModuleId="idm" onClick={() => void load()} disabled={loading || busy}>
<RefreshCw size={16} aria-hidden="true" /> Reload
</Button>
<AdminIconButton
label="Create typed group"
icon={<Plus size={16} aria-hidden="true" />}
variant="primary"
helpContextId="idm.typed-groups.action.create"
helpModuleId="idm"
disabled={!canWrite || busy}
disabledReason={!canWrite ? "Typed-group write permission is required." : undefined}
onClick={() => openGroupEditor("create")}
/>
</ActionToolbar>
)}
>
<DataGrid id="idm-typed-groups" rows={visibleGroups} columns={groupColumns} getRowKey={(row) => row.id} emptyText="No typed groups found." initialFit="container" />
</Card>
<Card
bodyLayout="table"
title="Effective identity relationships"
titleHelp={<DocumentationHelpLink reference={IDM_RELATIONSHIP_DOCUMENTATION} />}
collapsible
collapseKey="idm.identity-relationships"
actions={(
<ActionToolbar justify="end">
<ToggleSwitch
label="Show revoked relationships"
checked={showRevokedRelationships}
helpContextId="idm.relationships.field.show-revoked"
helpModuleId="idm"
onChange={setShowRevokedRelationships}
/>
<Button helpContextId="idm.relationships.action.reload" helpModuleId="idm" onClick={() => void load()} disabled={loading || busy}>
<RefreshCw size={16} aria-hidden="true" /> Reload
</Button>
<AdminIconButton
label="Create relationship"
icon={<Plus size={16} aria-hidden="true" />}
variant="primary"
helpContextId="idm.relationships.action.create"
helpModuleId="idm"
disabled={!canWrite || busy || groups.every((item) => item.status !== "active")}
disabledReason={!canWrite ? "Relationship write permission is required." : groups.every((item) => item.status !== "active") ? "Create an active typed group first." : undefined}
onClick={() => openRelationshipEditor("create")}
/>
</ActionToolbar>
)}
>
<DataGrid id="idm-identity-relationships" rows={relationships} columns={relationshipColumns} getRowKey={(row) => row.id} emptyText="No identity relationships found." initialFit="container" />
</Card>
</ContentGrid>
</LoadingFrame>
{renderGroupEditor()}
{renderRelationshipEditor()}
{renderRevokeDialog()}
{renderMembershipDialog()}
</div>
);
function openGroupEditor(item: TypedGroupItem | "create") {
const next = item === "create" ? { ...EMPTY_GROUP_DRAFT } : groupDraftFrom(item);
setGroupEditor(item);
setGroupDraft(next);
setGroupBaseline(next);
setRelationshipEditor(null);
setError("");
}
function openRelationshipEditor(item: IdentityRelationshipItem | "create") {
const next = item === "create" ? { ...EMPTY_RELATIONSHIP_DRAFT } : relationshipDraftFrom(item);
setRelationshipEditor(item);
setRelationshipDraft(next);
setRelationshipBaseline(next);
setGroupEditor(null);
setError("");
}
function closeEditors() {
setGroupEditor(null);
setRelationshipEditor(null);
setGroupDraft({ ...EMPTY_GROUP_DRAFT });
setGroupBaseline({ ...EMPTY_GROUP_DRAFT });
setRelationshipDraft({ ...EMPTY_RELATIONSHIP_DRAFT });
setRelationshipBaseline({ ...EMPTY_RELATIONSHIP_DRAFT });
}
function requestCloseEditors() {
if (busy) return;
if (dirty) requestDiscard(closeEditors);
else closeEditors();
}
async function saveGroup(): Promise<boolean> {
if (!groupEditor || !canWrite) return false;
setBusy(true);
setError("");
try {
const payload = groupPayload(groupDraft);
if (groupEditor === "create") {
await createTypedGroup(settings, payload);
setSuccess("Typed group created.");
} else {
await patchTypedGroup(settings, groupEditor.id, {
...payload,
base_revision: groupEditor.revision,
status: groupDraft.status
});
setSuccess("Typed group updated.");
}
closeEditors();
await load();
return true;
} catch (caught) {
setError(apiErrorMessage(caught));
return false;
} finally {
setBusy(false);
}
}
async function saveRelationship(): Promise<boolean> {
if (!relationshipEditor || !canWrite) return false;
setBusy(true);
setError("");
try {
const payload = relationshipPayload(relationshipDraft);
if (relationshipEditor === "create") {
await createIdentityRelationship(settings, payload);
setSuccess("Identity relationship created.");
} else {
const { subject_identity_id: _subject, ...update } = payload;
await patchIdentityRelationship(settings, relationshipEditor.id, {
...update,
base_revision: relationshipEditor.revision
});
setSuccess("Identity relationship updated.");
}
closeEditors();
await load();
return true;
} catch (caught) {
setError(apiErrorMessage(caught));
return false;
} finally {
setBusy(false);
}
}
async function revokeRelationship(): Promise<void> {
if (!revokeTarget || !revocationReason.trim() || !canWrite) return;
setBusy(true);
setError("");
try {
await revokeIdentityRelationship(settings, revokeTarget, revocationReason.trim());
setSuccess("Identity relationship revoked. Effective membership and downstream business resolution stop immediately.");
setRevokeTarget(null);
setRevocationReason("");
await load();
} catch (caught) {
setError(apiErrorMessage(caught));
} finally {
setBusy(false);
}
}
function openMembership(group: TypedGroupItem) {
setMembershipGroup(group);
setMembershipEffectiveAt("");
setMembershipKinds("member");
setMembershipResolution(null);
void loadMembership(group, "", "member");
}
async function loadMembership(group = membershipGroup, effectiveAt = membershipEffectiveAt, kinds = membershipKinds): Promise<void> {
if (!group) return;
setMembershipLoading(true);
setError("");
try {
const resolution = await resolveTypedGroupMemberships(settings, group.id, {
effectiveAt: optionalDateTime(effectiveAt) ?? undefined,
relationshipKinds: kinds.split(",").map((item) => item.trim()).filter(Boolean)
});
setMembershipResolution(resolution);
} catch (caught) {
setError(apiErrorMessage(caught));
} finally {
setMembershipLoading(false);
}
}
function renderGroupEditor() {
const formId = "idm-typed-group-editor";
const editing = groupEditor && groupEditor !== "create" ? groupEditor : null;
return (
<Dialog
variant="administration"
size="wide"
open={Boolean(groupEditor)}
title={editing ? "Edit typed group" : "Create typed group"}
helpContextId="idm.typed-groups.editor"
helpModuleId="idm"
onClose={requestCloseEditors}
closeDisabled={busy}
className=""
footer={<><Button onClick={requestCloseEditors} disabled={busy}>Cancel</Button><Button type="submit" form={formId} variant="primary" helpContextId="idm.typed-groups.action.save" helpModuleId="idm" disabled={!canWrite || busy || !groupDraft.key.trim() || !groupDraft.name.trim() || !groupDraft.groupType.trim()}>{busy ? "Saving..." : "Save group"}</Button></>}
>
<FormLayout id={formId} columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void saveGroup(); }}>
<FormField label="Key" helpContextId="idm.typed-groups.field.key" helpModuleId="idm"><input required value={groupDraft.key} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, key: event.target.value })} /></FormField>
<FormField label="Name" helpContextId="idm.typed-groups.field.name" helpModuleId="idm"><input required value={groupDraft.name} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, name: event.target.value })} /></FormField>
<FormField label="Group type" helpContextId="idm.typed-groups.field.type" helpModuleId="idm"><input required value={groupDraft.groupType} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, groupType: event.target.value })} /></FormField>
{editing && <FormField label="Status" helpContextId="idm.typed-groups.field.status" helpModuleId="idm"><select value={groupDraft.status} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, status: event.target.value as GroupDraft["status"] })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>}
<FormField label="Description" helpContextId="idm.typed-groups.field.description" helpModuleId="idm" className="wide"><textarea rows={3} value={groupDraft.description} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, description: event.target.value })} /></FormField>
<FormField label="Source provider" helpContextId="idm.typed-groups.field.source-provider" helpModuleId="idm"><input required value={groupDraft.sourceProvider} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceProvider: event.target.value })} /></FormField>
<FormField label="Source resource type" helpContextId="idm.typed-groups.field.source-resource-type" helpModuleId="idm"><input value={groupDraft.sourceResourceType} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceResourceType: event.target.value })} /></FormField>
<FormField label="Source resource ID" helpContextId="idm.typed-groups.field.source-resource-id" helpModuleId="idm"><input value={groupDraft.sourceResourceId} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceResourceId: event.target.value })} /></FormField>
<FormField label="Source revision" helpContextId="idm.typed-groups.field.source-revision" helpModuleId="idm"><input value={groupDraft.sourceRevision} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceRevision: event.target.value })} /></FormField>
<FormField label="Properties (JSON object)" helpContextId="idm.typed-groups.field.properties" helpModuleId="idm" className="wide"><textarea rows={4} value={groupDraft.properties} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, properties: event.target.value })} /></FormField>
<FormField label="Provenance (JSON object)" helpContextId="idm.typed-groups.field.provenance" helpModuleId="idm" className="wide"><textarea rows={4} value={groupDraft.provenance} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, provenance: event.target.value })} /></FormField>
{editing && <p className="idm-muted wide">{i18nMessage("i18n:govoplan-idm.group_revision_help", { value0: editing.revision })}</p>}
</FormLayout>
</Dialog>
);
}
function renderRelationshipEditor() {
const formId = "idm-identity-relationship-editor";
const editing = relationshipEditor && relationshipEditor !== "create" ? relationshipEditor : null;
const subjectOption = selectedIdentityOption(relationshipDraft.subjectIdentityId, identityById);
const relatedOption = selectedIdentityOption(relationshipDraft.relatedIdentityId, identityById);
const targetGroupOption = relationshipDraft.targetGroupId ? groupOption(groupById.get(relationshipDraft.targetGroupId) ?? fallbackGroup(relationshipDraft.targetGroupId)) : null;
const complete = Boolean(
relationshipDraft.relationshipKind.trim()
&& relationshipDraft.subjectIdentityId
&& (relationshipDraft.targetType === "group" ? relationshipDraft.targetGroupId : relationshipDraft.relatedIdentityId)
);
return (
<Dialog
variant="administration"
size="wide"
open={Boolean(relationshipEditor)}
title={editing ? "Edit identity relationship" : "Create identity relationship"}
helpContextId="idm.relationships.editor"
helpModuleId="idm"
onClose={requestCloseEditors}
closeDisabled={busy}
className=""
footer={<><Button onClick={requestCloseEditors} disabled={busy}>Cancel</Button><Button type="submit" form={formId} variant="primary" helpContextId="idm.relationships.action.save" helpModuleId="idm" disabled={!canWrite || busy || !complete}>{busy ? "Saving..." : "Save relationship"}</Button></>}
>
<FormLayout id={formId} columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void saveRelationship(); }}>
<FormField label="Relationship kind" helpContextId="idm.relationships.field.kind" helpModuleId="idm"><input required value={relationshipDraft.relationshipKind} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, relationshipKind: event.target.value })} /></FormField>
<FormField label="Role" helpContextId="idm.relationships.field.role" helpModuleId="idm"><input value={relationshipDraft.role} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, role: event.target.value })} /></FormField>
<FormField label="Subject identity" helpContextId="idm.relationships.field.subject-identity" helpModuleId="idm">
<SearchableSelect
value={relationshipDraft.subjectIdentityId}
selectedOption={subjectOption}
loadOptions={loadIdentityOptions}
aria-label="Subject identity"
placeholder="Search identities"
minQueryLength={0}
required
disabled={busy || Boolean(editing)}
helpContextId="idm.relationships.field.subject-identity"
helpModuleId="idm"
onChange={(subjectIdentityId) => setRelationshipDraft({ ...relationshipDraft, subjectIdentityId })}
/>
</FormField>
<FormField label="Target type" helpContextId="idm.relationships.field.target-type" helpModuleId="idm"><select value={relationshipDraft.targetType} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, targetType: event.target.value as RelationshipDraft["targetType"], targetGroupId: "", relatedIdentityId: "" })}><option value="group">Typed group</option><option value="identity">Related identity</option></select></FormField>
{relationshipDraft.targetType === "group" ? (
<FormField label="Target group" helpContextId="idm.relationships.field.target-group" helpModuleId="idm">
<SearchableSelect
value={relationshipDraft.targetGroupId}
selectedOption={targetGroupOption}
options={groupOptions}
aria-label="Target typed group"
placeholder="Search typed groups"
required
disabled={busy}
helpContextId="idm.relationships.field.target-group"
helpModuleId="idm"
onChange={(targetGroupId) => setRelationshipDraft({ ...relationshipDraft, targetGroupId })}
/>
</FormField>
) : (
<FormField label="Related identity" helpContextId="idm.relationships.field.related-identity" helpModuleId="idm">
<SearchableSelect
value={relationshipDraft.relatedIdentityId}
selectedOption={relatedOption}
loadOptions={loadIdentityOptions}
aria-label="Related identity"
placeholder="Search identities"
minQueryLength={0}
required
disabled={busy}
helpContextId="idm.relationships.field.related-identity"
helpModuleId="idm"
onChange={(relatedIdentityId) => setRelationshipDraft({ ...relationshipDraft, relatedIdentityId })}
/>
</FormField>
)}
<FormField label="Valid from" helpContextId="idm.relationships.field.valid-from" helpModuleId="idm"><DateTimeField value={relationshipDraft.validFrom} disabled={busy} helpContextId="idm.relationships.field.valid-from" helpModuleId="idm" onChange={(validFrom) => setRelationshipDraft({ ...relationshipDraft, validFrom })} /></FormField>
<FormField label="Valid until" helpContextId="idm.relationships.field.valid-until" helpModuleId="idm"><DateTimeField value={relationshipDraft.validUntil} disabled={busy} helpContextId="idm.relationships.field.valid-until" helpModuleId="idm" onChange={(validUntil) => setRelationshipDraft({ ...relationshipDraft, validUntil })} /></FormField>
<FormField label="Source provider" helpContextId="idm.relationships.field.source-provider" helpModuleId="idm"><input required value={relationshipDraft.sourceProvider} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceProvider: event.target.value })} /></FormField>
<FormField label="Source resource type" helpContextId="idm.relationships.field.source-resource-type" helpModuleId="idm"><input value={relationshipDraft.sourceResourceType} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceResourceType: event.target.value })} /></FormField>
<FormField label="Source resource ID" helpContextId="idm.relationships.field.source-resource-id" helpModuleId="idm"><input value={relationshipDraft.sourceResourceId} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceResourceId: event.target.value })} /></FormField>
<FormField label="Source revision" helpContextId="idm.relationships.field.source-revision" helpModuleId="idm"><input value={relationshipDraft.sourceRevision} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceRevision: event.target.value })} /></FormField>
<FormField label="Properties (JSON object)" helpContextId="idm.relationships.field.properties" helpModuleId="idm" className="wide"><textarea rows={4} value={relationshipDraft.properties} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, properties: event.target.value })} /></FormField>
<FormField label="Provenance (JSON object)" helpContextId="idm.relationships.field.provenance" helpModuleId="idm" className="wide"><textarea rows={4} value={relationshipDraft.provenance} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, provenance: event.target.value })} /></FormField>
<p className="idm-muted wide">Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.</p>
{editing && <p className="idm-muted wide">{i18nMessage("i18n:govoplan-idm.relationship_revision_help", { value0: editing.revision })}</p>}
</FormLayout>
</Dialog>
);
}
function renderRevokeDialog() {
return (
<Dialog
variant="administration"
size="large"
open={Boolean(revokeTarget)}
title="Revoke identity relationship"
helpContextId="idm.relationships.confirm-revoke"
helpModuleId="idm"
onClose={() => {
if (busy) return;
const close = () => { setRevokeTarget(null); setRevocationReason(""); };
if (revocationReason.trim()) requestDiscard(close);
else close();
}}
closeDisabled={busy}
className=""
footer={<><Button onClick={() => { setRevokeTarget(null); setRevocationReason(""); }} disabled={busy}>Cancel</Button><Button variant="danger" helpContextId="idm.relationships.action.confirm-revoke" helpModuleId="idm" disabled={!canWrite || busy || !revocationReason.trim()} onClick={() => void revokeRelationship()}>{busy ? "Revoking..." : "Revoke relationship"}</Button></>}
>
<p>Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.</p>
<FormField label="Revocation reason" helpContextId="idm.relationships.field.revocation-reason" helpModuleId="idm"><textarea rows={4} required value={revocationReason} disabled={busy} onChange={(event) => setRevocationReason(event.target.value)} /></FormField>
</Dialog>
);
}
function renderMembershipDialog() {
return (
<Dialog
variant="administration"
size="wide"
open={Boolean(membershipGroup)}
title={membershipGroup ? i18nMessage("i18n:govoplan-idm.effective_memberships_value", { value0: membershipGroup.name }) : "Effective memberships"}
helpContextId="idm.typed-groups.membership-resolution"
helpModuleId="idm"
onClose={() => !membershipLoading && setMembershipGroup(null)}
closeDisabled={membershipLoading}
className=""
footer={<Button variant="primary" onClick={() => setMembershipGroup(null)} disabled={membershipLoading}>Close</Button>}
>
<FormLayout columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void loadMembership(); }}>
<FormField label="Effective at" helpContextId="idm.typed-groups.field.membership-effective-at" helpModuleId="idm"><DateTimeField value={membershipEffectiveAt} disabled={membershipLoading} helpContextId="idm.typed-groups.field.membership-effective-at" helpModuleId="idm" onChange={setMembershipEffectiveAt} /></FormField>
<FormField label="Relationship kinds" helpContextId="idm.typed-groups.field.membership-kinds" helpModuleId="idm"><input value={membershipKinds} disabled={membershipLoading} placeholder="member" onChange={(event) => setMembershipKinds(event.target.value)} /></FormField>
<div className="wide button-row compact-actions"><Button type="submit" helpContextId="idm.typed-groups.action.resolve-memberships" helpModuleId="idm" disabled={membershipLoading || !membershipKinds.trim()}><RefreshCw size={16} aria-hidden="true" /> Resolve memberships</Button></div>
</FormLayout>
<LoadingFrame loading={membershipLoading} label="Resolving effective memberships">
{membershipResolution && (
<>
<p className="idm-muted">{i18nMessage("i18n:govoplan-idm.memberships_resolved_summary", { value0: new Date(membershipResolution.effective_at).toLocaleString(language), value1: membershipResolution.identity_ids.length })}</p>
<DataGrid id="idm-typed-group-membership-resolution" rows={membershipResolution.decisions} columns={membershipColumns} getRowKey={(row) => row.relationship.id} emptyText="No membership relationships were evaluated." initialFit="container" />
</>
)}
</LoadingFrame>
</Dialog>
);
}
}
function groupDraftFrom(item: TypedGroupItem): GroupDraft {
return {
key: item.key,
name: item.name,
groupType: item.group_type,
description: item.description ?? "",
status: item.status,
sourceProvider: item.source_provider,
sourceResourceType: item.source_resource_type ?? "",
sourceResourceId: item.source_resource_id ?? "",
sourceRevision: item.source_revision ?? "",
properties: prettyJson(item.properties),
provenance: prettyJson(item.provenance)
};
}
function groupPayload(draft: GroupDraft): TypedGroupPayload {
return {
key: draft.key.trim(),
name: draft.name.trim(),
group_type: draft.groupType.trim(),
description: optionalText(draft.description),
source_provider: draft.sourceProvider.trim(),
source_resource_type: optionalText(draft.sourceResourceType),
source_resource_id: optionalText(draft.sourceResourceId),
source_revision: optionalText(draft.sourceRevision),
properties: parseJsonObject(draft.properties, "Properties"),
provenance: parseJsonObject(draft.provenance, "Provenance")
};
}
function relationshipDraftFrom(item: IdentityRelationshipItem): RelationshipDraft {
return {
relationshipKind: item.relationship_kind,
subjectIdentityId: item.subject_identity_id,
targetType: item.target_group_id ? "group" : "identity",
targetGroupId: item.target_group_id ?? "",
relatedIdentityId: item.related_identity_id ?? "",
role: item.role ?? "",
validFrom: item.valid_from ?? "",
validUntil: item.valid_until ?? "",
sourceProvider: item.source_provider,
sourceResourceType: item.source_resource_type ?? "",
sourceResourceId: item.source_resource_id ?? "",
sourceRevision: item.source_revision ?? "",
properties: prettyJson(item.properties),
provenance: prettyJson(item.provenance)
};
}
function relationshipPayload(draft: RelationshipDraft): IdentityRelationshipPayload {
return {
relationship_kind: draft.relationshipKind.trim(),
subject_identity_id: draft.subjectIdentityId,
target_group_id: draft.targetType === "group" ? draft.targetGroupId || null : null,
related_identity_id: draft.targetType === "identity" ? draft.relatedIdentityId || null : null,
role: optionalText(draft.role),
valid_from: optionalDateTime(draft.validFrom),
valid_until: optionalDateTime(draft.validUntil),
source_provider: draft.sourceProvider.trim(),
source_resource_type: optionalText(draft.sourceResourceType),
source_resource_id: optionalText(draft.sourceResourceId),
source_revision: optionalText(draft.sourceRevision),
properties: parseJsonObject(draft.properties, "Properties"),
provenance: parseJsonObject(draft.provenance, "Provenance")
};
}
function relationshipState(item: IdentityRelationshipItem): "future" | "active" | "expired" | "revoked" {
if (item.status === "revoked") return "revoked";
const now = Date.now();
if (item.valid_from && new Date(item.valid_from).getTime() > now) return "future";
if (item.valid_until && new Date(item.valid_until).getTime() <= now) return "expired";
return "active";
}
function stateStatus(state: ReturnType<typeof relationshipState>): string {
if (state === "future") return "pending";
return state;
}
function relationshipStateLabel(state: ReturnType<typeof relationshipState>): string {
return state === "future" ? "Future" : state === "active" ? "Active" : state === "expired" ? "Expired" : "Revoked";
}
function groupStatusLabel(status: TypedGroupItem["status"]): string {
return status === "active" ? "Active" : "Inactive";
}
function identityOption(item: IdentityOption): SearchableSelectOption {
const label = identityLabel(item, item.id);
return { value: item.id, label, description: item.external_subject ?? item.id, searchText: `${item.id} ${item.account_ids.join(" ")}` };
}
function selectedIdentityOption(id: string, identityById: ReadonlyMap<string, IdentityOption>): SearchableSelectOption | null {
if (!id) return null;
const item = identityById.get(id);
return item ? identityOption(item) : { value: id, label: id, description: "Identity reference" };
}
function identityLabel(item: IdentityOption | undefined, fallback: string): string {
return item?.display_name || item?.external_subject || fallback;
}
function identityDisplay(item: IdentityOption | undefined, fallback: string): JSX.Element {
return <div><strong>{identityLabel(item, fallback)}</strong><div className="idm-id">{fallback}</div></div>;
}
function groupOption(item: TypedGroupItem): SearchableSelectOption {
return { value: item.id, label: item.name, description: `${item.group_type} · ${item.key}`, searchText: `${item.id} ${item.key}` };
}
function fallbackGroup(id: string): TypedGroupItem {
return { id, tenant_id: "", key: id, name: id, group_type: "unknown", status: "inactive", source_provider: "unknown", properties: {}, provenance: {}, revision: 1 };
}
function sourceSummary(item: Pick<TypedGroupItem, "source_provider" | "source_revision">): JSX.Element {
return <div><span>{item.source_provider}</span>{item.source_revision && <div className="idm-id">{item.source_revision}</div>}</div>;
}
function relationshipTargetLabel(
item: IdentityRelationshipItem,
groupById: ReadonlyMap<string, TypedGroupItem>,
identityById: ReadonlyMap<string, IdentityOption>
): string {
if (item.target_group_id) return groupById.get(item.target_group_id)?.name ?? item.target_group_id;
return identityLabel(identityById.get(item.related_identity_id ?? ""), item.related_identity_id ?? "");
}
function relationshipTarget(
item: IdentityRelationshipItem,
groupById: ReadonlyMap<string, TypedGroupItem>,
identityById: ReadonlyMap<string, IdentityOption>
): JSX.Element {
if (item.target_group_id) {
const group = groupById.get(item.target_group_id);
return <div><strong>{group?.name ?? item.target_group_id}</strong><div className="idm-id">{group ? `${group.group_type} · ${group.key}` : item.target_group_id}</div></div>;
}
return identityDisplay(identityById.get(item.related_identity_id ?? ""), item.related_identity_id ?? "");
}
function effectiveWindow(item: IdentityRelationshipItem, language: string): JSX.Element {
return <div><div>{item.valid_from ? new Date(item.valid_from).toLocaleString(language) : "No start limit"}</div><div className="idm-muted">{item.valid_until ? new Date(item.valid_until).toLocaleString(language) : "No end limit"}</div></div>;
}
function mergeIdentities(current: IdentityOption[], incoming: IdentityOption[]): IdentityOption[] {
const merged = new Map(current.map((item) => [item.id, item]));
for (const item of incoming) merged.set(item.id, item);
return Array.from(merged.values());
}
function optionalText(value: string): string | null {
const trimmed = value.trim();
return trimmed || null;
}
function optionalDateTime(value: string): string | null {
if (!value.trim()) return null;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) throw new Error("Enter a valid date and time.");
return parsed.toISOString();
}
function parseJsonObject(value: string, label: string): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(value || "{}");
} catch {
throw new Error(`${label} must contain valid JSON.`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`${label} must be a JSON object.`);
}
return parsed as Record<string, unknown>;
}
function prettyJson(value: Record<string, unknown>): string {
return JSON.stringify(value, null, 2);
}
function draftKey(value: unknown): string {
return JSON.stringify(value);
}
function apiErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
try {
const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } };
if (typeof parsed.detail === "string") return parsed.detail;
if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message;
} catch {
// Fall back to the transport message.
}
return error.message;
}
if (error instanceof Error) return error.message;
return String(error);
}
+5 -1
View File
@@ -15,6 +15,11 @@ export const IDM_FIELD_DOCUMENTATION = {
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const IDM_RELATIONSHIP_DOCUMENTATION = {
topicId: "idm.reference.typed-relationships",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const IDM_INTERFACE_I18N = {
loading: "i18n:govoplan-idm.loading_reason",
busy: "i18n:govoplan-idm.busy_reason",
@@ -45,4 +50,3 @@ export function idmDisabledReason(
if (!permitted) return IDM_INTERFACE_I18N.writeReason;
return undefined;
}
+212 -4
View File
@@ -2,6 +2,9 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"Direct changes to this governed function are": "Direct changes to this governed function are",
"emergency overrides": "emergency overrides",
". Use a request or grant above for the normal process.": ". Use a request or grant above for the normal process.",
"i18n:govoplan-idm.account.2b2936f8": "Account",
"i18n:govoplan-idm.active.7bd0e9f8": "Active",
"i18n:govoplan-idm.acting_for.8650e6a6": "acting for",
@@ -31,7 +34,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-idm.governance.b989a277": "governance",
"i18n:govoplan-idm.identity.544a8347": "Identity",
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identity is required.",
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Link identities to organization functions. Organizations defines the functions; IDM owns who holds them.",
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Manage organization-function assignments, typed business groups, and effective identity relationships. IDM records institutional facts; Access evaluates application authority separately.",
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identity lookup is unavailable.",
"i18n:govoplan-idm.identity_search.d3460fcf": "Identity search",
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM governance",
@@ -151,9 +154,113 @@ export const generatedTranslations: PlatformTranslations = {
"History": "History",
"Confirm function decision": "Confirm function decision",
"Confirm": "Confirm",
"None": "None"
"None": "None",
"i18n:govoplan-idm.edit_group_value": "Edit {value0}",
"i18n:govoplan-idm.inspect_memberships_value": "Inspect memberships for {value0}",
"i18n:govoplan-idm.effective_memberships_value": "Effective memberships: {value0}",
"i18n:govoplan-idm.memberships_resolved_summary": "Resolved at {value0}. {value1} identities are effective; excluded decisions remain visible for explanation.",
"i18n:govoplan-idm.group_revision_help": "Revision {value0} is used for optimistic concurrency. If another administrator saves first, reload before applying your change.",
"i18n:govoplan-idm.relationship_revision_help": "Revision {value0} is used for optimistic concurrency. The subject identity is immutable; replace the relationship if the subject is wrong.",
"Access role assignments": "Access role assignments",
"Active": "Active",
"An Access or tenant administrator": "An Access or tenant administrator",
"Ask for typed-relationship read permission.": "Ask for typed-relationship read permission.",
"Ask for typed-relationship write permission before creating, editing, or revoking records.": "Ask for typed-relationship write permission before creating, editing, or revoking records.",
"Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.": "Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.",
"Create an active typed group first.": "Create an active typed group first.",
"Create identity relationship": "Create identity relationship",
"Create relationship": "Create relationship",
"Create typed group": "Create typed group",
"Description": "Description",
"Destination": "Destination",
"Edit identity relationship": "Edit identity relationship",
"Edit relationship": "Edit relationship",
"Edit typed group": "Edit typed group",
"Effective at": "Effective at",
"Effective identity relationships": "Effective identity relationships",
"Effective memberships": "Effective memberships",
"Effective state": "Effective state",
"Effective window": "Effective window",
"Enter a valid date and time.": "Enter a valid date and time.",
"Expired": "Expired",
"Future": "Future",
"Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.": "Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.",
"Group type": "Group type",
"Identity": "Identity",
"Identity reference": "Identity reference",
"Identity relationship created.": "Identity relationship created.",
"Identity relationship revoked. Effective membership and downstream business resolution stop immediately.": "Identity relationship revoked. Effective membership and downstream business resolution stop immediately.",
"Identity relationship updated.": "Identity relationship updated.",
"Identity state": "Identity state",
"Inactive": "Inactive",
"Key": "Key",
"Loading typed groups and relationships": "Loading typed groups and relationships",
"Name": "Name",
"No end limit": "No end limit",
"No identity relationships found.": "No identity relationships found.",
"No membership relationships were evaluated.": "No membership relationships were evaluated.",
"No start limit": "No start limit",
"No typed groups found.": "No typed groups found.",
"Not available": "Not available",
"Properties (JSON object)": "Properties (JSON object)",
"Properties must be a JSON object.": "Properties must be a JSON object.",
"Properties must contain valid JSON.": "Properties must contain valid JSON.",
"Provenance (JSON object)": "Provenance (JSON object)",
"Provenance must be a JSON object.": "Provenance must be a JSON object.",
"Provenance must contain valid JSON.": "Provenance must contain valid JSON.",
"Related identity": "Related identity",
"Reload": "Reload",
"Relationship": "Relationship",
"Relationship kind": "Relationship kind",
"Relationship kinds": "Relationship kinds",
"Relationship write permission is required.": "Relationship write permission is required.",
"Required action": "Required action",
"Resolution": "Resolution",
"Resolve memberships": "Resolve memberships",
"Resolving effective memberships": "Resolving effective memberships",
"Responsible actor": "Responsible actor",
"Revision": "Revision",
"Revocation reason": "Revocation reason",
"Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.": "Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.",
"Revoke identity relationship": "Revoke identity relationship",
"Revoke relationship": "Revoke relationship",
"Revoked": "Revoked",
"Revoked relationships are retained as immutable evidence.": "Revoked relationships are retained as immutable evidence.",
"Revoking...": "Revoking...",
"Role": "Role",
"Save group": "Save group",
"Save or discard the typed-group or relationship draft before leaving this surface.": "Save or discard the typed-group or relationship draft before leaving this surface.",
"Save relationship": "Save relationship",
"Saving...": "Saving...",
"Search identities": "Search identities",
"Search typed groups": "Search typed groups",
"Show inactive groups": "Show inactive groups",
"Show revoked relationships": "Show revoked relationships",
"Source": "Source",
"Source provider": "Source provider",
"Source resource ID": "Source resource ID",
"Source resource type": "Source resource type",
"Source revision": "Source revision",
"Status": "Status",
"Subject identity": "Subject identity",
"Target": "Target",
"Target group": "Target group",
"Target type": "Target type",
"Target typed group": "Target typed group",
"Typed group": "Typed group",
"Typed group created.": "Typed group created.",
"Typed group updated.": "Typed group updated.",
"Typed groups": "Typed groups",
"Typed groups and identity relationships": "Typed groups and identity relationships",
"Typed-group write permission is required.": "Typed-group write permission is required.",
"Unsaved relationship administration": "Unsaved relationship administration",
"You do not have permission to view typed identity relationships.": "You do not have permission to view typed identity relationships.",
"You may inspect relationship evidence but not change it.": "You may inspect relationship evidence but not change it."
},
de: {
"Direct changes to this governed function are": "Direkte Änderungen an dieser gesteuerten Funktion sind",
"emergency overrides": "Notfallübersteuerungen",
". Use a request or grant above for the normal process.": ". Verwenden Sie für den regulären Prozess einen Antrag oder eine Vergabe.",
"i18n:govoplan-idm.account.2b2936f8": "Konto",
"i18n:govoplan-idm.active.7bd0e9f8": "Aktiv",
"i18n:govoplan-idm.acting_for.8650e6a6": "in Vertretung",
@@ -183,7 +290,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-idm.governance.b989a277": "Governance",
"i18n:govoplan-idm.identity.544a8347": "Identität",
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identität ist erforderlich.",
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verknüpfe Identitäten mit Organisationsfunktionen. Organisationen definiert die Funktionen; IDM verwaltet, wer sie innehat.",
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verwalten Sie Organisationsfunktionszuordnungen, typisierte Fachgruppen und zeitlich wirksame Identitätsbeziehungen. IDM erfasst institutionelle Tatsachen; Access bewertet Anwendungsbefugnisse getrennt.",
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identitätssuche ist nicht verfügbar.",
"i18n:govoplan-idm.identity_search.d3460fcf": "Identitätssuche",
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM-Governance",
@@ -303,6 +410,107 @@ export const generatedTranslations: PlatformTranslations = {
"History": "Verlauf",
"Confirm function decision": "Funktionsentscheidung bestätigen",
"Confirm": "Bestätigen",
"None": "Keine"
"None": "Keine",
"i18n:govoplan-idm.edit_group_value": "{value0} bearbeiten",
"i18n:govoplan-idm.inspect_memberships_value": "Mitgliedschaften von {value0} prüfen",
"i18n:govoplan-idm.effective_memberships_value": "Wirksame Mitgliedschaften: {value0}",
"i18n:govoplan-idm.memberships_resolved_summary": "Aufgelöst zum Zeitpunkt {value0}. {value1} Identitäten sind wirksam; ausgeschlossene Entscheidungen bleiben zur Erläuterung sichtbar.",
"i18n:govoplan-idm.group_revision_help": "Revision {value0} schützt vor dem Überschreiben paralleler Änderungen. Falls eine andere Administration zuerst speichert, laden Sie vor Ihrer Änderung neu.",
"i18n:govoplan-idm.relationship_revision_help": "Revision {value0} schützt vor dem Überschreiben paralleler Änderungen. Die Ausgangsidentität ist unveränderbar; ersetzen Sie die Beziehung, wenn sie falsch ist.",
"Access role assignments": "Zuweisungen von Zugriffsrollen",
"Active": "Aktiv",
"An Access or tenant administrator": "Access- oder Mandantenadministration",
"Ask for typed-relationship read permission.": "Fordern Sie die Leseberechtigung für typisierte Beziehungen an.",
"Ask for typed-relationship write permission before creating, editing, or revoking records.": "Fordern Sie vor dem Erstellen, Bearbeiten oder Widerrufen die Schreibberechtigung für typisierte Beziehungen an.",
"Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.": "Eine fachliche Mitgliedschaft ist eine institutionelle Tatsache. Sie erteilt keine Anwendungsberechtigungen; Access bewertet Befugnisse getrennt.",
"Create an active typed group first.": "Erstellen Sie zuerst eine aktive typisierte Gruppe.",
"Create identity relationship": "Identitätsbeziehung erstellen",
"Create relationship": "Beziehung erstellen",
"Create typed group": "Typisierte Gruppe erstellen",
"Description": "Beschreibung",
"Destination": "Ziel",
"Edit identity relationship": "Identitätsbeziehung bearbeiten",
"Edit relationship": "Beziehung bearbeiten",
"Edit typed group": "Typisierte Gruppe bearbeiten",
"Effective at": "Wirksam zum Zeitpunkt",
"Effective identity relationships": "Wirksame Identitätsbeziehungen",
"Effective memberships": "Wirksame Mitgliedschaften",
"Effective state": "Wirksamkeitsstatus",
"Effective window": "Wirksamkeitszeitraum",
"Enter a valid date and time.": "Geben Sie ein gültiges Datum mit Uhrzeit ein.",
"Expired": "Abgelaufen",
"Future": "Zukünftig",
"Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.": "Ein zukünftiger Beginn plant die Tatsache, ohne eine aktuelle Mitgliedschaft zu erzeugen. Ablauf und Widerruf entfernen sie aus der wirksamen Auflösung, während Quellen- und Entscheidungsnachweise erhalten bleiben. Eine Mitgliedschaft erteilt niemals selbstständig Access-Berechtigungen.",
"Group type": "Gruppentyp",
"Identity": "Identität",
"Identity reference": "Identitätsreferenz",
"Identity relationship created.": "Identitätsbeziehung erstellt.",
"Identity relationship revoked. Effective membership and downstream business resolution stop immediately.": "Identitätsbeziehung widerrufen. Wirksame Mitgliedschaft und nachgelagerte fachliche Auflösungen enden sofort.",
"Identity relationship updated.": "Identitätsbeziehung aktualisiert.",
"Identity state": "Identitätsstatus",
"Inactive": "Inaktiv",
"Key": "Schlüssel",
"Loading typed groups and relationships": "Typisierte Gruppen und Beziehungen werden geladen",
"Name": "Name",
"No end limit": "Kein Endzeitpunkt",
"No identity relationships found.": "Keine Identitätsbeziehungen gefunden.",
"No membership relationships were evaluated.": "Es wurden keine Mitgliedschaftsbeziehungen ausgewertet.",
"No start limit": "Kein Startzeitpunkt",
"No typed groups found.": "Keine typisierten Gruppen gefunden.",
"Not available": "Nicht verfügbar",
"Properties (JSON object)": "Eigenschaften (JSON-Objekt)",
"Properties must be a JSON object.": "Eigenschaften müssen ein JSON-Objekt sein.",
"Properties must contain valid JSON.": "Eigenschaften müssen gültiges JSON enthalten.",
"Provenance (JSON object)": "Herkunftsnachweis (JSON-Objekt)",
"Provenance must be a JSON object.": "Der Herkunftsnachweis muss ein JSON-Objekt sein.",
"Provenance must contain valid JSON.": "Der Herkunftsnachweis muss gültiges JSON enthalten.",
"Related identity": "Verknüpfte Identität",
"Reload": "Neu laden",
"Relationship": "Beziehung",
"Relationship kind": "Beziehungsart",
"Relationship kinds": "Beziehungsarten",
"Relationship write permission is required.": "Die Schreibberechtigung für Beziehungen ist erforderlich.",
"Required action": "Erforderliche Aktion",
"Resolution": "Auflösung",
"Resolve memberships": "Mitgliedschaften auflösen",
"Resolving effective memberships": "Wirksame Mitgliedschaften werden aufgelöst",
"Responsible actor": "Verantwortliche Stelle",
"Revision": "Revision",
"Revocation reason": "Widerrufsgrund",
"Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.": "Der Widerruf wirkt sofort auf die Mitgliedschaftsauflösung und nachgelagerte fachliche Verbraucher. Datensatz, handelnde Person, Zeitpunkt, Quelle und Grund bleiben als Nachweis erhalten; eine widerrufene Beziehung kann weder bearbeitet noch reaktiviert werden.",
"Revoke identity relationship": "Identitätsbeziehung widerrufen",
"Revoke relationship": "Beziehung widerrufen",
"Revoked": "Widerrufen",
"Revoked relationships are retained as immutable evidence.": "Widerrufene Beziehungen bleiben als unveränderbarer Nachweis erhalten.",
"Revoking...": "Wird widerrufen …",
"Role": "Rolle",
"Save group": "Gruppe speichern",
"Save or discard the typed-group or relationship draft before leaving this surface.": "Speichern oder verwerfen Sie den Entwurf der typisierten Gruppe oder Beziehung, bevor Sie diesen Bereich verlassen.",
"Save relationship": "Beziehung speichern",
"Saving...": "Wird gespeichert …",
"Search identities": "Identitäten durchsuchen",
"Search typed groups": "Typisierte Gruppen durchsuchen",
"Show inactive groups": "Inaktive Gruppen anzeigen",
"Show revoked relationships": "Widerrufene Beziehungen anzeigen",
"Source": "Quelle",
"Source provider": "Quellanbieter",
"Source resource ID": "Quellressourcen-ID",
"Source resource type": "Quellressourcentyp",
"Source revision": "Quellrevision",
"Status": "Status",
"Subject identity": "Ausgangsidentität",
"Target": "Ziel",
"Target group": "Zielgruppe",
"Target type": "Zielart",
"Target typed group": "Typisierte Zielgruppe",
"Typed group": "Typisierte Gruppe",
"Typed group created.": "Typisierte Gruppe erstellt.",
"Typed group updated.": "Typisierte Gruppe aktualisiert.",
"Typed groups": "Typisierte Gruppen",
"Typed groups and identity relationships": "Typisierte Gruppen und Identitätsbeziehungen",
"Typed-group write permission is required.": "Die Schreibberechtigung für typisierte Gruppen ist erforderlich.",
"Unsaved relationship administration": "Ungespeicherte Beziehungsverwaltung",
"You do not have permission to view typed identity relationships.": "Sie haben keine Berechtigung, typisierte Identitätsbeziehungen anzuzeigen.",
"You may inspect relationship evidence but not change it.": "Sie können Beziehungsnachweise prüfen, aber nicht ändern."
}
};
+2
View File
@@ -9,6 +9,8 @@ const IdmPage = lazy(() => import("./features/IdmPage"));
const idmReadScopes = [
"idm:organization_assignment:read",
"idm:organization_assignment:write",
"idm:relationship:read",
"idm:relationship:write",
"organizations:function:assign"
];
+6 -9
View File
@@ -2,21 +2,12 @@
display: grid;
gap: 18px;
width: 100%;
max-width: 1480px;
}
.idm-heading {
margin-bottom: 4px;
}
.idm-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.idm-table-stack {
display: grid;
gap: 18px;
@@ -27,6 +18,12 @@
width: 100%;
}
.idm-relationship-stack {
display: grid;
gap: 18px;
width: 100%;
}
.idm-check-list {
display: grid;
gap: 10px;