From b0eda351957526bc7e59270c7a42016aa12bc1a3 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 22:04:06 +0200 Subject: [PATCH] feat(idm): administer typed identity relationships --- docs/INTERFACE_PATTERN_MIGRATION.md | 23 +- docs/TYPED_RELATIONSHIPS.md | 21 +- pyproject.toml | 2 +- src/govoplan_idm/backend/api/v1/routes.py | 1 + src/govoplan_idm/backend/manifest.py | 108 +- .../test_interface_documentation_contract.py | 27 +- webui/package.json | 2 +- .../test-interface-pattern-language.mjs | 12 + webui/src/api/idm.ts | 196 +++- webui/src/features/IdmPage.tsx | 34 +- .../src/features/TypedRelationshipsPanel.tsx | 972 ++++++++++++++++++ webui/src/features/interfacePatterns.ts | 6 +- webui/src/i18n/generatedTranslations.ts | 210 +++- webui/src/module.ts | 2 + webui/src/styles/idm.css | 10 + 15 files changed, 1590 insertions(+), 36 deletions(-) create mode 100644 webui/src/features/TypedRelationshipsPanel.tsx diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index 1a785b4..df0709c 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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. diff --git a/docs/TYPED_RELATIONSHIPS.md b/docs/TYPED_RELATIONSHIPS.md index 743d490..edca0e1 100644 --- a/docs/TYPED_RELATIONSHIPS.md +++ b/docs/TYPED_RELATIONSHIPS.md @@ -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. - diff --git a/pyproject.toml b/pyproject.toml index 1f37920..2bb0aa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-idm" -version = "0.1.18" +version = "0.1.19" description = "GovOPlaN identity management bridge module." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_idm/backend/api/v1/routes.py b/src/govoplan_idm/backend/api/v1/routes.py index f440e90..4137b26 100644 --- a/src/govoplan_idm/backend/api/v1/routes.py +++ b/src/govoplan_idm/backend/api/v1/routes.py @@ -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", ) diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py index b682380..6a9905c 100644 --- a/src/govoplan_idm/backend/manifest.py +++ b/src/govoplan_idm/backend/manifest.py @@ -56,7 +56,7 @@ from govoplan_idm.backend.workflow_definitions import ( from govoplan_idm.backend.search_source import create_idm_search_source -MODULE_VERSION = "0.1.18" +MODULE_VERSION = "0.1.19" IDM_READ_SCOPES = ( "idm:organization_assignment:read", @@ -500,17 +500,113 @@ 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( diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index 87f399e..ddd77d2 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -3,6 +3,7 @@ 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): @@ -24,6 +25,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"]) @@ -33,7 +35,30 @@ class IdmInterfaceDocumentationContractTests(unittest.TestCase): self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"]) self.assertIn("deactivate_or_expire", 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() - diff --git a/webui/package.json b/webui/package.json index 88443f7..df960e8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/idm-webui", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs index 3438547..bd01597 100644 --- a/webui/scripts/test-interface-pattern-language.mjs +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -10,6 +10,8 @@ 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"); @@ -26,5 +28,15 @@ assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label 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(""), "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"); console.log("IDM surfaces satisfy the recorded interface pattern-language contract."); diff --git a/webui/src/api/idm.ts b/webui/src/api/idm.ts index b443177..65d2333 100644 --- a/webui/src/api/idm.ts +++ b/webui/src/api/idm.ts @@ -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; + provenance: Record; + 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; + provenance?: Record; +}; + +export type TypedGroupUpdatePayload = Partial & { + 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; + provenance: Record; + 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; + provenance?: Record; +}; + +export type IdentityRelationshipUpdatePayload = Omit< + Partial, + "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; @@ -218,12 +329,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 { +export function searchOrganizationIdentityOptions( + settings: ApiSettings, + query = "", + limit = 50, + signal?: AbortSignal +): Promise { const params = new URLSearchParams(); const trimmed = query.trim(); if (trimmed) params.set("query", trimmed); params.set("limit", String(limit)); - return apiFetch(settings, `/api/v1/idm/organization-identities?${params.toString()}`); + return apiFetch(settings, `/api/v1/idm/organization-identities?${params.toString()}`, { signal }); +} + +export function getTypedGroups( + settings: ApiSettings, + options: { query?: string; includeInactive?: boolean; limit?: number; signal?: AbortSignal } = {} +): Promise { + 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(settings, `/api/v1/idm/typed-groups?${params.toString()}`, { signal: options.signal }); +} + +export function createTypedGroup(settings: ApiSettings, payload: TypedGroupPayload): Promise { + return apiPostJson(settings, "/api/v1/idm/typed-groups", payload); +} + +export function patchTypedGroup( + settings: ApiSettings, + groupId: string, + payload: TypedGroupUpdatePayload +): Promise { + 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 { + 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(settings, `/api/v1/idm/relationships?${params.toString()}`); +} + +export function createIdentityRelationship( + settings: ApiSettings, + payload: IdentityRelationshipPayload +): Promise { + return apiPostJson(settings, "/api/v1/idm/relationships", payload); +} + +export function patchIdentityRelationship( + settings: ApiSettings, + relationshipId: string, + payload: IdentityRelationshipUpdatePayload +): Promise { + return apiPatchJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationshipId)}`, payload); +} + +export function revokeIdentityRelationship( + settings: ApiSettings, + relationship: Pick, + reason: string +): Promise { + 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 { + 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( + settings, + `/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}/memberships?${params.toString()}` + ); } export function createOrganizationFunctionAssignment( diff --git a/webui/src/features/IdmPage.tsx b/webui/src/features/IdmPage.tsx index 92d96f5..230c616 100644 --- a/webui/src/features/IdmPage.tsx +++ b/webui/src/features/IdmPage.tsx @@ -41,6 +41,7 @@ import { type OrganizationUnitItem } from "../api/idm"; import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel"; +import TypedRelationshipsPanel from "./TypedRelationshipsPanel"; import { IDM_DOCUMENTATION, IDM_FIELD_DOCUMENTATION, @@ -277,7 +278,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 +340,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 +375,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(); @@ -640,7 +644,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) { {error && {error}} {success && !error && {success}} - {!canManage && ( + {canReadAssignments && !canManage && ( )} - {!model.functions.length && !loading && ( + {canUseAssignmentWorkspace && !model.functions.length && !loading && ( )} - + {canUseFunctionChanges && ( + + )} - } {renderAssignmentDialog()} diff --git a/webui/src/features/TypedRelationshipsPanel.tsx b/webui/src/features/TypedRelationshipsPanel.tsx new file mode 100644 index 0000000..a2ee3bd --- /dev/null +++ b/webui/src/features/TypedRelationshipsPanel.tsx @@ -0,0 +1,972 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { + ActionBlockerHint, + ActionToolbar, + AdminIconButton, + ApiError, + Button, + Card, + 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([]); + const [relationships, setRelationships] = useState([]); + const [identities, setIdentities] = useState([]); + const [showInactiveGroups, setShowInactiveGroups] = useState(false); + const [showRevokedRelationships, setShowRevokedRelationships] = useState(false); + const [groupEditor, setGroupEditor] = useState(null); + const [groupDraft, setGroupDraft] = useState({ ...EMPTY_GROUP_DRAFT }); + const [groupBaseline, setGroupBaseline] = useState({ ...EMPTY_GROUP_DRAFT }); + const [relationshipEditor, setRelationshipEditor] = useState(null); + const [relationshipDraft, setRelationshipDraft] = useState({ ...EMPTY_RELATIONSHIP_DRAFT }); + const [relationshipBaseline, setRelationshipBaseline] = useState({ ...EMPTY_RELATIONSHIP_DRAFT }); + const [revokeTarget, setRevokeTarget] = useState(null); + const [revocationReason, setRevocationReason] = useState(""); + const [membershipGroup, setMembershipGroup] = useState(null); + const [membershipEffectiveAt, setMembershipEffectiveAt] = useState(""); + const [membershipKinds, setMembershipKinds] = useState("member"); + const [membershipResolution, setMembershipResolution] = useState(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( + () => 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 => { + 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[]>(() => [ + { + id: "name", + header: "Name", + minWidth: 210, + sortable: true, + filterable: true, + value: (row) => row.name, + render: (row) =>
{row.name}
{row.key}
+ }, + { 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) => }, + { id: "revision", header: "Revision", width: 100, sortable: true, value: (row) => row.revision }, + { + id: "actions", + header: "Actions", + width: 108, + sticky: "end", + render: (row) =>