perf(access): provide bounded reference search
This commit is contained in:
@@ -42,6 +42,7 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
|
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
|
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:
|
def _optional_identity_directory(context: ModuleContext) -> IdentityDirectory | None:
|
||||||
if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||||
return None
|
return None
|
||||||
@@ -648,6 +658,10 @@ manifest = ModuleManifest(
|
|||||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
ModuleInterfaceProvider(
|
ModuleInterfaceProvider(
|
||||||
name="auth.automation_principal",
|
name="auth.automation_principal",
|
||||||
version="0.2.0",
|
version="0.2.0",
|
||||||
@@ -725,6 +739,7 @@ manifest = ModuleManifest(
|
|||||||
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,
|
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
|
||||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||||
},
|
},
|
||||||
documentation=ACCESS_DOCUMENTATION,
|
documentation=ACCESS_DOCUMENTATION,
|
||||||
|
|||||||
262
src/govoplan_access/backend/reference_options.py
Normal file
262
src/govoplan_access/backend/reference_options.py
Normal file
@@ -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"]
|
||||||
153
tests/test_reference_options.py
Normal file
153
tests/test_reference_options.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user