Project unread postbox work with shared authorization

This commit is contained in:
2026-08-06 16:06:17 +02:00
parent d107c09f74
commit 36530b6dce
11 changed files with 450 additions and 54 deletions
+30 -12
View File
@@ -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(
@@ -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",
]
@@ -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"]
+12 -38
View File
@@ -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:
+65
View File
@@ -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,
+174
View File
@@ -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"]