diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index b8fb835..fd05aff 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -42,6 +42,7 @@ from govoplan_core.core.modules import ( RoleTemplate, ) from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH +from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS from govoplan_core.core.views import ViewSurface @@ -538,6 +539,15 @@ def _access_semantic_directory(context: ModuleContext) -> object: ) +def _access_reference_options(context: ModuleContext) -> object: + del context + from govoplan_access.backend.reference_options import ( + SqlAccessReferenceOptionProvider, + ) + + return SqlAccessReferenceOptionProvider() + + def _optional_identity_directory(context: ModuleContext) -> IdentityDirectory | None: if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY): return None @@ -648,6 +658,10 @@ manifest = ModuleManifest( optional_dependencies=("identity", "organizations", "tenancy", "idm"), provides_interfaces=( ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"), + ModuleInterfaceProvider( + name=CAPABILITY_ACCESS_REFERENCE_OPTIONS, + version="0.1.0", + ), ModuleInterfaceProvider( name="auth.automation_principal", version="0.2.0", @@ -725,6 +739,7 @@ manifest = ModuleManifest( CAPABILITY_ACCESS_ADMINISTRATION: _access_administration, CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer, CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search, + CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options, ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, }, documentation=ACCESS_DOCUMENTATION, diff --git a/src/govoplan_access/backend/reference_options.py b/src/govoplan_access/backend/reference_options.py new file mode 100644 index 0000000..ec158ec --- /dev/null +++ b/src/govoplan_access/backend/reference_options.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import Account, Group, User +from govoplan_core.core.references import ( + ReferenceOption, + ReferenceSearchPage, + ReferenceSearchRequest, +) + + +class SqlAccessReferenceOptionProvider: + """Principal-aware, bounded Access directory search.""" + + def search_reference_options( + self, + session: object, + principal: object, + *, + request: ReferenceSearchRequest, + ) -> ReferenceSearchPage: + db = _session(session) + tenant_id = str(request.tenant_id or "").strip() + if not tenant_id: + return ReferenceSearchPage() + limit = max(1, min(int(request.limit), 200)) + offset = _cursor_offset(request.cursor) + selected = tuple( + dict.fromkeys( + str(value).strip() + for value in request.selected_values + if str(value).strip() + ) + )[:200] + administrative = request.context.get("administrative") is True + query = str(request.query or "").strip().casefold() + if request.kind in {"user", "membership"}: + return _search_users( + db, + principal, + tenant_id=tenant_id, + kind=request.kind, + query=query, + selected=selected, + limit=limit, + offset=offset, + administrative=administrative, + ) + if request.kind == "group": + return _search_groups( + db, + principal, + tenant_id=tenant_id, + query=query, + selected=selected, + limit=limit, + offset=offset, + administrative=administrative, + ) + raise ValueError(f"Unsupported Access reference kind: {request.kind}") + + +def _search_users( + session: Session, + principal: object, + *, + tenant_id: str, + kind: str, + query: str, + selected: Sequence[str], + limit: int, + offset: int, + administrative: bool, +) -> ReferenceSearchPage: + value_column = User.id if kind == "membership" else User.account_id + base = ( + session.query(User, Account) + .join(Account, Account.id == User.account_id) + .filter(User.tenant_id == tenant_id) + ) + if not administrative: + account_id = str(getattr(principal, "account_id", "") or "") + if not account_id: + return ReferenceSearchPage() + base = base.filter(User.account_id == account_id) + + selected_rows = ( + base.filter(value_column.in_(selected)).all() + if selected + else [] + ) + search_query = base + if selected: + search_query = search_query.filter(value_column.notin_(selected)) + if query: + search_query = search_query.filter( + or_( + func.lower(func.coalesce(User.display_name, "")).contains( + query, + autoescape=True, + ), + func.lower(User.email).contains(query, autoescape=True), + func.lower(Account.email).contains(query, autoescape=True), + func.lower(value_column).contains(query, autoescape=True), + ) + ) + rows = ( + search_query.order_by( + func.lower(func.coalesce(User.display_name, User.email)).asc(), + value_column.asc(), + ) + .offset(offset) + .limit(limit + 1) + .all() + ) + has_more = len(rows) > limit + options = [ + _user_option(user, account, kind=kind) + for user, account in rows[:limit] + ] + selected_by_value = { + _user_value(user, kind=kind): _user_option(user, account, kind=kind) + for user, account in selected_rows + } + options.extend( + selected_by_value[value] + for value in selected + if value in selected_by_value + ) + return ReferenceSearchPage( + options=tuple(options), + next_cursor=f"offset:{offset + limit}" if has_more else None, + has_more=has_more, + ) + + +def _search_groups( + session: Session, + principal: object, + *, + tenant_id: str, + query: str, + selected: Sequence[str], + limit: int, + offset: int, + administrative: bool, +) -> ReferenceSearchPage: + base = session.query(Group).filter(Group.tenant_id == tenant_id) + if not administrative: + permitted = tuple( + dict.fromkeys( + str(group_id) + for group_id in getattr(principal, "group_ids", ()) + if str(group_id) + ) + ) + if not permitted: + return ReferenceSearchPage() + base = base.filter(Group.id.in_(permitted)) + + selected_rows = base.filter(Group.id.in_(selected)).all() if selected else [] + search_query = base + if selected: + search_query = search_query.filter(Group.id.notin_(selected)) + if query: + search_query = search_query.filter( + or_( + func.lower(Group.name).contains(query, autoescape=True), + func.lower(Group.slug).contains(query, autoescape=True), + func.lower(Group.id).contains(query, autoescape=True), + ) + ) + rows = ( + search_query.order_by(func.lower(Group.name).asc(), Group.id.asc()) + .offset(offset) + .limit(limit + 1) + .all() + ) + has_more = len(rows) > limit + options = [_group_option(group) for group in rows[:limit]] + selected_by_value = {group.id: _group_option(group) for group in selected_rows} + options.extend( + selected_by_value[value] + for value in selected + if value in selected_by_value + ) + return ReferenceSearchPage( + options=tuple(options), + next_cursor=f"offset:{offset + limit}" if has_more else None, + has_more=has_more, + ) + + +def _user_value(user: User, *, kind: str) -> str: + return user.id if kind == "membership" else user.account_id + + +def _user_option(user: User, account: Account, *, kind: str) -> ReferenceOption: + inactive = not user.is_active or not account.is_active + value = _user_value(user, kind=kind) + description_parts = [ + user.email, + "Inactive" if inactive else None, + ] + return ReferenceOption( + value=value, + label=user.display_name or user.email or value, + description=" ยท ".join( + part for part in description_parts if part + ) or None, + kind=kind, + availability="inactive" if inactive else "available", + disabled=inactive, + source_module="access", + provenance={ + "tenant_id": user.tenant_id, + "membership_id": user.id, + "account_id": user.account_id, + }, + ) + + +def _group_option(group: Group) -> ReferenceOption: + inactive = not group.is_active + return ReferenceOption( + value=group.id, + label=group.name or group.id, + description="Inactive" if inactive else None, + kind="group", + availability="inactive" if inactive else "available", + disabled=inactive, + source_module="access", + provenance={"tenant_id": group.tenant_id, "group_id": group.id}, + ) + + +def _cursor_offset(cursor: str | None) -> int: + if cursor is None: + return 0 + prefix = "offset:" + if not cursor.startswith(prefix): + raise ValueError("Invalid reference search cursor.") + try: + offset = int(cursor[len(prefix):]) + except ValueError as exc: + raise ValueError("Invalid reference search cursor.") from exc + if offset < 0: + raise ValueError("Invalid reference search cursor.") + return offset + + +def _session(session: object) -> Session: + if not isinstance(session, Session): + raise TypeError("Access reference search requires a SQLAlchemy Session") + return session + + +__all__ = ["SqlAccessReferenceOptionProvider"] diff --git a/tests/test_reference_options.py b/tests/test_reference_options.py new file mode 100644 index 0000000..5b79bef --- /dev/null +++ b/tests/test_reference_options.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import Account, Group, User +from govoplan_access.backend.reference_options import ( + SqlAccessReferenceOptionProvider, +) +from govoplan_core.core.references import ReferenceSearchRequest +from govoplan_core.db.base import Base + + +class AccessReferenceOptionProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[Account.__table__, User.__table__, Group.__table__], + ) + self.session = Session(self.engine) + for index in range(120): + account = Account( + id=f"account-{index:03}", + email=f"person-{index:03}@example.test", + normalized_email=f"person-{index:03}@example.test", + ) + self.session.add(account) + self.session.add( + User( + id=f"membership-{index:03}", + tenant_id="tenant-1", + account_id=account.id, + email=account.email, + display_name=f"Person {index:03}", + ) + ) + for index in range(75): + self.session.add( + Group( + id=f"group-{index:03}", + tenant_id="tenant-1", + slug=f"group-{index:03}", + name=f"Group {index:03}", + ) + ) + self.session.commit() + self.provider = SqlAccessReferenceOptionProvider() + self.admin = SimpleNamespace( + tenant_id="tenant-1", + account_id="account-000", + group_ids=frozenset(), + ) + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_large_directory_search_is_bounded_and_paged(self) -> None: + first = self.provider.search_reference_options( + self.session, + self.admin, + request=ReferenceSearchRequest( + kind="membership", + tenant_id="tenant-1", + limit=25, + context={"administrative": True}, + ), + ) + second = self.provider.search_reference_options( + self.session, + self.admin, + request=ReferenceSearchRequest( + kind="membership", + tenant_id="tenant-1", + limit=25, + cursor=first.next_cursor, + context={"administrative": True}, + ), + ) + + self.assertEqual(25, len(first.options)) + self.assertTrue(first.has_more) + self.assertEqual("offset:25", first.next_cursor) + self.assertEqual("membership-000", first.options[0].value) + self.assertEqual("membership-025", second.options[0].value) + + def test_search_and_selected_values_do_not_materialize_the_directory(self) -> None: + page = self.provider.search_reference_options( + self.session, + self.admin, + request=ReferenceSearchRequest( + kind="membership", + tenant_id="tenant-1", + query="PERSON 119", + selected_values=("membership-005", "removed-membership"), + limit=10, + context={"administrative": True}, + ), + ) + + self.assertEqual( + ["membership-119", "membership-005"], + [option.value for option in page.options], + ) + self.assertFalse(page.has_more) + + def test_non_administrators_only_search_their_permitted_references(self) -> None: + principal = SimpleNamespace( + tenant_id="tenant-1", + account_id="account-004", + group_ids=frozenset({"group-007"}), + ) + + users = self.provider.search_reference_options( + self.session, + principal, + request=ReferenceSearchRequest( + kind="user", + tenant_id="tenant-1", + ), + ) + groups = self.provider.search_reference_options( + self.session, + principal, + request=ReferenceSearchRequest( + kind="group", + tenant_id="tenant-1", + ), + ) + + self.assertEqual(["account-004"], [option.value for option in users.options]) + self.assertEqual(["group-007"], [option.value for option in groups.options]) + + def test_invalid_cursor_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "Invalid reference search cursor"): + self.provider.search_reference_options( + self.session, + self.admin, + request=ReferenceSearchRequest( + kind="group", + tenant_id="tenant-1", + cursor="page:2", + context={"administrative": True}, + ), + ) + + +if __name__ == "__main__": + unittest.main()