diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index b003588..0ebbe9c 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -44,9 +44,11 @@ from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_MESSAGES, CAPABILITY_POSTBOX_ROUTING, ) +from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_postbox.backend.db import models as postbox_models +from govoplan_postbox.backend.search_source import create_postbox_search_source MODULE_ID = "postbox" @@ -222,6 +224,7 @@ manifest = ModuleManifest( "portal", "views", "workflow_engine", + "search", ), required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, @@ -266,9 +269,21 @@ manifest = ModuleManifest( version_max_exclusive="2.0.0", optional=True, ), + ModuleInterfaceRequirement( + name="search.source", + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, + search_sources=( + SearchSourceProviderRegistration( + id="postbox.messages", + factory=create_postbox_search_source, + ), + ), nav_items=( NavItem( path="/postbox", @@ -363,6 +378,23 @@ manifest = ModuleManifest( CAPABILITY_POSTBOX_ROUTING: _configure, }, documentation=( + DocumentationTopic( + id="postbox.search.messages", + title="Search authorized Postbox messages", + summary="Expose Postbox subjects and permitted plaintext content to permission-aware platform Search.", + body=( + "When Search is installed, Postbox contributes message subjects, sender labels, and plaintext " + "content only. Ciphertext and key material are never indexed. Every result is tenant-bounded and " + "rechecks current function assignment, Postbox binding, classification, acting context, and generic " + "read authority without recording a message read. Committed deliveries and message changes update " + "the derived index through the durable platform event path." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("administrator", "user", "campaign_manager"), + related_modules=("search", "idm", "encryption"), + order=34, + ), DocumentationTopic( id="postbox.function-bound-containers", title="Function-bound Postboxes", diff --git a/src/govoplan_postbox/backend/search_source.py b/src/govoplan_postbox/backend/search_source.py new file mode 100644 index 0000000..36553d1 --- /dev/null +++ b/src/govoplan_postbox/backend/search_source.py @@ -0,0 +1,296 @@ +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.postbox import PostboxActorRef +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillPage, + SearchBackfillRequest, + SearchDocument, + SearchIndexChange, + SearchResourceReference, + SearchResourceType, +) +from govoplan_postbox.backend.db.models import ( + Postbox, + PostboxMessage, + PostboxRoute, +) +from govoplan_postbox.backend.service import PostboxService + + +PROVIDER_ID = "postbox.messages" +RESOURCE_TYPE = "postbox_message" +READ_SCOPE = "postbox:postbox:read" +CONFIDENTIAL_SCOPE = "postbox:classification:confidential" +RESTRICTED_SCOPE = "postbox:classification:restricted" + + +class PostboxSearchSource: + def __init__(self, service: PostboxService) -> None: + self.service = service + + def resource_types(self) -> Sequence[SearchResourceType]: + return ( + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="postbox", + resource_type=RESOURCE_TYPE, + label="Postbox messages", + 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(PostboxMessage, Postbox) + .join(Postbox, Postbox.id == PostboxMessage.postbox_id) + .where( + PostboxMessage.tenant_id == request.tenant_id, + Postbox.tenant_id == request.tenant_id, + ) + ) + if request.cursor: + statement = statement.where(PostboxMessage.id > request.cursor) + rows = list( + db.execute( + statement.order_by(PostboxMessage.id).limit(request.limit + 1) + ).all() + ) + has_more = len(rows) > request.limit + selected = rows[: request.limit] + high_watermark = db.scalar( + select(func.max(PostboxMessage.updated_at)).where( + PostboxMessage.tenant_id == request.tenant_id + ) + ) + return SearchBackfillPage( + documents=tuple( + _document(message, postbox=postbox) + for message, postbox 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 + actor = _actor(principal) + db = _session(session) + for item in requests: + reference = item.reference + if ( + reference.tenant_id != principal.tenant_id + or reference.module_id != "postbox" + or reference.resource_type != RESOURCE_TYPE + ): + continue + try: + decisions[reference.key] = self.service.can_read_message( + db, + tenant_id=principal.tenant_id, + message_id=reference.resource_id, + actor=actor, + ) + except (RuntimeError, ValueError): + decisions[reference.key] = False + return decisions + + def index_changes_for_event( + self, + session: object, + *, + event: PlatformEvent, + delivery_key: str, + ) -> Sequence[SearchIndexChange]: + if ( + event.module_id != "postbox" + or event.tenant is None + or event.resource is None + or event.resource.id is None + or event.resource.type not in {RESOURCE_TYPE, "postbox_route"} + ): + return () + db = _session(session) + message_id = event.resource.id + if event.resource.type == "postbox_route": + route = db.get(PostboxRoute, event.resource.id) + if ( + route is None + or route.tenant_id != event.tenant.id + or route.target_message_id is None + ): + return () + message_id = route.target_message_id + row = db.get(PostboxMessage, message_id) + postbox = ( + db.get(Postbox, row.postbox_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 postbox is None + ) + cursor = event.event_id + document = ( + None + if deleted + else _document(row, postbox=postbox, change_cursor=cursor) + ) + reference = SearchResourceReference( + tenant_id=event.tenant.id, + module_id="postbox", + resource_type=RESOURCE_TYPE, + resource_id=message_id, + ) + return ( + SearchIndexChange( + change_id=f"{delivery_key}:{PROVIDER_ID}:{message_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_postbox_search_source( + context: ModuleContext, +) -> PostboxSearchSource: + return PostboxSearchSource(PostboxService.from_registry(context.registry)) + + +def _actor(principal: ApiPrincipal) -> PostboxActorRef: + selected = principal.acting_assignment_id + if selected is None and len(principal.function_assignment_ids) == 1: + selected = next(iter(principal.function_assignment_ids)) + classifications = {"public", "internal"} + if principal.has(CONFIDENTIAL_SCOPE): + classifications.add("confidential") + if principal.has(RESTRICTED_SCOPE): + classifications.update(("confidential", "restricted")) + return PostboxActorRef( + account_id=principal.account_id, + identity_id=principal.identity_id, + selected_assignment_id=selected, + acting_for_account_id=principal.acting_for_account_id, + authorized_actions=frozenset({"discover", "read"}), + authorized_classifications=frozenset(classifications), + ) + + +def _document( + message: PostboxMessage, + *, + postbox: Postbox, + change_cursor: str | None = None, +) -> SearchDocument: + updated_at = message.updated_at or message.created_at + body = ( + message.body_text + if message.encryption_profile == "plaintext_v1" + and message.body_ciphertext is None + else None + ) + tokens = [f"scope:{READ_SCOPE}"] + if message.classification == "confidential": + tokens.append(f"scope:{CONFIDENTIAL_SCOPE}") + elif message.classification == "restricted": + tokens.append(f"scope:{RESTRICTED_SCOPE}") + return SearchDocument( + tenant_id=message.tenant_id, + module_id="postbox", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + resource_id=message.id, + title=message.subject[:500], + url=f"/postbox?messageId={quote(message.id, safe='')}", + summary=(message.sender_label or postbox.name)[:4000], + body=body[:200_000] if body else None, + keywords=tuple( + item[:200] + for item in ( + postbox.name, + message.status, + message.classification, + message.producer_module or "", + ) + if item + ), + visibility="restricted", + acl_tokens=tuple(dict.fromkeys(tokens)), + metadata={ + "postbox_id": message.postbox_id, + "postbox_name": postbox.name, + "status": message.status, + "classification": message.classification, + "sender_label": message.sender_label, + "delivered_at": message.delivered_at.isoformat(), + "withdrawn_at": ( + message.withdrawn_at.isoformat() + if message.withdrawn_at + else None + ), + "encrypted": body is None and message.body_ciphertext is not None, + }, + source_revision=f"{message.status}:{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 Postbox search source.") + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Postbox search requires a SQLAlchemy session.") + return value + + +__all__ = [ + "PROVIDER_ID", + "PostboxSearchSource", + "RESOURCE_TYPE", + "create_postbox_search_source", +] diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index 625d38a..9f328cd 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -845,6 +845,32 @@ class PostboxService: ) return self._message_ref(message, account_id=actor.account_id) + def can_read_message( + self, + session: object, + *, + tenant_id: str, + message_id: str, + actor: PostboxActorRef, + ) -> bool: + """Recheck message access without creating a read or denial event.""" + + db = _session(session) + message = self._get_message( + db, + tenant_id=tenant_id, + message_id=message_id, + required=False, + ) + if message is None: + return False + return self._message_access_decision( + db, + message=message, + actor=actor, + action="read", + ).allowed + def mark_message( self, session: object, diff --git a/tests/test_search_source.py b/tests/test_search_source.py new file mode 100644 index 0000000..d87f940 --- /dev/null +++ b/tests/test_search_source.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from datetime import datetime, timezone +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.search import ( + SearchAuthorizationRequest, + SearchBackfillRequest, + SearchResourceReference, +) +from govoplan_core.db.base import Base +from govoplan_postbox.backend.db.models import ( + Postbox, + PostboxAddress, + PostboxMessage, +) +from govoplan_postbox.backend.search_source import ( + PROVIDER_ID, + RESOURCE_TYPE, + PostboxSearchSource, +) + + +class _AccessService: + def __init__(self, allowed: bool = True) -> None: + self.allowed = allowed + + def can_read_message(self, session, **kwargs): + del session, kwargs + return self.allowed + + +class PostboxSearchSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite://") + Base.metadata.create_all( + self.engine, + tables=( + PostboxAddress.__table__, + Postbox.__table__, + PostboxMessage.__table__, + ), + ) + self.session = Session(self.engine) + self.session.add_all( + ( + PostboxAddress( + id="address-1", + tenant_id="tenant-1", + address_key="permits", + address="permits@example.test", + ), + Postbox( + id="postbox-1", + tenant_id="tenant-1", + address_id="address-1", + name="Permit office", + ), + PostboxMessage( + id="message-1", + tenant_id="tenant-1", + postbox_id="postbox-1", + subject="Permit decision", + body_text=None, + body_ciphertext=b"ciphertext", + encryption_profile="server_envelope_v1", + delivered_at=datetime(2026, 8, 5, tzinfo=timezone.utc), + ), + ) + ) + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_encrypted_content_is_not_indexed_and_access_is_rechecked(self) -> None: + source = PostboxSearchSource(_AccessService()) # type: ignore[arg-type] + page = source.backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + rebuild_id="rebuild-1", + ), + ) + self.assertEqual(1, len(page.documents)) + self.assertIsNone(page.documents[0].body) + self.assertNotIn("ciphertext", str(page.documents[0].metadata).casefold()) + reference = SearchResourceReference( + tenant_id="tenant-1", + module_id="postbox", + resource_type=RESOURCE_TYPE, + resource_id="message-1", + ) + request = SearchAuthorizationRequest(reference=reference, source_revision="1") + self.assertTrue( + source.authorize( + self.session, + _principal(), + requests=(request,), + )[reference.key] + ) + denied = PostboxSearchSource(_AccessService(False)) # type: ignore[arg-type] + self.assertFalse( + denied.authorize( + self.session, + _principal(), + requests=(request,), + )[reference.key] + ) + + +def _principal() -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="user-1", + identity_id="identity-1", + tenant_id="tenant-1", + scopes=frozenset({"postbox:postbox:read"}), + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="user-1"), + ) + + +if __name__ == "__main__": + unittest.main()