feat: initialize governed postbox module

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 19216e5043
commit d848a9a503
31 changed files with 9776 additions and 1 deletions
+732
View File
@@ -0,0 +1,732 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.postbox import (
PostboxActorRef,
PostboxAttachmentRef,
PostboxDeliveryRequest,
PostboxParticipantRef,
PostboxTargetRef,
)
from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
DELIVERY_SCOPE,
READ_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
from govoplan_postbox.backend.runtime import get_service
from govoplan_postbox.backend.schemas import (
PostboxAccessDecisionResponse,
PostboxDeliveryCreateRequest,
PostboxDeliveryResponse,
PostboxDirectoryItem,
PostboxDirectoryResponse,
PostboxExactCreateRequest,
PostboxGroupingItem,
PostboxGroupingListResponse,
PostboxGroupingPayload,
PostboxMaterializeRequest,
PostboxMessageItem,
PostboxMessageListResponse,
PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse,
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
PostboxTemplatePublishRequest,
PostboxTemplateRevisionPayload,
)
from govoplan_postbox.backend.service import PostboxError
router = APIRouter(prefix="/postbox", tags=["postbox"])
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing scope: {scope}",
)
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
if any(has_scope(principal, scope) for scope in scopes):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of: {', '.join(scopes)}",
)
def _actor(
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, ACKNOWLEDGE_SCOPE):
actions.add("acknowledge")
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
principal,
TEMPLATE_ADMIN_SCOPE,
):
actions.add("administer")
selected = assignment_context_id
if selected is None and len(principal.function_assignment_ids) == 1:
selected = next(iter(principal.function_assignment_ids))
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]
)
def _http_error(exc: PostboxError) -> HTTPException:
if exc.code.endswith("_not_found") or exc.code in {
"message_not_found",
"postbox_not_found",
"template_not_found",
"revision_not_found",
"grouping_not_found",
"target_not_found",
}:
code = status.HTTP_404_NOT_FOUND
elif exc.code in {"access_denied", "grouping_source_denied"}:
code = status.HTTP_403_FORBIDDEN
elif exc.code in {
"template_slug_exists",
"address_collision",
"idempotency_conflict",
}:
code = status.HTTP_409_CONFLICT
else:
code = status.HTTP_400_BAD_REQUEST
return HTTPException(
status_code=code,
detail={"code": exc.code, "message": str(exc)},
)
def _directory_item(value) -> PostboxDirectoryItem:
return PostboxDirectoryItem.model_validate(asdict(value))
def _message_item(value) -> PostboxMessageItem:
return PostboxMessageItem.model_validate(asdict(value))
def _template_item(template) -> PostboxTemplateItem:
return PostboxTemplateItem(
id=template.id,
tenant_id=template.tenant_id,
slug=template.slug,
name=template.name,
description=template.description,
status=template.status,
current_revision=template.current_revision,
published_revision_id=template.published_revision_id,
revisions=[
{
"id": revision.id,
"revision": revision.revision,
"function_type_id": revision.function_type_id,
"scope_kind": revision.scope_kind,
"scope_id": revision.scope_id,
"name_pattern": revision.name_pattern,
"address_pattern": revision.address_pattern,
"classification": revision.classification,
"allow_vacant_delivery": revision.allow_vacant_delivery,
"encryption_profile": revision.encryption_profile,
"history_policy": dict(revision.history_policy or {}),
"routing_policy": dict(revision.routing_policy or {}),
"retention_policy": dict(revision.retention_policy or {}),
"published_at": revision.published_at,
"created_at": revision.created_at,
}
for revision in template.revisions
],
created_at=template.created_at,
updated_at=template.updated_at,
)
def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem:
return PostboxGroupingItem(
id=grouping.id,
name=grouping.name,
is_default=grouping.is_default,
postbox_ids=[
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
],
created_at=grouping.created_at,
updated_at=grouping.updated_at,
)
@router.get("/directory", response_model=PostboxDirectoryResponse)
def api_postbox_directory(
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryResponse:
_require(principal, READ_SCOPE)
entries = get_service().list_visible_postboxes(
session,
tenant_id=principal.tenant_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
)
return PostboxDirectoryResponse(
postboxes=[_directory_item(entry) for entry in entries]
)
@router.get(
"/directory/{postbox_id}/access",
response_model=PostboxAccessDecisionResponse,
)
def api_postbox_access(
postbox_id: str,
action: Literal[
"discover",
"read",
"send",
"acknowledge",
"administer",
] = "read",
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxAccessDecisionResponse:
_require(principal, READ_SCOPE)
try:
decision = get_service().explain_access(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
action=action,
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxAccessDecisionResponse.model_validate(asdict(decision))
@router.get("/messages", response_model=PostboxMessageListResponse)
def api_list_postbox_messages(
postbox_id: list[str] = Query(default=[]),
assignment_context_id: str | None = None,
q: str | None = Query(default=None, max_length=200),
state_filter: Literal[
"all",
"unread",
"read",
"acknowledged",
] = Query(default="all", alias="state"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxMessageListResponse:
_require(principal, READ_SCOPE)
actor = _actor(
principal,
assignment_context_id=assignment_context_id,
)
requested = tuple(postbox_id)
if not requested:
requested = tuple(
entry.id
for entry in get_service().list_visible_postboxes(
session,
tenant_id=principal.tenant_id,
actor=actor,
)
)
messages = get_service().list_messages(
session,
tenant_id=principal.tenant_id,
postbox_ids=requested,
actor=actor,
limit=limit,
offset=offset,
query=q,
state=state_filter,
)
total = get_service().count_messages(
session,
tenant_id=principal.tenant_id,
postbox_ids=requested,
actor=actor,
query=q,
state=state_filter,
)
return PostboxMessageListResponse(
messages=[_message_item(message) for message in messages],
total=total,
limit=limit,
offset=offset,
)
@router.get("/messages/{message_id}", response_model=PostboxMessageItem)
def api_get_postbox_message(
message_id: str,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxMessageItem:
_require(principal, READ_SCOPE)
try:
message = get_service().get_message(
session,
tenant_id=principal.tenant_id,
message_id=message_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
)
except PostboxError as exc:
raise _http_error(exc) from exc
if message is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Postbox message not found.",
)
session.commit()
return _message_item(message)
@router.patch(
"/messages/{message_id}/state",
response_model=PostboxMessageItem,
)
def api_mark_postbox_message(
message_id: str,
payload: PostboxMessageStateRequest,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxMessageItem:
_require(
principal,
ACKNOWLEDGE_SCOPE if payload.state == "acknowledged" else READ_SCOPE,
)
try:
message = get_service().mark_message(
session,
tenant_id=principal.tenant_id,
message_id=message_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
state=payload.state,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _message_item(message)
@router.post(
"/deliveries",
response_model=PostboxDeliveryResponse,
status_code=status.HTTP_201_CREATED,
)
def api_deliver_to_postbox(
payload: PostboxDeliveryCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDeliveryResponse:
_require(principal, DELIVERY_SCOPE)
request = PostboxDeliveryRequest(
tenant_id=principal.tenant_id,
target=PostboxTargetRef(**payload.target.model_dump()),
producer_module=payload.producer_module,
producer_resource_type=payload.producer_resource_type,
producer_resource_id=payload.producer_resource_id,
idempotency_key=payload.idempotency_key,
subject=payload.subject,
body_text=payload.body_text,
sender_label=payload.sender_label,
classification=payload.classification,
participants=tuple(
PostboxParticipantRef(**participant.model_dump())
for participant in payload.participants
),
attachments=tuple(
PostboxAttachmentRef(**attachment.model_dump())
for attachment in payload.attachments
),
expires_at=payload.expires_at,
metadata=payload.metadata,
)
try:
result = get_service().deliver(session, request)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
session.commit()
return PostboxDeliveryResponse.model_validate(asdict(result))
@router.get("/groupings", response_model=PostboxGroupingListResponse)
def api_list_postbox_groupings(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingListResponse:
_require(principal, READ_SCOPE)
actor = _actor(principal)
visible_ids = {
item.id
for item in get_service().list_visible_postboxes(
session,
tenant_id=principal.tenant_id,
actor=actor,
)
}
groupings = get_service().list_groupings(
session,
tenant_id=principal.tenant_id,
actor=actor,
)
return PostboxGroupingListResponse(
groupings=[
_grouping_item(grouping, visible_ids=visible_ids)
for grouping in groupings
]
)
@router.post(
"/groupings",
response_model=PostboxGroupingItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_grouping(
payload: PostboxGroupingPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingItem:
_require(principal, READ_SCOPE)
actor = _actor(principal)
try:
grouping = get_service().save_grouping(
session,
tenant_id=principal.tenant_id,
actor=actor,
grouping_id=None,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
@router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem)
def api_update_postbox_grouping(
grouping_id: str,
payload: PostboxGroupingPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingItem:
_require(principal, READ_SCOPE)
actor = _actor(principal)
try:
grouping = get_service().save_grouping(
session,
tenant_id=principal.tenant_id,
actor=actor,
grouping_id=grouping_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
@router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_postbox_grouping(
grouping_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> None:
_require(principal, READ_SCOPE)
try:
get_service().delete_grouping(
session,
tenant_id=principal.tenant_id,
actor=_actor(principal),
grouping_id=grouping_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
@router.get(
"/admin/organization-targets",
response_model=PostboxOrganizationTargetsResponse,
)
def api_postbox_organization_targets(
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxOrganizationTargetsResponse:
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
return PostboxOrganizationTargetsResponse(
units=list(
get_service().organization_targets(tenant_id=principal.tenant_id)
)
)
@router.get("/admin/postboxes", response_model=PostboxDirectoryResponse)
def api_admin_postboxes(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryResponse:
_require(principal, BINDING_ADMIN_SCOPE)
return PostboxDirectoryResponse(
postboxes=[
_directory_item(item)
for item in get_service().list_admin_postboxes(
session,
tenant_id=principal.tenant_id,
)
]
)
@router.post(
"/admin/postboxes",
response_model=PostboxDirectoryItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_exact_postbox(
payload: PostboxExactCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
try:
postbox = get_service().create_exact_postbox(
session,
tenant_id=principal.tenant_id,
actor_id=principal.account_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id),
)
)
@router.delete(
"/admin/postboxes/{postbox_id}",
response_model=PostboxDirectoryItem,
)
def api_archive_postbox(
postbox_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
try:
postbox = get_service().archive_postbox(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
actor_id=principal.account_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id),
)
)
@router.get("/admin/templates", response_model=PostboxTemplateListResponse)
def api_postbox_templates(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateListResponse:
_require(principal, TEMPLATE_ADMIN_SCOPE)
return PostboxTemplateListResponse(
templates=[
_template_item(template)
for template in get_service().list_templates(
session,
tenant_id=principal.tenant_id,
)
]
)
@router.post(
"/admin/templates",
response_model=PostboxTemplateItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_template(
payload: PostboxTemplateCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
template = get_service().create_template(
session,
tenant_id=principal.tenant_id,
actor_id=principal.account_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
@router.post(
"/admin/templates/{template_id}/revisions",
response_model=PostboxTemplateItem,
)
def api_revise_postbox_template(
template_id: str,
payload: PostboxTemplateRevisionPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
template = get_service().revise_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
actor_id=principal.account_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
@router.post(
"/admin/templates/{template_id}/publish",
response_model=PostboxTemplateItem,
)
def api_publish_postbox_template(
template_id: str,
payload: PostboxTemplatePublishRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
template = get_service().publish_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
revision_number=payload.revision,
actor_id=principal.account_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
@router.post(
"/admin/templates/{template_id}/retire",
response_model=PostboxTemplateItem,
)
def api_retire_postbox_template(
template_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
template = get_service().retire_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
actor_id=principal.account_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
@router.post(
"/admin/templates/{template_id}/materialize",
response_model=PostboxDirectoryItem,
)
def api_materialize_postbox_template(
template_id: str,
payload: PostboxMaterializeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
postbox = get_service().materialize_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
actor_id=principal.account_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
entry = get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id),
)
if entry is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Materialized Postbox could not be reloaded.",
)
return _directory_item(entry)