From ba04593e29140298687a29a6bdf46d1376974c02 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 12:19:39 +0200 Subject: [PATCH] refactor(idm): share directory dependency resolution Release v0.1.26. Coordinated integrity review: GovOPlaN/govoplan-core#298. --- package.json | 2 +- pyproject.toml | 2 +- .../backend/api/v1/directory_dependencies.py | 20 ++++++ .../backend/api/v1/relationships.py | 16 +---- src/govoplan_idm/backend/api/v1/routes.py | 16 +---- src/govoplan_idm/backend/manifest.py | 2 +- tests/test_directory_dependencies.py | 61 +++++++++++++++++++ webui/package.json | 2 +- 8 files changed, 89 insertions(+), 32 deletions(-) create mode 100755 src/govoplan_idm/backend/api/v1/directory_dependencies.py create mode 100755 tests/test_directory_dependencies.py diff --git a/package.json b/package.json index f2030f4..8c7e141 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/idm-webui", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "main": "webui/src/index.ts", diff --git a/pyproject.toml b/pyproject.toml index e9b0cb5..e203a28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-idm" -version = "0.1.25" +version = "0.1.26" description = "GovOPlaN identity management bridge module." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_idm/backend/api/v1/directory_dependencies.py b/src/govoplan_idm/backend/api/v1/directory_dependencies.py new file mode 100755 index 0000000..1e9f0c7 --- /dev/null +++ b/src/govoplan_idm/backend/api/v1/directory_dependencies.py @@ -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 diff --git a/src/govoplan_idm/backend/api/v1/relationships.py b/src/govoplan_idm/backend/api/v1/relationships.py index 63aea30..fc2b6bc 100644 --- a/src/govoplan_idm/backend/api/v1/relationships.py +++ b/src/govoplan_idm/backend/api/v1/relationships.py @@ -15,7 +15,6 @@ from govoplan_core.core.events import ( emit_platform_event, ) from govoplan_core.core.identity import ( - CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory, ) from govoplan_core.core.idm import ( @@ -34,6 +33,7 @@ from govoplan_idm.backend.db.models import ( IdmTypedGroup, ) +from .directory_dependencies import require_identity_directory from .schemas import ( IdentityRelationshipCreateRequest, IdentityRelationshipDecisionItem, @@ -154,19 +154,7 @@ def _tenant_row(session: Session, model, item_id: str, tenant_id: str, label: st def _identity_directory() -> IdentityDirectory: - registry = get_registry() - if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY): - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Identity directory is unavailable", - ) - capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY) - if not isinstance(capability, IdentityDirectory): - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}", - ) - return capability + return require_identity_directory(get_registry()) def _relationship_directory() -> IdmRelationshipDirectory: diff --git a/src/govoplan_idm/backend/api/v1/routes.py b/src/govoplan_idm/backend/api/v1/routes.py index ae0bf7a..f7cba05 100644 --- a/src/govoplan_idm/backend/api/v1/routes.py +++ b/src/govoplan_idm/backend/api/v1/routes.py @@ -18,7 +18,6 @@ from govoplan_core.core.configuration_control import ( ) from govoplan_core.core.principal_cache import invalidate_auth_principals from govoplan_core.core.identity import ( - CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH, IdentityDirectory, IdentityRef, @@ -44,6 +43,7 @@ from govoplan_idm.backend.assignment_transitions import ( from govoplan_idm.backend.assignment_events import emit_assignment_event from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings +from .directory_dependencies import require_identity_directory from .schemas import ( IdmSettingsItem, IdmSettingsUpdateRequest, @@ -187,19 +187,7 @@ def _default_settings(tenant_id: str) -> IdmSettingsItem: def _identity_directory() -> IdentityDirectory: - registry = get_registry() - if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY): - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Identity directory is unavailable", - ) - capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY) - if not isinstance(capability, IdentityDirectory): - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}", - ) - return capability + return require_identity_directory(get_registry()) def _identity_search() -> IdentitySearchProvider: diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py index 38dfdec..ace24fd 100644 --- a/src/govoplan_idm/backend/manifest.py +++ b/src/govoplan_idm/backend/manifest.py @@ -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 -MODULE_VERSION = "0.1.25" +MODULE_VERSION = "0.1.26" IDM_READ_SCOPES = ( "idm:organization_assignment:read", diff --git a/tests/test_directory_dependencies.py b/tests/test_directory_dependencies.py new file mode 100755 index 0000000..333220a --- /dev/null +++ b/tests/test_directory_dependencies.py @@ -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() diff --git a/webui/package.json b/webui/package.json index bbc1933..07e572f 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/idm-webui", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "main": "src/index.ts",