Project unread postbox work with shared authorization
This commit is contained in:
@@ -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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user