diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index 01f2b1f..b7814c9 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -24,11 +24,13 @@ 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 ( ExternalProviderDeclaration, ExternalProviderStateProviderRegistration, @@ -40,6 +42,7 @@ from govoplan_core.core.provider_governance import ( ) from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base +from govoplan_calendar.backend.search_source import create_calendar_search_source CALDAV_PROVIDER_ID = "calendar.caldav_sync" @@ -496,17 +499,31 @@ manifest = ModuleManifest( name="Calendar", version="0.1.8", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), - optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors"), + optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors", "search"), provides_interfaces=( ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"), ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"), ModuleInterfaceProvider(name="calendar.invitations", version="0.2.0"), ModuleInterfaceProvider(name="calendar.external_profiles", version="0.1.0"), ), + requires_interfaces=( + ModuleInterfaceRequirement( + name="search.source", + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), + ), permissions=PERMISSIONS, route_factory=_calendar_router, role_templates=ROLE_TEMPLATES, tenant_summary_providers=(_tenant_summary,), + search_sources=( + SearchSourceProviderRegistration( + id="calendar.events", + factory=create_calendar_search_source, + ), + ), architecture=CALENDAR_ARCHITECTURE, external_providers=CALENDAR_EXTERNAL_PROVIDERS, external_provider_state_providers=( @@ -537,6 +554,22 @@ manifest = ModuleManifest( ), ), documentation=( + DocumentationTopic( + id="calendar.search.events", + title="Search authorized calendar events", + summary="Expose event titles, schedules, locations, and descriptions to permission-aware platform Search.", + body=( + "When Search is installed, Calendar contributes non-deleted events from visible calendars. " + "Every result is tenant-bounded and rechecks the current event-read permission plus private " + "calendar ownership or group membership before it is returned. Committed event changes update " + "the derived index through the platform event outbox; rebuilding Search never changes Calendar data." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "calendar_manager", "administrator"), + related_modules=("search",), + order=19, + ), DocumentationTopic( id="calendar.manage-calendars-and-events", title="Use calendars and events", diff --git a/src/govoplan_calendar/backend/search_source.py b/src/govoplan_calendar/backend/search_source.py new file mode 100644 index 0000000..49b7bca --- /dev/null +++ b/src/govoplan_calendar/backend/search_source.py @@ -0,0 +1,285 @@ +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_calendar.backend.db.models import CalendarCollection, CalendarEvent +from govoplan_calendar.backend.service import calendar_is_visible_to_principal + + +PROVIDER_ID = "calendar.events" +RESOURCE_TYPE = "calendar_event" +READ_SCOPE = "calendar:event:read" +ADMIN_SCOPE = "calendar:calendar:admin" + + +class CalendarSearchSource: + def resource_types(self) -> Sequence[SearchResourceType]: + return ( + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="calendar", + resource_type=RESOURCE_TYPE, + label="Calendar events", + requires_authorization_recheck=True, + ), + ) + + def backfill( + self, + session: object, + *, + request: SearchBackfillRequest, + ) -> SearchBackfillPage: + _assert_source(request.provider_id, request.resource_type) + db = _session(session) + statement = ( + select(CalendarEvent, CalendarCollection) + .join( + CalendarCollection, + CalendarCollection.id == CalendarEvent.calendar_id, + ) + .where( + CalendarEvent.tenant_id == request.tenant_id, + CalendarEvent.deleted_at.is_(None), + CalendarCollection.tenant_id == request.tenant_id, + CalendarCollection.deleted_at.is_(None), + ) + ) + if request.cursor: + statement = statement.where(CalendarEvent.id > request.cursor) + rows = list( + db.execute( + statement.order_by(CalendarEvent.id).limit(request.limit + 1) + ).all() + ) + has_more = len(rows) > request.limit + selected = rows[: request.limit] + high_watermark = db.scalar( + select(func.max(CalendarEvent.updated_at)).where( + CalendarEvent.tenant_id == request.tenant_id, + CalendarEvent.deleted_at.is_(None), + ) + ) + return SearchBackfillPage( + documents=tuple( + _document(event, calendar=calendar) + for event, calendar in selected + ), + next_cursor=( + selected[-1][0].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) or not principal.has(READ_SCOPE): + return decisions + valid = tuple( + item + for item in requests + if item.reference.tenant_id == principal.tenant_id + and item.reference.module_id == "calendar" + and item.reference.resource_type == RESOURCE_TYPE + ) + if not valid: + return decisions + db = _session(session) + ids = {item.reference.resource_id for item in valid} + events = { + event.id: (event, calendar) + for event, calendar in db.execute( + select(CalendarEvent, CalendarCollection) + .join( + CalendarCollection, + CalendarCollection.id == CalendarEvent.calendar_id, + ) + .where( + CalendarEvent.id.in_(ids), + CalendarEvent.tenant_id == principal.tenant_id, + CalendarEvent.deleted_at.is_(None), + CalendarCollection.tenant_id == principal.tenant_id, + CalendarCollection.deleted_at.is_(None), + ) + ).all() + } + user_id = str( + getattr(principal.user, "id", "") or principal.membership_id or "" + ) + for item in valid: + match = events.get(item.reference.resource_id) + if match is None: + continue + _event, calendar = match + decisions[item.reference.key] = calendar_is_visible_to_principal( + calendar, + user_id=user_id, + group_ids=principal.group_ids, + can_admin=principal.has(ADMIN_SCOPE), + ) + return decisions + + def index_changes_for_event( + self, + session: object, + *, + event: PlatformEvent, + delivery_key: str, + ) -> Sequence[SearchIndexChange]: + if ( + event.module_id != "calendar" + or event.tenant is None + or event.resource is None + or event.resource.type != RESOURCE_TYPE + or event.resource.id is None + ): + return () + db = _session(session) + row = db.get(CalendarEvent, event.resource.id) + calendar = ( + db.get(CalendarCollection, row.calendar_id) + if row is not None and row.tenant_id == event.tenant.id + else None + ) + deleted = ( + row is None + or row.tenant_id != event.tenant.id + or row.deleted_at is not None + or calendar is None + or calendar.deleted_at is not None + ) + cursor = event.event_id + document = ( + None + if deleted + else _document(row, calendar=calendar, change_cursor=cursor) + ) + reference = SearchResourceReference( + tenant_id=event.tenant.id, + module_id="calendar", + resource_type=RESOURCE_TYPE, + resource_id=event.resource.id, + ) + return ( + SearchIndexChange( + change_id=f"{delivery_key}:{PROVIDER_ID}", + 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_calendar_search_source( + _context: ModuleContext, +) -> CalendarSearchSource: + return CalendarSearchSource() + + +def _document( + event: CalendarEvent, + *, + calendar: CalendarCollection, + change_cursor: str | None = None, +) -> SearchDocument: + tokens = [f"scope:{READ_SCOPE}"] + if calendar.visibility == "private": + if calendar.owner_type == "user" and calendar.owner_id: + tokens.append(f"membership:{calendar.owner_id}") + elif calendar.owner_type == "group" and calendar.owner_id: + tokens.append(f"group:{calendar.owner_id}") + elif calendar.created_by_user_id: + tokens.append(f"membership:{calendar.created_by_user_id}") + updated_at = event.updated_at or event.created_at + keywords = tuple( + value + for value in ( + calendar.name, + event.location, + event.status, + *tuple(event.categories or ()), + ) + if value + )[:100] + return SearchDocument( + tenant_id=event.tenant_id, + module_id="calendar", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + resource_id=event.id, + title=event.summary, + url=f"/calendar?eventId={quote(event.id, safe='')}", + summary=(event.description or event.location or "")[:4000] or None, + body=" ".join( + value + for value in (event.description, event.location, calendar.name) + if value + )[:200_000], + keywords=tuple(value[:200] for value in keywords), + visibility="restricted", + acl_tokens=tuple(dict.fromkeys(tokens)), + metadata={ + "calendar_id": event.calendar_id, + "calendar_name": calendar.name, + "start_at": event.start_at.isoformat(), + "end_at": event.end_at.isoformat() if event.end_at else None, + "all_day": event.all_day, + "status": event.status, + }, + source_revision=f"{event.sequence}:{updated_at.isoformat()}", + change_cursor=change_cursor, + source_updated_at=updated_at, + requires_authorization_recheck=True, + ) + + +def _assert_source(provider_id: str, resource_type: str) -> None: + if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE: + raise ValueError("Unsupported Calendar search source.") + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Calendar search requires a SQLAlchemy session.") + return value + + +__all__ = [ + "CalendarSearchSource", + "PROVIDER_ID", + "RESOURCE_TYPE", + "create_calendar_search_source", +] diff --git a/tests/test_search_source.py b/tests/test_search_source.py new file mode 100644 index 0000000..c2e1c54 --- /dev/null +++ b/tests/test_search_source.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import Account, User +from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent +from govoplan_calendar.backend.search_source import ( + CalendarSearchSource, + PROVIDER_ID, + RESOURCE_TYPE, +) +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillRequest, + SearchResourceReference, +) +from govoplan_core.db.base import Base + + +class CalendarSearchSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite://") + Base.metadata.create_all( + self.engine, + tables=( + Account.__table__, + User.__table__, + CalendarCollection.__table__, + CalendarEvent.__table__, + ), + ) + self.session = Session(self.engine) + start = datetime(2026, 8, 5, 9, tzinfo=timezone.utc) + self.session.add_all( + ( + Account( + id="account-1", + email="one@example.test", + normalized_email="one@example.test", + ), + User( + id="user-1", + tenant_id="tenant-1", + account_id="account-1", + email="one@example.test", + ), + CalendarCollection( + id="calendar-1", + tenant_id="tenant-1", + slug="private", + name="Private calendar", + owner_type="user", + owner_id="user-1", + visibility="private", + created_by_user_id="user-1", + ), + CalendarEvent( + id="event-1", + tenant_id="tenant-1", + calendar_id="calendar-1", + uid="event-1@example.test", + summary="Permit review", + description="Review monthly permits", + start_at=start, + end_at=start + timedelta(hours=1), + ), + ) + ) + self.session.commit() + self.source = CalendarSearchSource() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_private_calendar_is_visible_only_to_current_owner(self) -> None: + page = self.source.backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + rebuild_id="rebuild-1", + ), + ) + self.assertEqual(("event-1",), tuple(doc.resource_id for doc in page.documents)) + reference = SearchResourceReference( + tenant_id="tenant-1", + module_id="calendar", + resource_type=RESOURCE_TYPE, + resource_id="event-1", + ) + request = SearchAuthorizationRequest(reference=reference, source_revision="1") + self.assertTrue( + self.source.authorize( + self.session, + _principal("user-1"), + requests=(request,), + )[reference.key] + ) + self.assertFalse( + self.source.authorize( + self.session, + _principal("user-2"), + requests=(request,), + )[reference.key] + ) + + +def _principal(user_id: str) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id=f"account-{user_id}", + membership_id=user_id, + tenant_id="tenant-1", + scopes=frozenset({"calendar:event:read"}), + ), + account=SimpleNamespace(id=f"account-{user_id}"), + user=SimpleNamespace(id=user_id), + ) + + +if __name__ == "__main__": + unittest.main()