3 Commits
Author SHA1 Message Date
zemion ba04593e29 refactor(idm): share directory dependency resolution
Module Package Release / publish-packages (push) Successful in 18s
Release v0.1.26. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:39 +02:00
zemion 46e09d0c68 Release govoplan-idm v0.1.25: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 01:32:43 +02:00
zemion 5c7586f6d9 fix(webui): bind identity retention to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:40 +02:00
13 changed files with 118 additions and 43 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.23", "version": "0.1.26",
"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
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-idm" name = "govoplan-idm"
version = "0.1.23" version = "0.1.26"
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",
] ]
+20
View File
@@ -0,0 +1,20 @@
"""IDM route dependencies; resolve the current optional Core capability per call."""
from fastapi import HTTPException, status
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
def require_identity_directory(registry: object | None) -> IdentityDirectory:
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
)
return capability
@@ -15,7 +15,6 @@ from govoplan_core.core.events import (
emit_platform_event, emit_platform_event,
) )
from govoplan_core.core.identity import ( from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
IdentityDirectory, IdentityDirectory,
) )
from govoplan_core.core.idm import ( from govoplan_core.core.idm import (
@@ -34,6 +33,7 @@ from govoplan_idm.backend.db.models import (
IdmTypedGroup, IdmTypedGroup,
) )
from .directory_dependencies import require_identity_directory
from .schemas import ( from .schemas import (
IdentityRelationshipCreateRequest, IdentityRelationshipCreateRequest,
IdentityRelationshipDecisionItem, IdentityRelationshipDecisionItem,
@@ -154,19 +154,7 @@ def _tenant_row(session: Session, model, item_id: str, tenant_id: str, label: st
def _identity_directory() -> IdentityDirectory: def _identity_directory() -> IdentityDirectory:
registry = get_registry() return require_identity_directory(get_registry())
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
)
return capability
def _relationship_directory() -> IdmRelationshipDirectory: def _relationship_directory() -> IdmRelationshipDirectory:
+2 -14
View File
@@ -18,7 +18,6 @@ from govoplan_core.core.configuration_control import (
) )
from govoplan_core.core.principal_cache import invalidate_auth_principals from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.core.identity import ( from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
CAPABILITY_IDENTITY_SEARCH, CAPABILITY_IDENTITY_SEARCH,
IdentityDirectory, IdentityDirectory,
IdentityRef, IdentityRef,
@@ -44,6 +43,7 @@ from govoplan_idm.backend.assignment_transitions import (
from govoplan_idm.backend.assignment_events import emit_assignment_event from govoplan_idm.backend.assignment_events import emit_assignment_event
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from .directory_dependencies import require_identity_directory
from .schemas import ( from .schemas import (
IdmSettingsItem, IdmSettingsItem,
IdmSettingsUpdateRequest, IdmSettingsUpdateRequest,
@@ -187,19 +187,7 @@ def _default_settings(tenant_id: str) -> IdmSettingsItem:
def _identity_directory() -> IdentityDirectory: def _identity_directory() -> IdentityDirectory:
registry = get_registry() return require_identity_directory(get_registry())
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
)
return capability
def _identity_search() -> IdentitySearchProvider: def _identity_search() -> IdentitySearchProvider:
+16 -1
View File
@@ -69,7 +69,7 @@ from govoplan_idm.backend.search_source import create_idm_search_source
from govoplan_idm.backend.scim import SCIM_EXTERNAL_PROVIDER_ID from govoplan_idm.backend.scim import SCIM_EXTERNAL_PROVIDER_ID
MODULE_VERSION = "0.1.23" MODULE_VERSION = "0.1.26"
IDM_READ_SCOPES = ( IDM_READ_SCOPES = (
"idm:organization_assignment:read", "idm:organization_assignment:read",
@@ -476,6 +476,21 @@ manifest = ModuleManifest(
), ),
), ),
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( DocumentationTopic(
id="idm.scim-provisioning", id="idm.scim-provisioning",
title="Preview SCIM 2.0 identity provisioning", title="Preview SCIM 2.0 identity provisioning",
+61
View File
@@ -0,0 +1,61 @@
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from fastapi import HTTPException
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_idm.backend.api.v1 import function_changes, relationships, routes
class DirectoryDependencyTests(unittest.TestCase):
def test_identical_routes_preserve_missing_invalid_and_success_contracts(self) -> None:
valid = Mock(spec=IdentityDirectory)
for route in (relationships, routes):
for registry, expected_status, expected_detail in (
(None, 503, "Identity directory is unavailable"),
(Mock(has_capability=Mock(return_value=False)), 503, "Identity directory is unavailable"),
(Mock(has_capability=Mock(return_value=True), require_capability=Mock(return_value=object())), 500,
f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}"),
):
with self.subTest(route=route.__name__, status=expected_status), patch.object(route, "get_registry", return_value=registry):
with self.assertRaises(HTTPException) as caught:
route._identity_directory()
self.assertEqual(expected_status, caught.exception.status_code)
self.assertEqual(expected_detail, caught.exception.detail)
registry = Mock(has_capability=Mock(return_value=True), require_capability=Mock(return_value=valid))
with patch.object(route, "get_registry", return_value=registry):
self.assertIs(valid, route._identity_directory())
registry.has_capability.assert_called_once_with(CAPABILITY_IDENTITY_DIRECTORY)
registry.require_capability.assert_called_once_with(CAPABILITY_IDENTITY_DIRECTORY)
def test_each_call_resolves_current_registry_without_caching_authority(self) -> None:
for route in (relationships, routes):
valid = Mock(spec=IdentityDirectory)
registry = Mock(has_capability=Mock(return_value=True), require_capability=Mock(return_value=valid))
with patch.object(route, "get_registry", side_effect=[registry, None]) as get_registry:
self.assertIs(valid, route._identity_directory())
with self.assertRaises(HTTPException) as caught:
route._identity_directory()
self.assertEqual(503, caught.exception.status_code)
self.assertEqual(2, get_registry.call_count)
def test_lookup_failure_is_not_silently_replaced_or_retried(self) -> None:
failure = RuntimeError("registry changed during lookup")
for route in (relationships, routes):
registry = Mock(has_capability=Mock(return_value=True), require_capability=Mock(side_effect=failure))
with patch.object(route, "get_registry", return_value=registry), self.assertRaises(RuntimeError) as caught:
route._identity_directory()
self.assertIs(failure, caught.exception)
self.assertEqual(1, registry.require_capability.call_count)
def test_function_changes_keeps_its_distinct_unavailable_contract(self) -> None:
registry = SimpleNamespace(capability=lambda _name: object())
with patch.object(function_changes, "get_registry", return_value=registry), self.assertRaises(HTTPException) as caught:
function_changes._identity_directory()
self.assertEqual(503, caught.exception.status_code)
self.assertEqual("The Identity directory is unavailable.", caught.exception.detail)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.23", "version": "0.1.26",
"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"
+2 -2
View File
@@ -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()}
-4
View File
@@ -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;