Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e09d0c68 | ||
|
|
5c7586f6d9 | ||
|
|
b2a641cac2 | ||
|
|
e9f8e0a1f8 | ||
|
|
3dd7766b08 |
@@ -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.
|
||||||
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/idm-webui",
|
"name": "@govoplan/idm-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-idm"
|
name = "govoplan-idm"
|
||||||
version = "0.1.20"
|
version = "0.1.25"
|
||||||
description = "GovOPlaN identity management bridge module."
|
description = "GovOPlaN identity management bridge module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.29",
|
"govoplan-core>=0.1.45",
|
||||||
"govoplan-identity>=0.1.18",
|
"govoplan-identity>=0.1.18",
|
||||||
"govoplan-organizations>=0.1.18",
|
"govoplan-organizations>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'idm.reference.assignment-governance': {'consequence_classes': {'emergency_override': 'Umgeht den '
|
||||||
|
'normalen '
|
||||||
|
'geregelten '
|
||||||
|
'Antrags- '
|
||||||
|
'oder '
|
||||||
|
'Gewährungspfad '
|
||||||
|
'und '
|
||||||
|
'erfordert '
|
||||||
|
'beibehaltene '
|
||||||
|
'Gründe und '
|
||||||
|
'Nachweise.',
|
||||||
|
'function_decision': 'Erweitert '
|
||||||
|
'oder '
|
||||||
|
'beendet '
|
||||||
|
'eine '
|
||||||
|
'geregelte '
|
||||||
|
'Änderung '
|
||||||
|
'und behält '
|
||||||
|
'handelnde '
|
||||||
|
'Person, '
|
||||||
|
'Kommentar, '
|
||||||
|
'Politik und '
|
||||||
|
'Workflow-Nachweise.',
|
||||||
|
'governance_settings': 'Ändert, '
|
||||||
|
'ob '
|
||||||
|
'direkte '
|
||||||
|
'Zuordnungsmutationen '
|
||||||
|
'genehmigte '
|
||||||
|
'Änderungsnachweise '
|
||||||
|
'erfordern.',
|
||||||
|
'timed_escalation': 'Zeichnet '
|
||||||
|
'eine '
|
||||||
|
'überfällige '
|
||||||
|
'Überprüfung '
|
||||||
|
'und genaue '
|
||||||
|
'Zielfunktion '
|
||||||
|
'auf, ohne '
|
||||||
|
'eine '
|
||||||
|
'Genehmigung '
|
||||||
|
'zu ersetzen '
|
||||||
|
'oder '
|
||||||
|
'abzuschließen.'}},
|
||||||
|
'idm.reference.fields-and-consequences': {'consequence_classes': {'acting_for': 'Ermöglicht es '
|
||||||
|
'einem gebundenen '
|
||||||
|
'Konto, anstelle '
|
||||||
|
'einer '
|
||||||
|
'Quellzuweisung '
|
||||||
|
'zu handeln, wenn '
|
||||||
|
'Organisationen '
|
||||||
|
'dies zulassen.',
|
||||||
|
'assignment': 'Ändert die '
|
||||||
|
'effektive '
|
||||||
|
'institutionelle '
|
||||||
|
'Funktion, die '
|
||||||
|
'von optionalen '
|
||||||
|
'nachgelagerten '
|
||||||
|
'Fähigkeiten '
|
||||||
|
'verbraucht wird.',
|
||||||
|
'deactivate_or_expire': 'Entfernt '
|
||||||
|
'die '
|
||||||
|
'Tatsache '
|
||||||
|
'aus '
|
||||||
|
'der '
|
||||||
|
'effektiven '
|
||||||
|
'Auflösung, '
|
||||||
|
'während '
|
||||||
|
'Provenienz '
|
||||||
|
'und '
|
||||||
|
'Lebenszyklus '
|
||||||
|
'Nachweise '
|
||||||
|
'beibehalten.',
|
||||||
|
'delegation': 'Erstellt eine '
|
||||||
|
'begrenzte '
|
||||||
|
'abgeleitete '
|
||||||
|
'Zuweisung, die '
|
||||||
|
'an die '
|
||||||
|
'Quellzuweisung '
|
||||||
|
'gebunden bleibt.',
|
||||||
|
'escalation': 'Leitet eine '
|
||||||
|
'überfällige '
|
||||||
|
'Überprüfung '
|
||||||
|
'sichtbar zu '
|
||||||
|
'einer genau '
|
||||||
|
'konfigurierten '
|
||||||
|
'Funktion, ohne '
|
||||||
|
'die Entscheidung '
|
||||||
|
'abzuschließen.',
|
||||||
|
'retention': 'Ändert, wie lange '
|
||||||
|
'detaillierte '
|
||||||
|
'Zuordnungsänderungsnachweise '
|
||||||
|
'verfügbar '
|
||||||
|
'bleiben.'}},
|
||||||
|
'idm.reference.typed-relationships': {'consequences': ['Ein zukünftiger Start verzögert die '
|
||||||
|
'Mitgliedschaft bis zum ausgewählten '
|
||||||
|
'Zeitpunkt.',
|
||||||
|
'Expiry entfernt die Beziehung von der '
|
||||||
|
'effektiven Auflösung, während Nachweise '
|
||||||
|
'aufbewahrt werden.',
|
||||||
|
'Der Widerruf entfernt die Beziehung '
|
||||||
|
'sofort von der effektiven Auflösung und '
|
||||||
|
'kann nicht rückgängig gemacht werden.',
|
||||||
|
'Das Ändern einer extern beschafften '
|
||||||
|
'Tatsache ohne übereinstimmende Provenienz '
|
||||||
|
'kann die Verantwortlichkeit für die '
|
||||||
|
'Abstimmung unterbrechen.'],
|
||||||
|
'limitations': ['Mitgliedschaftsbeschlüsse sind '
|
||||||
|
'mieterspezifisch und lehnen '
|
||||||
|
'mieterübergreifende Gruppenreferenzen ab.',
|
||||||
|
'Eine widerrufene Beziehung ist '
|
||||||
|
'unveränderlich und erfordert einen Ersatz '
|
||||||
|
'für eine spätere Wiederverwendung.',
|
||||||
|
'Die Mitgliedschaft allein aktiviert '
|
||||||
|
'niemals eine Identität oder erteilt eine '
|
||||||
|
'Antragsberechtigung.'],
|
||||||
|
'outcome': 'Der Mandant hat erklärbare, effektiv datierte '
|
||||||
|
'Geschäftsmitgliedschaftsfakten, die '
|
||||||
|
'nachgelagerte Verbraucher lösen können, ohne '
|
||||||
|
'IDM-Interna zu importieren oder Zugriffsrechte '
|
||||||
|
'abzuleiten.',
|
||||||
|
'prerequisites': ['Die Identitäten existieren im '
|
||||||
|
'Mandanten-Identitätsverzeichnis.',
|
||||||
|
'Die handelnde Person hat die Berechtigung '
|
||||||
|
'zum Lesen von Beziehungen und die '
|
||||||
|
'Berechtigung zum Schreiben von '
|
||||||
|
'Mutationen.',
|
||||||
|
'Die verantwortliche Quelle, das '
|
||||||
|
'effektive Fenster, die Art der Beziehung '
|
||||||
|
'und der Geschäftszweck sind bekannt.'],
|
||||||
|
'steps': ['Erstellen oder wählen Sie eine typisierte Gruppe '
|
||||||
|
'mit einem stabilen Schlüssel, Typ und Herkunft '
|
||||||
|
'aus.',
|
||||||
|
'Erstellen Sie eine Beziehung zu durchsuchbaren '
|
||||||
|
'Betreff- und Zielreferenzen und dem '
|
||||||
|
'beabsichtigten Gültigkeitsfenster.',
|
||||||
|
'Überprüfen Sie effektive Mitgliedschaften zum '
|
||||||
|
'jeweiligen Zeitpunkt und überprüfen Sie jede '
|
||||||
|
'eingeschlossene oder ausgeschlossene '
|
||||||
|
'Entscheidung.',
|
||||||
|
'Widerrufen Sie eine Beziehung mit einem '
|
||||||
|
'vorgehaltenen Grund, wenn die Tatsache vor ihrem '
|
||||||
|
'geplanten Ende aufhören muss.'],
|
||||||
|
'verification': 'Laden Sie beide Verzeichnisse neu, '
|
||||||
|
'bestätigen Sie die Datensatzrevision und '
|
||||||
|
'die Quellfelder und lösen Sie dann die '
|
||||||
|
'Mitgliedschaften der Zielgruppe zu Zeiten '
|
||||||
|
'vor, während und nach dem '
|
||||||
|
'Gültigkeitsfenster auf. Stellen Sie '
|
||||||
|
'sicher, dass Zugriffsberechtigungen '
|
||||||
|
'unverändert bleiben.'},
|
||||||
|
'idm.scim-provisioning': {'consequences': ['Ein unvollständiger oder fehlgeschlagener Snapshot '
|
||||||
|
'kann eine lokale Identität nicht deaktivieren.',
|
||||||
|
'Ein geänderter unveränderlicher Wert oder eine '
|
||||||
|
'Kollision blockiert die automatische Verknüpfung.',
|
||||||
|
'Ein vollständiger Snapshot kann nur dann eine '
|
||||||
|
'Deaktivierung vorschlagen, wenn die Mandantrichtlinie '
|
||||||
|
'ihn explizit auswählt.'],
|
||||||
|
'limitations': ['Diese Version zeigt eine Vorschau, wendet jedoch keine '
|
||||||
|
'SCIM-Bereitstellungspläne an.',
|
||||||
|
'Cursor-Paginierung wird nicht verwendet, bis '
|
||||||
|
'angekündigt und durch einen Provider-Zieltest '
|
||||||
|
'abgedeckt.',
|
||||||
|
'Eine SCIM-Gruppenmitgliedschaft wird niemals '
|
||||||
|
'automatisch zur Zugriffsberechtigung.'],
|
||||||
|
'prerequisites': ['Der Anbieter stellt RFC 7643 Benutzer- und '
|
||||||
|
'Gruppenressourcen über SCIM 2.0 zur Verfügung.',
|
||||||
|
'Ein anbietereigenes unveränderliches '
|
||||||
|
'Übereinstimmungsattribut wurde ausgewählt und '
|
||||||
|
'kollisionsgetestet.',
|
||||||
|
'Die Authentifizierung erfolgt in einem Scoped Access '
|
||||||
|
'Credential-Umschlag.'],
|
||||||
|
'steps': ['Lesen Sie jede Benutzer- und Gruppenseite in einem '
|
||||||
|
'vollständigen Snapshot.',
|
||||||
|
'Überprüfung von Schema, Paginierung, Kollision und Diagnose '
|
||||||
|
'von unveränderlichen Werten.',
|
||||||
|
'Überprüfen Sie jede Erstellung, Verknüpfung, Aktualisierung, '
|
||||||
|
'Deaktivierung oder Quarantäne und die erwartete lokale '
|
||||||
|
'Überarbeitung.',
|
||||||
|
'Entsorgen und neu erstellen Sie den Plan nach jeder '
|
||||||
|
'Anbieter, Mapping oder lokale Revision Änderung.'],
|
||||||
|
'verification': 'Vergleichen Sie Seitensummen, Quellen- und '
|
||||||
|
'Planverdauungen, erwartete lokale Überarbeitungen, '
|
||||||
|
'Kollisionsdiagnosen und die Abwesenheitsrichtlinien, '
|
||||||
|
'bevor Sie eine spätere Ausführung genehmigen.'}}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __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 pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -46,7 +50,15 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
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_core.db.base import Base
|
||||||
from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata
|
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.dsar_provider import IDM_DSAR_CAPABILITY
|
||||||
@@ -54,9 +66,10 @@ from govoplan_idm.backend.workflow_definitions import (
|
|||||||
function_assignment_workflow_definitions,
|
function_assignment_workflow_definitions,
|
||||||
)
|
)
|
||||||
from govoplan_idm.backend.search_source import create_idm_search_source
|
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.20"
|
MODULE_VERSION = "0.1.25"
|
||||||
|
|
||||||
IDM_READ_SCOPES = (
|
IDM_READ_SCOPES = (
|
||||||
"idm:organization_assignment:read",
|
"idm:organization_assignment:read",
|
||||||
@@ -194,7 +207,9 @@ def _idm_directory(context: ModuleContext) -> object:
|
|||||||
from govoplan_idm.backend.directory import SqlIdmDirectory
|
from govoplan_idm.backend.directory import SqlIdmDirectory
|
||||||
|
|
||||||
identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
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):
|
if not isinstance(identities, IdentityDirectory):
|
||||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||||
if not isinstance(organizations, OrganizationDirectory):
|
if not isinstance(organizations, OrganizationDirectory):
|
||||||
@@ -226,6 +241,82 @@ def _idm_dsar_provider(context: ModuleContext) -> object:
|
|||||||
return 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(
|
manifest = ModuleManifest(
|
||||||
id="idm",
|
id="idm",
|
||||||
name="IDM",
|
name="IDM",
|
||||||
@@ -290,12 +381,32 @@ manifest = ModuleManifest(
|
|||||||
factory=create_idm_search_source,
|
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(
|
frontend=FrontendModule(
|
||||||
module_id="idm",
|
module_id="idm",
|
||||||
package_name="@govoplan/idm-webui",
|
package_name="@govoplan/idm-webui",
|
||||||
routes=(FrontendRoute(path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72),),
|
routes=(
|
||||||
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
|
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=(
|
product_areas=(
|
||||||
ProductAreaContribution(
|
ProductAreaContribution(
|
||||||
id="people-responsibility",
|
id="people-responsibility",
|
||||||
@@ -356,7 +467,87 @@ manifest = ModuleManifest(
|
|||||||
workflow_definitions=function_assignment_workflow_definitions(
|
workflow_definitions=function_assignment_workflow_definitions(
|
||||||
module_version=MODULE_VERSION,
|
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=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="idm.workspace-layout",
|
||||||
|
title="IDM workspace layout",
|
||||||
|
summary="Find workspace actions and read consistently arranged content.",
|
||||||
|
body="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": "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(
|
DocumentationTopic(
|
||||||
id="idm.privacy.data-subject-requests",
|
id="idm.privacy.data-subject-requests",
|
||||||
title="Review IDM data in a data-subject request",
|
title="Review IDM data in a data-subject request",
|
||||||
@@ -397,9 +588,7 @@ manifest = ModuleManifest(
|
|||||||
"organizations",
|
"organizations",
|
||||||
"records",
|
"records",
|
||||||
),
|
),
|
||||||
conditions=(
|
conditions=(DocumentationCondition(required_modules=("idm", "access")),),
|
||||||
DocumentationCondition(required_modules=("idm", "access")),
|
|
||||||
),
|
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Data-subject requests",
|
label="Data-subject requests",
|
||||||
@@ -412,6 +601,28 @@ manifest = ModuleManifest(
|
|||||||
kind="runtime",
|
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={
|
metadata={
|
||||||
"kind": "guide",
|
"kind": "guide",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -436,6 +647,21 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "access_admin", "operator"),
|
audience=("tenant_admin", "access_admin", "operator"),
|
||||||
related_modules=("search", "identity", "organizations"),
|
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,
|
order=25,
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -451,6 +677,20 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "access_admin", "operator"),
|
audience=("tenant_admin", "access_admin", "operator"),
|
||||||
related_modules=("identity", "organizations", "access"),
|
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,
|
order=26,
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -480,6 +720,26 @@ manifest = ModuleManifest(
|
|||||||
kind="api",
|
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={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -521,10 +781,24 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(label="IDM relationship administration", href="/idm", kind="runtime"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Typed groups API", href="/api/v1/idm/typed-groups", kind="api"),
|
label="IDM relationship administration", href="/idm", kind="runtime"
|
||||||
DocumentationLink(label="Identity relationships API", href="/api/v1/idm/relationships", kind="api"),
|
),
|
||||||
DocumentationLink(label="Typed relationship contract", href="docs/TYPED_RELATIONSHIPS.md", kind="repository"),
|
DocumentationLink(
|
||||||
|
label="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={
|
translations={
|
||||||
"de": {
|
"de": {
|
||||||
@@ -628,9 +902,7 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||||
related_modules=("identity", "organizations", "access"),
|
related_modules=("identity", "organizations", "access"),
|
||||||
conditions=(
|
conditions=(DocumentationCondition(any_scopes=IDM_READ_SCOPES),),
|
||||||
DocumentationCondition(any_scopes=IDM_READ_SCOPES),
|
|
||||||
),
|
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="IDM assignments",
|
label="IDM assignments",
|
||||||
@@ -643,6 +915,24 @@ manifest = ModuleManifest(
|
|||||||
kind="api",
|
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={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -684,7 +974,14 @@ manifest = ModuleManifest(
|
|||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "access_admin", "operator", "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=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Function assignment workflows",
|
label="Function assignment workflows",
|
||||||
@@ -692,6 +989,30 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
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={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -727,13 +1048,41 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||||
test_ref="tests/test_assignment_workflow.py",
|
test_ref="tests/test_assignment_workflow.py",
|
||||||
known_limits=("External directory provisioning remains outside the reference workflow.",),
|
known_limits=(
|
||||||
owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request", "typed group", "identity relationship"),
|
"SCIM provisioning is a deterministic preview; governed plan execution is not implemented yet.",
|
||||||
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",),
|
|
||||||
),
|
),
|
||||||
|
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,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",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -7,6 +7,14 @@ from govoplan_idm.backend.api.v1.routes import ORGANIZATION_IDENTITY_READ_SCOPES
|
|||||||
|
|
||||||
|
|
||||||
class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
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:
|
def test_route_and_contributed_action_remain_declared(self) -> None:
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
self.assertIsNotNone(frontend)
|
self.assertIsNotNone(frontend)
|
||||||
@@ -54,8 +62,13 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"idm.typed-groups.action.resolve-memberships",
|
"idm.typed-groups.action.resolve-memberships",
|
||||||
relationships.metadata["help_contexts"],
|
relationships.metadata["help_contexts"],
|
||||||
)
|
)
|
||||||
self.assertIn("Revocation immediately", relationships.metadata["consequences"][2])
|
self.assertIn(
|
||||||
self.assertIn("Access permissions remain unchanged", relationships.metadata["verification"])
|
"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:
|
def test_relationship_writers_may_use_identity_search_selectors(self) -> None:
|
||||||
self.assertIn("idm:relationship:write", ORGANIZATION_IDENTITY_READ_SCOPES)
|
self.assertIn("idm:relationship:write", ORGANIZATION_IDENTITY_READ_SCOPES)
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/idm-webui",
|
"name": "@govoplan/idm-webui",
|
||||||
"version": "0.1.20",
|
"version": "0.1.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"./styles/idm.css": "./src/styles/idm.css"
|
"./styles/idm.css": "./src/styles/idm.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -38,5 +38,7 @@ assert(api.includes("/api/v1/idm/typed-groups") && api.includes("/api/v1/idm/rel
|
|||||||
assert(moduleSource.includes('"idm:relationship:read"') && moduleSource.includes('"idm:relationship:write"'), "Relationship-only administrators can enter the IDM product surface");
|
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(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("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.");
|
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
|||||||
<>
|
<>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
<Card
|
<Card
|
||||||
|
bodyLayout="table"
|
||||||
title="Function requests and grants"
|
title="Function requests and grants"
|
||||||
collapsible
|
collapsible
|
||||||
collapseKey="idm.function-assignment-changes"
|
collapseKey="idm.function-assignment-changes"
|
||||||
|
|||||||
@@ -772,7 +772,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
|
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -835,7 +835,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
|||||||
|
|
||||||
<TypedRelationshipsPanel settings={settings} auth={auth} />
|
<TypedRelationshipsPanel settings={settings} auth={auth} />
|
||||||
|
|
||||||
{canReadAssignments && <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} />}>
|
{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
|
<DataGrid
|
||||||
id="idm-organization-function-assignments"
|
id="idm-organization-function-assignments"
|
||||||
rows={assignments}
|
rows={assignments}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
|
||||||
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
ContentGrid,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
DateTimeField,
|
DateTimeField,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -385,7 +386,9 @@ export default function TypedRelationshipsPanel({ settings, auth }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading typed groups and relationships">
|
<LoadingFrame loading={loading} label="Loading typed groups and relationships">
|
||||||
|
<ContentGrid columns={1}>
|
||||||
<Card
|
<Card
|
||||||
|
bodyLayout="table"
|
||||||
title="Typed groups"
|
title="Typed groups"
|
||||||
collapsible
|
collapsible
|
||||||
collapseKey="idm.typed-groups"
|
collapseKey="idm.typed-groups"
|
||||||
@@ -419,6 +422,7 @@ export default function TypedRelationshipsPanel({ settings, auth }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
|
bodyLayout="table"
|
||||||
title="Effective identity relationships"
|
title="Effective identity relationships"
|
||||||
collapsible
|
collapsible
|
||||||
collapseKey="idm.identity-relationships"
|
collapseKey="idm.identity-relationships"
|
||||||
@@ -449,8 +453,8 @@ export default function TypedRelationshipsPanel({ settings, auth }: Props) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DataGrid id="idm-identity-relationships" rows={relationships} columns={relationshipColumns} getRowKey={(row) => row.id} emptyText="No identity relationships found." initialFit="container" />
|
<DataGrid id="idm-identity-relationships" rows={relationships} columns={relationshipColumns} getRowKey={(row) => row.id} emptyText="No identity relationships found." initialFit="container" />
|
||||||
<p className="idm-muted idm-card-note">Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
</ContentGrid>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
{renderGroupEditor()}
|
{renderGroupEditor()}
|
||||||
|
|||||||
@@ -24,10 +24,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.idm-card-note {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.idm-check-list {
|
.idm-check-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user