feat(access): expose tenant-bounded people search
This commit is contained in:
@@ -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,
|
||||
|
||||
102
src/govoplan_access/backend/people_search.py
Normal file
102
src/govoplan_access/backend/people_search.py
Normal 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"]
|
||||
95
tests/test_people_search.py
Normal file
95
tests/test_people_search.py
Normal 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()
|
||||
Reference in New Issue
Block a user