From 1a29e75db47488d99d73380443f72c232c536014 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 20 Jul 2026 20:03:15 +0200 Subject: [PATCH] feat(identity): define the search provider contract --- docs/MODULE_ARCHITECTURE.md | 33 +++++++++++++---- src/govoplan_core/core/identity.py | 13 +++++++ tests/test_identity_organization_contracts.py | 5 ++- tests/test_idm_api.py | 37 +++++++++++++++++++ 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index 4f7f168..c27a472 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -126,6 +126,15 @@ Feature modules should prefer these capabilities over direct reads of access/tenant ORM models when they need labels, group membership, default access provisioning, counts, audit actor labels, or tenant metadata. +Other stable runtime capabilities currently include: + +- `identity.directory` and `identity.search` +- `organizations.directory` +- `idm.directory` +- `calendar.outbox` and `calendar.scheduling` +- `poll.scheduling` +- `notifications.dispatch` + ### Named Interface Contracts Capabilities are runtime objects. Named interface contracts are compatibility @@ -147,15 +156,25 @@ intended for SemVer major-version lines. Missing optional interfaces are allowed, but an installed provider with an incompatible version blocks activation because the integration would otherwise bind to an unsafe API. -Current named interfaces: +Current named interfaces, generated from the source manifests by the workspace +contract checks, are: -- `files.campaign_attachments` +- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source` +- `calendar.outbox`, `calendar.scheduling` +- `campaigns.access`, `campaigns.delivery_tasks`, + `campaigns.mail_policy_context`, `campaigns.policy_context`, + `campaigns.retention` +- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer` +- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring` +- `files.access`, `files.campaign_attachments` - `mail.campaign_delivery` -- `campaigns.access` -- `campaigns.delivery_tasks` -- `campaigns.mail_policy_context` -- `campaigns.policy_context` -- `campaigns.retention` +- `notifications.dispatch` +- `poll.availability_matrix`, `poll.option_selection`, + `poll.response_collection`, `poll.signed_participation`, + `poll.workflow_context` +- `rest.function_publication` +- `scheduling.candidate_slots`, `scheduling.decision_handoff` +- `soap.operation_publication` Core validates named interface contracts in three places: diff --git a/src/govoplan_core/core/identity.py b/src/govoplan_core/core/identity.py index f6e31ad..900658b 100644 --- a/src/govoplan_core/core/identity.py +++ b/src/govoplan_core/core/identity.py @@ -7,6 +7,7 @@ from typing import Literal, Protocol, runtime_checkable IDENTITY_MODULE_ID = "identity" CAPABILITY_IDENTITY_DIRECTORY = "identity.directory" +CAPABILITY_IDENTITY_SEARCH = "identity.search" IdentityStatus = Literal["active", "inactive", "suspended"] @@ -44,3 +45,15 @@ class IdentityDirectory(Protocol): def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]: ... + + +@runtime_checkable +class IdentitySearchProvider(Protocol): + def search_identities( + self, + query: str | None = None, + *, + include_inactive: bool = False, + limit: int = 25, + ) -> Sequence[IdentityRef]: + ... diff --git a/tests/test_identity_organization_contracts.py b/tests/test_identity_organization_contracts.py index 2b5f837..17fbfa2 100644 --- a/tests/test_identity_organization_contracts.py +++ b/tests/test_identity_organization_contracts.py @@ -396,8 +396,11 @@ class IdentityOrganizationContractTests(unittest.TestCase): from govoplan_organizations.backend.directory import SqlOrganizationDirectory identity_directory = SqlIdentityDirectory() - idm_directory = SqlIdmDirectory() organization_directory = SqlOrganizationDirectory() + idm_directory = SqlIdmDirectory( + identities=identity_directory, + organizations=organization_directory, + ) self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr] self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr] diff --git a/tests/test_idm_api.py b/tests/test_idm_api.py index a5cf243..3b7bd18 100644 --- a/tests/test_idm_api.py +++ b/tests/test_idm_api.py @@ -12,11 +12,18 @@ from govoplan_access.backend.db.models import Account, User from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.core.access import PrincipalRef from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request +from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH +from govoplan_core.core.modules import ModuleContext, ModuleManifest +from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.core.runtime import clear_runtime, configure_runtime from govoplan_core.db.base import Base +from govoplan_identity.backend.directory import SqlIdentityDirectory from govoplan_identity.backend.db.models import Identity, IdentityAccountLink from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings from govoplan_idm.backend.api.v1.routes import router as idm_router from govoplan_idm.backend.manifest import get_manifest +from govoplan_organizations.backend.directory import SqlOrganizationDirectory from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit from tests.db_isolation import temporary_database @@ -50,7 +57,37 @@ class IdmApiTests(unittest.TestCase): ) def _app(self, scopes: set[str]) -> FastAPI: + identity_directory = SqlIdentityDirectory() + organization_directory = SqlOrganizationDirectory() + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="identity", + name="Identity", + version="test", + capability_factories={ + CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory, + CAPABILITY_IDENTITY_SEARCH: lambda context: identity_directory, + }, + ) + ) + registry.register( + ModuleManifest( + id="organizations", + name="Organizations", + version="test", + capability_factories={ + CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory, + }, + ) + ) + context = ModuleContext(registry=registry, settings=object()) + registry.configure_capability_context(context) + configure_runtime(context) + self.addCleanup(clear_runtime) + app = FastAPI() + app.state.govoplan_registry = registry app.include_router(idm_router, prefix="/api/v1") app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes) return app