From 198803d5b90995f6b99880fe8175e37aff516e48 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 03:02:05 +0200 Subject: [PATCH] feat(access): expose tenant-bounded people search --- src/govoplan_access/backend/manifest.py | 14 ++- src/govoplan_access/backend/people_search.py | 102 +++++++++++++++++++ tests/test_people_search.py | 95 +++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/govoplan_access/backend/people_search.py create mode 100644 tests/test_people_search.py diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 1025684..90b009f 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -2,6 +2,7 @@ from __future__ import annotations from pathlib import Path +from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata from govoplan_core.core.access import ( @@ -33,12 +34,13 @@ from govoplan_core.core.modules import ( FrontendRoute, MigrationSpec, ModuleContext, + ModuleInterfaceProvider, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate, ) -from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY +from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition: @@ -597,11 +599,20 @@ def _route_factory(context: ModuleContext): return router +def _people_search(context: ModuleContext) -> object: + from govoplan_access.backend.people_search import people_search_capability + + return people_search_capability(context) + + manifest = ModuleManifest( id="access", name="Access", version="0.1.10", optional_dependencies=("identity", "organizations", "tenancy", "idm"), + provides_interfaces=( + ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"), + ), permissions=ACCESS_PERMISSIONS, role_templates=ACCESS_ROLE_TEMPLATES, route_factory=_route_factory, @@ -653,6 +664,7 @@ manifest = ModuleManifest( CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner, CAPABILITY_ACCESS_ADMINISTRATION: _access_administration, CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer, + CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search, ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, }, documentation=ACCESS_DOCUMENTATION, diff --git a/src/govoplan_access/backend/people_search.py b/src/govoplan_access/backend/people_search.py new file mode 100644 index 0000000..d82657e --- /dev/null +++ b/src/govoplan_access/backend/people_search.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import Account, User +from govoplan_core.core.people import ( + PeopleSearchError, + PeopleSearchGroup, + PersonSearchCandidate, + person_selection_key, +) + + +def _principal_tenant_id(principal: object) -> str: + try: + tenant_id = getattr(principal, "tenant_id") + except (AttributeError, RuntimeError) as exc: + raise PeopleSearchError("People search requires an active tenant context.") from exc + normalized = str(tenant_id or "").strip() + if not normalized: + raise PeopleSearchError("People search requires an active tenant context.") + return normalized + + +def _like_pattern(query: str) -> str: + escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + +class AccessPeopleSearchProvider: + """Search active accounts through their active tenant membership. + + The active principal's tenant is the only accepted visibility boundary; + global accounts and memberships of other tenants are never candidates. + Feature routers authorize the surrounding task before invoking this + capability. + """ + + def search_people( + self, + session: object, + principal: object, + *, + query: str, + limit: int = 25, + ) -> tuple[PeopleSearchGroup, ...]: + if not isinstance(session, Session): + raise PeopleSearchError("People search requires a database session.") + tenant_id = _principal_tenant_id(principal) + normalized_limit = max(1, min(int(limit), 100)) + account_query = ( + session.query(User, Account) + .join(Account, Account.id == User.account_id) + .filter( + User.tenant_id == tenant_id, + User.is_active.is_(True), + Account.is_active.is_(True), + ) + ) + normalized_query = str(query or "").strip() + if normalized_query: + pattern = _like_pattern(normalized_query) + account_query = account_query.filter( + or_( + func.lower(User.display_name).like(pattern, escape="\\"), + func.lower(User.email).like(pattern, escape="\\"), + func.lower(Account.display_name).like(pattern, escape="\\"), + func.lower(Account.email).like(pattern, escape="\\"), + ) + ) + + rows = ( + account_query + .order_by( + func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(), + Account.id.asc(), + ) + .limit(normalized_limit) + .all() + ) + candidates = tuple( + PersonSearchCandidate( + selection_key=person_selection_key("account", account.id), + kind="account", + reference_id=account.id, + display_name=user.display_name or account.display_name or user.email or account.email, + email=user.email or account.email, + source_module="access", + source_label="Accounts", + source_ref=f"access:account:{account.id}", + ) + for user, account in rows + ) + return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),) + + +def people_search_capability(_context: object) -> AccessPeopleSearchProvider: + return AccessPeopleSearchProvider() + + +__all__ = ["AccessPeopleSearchProvider", "people_search_capability"] diff --git a/tests/test_people_search.py b/tests/test_people_search.py new file mode 100644 index 0000000..8fc7825 --- /dev/null +++ b/tests/test_people_search.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_access.backend.db.models import Account, User +from govoplan_access.backend.manifest import manifest +from govoplan_access.backend.people_search import AccessPeopleSearchProvider +from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH, PeopleSearchError, PeopleSearchProvider +from govoplan_core.db.base import Base + + +class AccessPeopleSearchTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=self.engine) + self.Session = sessionmaker(bind=self.engine) + self.session = self.Session() + self.provider = AccessPeopleSearchProvider() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all(bind=self.engine) + self.engine.dispose() + + def _add_user( + self, + suffix: str, + *, + tenant_id: str = "tenant-1", + display_name: str | None = None, + user_active: bool = True, + account_active: bool = True, + ) -> None: + email = f"{suffix}@example.test" + account = Account( + id=f"account-{suffix}", + email=email, + normalized_email=email, + display_name=display_name, + is_active=account_active, + ) + self.session.add_all([ + account, + User( + id=f"user-{suffix}", + tenant_id=tenant_id, + account_id=account.id, + email=email, + display_name=display_name, + is_active=user_active, + ), + ]) + self.session.flush() + + def test_search_is_tenant_bounded_and_excludes_inactive_records(self) -> None: + self._add_user("ada", display_name="Ada Lovelace") + self._add_user("other", tenant_id="tenant-2", display_name="Ada Other Tenant") + self._add_user("inactive-user", display_name="Ada Inactive User", user_active=False) + self._add_user("inactive-account", display_name="Ada Inactive Account", account_active=False) + + groups = self.provider.search_people( + self.session, + SimpleNamespace(tenant_id="tenant-1"), + query="ada", + ) + + self.assertEqual([group.key for group in groups], ["accounts"]) + self.assertEqual([item.reference_id for item in groups[0].candidates], ["account-ada"]) + self.assertEqual(groups[0].candidates[0].email, "ada@example.test") + self.assertNotIn("tenant_id", groups[0].candidates[0].metadata) + + def test_search_escapes_like_wildcards_and_requires_tenant_context(self) -> None: + self._add_user("ada", display_name="Ada Lovelace") + + groups = self.provider.search_people( + self.session, + SimpleNamespace(tenant_id="tenant-1"), + query="%", + ) + self.assertEqual(groups[0].candidates, ()) + with self.assertRaises(PeopleSearchError): + self.provider.search_people(self.session, SimpleNamespace(tenant_id=None), query="ada") + + def test_manifest_exposes_the_shared_interface(self) -> None: + self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, manifest.capability_factories) + self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces}) + self.assertIsInstance(self.provider, PeopleSearchProvider) + + +if __name__ == "__main__": + unittest.main()