diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py index 3bfab93..d212fe4 100644 --- a/src/govoplan_idm/backend/manifest.py +++ b/src/govoplan_idm/backend/manifest.py @@ -37,17 +37,20 @@ from govoplan_core.core.modules import ( MigrationSpec, ModuleContext, ModuleInterfaceProvider, + ModuleInterfaceRequirement, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate, ) +from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.db.base import Base from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata from govoplan_idm.backend.workflow_definitions import ( function_assignment_workflow_definitions, ) +from govoplan_idm.backend.search_source import create_idm_search_source MODULE_VERSION = "0.1.8" @@ -224,6 +227,7 @@ manifest = ModuleManifest( "notifications", "policy", "workflow_engine", + "search", ), optional_capabilities=( CAPABILITY_NOTIFICATIONS_DISPATCH, @@ -255,9 +259,23 @@ manifest = ModuleManifest( version="1.0.0", ), ), + requires_interfaces=( + ModuleInterfaceRequirement( + name="search.source", + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), + ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, route_factory=_route_factory, + search_sources=( + SearchSourceProviderRegistration( + id="idm.directory", + factory=create_idm_search_source, + ), + ), nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),), frontend=FrontendModule( module_id="idm", @@ -300,6 +318,22 @@ manifest = ModuleManifest( module_version=MODULE_VERSION, ), documentation=( + DocumentationTopic( + id="idm.search.directory", + title="Search authorized IDM records", + summary="Expose typed groups, effective-dated relationships, and organization-function assignments to permission-aware platform Search.", + body=( + "When Search is installed, IDM contributes bounded directory and assignment metadata without " + "copying unrestricted provenance payloads. Every result is tenant-bounded and rechecks the current " + "assignment or relationship read authority. Committed IDM lifecycle events update the derived " + "index, while an operator rebuild reconciles records created before Search was enabled." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "access_admin", "operator"), + related_modules=("search", "identity", "organizations"), + order=25, + ), DocumentationTopic( id="idm.organization_identity_bridge", title="Identity to organization bridge", diff --git a/src/govoplan_idm/backend/search_source.py b/src/govoplan_idm/backend/search_source.py new file mode 100644 index 0000000..15dbbb6 --- /dev/null +++ b/src/govoplan_idm/backend/search_source.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from urllib.parse import quote + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.events import PlatformEvent +from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillPage, + SearchBackfillRequest, + SearchDocument, + SearchIndexChange, + SearchResourceReference, + SearchResourceType, +) +from govoplan_idm.backend.db.models import ( + IdmIdentityRelationship, + IdmOrganizationFunctionAssignment, + IdmTypedGroup, +) + + +PROVIDER_ID = "idm.directory" +RESOURCE_MODELS = { + "organization_function_assignment": IdmOrganizationFunctionAssignment, + "typed_group": IdmTypedGroup, + "identity_relationship": IdmIdentityRelationship, +} +RESOURCE_SCOPES = { + "organization_function_assignment": ( + "idm:organization_assignment:read", + "idm:organization_assignment:write", + ), + "typed_group": ("idm:relationship:read", "idm:relationship:write"), + "identity_relationship": ( + "idm:relationship:read", + "idm:relationship:write", + ), +} + + +class IdmSearchSource: + def resource_types(self) -> Sequence[SearchResourceType]: + return ( + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="idm", + resource_type="organization_function_assignment", + label="Function assignments", + requires_authorization_recheck=True, + ), + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="idm", + resource_type="typed_group", + label="Typed groups", + requires_authorization_recheck=True, + ), + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="idm", + resource_type="identity_relationship", + label="Identity relationships", + requires_authorization_recheck=True, + ), + ) + + def backfill( + self, + session: object, + *, + request: SearchBackfillRequest, + ) -> SearchBackfillPage: + model = _model(request.provider_id, request.resource_type) + db = _session(session) + statement = select(model).where(model.tenant_id == request.tenant_id) + if request.cursor: + statement = statement.where(model.id > request.cursor) + rows = list( + db.scalars( + statement.order_by(model.id).limit(request.limit + 1) + ) + ) + has_more = len(rows) > request.limit + selected = rows[: request.limit] + high_watermark = db.scalar( + select(func.max(model.updated_at)).where( + model.tenant_id == request.tenant_id + ) + ) + return SearchBackfillPage( + documents=tuple( + _document(row, resource_type=request.resource_type) + for row in selected + ), + next_cursor=selected[-1].id if has_more and selected else None, + complete=not has_more, + high_watermark=( + high_watermark.isoformat() + if high_watermark is not None + else None + ), + ) + + def authorize( + self, + session: object, + principal: object, + *, + requests: Sequence[SearchAuthorizationRequest], + ) -> Mapping[str, bool]: + decisions = {item.reference.key: False for item in requests} + if not isinstance(principal, ApiPrincipal): + return decisions + db = _session(session) + by_type: dict[str, list[SearchAuthorizationRequest]] = {} + for item in requests: + reference = item.reference + if ( + reference.tenant_id != principal.tenant_id + or reference.module_id != "idm" + or reference.resource_type not in RESOURCE_MODELS + or not any( + principal.has(scope) + for scope in RESOURCE_SCOPES[reference.resource_type] + ) + ): + continue + by_type.setdefault(reference.resource_type, []).append(item) + for resource_type, items in by_type.items(): + model = RESOURCE_MODELS[resource_type] + ids = {item.reference.resource_id for item in items} + existing = set( + db.scalars( + select(model.id).where( + model.id.in_(ids), + model.tenant_id == principal.tenant_id, + ) + ) + ) + for item in items: + decisions[item.reference.key] = ( + item.reference.resource_id in existing + ) + return decisions + + def index_changes_for_event( + self, + session: object, + *, + event: PlatformEvent, + delivery_key: str, + ) -> Sequence[SearchIndexChange]: + if ( + event.module_id != "idm" + or event.tenant is None + or event.resource is None + or event.resource.type not in RESOURCE_MODELS + or event.resource.id is None + ): + return () + db = _session(session) + resource_type = event.resource.type + model = RESOURCE_MODELS[resource_type] + row = db.get(model, event.resource.id) + deleted = row is None or row.tenant_id != event.tenant.id + cursor = event.event_id + document = ( + None + if deleted + else _document( + row, + resource_type=resource_type, + change_cursor=cursor, + ) + ) + reference = SearchResourceReference( + tenant_id=event.tenant.id, + module_id="idm", + resource_type=resource_type, + resource_id=event.resource.id, + ) + return ( + SearchIndexChange( + change_id=f"{delivery_key}:{PROVIDER_ID}:{resource_type}", + provider_id=PROVIDER_ID, + kind="delete" if deleted else "upsert", + reference=reference, + source_revision=( + document.source_revision if document is not None else cursor + ), + cursor=cursor, + document=document, + occurred_at=event.occurred_at, + ), + ) + + +def create_idm_search_source(_context: ModuleContext) -> IdmSearchSource: + return IdmSearchSource() + + +def _document( + row: object, + *, + resource_type: str, + change_cursor: str | None = None, +) -> SearchDocument: + scopes = RESOURCE_SCOPES[resource_type] + updated_at = getattr(row, "updated_at") or getattr(row, "created_at") + title, summary, body, keywords, metadata, url = _resource_content( + row, + resource_type=resource_type, + ) + revision = getattr(row, "revision", None) + return SearchDocument( + tenant_id=str(getattr(row, "tenant_id")), + module_id="idm", + provider_id=PROVIDER_ID, + resource_type=resource_type, + resource_id=str(getattr(row, "id")), + title=title[:500], + url=url, + summary=summary[:4000] if summary else None, + body=body[:200_000] if body else None, + keywords=tuple(item[:200] for item in keywords[:100]), + visibility="restricted", + acl_tokens=tuple(f"scope:{scope}" for scope in scopes), + metadata=metadata, + source_revision=f"{revision or 1}:{updated_at.isoformat()}", + change_cursor=change_cursor, + source_updated_at=updated_at, + requires_authorization_recheck=True, + ) + + +def _resource_content( + row: object, + *, + resource_type: str, +) -> tuple[str, str, str, tuple[str, ...], dict[str, object], str]: + item_id = str(getattr(row, "id")) + if resource_type == "typed_group": + group = row + return ( + str(getattr(group, "name")), + str(getattr(group, "description") or ""), + " ".join( + str(value) + for value in ( + getattr(group, "key"), + getattr(group, "description"), + getattr(group, "source_resource_id"), + ) + if value + ), + ( + str(getattr(group, "group_type")), + str(getattr(group, "status")), + str(getattr(group, "source_provider")), + ), + { + "key": getattr(group, "key"), + "group_type": getattr(group, "group_type"), + "status": getattr(group, "status"), + "source_provider": getattr(group, "source_provider"), + }, + f"/idm?groupId={quote(item_id, safe='')}", + ) + if resource_type == "identity_relationship": + relationship = row + kind = str(getattr(relationship, "relationship_kind")) + return ( + f"Relationship: {kind}", + str(getattr(relationship, "role") or ""), + " ".join( + str(value) + for value in ( + getattr(relationship, "subject_identity_id"), + getattr(relationship, "target_group_id"), + getattr(relationship, "related_identity_id"), + getattr(relationship, "role"), + ) + if value + ), + (kind, str(getattr(relationship, "status"))), + { + "relationship_kind": kind, + "status": getattr(relationship, "status"), + "subject_identity_id": getattr( + relationship, "subject_identity_id" + ), + "target_group_id": getattr(relationship, "target_group_id"), + "related_identity_id": getattr( + relationship, "related_identity_id" + ), + }, + f"/idm?relationshipId={quote(item_id, safe='')}", + ) + assignment = row + return ( + "Organization function assignment", + " ".join( + ( + str(getattr(assignment, "function_id")), + str(getattr(assignment, "organization_unit_id")), + ) + ), + " ".join( + str(value) + for value in ( + getattr(assignment, "identity_id"), + getattr(assignment, "account_id"), + getattr(assignment, "function_id"), + getattr(assignment, "organization_unit_id"), + ) + if value + ), + ( + str(getattr(assignment, "source")), + "active" if getattr(assignment, "is_active") else "inactive", + ), + { + "identity_id": getattr(assignment, "identity_id"), + "function_id": getattr(assignment, "function_id"), + "organization_unit_id": getattr( + assignment, "organization_unit_id" + ), + "is_active": getattr(assignment, "is_active"), + "valid_from": ( + getattr(assignment, "valid_from").isoformat() + if getattr(assignment, "valid_from") + else None + ), + "valid_until": ( + getattr(assignment, "valid_until").isoformat() + if getattr(assignment, "valid_until") + else None + ), + }, + f"/idm?assignmentId={quote(item_id, safe='')}", + ) + + +def _model(provider_id: str, resource_type: str): + if provider_id != PROVIDER_ID or resource_type not in RESOURCE_MODELS: + raise ValueError("Unsupported IDM search source.") + return RESOURCE_MODELS[resource_type] + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("IDM search requires a SQLAlchemy session.") + return value + + +__all__ = [ + "IdmSearchSource", + "PROVIDER_ID", + "RESOURCE_MODELS", + "create_idm_search_source", +] diff --git a/tests/test_search_source.py b/tests/test_search_source.py new file mode 100644 index 0000000..351a004 --- /dev/null +++ b/tests/test_search_source.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillRequest, + SearchResourceReference, +) +from govoplan_core.db.base import Base +from govoplan_idm.backend.db.models import IdmTypedGroup +from govoplan_idm.backend.search_source import IdmSearchSource, PROVIDER_ID + + +class IdmSearchSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite://") + Base.metadata.create_all( + self.engine, + tables=(IdmTypedGroup.__table__,), + ) + self.session = Session(self.engine) + self.session.add_all( + ( + IdmTypedGroup( + id="group-1", + tenant_id="tenant-1", + key="permit-holder", + name="Permit holders", + group_type="business_status", + ), + IdmTypedGroup( + id="group-other", + tenant_id="tenant-2", + key="other", + name="Other tenant", + group_type="business_status", + ), + ) + ) + self.session.commit() + self.source = IdmSearchSource() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_typed_group_search_is_tenant_and_scope_bounded(self) -> None: + page = self.source.backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type="typed_group", + rebuild_id="rebuild-1", + ), + ) + self.assertEqual(("group-1",), tuple(doc.resource_id for doc in page.documents)) + reference = SearchResourceReference( + tenant_id="tenant-1", + module_id="idm", + resource_type="typed_group", + resource_id="group-1", + ) + request = SearchAuthorizationRequest(reference=reference, source_revision="1") + self.assertTrue( + self.source.authorize( + self.session, + _principal({"idm:relationship:read"}), + requests=(request,), + )[reference.key] + ) + self.assertFalse( + self.source.authorize( + self.session, + _principal(set()), + requests=(request,), + )[reference.key] + ) + + def test_typed_group_event_is_translated_to_an_upsert(self) -> None: + event = PlatformEvent( + type="idm.typed_group.updated", + module_id="idm", + tenant=EventTenantRef(id="tenant-1"), + resource=EventObjectRef(type="typed_group", id="group-1"), + ) + change = self.source.index_changes_for_event( + self.session, + event=event, + delivery_key="delivery-1", + )[0] + self.assertEqual("upsert", change.kind) + self.assertEqual("group-1", change.reference.resource_id) + + +def _principal(scopes: set[str]) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="user-1", + tenant_id="tenant-1", + scopes=frozenset(scopes), + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="user-1"), + ) + + +if __name__ == "__main__": + unittest.main()