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 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.base import AccessBase
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -33,12 +34,13 @@ from govoplan_core.core.modules import (
|
|||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
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:
|
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
|
||||||
@@ -597,11 +599,20 @@ def _route_factory(context: ModuleContext):
|
|||||||
return router
|
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(
|
manifest = ModuleManifest(
|
||||||
id="access",
|
id="access",
|
||||||
name="Access",
|
name="Access",
|
||||||
version="0.1.10",
|
version="0.1.10",
|
||||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||||
|
),
|
||||||
permissions=ACCESS_PERMISSIONS,
|
permissions=ACCESS_PERMISSIONS,
|
||||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
@@ -653,6 +664,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
|
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
|
||||||
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
||||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
|
||||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||||
},
|
},
|
||||||
documentation=ACCESS_DOCUMENTATION,
|
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