From 36530b6dceb6e3db7f3acb17593a8c0dc95d2f6d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 6 Aug 2026 16:06:17 +0200 Subject: [PATCH] Project unread postbox work with shared authorization --- docs/POSTBOX_CONCEPT.md | 6 + src/govoplan_postbox/backend/manifest.py | 42 +++-- src/govoplan_postbox/backend/permissions.py | 25 +++ src/govoplan_postbox/backend/principals.py | 65 +++++++ src/govoplan_postbox/backend/router.py | 50 ++--- src/govoplan_postbox/backend/service.py | 65 +++++++ src/govoplan_postbox/backend/work_items.py | 174 ++++++++++++++++++ tests/test_manifest.py | 1 + tests/test_real_directory_access.py | 66 +++++++ .../test-interface-pattern-language.mjs | 3 +- webui/src/module.ts | 7 +- 11 files changed, 450 insertions(+), 54 deletions(-) create mode 100644 src/govoplan_postbox/backend/permissions.py create mode 100644 src/govoplan_postbox/backend/principals.py create mode 100644 src/govoplan_postbox/backend/work_items.py diff --git a/docs/POSTBOX_CONCEPT.md b/docs/POSTBOX_CONCEPT.md index ce40c45..e4fefd3 100644 --- a/docs/POSTBOX_CONCEPT.md +++ b/docs/POSTBOX_CONCEPT.md @@ -168,6 +168,12 @@ unified inbox views and keep other responsibilities separate. Grouping is a query projection only. It never merges source containers, messages, read or acknowledgement state, retention, encryption keys, or audit evidence. +The optional Tasks module may aggregate available unread Postbox messages into +the common work inbox. This is a current, permission-rechecked projection of a +personal read receipt, not a copied task or message. Reading the message in +Postbox removes the projection; Postbox remains authoritative for content, +access, acknowledgement, reply, retention, and evidence. + Grouping summaries calculate total and unread counts over the currently visible source Postboxes in one tenant-bounded query. Hidden sources retained for later reassignment do not leak counts into the projection. diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index 26181dd..ec3184c 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -45,27 +45,28 @@ from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_ROUTING, ) from govoplan_core.core.search import SearchSourceProviderRegistration +from govoplan_core.core.tasks import WorkItemProviderRegistration 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 +from govoplan_postbox.backend.permissions import ( + ACKNOWLEDGE_SCOPE, + BINDING_ADMIN_SCOPE, + CONFIDENTIAL_SCOPE, + DELIVERY_SCOPE, + READ_SCOPE, + REPLY_SCOPE, + RESTRICTED_SCOPE, + SEND_SCOPE, + TEMPLATE_ADMIN_SCOPE, +) MODULE_ID = "postbox" MODULE_NAME = "Postbox" MODULE_VERSION = "0.1.18" -READ_SCOPE = "postbox:postbox:read" -SEND_SCOPE = "postbox:message:write" -REPLY_SCOPE = "postbox:message:reply" -ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge" -DELIVERY_SCOPE = "postbox:delivery:write" -BINDING_ADMIN_SCOPE = "postbox:binding:admin" -TEMPLATE_ADMIN_SCOPE = "postbox:template:admin" -CONFIDENTIAL_SCOPE = "postbox:classification:confidential" -RESTRICTED_SCOPE = "postbox:classification:restricted" - - def _permission(scope: str, label: str, description: str) -> PermissionDefinition: module_id, resource, action = scope.split(":", 2) return PermissionDefinition( @@ -169,6 +170,12 @@ def _router(context: ModuleContext): return router +def _work_items(context: ModuleContext): + from govoplan_postbox.backend.work_items import PostboxWorkItemProvider + + return PostboxWorkItemProvider(registry=context.registry) + + def _tenant_summary(session, tenant_id: str) -> dict[str, int]: return { "postboxes": session.query(postbox_models.Postbox) @@ -225,6 +232,7 @@ manifest = ModuleManifest( "views", "workflow_engine", "search", + "tasks", ), required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, @@ -284,6 +292,13 @@ manifest = ModuleManifest( factory=create_postbox_search_source, ), ), + work_item_providers=( + WorkItemProviderRegistration( + id="postbox.unread", + factory=_work_items, + order=40, + ), + ), nav_items=( NavItem( path="/postbox", @@ -413,7 +428,9 @@ manifest = ModuleManifest( "server-envelope profile stores message bodies as ciphertext and " "uses the optional Encryption capability for authorized reads. " "External ciphertext profiles retain producer-managed references " - "and keys; neither profile is described as end-to-end encryption." + "and keys; neither profile is described as end-to-end encryption. " + "When Tasks is enabled, currently readable unread messages also appear " + "in the common work inbox and disappear when the personal read receipt is recorded." ), layer="available", documentation_types=("admin", "user"), @@ -425,6 +442,7 @@ manifest = ModuleManifest( "campaigns", "files", "notifications", + "tasks", ), links=( DocumentationLink( diff --git a/src/govoplan_postbox/backend/permissions.py b/src/govoplan_postbox/backend/permissions.py new file mode 100644 index 0000000..6f42e62 --- /dev/null +++ b/src/govoplan_postbox/backend/permissions.py @@ -0,0 +1,25 @@ +from __future__ import annotations + + +READ_SCOPE = "postbox:postbox:read" +SEND_SCOPE = "postbox:message:write" +REPLY_SCOPE = "postbox:message:reply" +ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge" +DELIVERY_SCOPE = "postbox:delivery:write" +BINDING_ADMIN_SCOPE = "postbox:binding:admin" +TEMPLATE_ADMIN_SCOPE = "postbox:template:admin" +CONFIDENTIAL_SCOPE = "postbox:classification:confidential" +RESTRICTED_SCOPE = "postbox:classification:restricted" + + +__all__ = [ + "ACKNOWLEDGE_SCOPE", + "BINDING_ADMIN_SCOPE", + "CONFIDENTIAL_SCOPE", + "DELIVERY_SCOPE", + "READ_SCOPE", + "REPLY_SCOPE", + "RESTRICTED_SCOPE", + "SEND_SCOPE", + "TEMPLATE_ADMIN_SCOPE", +] diff --git a/src/govoplan_postbox/backend/principals.py b/src/govoplan_postbox/backend/principals.py new file mode 100644 index 0000000..25c6591 --- /dev/null +++ b/src/govoplan_postbox/backend/principals.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from govoplan_core.auth import ApiPrincipal, has_scope +from govoplan_core.core.postbox import PostboxActorRef +from govoplan_postbox.backend.permissions import ( + ACKNOWLEDGE_SCOPE, + BINDING_ADMIN_SCOPE, + CONFIDENTIAL_SCOPE, + READ_SCOPE, + REPLY_SCOPE, + RESTRICTED_SCOPE, + SEND_SCOPE, + TEMPLATE_ADMIN_SCOPE, +) + + +class PostboxPrincipalError(ValueError): + pass + + +def actor_from_principal( + principal: ApiPrincipal, + *, + assignment_context_id: str | None = None, +) -> PostboxActorRef: + actions: set[str] = set() + if has_scope(principal, READ_SCOPE): + actions.update(("discover", "read")) + if has_scope(principal, SEND_SCOPE): + actions.add("send") + if has_scope(principal, REPLY_SCOPE): + actions.add("reply") + if has_scope(principal, ACKNOWLEDGE_SCOPE): + actions.add("acknowledge") + if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope( + principal, + TEMPLATE_ADMIN_SCOPE, + ): + actions.add("administer") + if ( + assignment_context_id is not None + and assignment_context_id not in principal.function_assignment_ids + ): + raise PostboxPrincipalError( + "The selected assignment context is not active for this principal." + ) + selected = assignment_context_id or 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 has_scope(principal, CONFIDENTIAL_SCOPE): + classifications.add("confidential") + if has_scope(principal, 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(actions), # type: ignore[arg-type] + authorized_classifications=frozenset(classifications), # type: ignore[arg-type] + ) + + +__all__ = ["PostboxPrincipalError", "actor_from_principal"] diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py index aeb2e94..8ca9985 100644 --- a/src/govoplan_postbox/backend/router.py +++ b/src/govoplan_postbox/backend/router.py @@ -28,11 +28,9 @@ from govoplan_core.db.session import get_session from govoplan_postbox.backend.manifest import ( ACKNOWLEDGE_SCOPE, BINDING_ADMIN_SCOPE, - CONFIDENTIAL_SCOPE, DELIVERY_SCOPE, READ_SCOPE, REPLY_SCOPE, - RESTRICTED_SCOPE, SEND_SCOPE, TEMPLATE_ADMIN_SCOPE, ) @@ -67,6 +65,10 @@ from govoplan_postbox.backend.schemas import ( PostboxTemplateReviseRequest, ) from govoplan_postbox.backend.service import PostboxError +from govoplan_postbox.backend.principals import ( + PostboxPrincipalError, + actor_from_principal, +) router = APIRouter(prefix="/postbox", tags=["postbox"]) @@ -94,44 +96,16 @@ def _actor( *, assignment_context_id: str | None = None, ) -> PostboxActorRef: - actions: set[str] = set() - if has_scope(principal, READ_SCOPE): - actions.update(("discover", "read")) - if has_scope(principal, SEND_SCOPE): - actions.add("send") - if has_scope(principal, REPLY_SCOPE): - actions.add("reply") - if has_scope(principal, ACKNOWLEDGE_SCOPE): - actions.add("acknowledge") - if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope( - principal, - TEMPLATE_ADMIN_SCOPE, - ): - actions.add("administer") - if ( - assignment_context_id is not None - and assignment_context_id not in principal.function_assignment_ids - ): + try: + return actor_from_principal( + principal, + assignment_context_id=assignment_context_id, + ) + except PostboxPrincipalError as exc: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="The selected assignment context is not active for this principal.", - ) - selected = assignment_context_id or 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 has_scope(principal, CONFIDENTIAL_SCOPE): - classifications.add("confidential") - if has_scope(principal, 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(actions), # type: ignore[arg-type] - authorized_classifications=frozenset(classifications), # type: ignore[arg-type] - ) + detail=str(exc), + ) from exc def _http_error(exc: PostboxError) -> HTTPException: diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index e07e03a..047dfa5 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -739,6 +739,71 @@ class PostboxService: or 0 ) + def list_available_unread_messages( + self, + session: object, + *, + tenant_id: str, + postbox_ids: Sequence[str], + actor: PostboxActorRef, + limit: int = 100, + query: str | None = None, + ) -> tuple[tuple[PostboxMessageRef, ...], int]: + """Return unread messages that can still be acted on by this actor.""" + + db = _session(session) + allowed_ids = self._allowed_postbox_ids( + db, + tenant_id=tenant_id, + postbox_ids=postbox_ids, + actor=actor, + action="read", + ) + if not allowed_ids: + return (), 0 + now = utc_now() + messages = self._messages_query( + db, + tenant_id=tenant_id, + allowed_ids=allowed_ids, + account_id=actor.account_id, + allowed_classifications=tuple(actor.authorized_classifications), + query=query, + state="unread", + ).filter( + PostboxMessage.withdrawn_at.is_(None), + or_( + PostboxMessage.expires_at.is_(None), + PostboxMessage.expires_at > now, + ), + ) + total = int( + messages.order_by(None) + .with_entities(func.count(PostboxMessage.id)) + .scalar() + or 0 + ) + rows = ( + messages.options( + selectinload(PostboxMessage.participants), + selectinload(PostboxMessage.attachments), + selectinload(PostboxMessage.receipts), + ) + .order_by( + PostboxMessage.delivered_at.desc(), + PostboxMessage.id.desc(), + ) + .limit(min(max(limit, 1), 500)) + .all() + ) + return ( + tuple( + self._message_ref(message, account_id=actor.account_id) + for message in rows + ), + total, + ) + def message_counts_by_postbox( self, session: object, diff --git a/src/govoplan_postbox/backend/work_items.py b/src/govoplan_postbox/backend/work_items.py new file mode 100644 index 0000000..d48ba1a --- /dev/null +++ b/src/govoplan_postbox/backend/work_items.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from urllib.parse import quote + +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, has_scope +from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxMessageRef +from govoplan_core.core.tasks import ( + WorkAssignmentRef, + WorkItem, + WorkItemPage, + WorkItemQuery, + WorkSourceRef, +) +from govoplan_postbox.backend.permissions import READ_SCOPE +from govoplan_postbox.backend.principals import actor_from_principal +from govoplan_postbox.backend.service import PostboxService + + +PROVIDER_ID = "postbox.unread" + + +class PostboxWorkItemProvider: + def __init__( + self, + *, + registry: object | None = None, + service: PostboxService | None = None, + ) -> None: + self.registry = registry + self.service = service + + def list_items( + self, + session: object, + principal: object, + *, + query: WorkItemQuery, + ) -> WorkItemPage: + if not isinstance(session, Session): + raise TypeError("Postbox work aggregation requires a SQLAlchemy Session.") + if not isinstance(principal, ApiPrincipal): + return WorkItemPage(items=(), total=0) + if principal.tenant_id != query.tenant_id or not has_scope( + principal, READ_SCOPE + ): + return WorkItemPage(items=(), total=0) + if query.statuses and "open" not in query.statuses: + return WorkItemPage(items=(), total=0) + if query.priorities and "normal" not in query.priorities: + return WorkItemPage(items=(), total=0) + if query.due_before is not None: + return WorkItemPage(items=(), total=0) + + actor = actor_from_principal(principal) + service = self._service() + postboxes = service.list_visible_postboxes( + session, + tenant_id=query.tenant_id, + actor=actor, + ) + by_id = {postbox.id: postbox for postbox in postboxes} + messages, total = service.list_available_unread_messages( + session, + tenant_id=query.tenant_id, + postbox_ids=tuple(by_id), + actor=actor, + limit=query.limit, + query=query.text, + ) + return WorkItemPage( + items=tuple( + _work_item(message, by_id[message.postbox_id], principal) + for message in messages + ), + total=total, + truncated=total > len(messages), + ) + + def _service(self) -> PostboxService: + if self.service is not None: + return self.service + if self.registry is None: + raise RuntimeError("Postbox work aggregation requires a registry.") + return PostboxService.from_registry(self.registry) # type: ignore[arg-type] + + +def _work_item( + message: PostboxMessageRef, + postbox: PostboxDirectoryEntryRef, + principal: ApiPrincipal, +) -> WorkItem: + action_url = ( + f"/postbox?postbox={quote(message.postbox_id, safe='')}" + f"&message={quote(message.id, safe='')}" + ) + sources = [ + WorkSourceRef( + module_id="postbox", + resource_type="postbox_message", + resource_id=message.id, + revision=message.delivered_at.isoformat(), + url=action_url, + label=message.subject, + ) + ] + if ( + message.producer_module + and message.producer_resource_type + and message.producer_resource_id + ): + sources.append( + WorkSourceRef( + module_id=message.producer_module, + resource_type=message.producer_resource_type, + resource_id=message.producer_resource_id, + ) + ) + return WorkItem( + id=message.id, + provider_id=PROVIDER_ID, + owner_module="postbox", + tenant_id=message.tenant_id, + title=message.subject, + summary=( + f"{postbox.name} ยท {message.sender_label}" + if message.sender_label + else postbox.name + ), + status="open", + priority="normal", + required_action="Read the Postbox message.", + action_url=action_url, + assignments=_assignments(postbox, principal), + sources=tuple(sources), + provenance={ + "postbox_id": postbox.id, + "address_key": postbox.address_key, + "producer_module": message.producer_module, + }, + metadata={ + "classification": message.classification, + "attachment_count": len(message.attachments), + "encrypted": message.encryption_profile != "plaintext_v1", + }, + revision=f"{message.status}:{message.delivered_at.isoformat()}", + created_at=message.delivered_at, + updated_at=message.delivered_at, + ) + + +def _assignments( + postbox: PostboxDirectoryEntryRef, + principal: ApiPrincipal, +) -> tuple[WorkAssignmentRef, ...]: + access = postbox.access + assignment_ids = tuple(access.assignment_ids) if access is not None else () + if access is not None and access.selected_assignment_id: + assignment_ids = (access.selected_assignment_id,) + assignments = tuple( + WorkAssignmentRef( + kind="function_assignment", + id=assignment_id, + label=postbox.function_name, + ) + for assignment_id in assignment_ids + ) + if assignments: + return assignments + return (WorkAssignmentRef(kind="account", id=principal.account_id),) + + +__all__ = ["PROVIDER_ID", "PostboxWorkItemProvider"] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index e8556e7..cf490bd 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -41,6 +41,7 @@ class PostboxManifestTests(unittest.TestCase): manifest.required_capabilities, ) self.assertIn("encryption", manifest.optional_dependencies) + self.assertEqual("postbox.unread", manifest.work_item_providers[0].id) self.assertTrue( any( requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER diff --git a/tests/test_real_directory_access.py b/tests/test_real_directory_access.py index 9646834..41225a1 100644 --- a/tests/test_real_directory_access.py +++ b/tests/test_real_directory_access.py @@ -6,7 +6,10 @@ from datetime import timedelta 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.postbox import PostboxActorRef +from govoplan_core.core.tasks import WorkItemQuery from govoplan_core.db.base import Base from govoplan_core.db.session import ( DatabaseHandle, @@ -41,6 +44,8 @@ from govoplan_postbox.backend.db.models import ( PostboxTemplateRevision, ) from govoplan_postbox.backend.service import PostboxService +from govoplan_postbox.backend.principals import actor_from_principal +from govoplan_postbox.backend.work_items import PostboxWorkItemProvider TABLES = ( @@ -269,6 +274,67 @@ class PostboxRealDirectoryAccessTests(unittest.TestCase): self.assertFalse(expired.allowed) self.assertEqual(expired.reason_code, "effective_assignment_missing") + def test_unread_message_is_projected_as_current_work_until_read(self) -> None: + self._add_assignment( + assignment_id="owner-assignment", + identity_id="identity-owner", + account_id="account-owner", + ) + principal = ApiPrincipal( + principal=PrincipalRef( + account_id="account-owner", + membership_id="membership-owner", + tenant_id="tenant-1", + identity_id="identity-owner", + scopes=frozenset({"postbox:postbox:read"}), + function_assignment_ids=frozenset({"owner-assignment"}), + ), + account=object(), + user=object(), + ) + provider = PostboxWorkItemProvider(service=self.service) + with self.database.SessionLocal() as session: + message = PostboxMessage( + tenant_id="tenant-1", + postbox_id=self.postbox_id, + subject="Review the submitted evidence", + status="delivered", + classification="internal", + sender_label="Permit service", + delivered_at=utc_now(), + wrapped_keys=[], + external_recipient_tokens=[], + metadata_={}, + ) + session.add(message) + session.commit() + + page = provider.list_items( + session, + principal, + query=WorkItemQuery(tenant_id="tenant-1"), + ) + self.assertEqual(1, page.total) + self.assertEqual(message.id, page.items[0].id) + self.assertEqual("owner-assignment", page.items[0].assignments[0].id) + + self.service.mark_message( + session, + tenant_id="tenant-1", + message_id=message.id, + actor=actor_from_principal(principal), + state="read", + ) + session.commit() + self.assertEqual( + 0, + provider.list_items( + session, + principal, + query=WorkItemQuery(tenant_id="tenant-1"), + ).total, + ) + def test_real_organization_state_and_function_move_fail_closed(self) -> None: self._add_assignment( assignment_id="owner-assignment", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs index be7cf6b..159b6ab 100644 --- a/webui/scripts/test-interface-pattern-language.mjs +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -22,10 +22,9 @@ assert(page.includes("useUnsavedDraftGuard") && admin.includes("useUnsavedDraftG assert(page.includes("delete_grouping_confirmation") && page.includes("ConfirmDialog"), "Deleting a unified view confirms that source records remain unchanged"); assert(admin.includes("archive_confirmation") && admin.includes("retire_template_confirmation"), "Address and template lifecycle actions use shared destructive confirmation"); assert(patterns.includes('topicId: "postbox.function-bound-containers"') && patterns.includes('topicId: "postbox.reference.fields-and-consequences"'), "Postbox uses manifest-backed help references"); -assert(moduleSource.includes('version: "0.1.2"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations"); +assert(moduleSource.includes('version: "0.1.18"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations"); assert(translations.includes('"i18n:govoplan-postbox.unavailable_message_reason"'), "Access-sensitive unavailable states are localized"); assert(widget.includes("usePlatformLanguage") && widget.includes("i18nMessage"), "Widget dates and dynamic accessible labels follow the platform locale"); assert(!page.includes("window.confirm") && !admin.includes("window.confirm"), "Postbox does not use browser-native consequential confirmation"); console.log("Postbox surfaces satisfy the recorded interface pattern-language contract."); - diff --git a/webui/src/module.ts b/webui/src/module.ts index 7008ada..58b9489 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -87,19 +87,22 @@ const postboxAdminSections: AdminSectionsUiCapability = { export const postboxModule: PlatformWebModule = { id: "postbox", label: "i18n:govoplan-postbox.postbox", - version: "0.1.2", + version: "0.1.18", dependencies: ["identity", "organizations", "idm"], optionalDependencies: [ "access", "audit", "campaigns", + "encryption", "files", "mail", "notifications", "policy", "portal", + "search", + "tasks", "views", - "workflow" + "workflow_engine" ], translations, navItems: [