feat(access): expose tenant-bounded people search

This commit is contained in:
2026-07-22 03:02:05 +02:00
parent 79781662e8
commit 198803d5b9
3 changed files with 210 additions and 1 deletions

View File

@@ -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,

View File

@@ -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"]

View File

@@ -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()