Files
govoplan-postbox/src/govoplan_postbox/backend/router.py
T

1417 lines
45 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict
from typing import Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.postbox import (
PostboxActorRef,
PostboxAttachmentRef,
PostboxDeliveryRequest,
PostboxExternalRecipientTokenRef,
PostboxMessageAuthoringRequest,
PostboxParticipantRef,
PostboxTargetRef,
PostboxWrappedKeyRef,
)
from govoplan_core.core.files import (
PostboxFileReferenceRequest,
postbox_file_reference_provider,
)
from govoplan_core.core.encryption import encryption_content_cipher
from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
DELIVERY_SCOPE,
READ_SCOPE,
REPLY_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
from govoplan_postbox.backend.runtime import get_registry, get_service
from govoplan_postbox.backend.schemas import (
PostboxAccessDecisionResponse,
PostboxAttachmentResolutionItem,
PostboxAttachmentResolutionResponse,
PostboxDeliveryCreateRequest,
PostboxDeliveryResponse,
PostboxDirectoryItem,
PostboxDirectoryResponse,
PostboxExactCreateRequest,
PostboxGroupingItem,
PostboxGroupingListResponse,
PostboxGroupingPayload,
PostboxGroupingUpdateRequest,
PostboxMaterializeRequest,
PostboxMessageItem,
PostboxMessageCreateRequest,
PostboxMessageAuthoringPayload,
PostboxMessageListResponse,
PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse,
PostboxMutationRequest,
PostboxProtectionProfileItem,
PostboxProtectionProfileListResponse,
PostboxProtectionPolicyUpdateRequest,
PostboxProtectionTransformRequest,
PostboxProtectionTransitionCreateRequest,
PostboxProtectionTransitionItemResponse,
PostboxProtectionTransitionListResponse,
PostboxProtectionTransitionResponse,
PostboxRouteDryRunRequest,
PostboxRouteDryRunResponse,
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
PostboxTemplatePreviewRequest,
PostboxTemplatePreviewResponse,
PostboxTemplatePublishRequest,
PostboxTemplateReviseRequest,
)
from govoplan_postbox.backend.service import PostboxError
from govoplan_postbox.backend.protection_profiles import (
POSTBOX_PROTECTION_PROFILE_DEFINITIONS,
POSTBOX_STANDARD_PROFILE,
)
from govoplan_postbox.backend.principals import (
PostboxPrincipalError,
actor_from_principal,
)
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:
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=str(exc),
) from exc
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",
"protection_transition_not_found",
"transition_item_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 _require_mutation_precondition(
if_match: str | None,
*,
resource_type: str,
resource_id: str,
base_revision: int,
) -> None:
try:
assert_revision_precondition(
if_match,
resource_type=resource_type,
resource_id=resource_id,
submitted_base_revision=base_revision,
)
except MissingPreconditionError as exc:
raise HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail=exc.as_dict(),
) from exc
except ConcurrencyError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "invalid_precondition", "message": str(exc)},
) from exc
def _concurrency_http_error(exc: ConcurrencyError) -> HTTPException:
if isinstance(exc, MissingPreconditionError):
return HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail=exc.as_dict(),
)
if isinstance(exc, RevisionConflictError):
return HTTPException(
status_code=status.HTTP_412_PRECONDITION_FAILED,
detail=exc.as_dict(),
)
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "concurrency_conflict", "message": str(exc)},
)
def _set_etag(response: Response, etag: str | None) -> None:
if etag:
response.headers["ETag"] = etag
def _authoring_request(
payload: PostboxMessageAuthoringPayload,
) -> PostboxMessageAuthoringRequest:
return PostboxMessageAuthoringRequest(
idempotency_key=payload.idempotency_key,
subject=payload.subject,
body_text=payload.body_text,
ciphertext_ref=payload.ciphertext_ref,
signed_manifest_ref=payload.signed_manifest_ref,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
),
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
),
metadata=payload.metadata,
)
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,
resource_revision=template.resource_revision,
etag=template.strong_etag,
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,
"scope_structure_id": revision.scope_structure_id,
"scope_relation_type_ids": list(revision.scope_relation_type_ids or []),
"name_pattern": revision.name_pattern,
"address_pattern": revision.address_pattern,
"classification": revision.classification,
"allow_vacant_delivery": revision.allow_vacant_delivery,
"portal_visible": revision.portal_visible,
"encryption_profile": revision.encryption_profile,
"encryption_vault_id": revision.encryption_vault_id,
"protection_policy": dict(revision.history_policy or {}),
"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],
counts_by_postbox: Mapping[str, Mapping[str, int]] | None = None,
) -> PostboxGroupingItem:
visible_source_ids = [
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
]
counts = counts_by_postbox or {}
return PostboxGroupingItem(
id=grouping.id,
name=grouping.name,
is_default=grouping.is_default,
resource_revision=grouping.resource_revision,
etag=grouping.strong_etag,
postbox_ids=visible_source_ids,
total_count=sum(
int(counts.get(postbox_id, {}).get("total", 0))
for postbox_id in visible_source_ids
),
unread_count=sum(
int(counts.get(postbox_id, {}).get("unread", 0))
for postbox_id in visible_source_ids
),
created_at=grouping.created_at,
updated_at=grouping.updated_at,
)
def _protection_transition_item(value) -> PostboxProtectionTransitionResponse:
return PostboxProtectionTransitionResponse(
id=value.id,
postbox_id=value.postbox_id,
source_profile=value.source_profile,
target_profile=value.target_profile,
source_vault_id=value.source_vault_id,
target_vault_id=value.target_vault_id,
history_mode=value.history_mode,
authority_mode=value.authority_mode,
required_quorum=value.required_quorum,
evidence_refs=list(value.evidence_refs or []),
reason=value.reason,
state=value.state,
message_count=value.message_count,
completed_count=value.completed_count,
failed_count=value.failed_count,
requested_by=value.requested_by,
activated_at=value.activated_at,
completed_at=value.completed_at,
resource_revision=value.resource_revision,
etag=value.strong_etag,
configuration_snapshot=dict(value.configuration_snapshot or {}),
items=[
PostboxProtectionTransitionItemResponse(
id=item.id,
message_id=item.message_id,
source_profile=item.source_profile,
target_profile=item.target_profile,
state=item.state,
source_digest=item.source_digest,
target_digest=item.target_digest,
completed_by=item.completed_by,
completed_at=item.completed_at,
error_code=item.error_code,
evidence=dict(item.evidence or {}),
)
for item in value.items
],
)
@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",
"reply",
"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.post(
"/messages",
response_model=PostboxMessageItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_message(
payload: PostboxMessageCreateRequest,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxMessageItem:
_require(principal, SEND_SCOPE)
try:
message = get_service().create_message(
session,
tenant_id=principal.tenant_id,
postbox_id=payload.postbox_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
request=_authoring_request(payload),
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
session.commit()
return _message_item(message)
@router.post(
"/messages/{message_id}/replies",
response_model=PostboxMessageItem,
status_code=status.HTTP_201_CREATED,
)
def api_reply_to_postbox_message(
message_id: str,
payload: PostboxMessageAuthoringPayload,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxMessageItem:
_require(principal, REPLY_SCOPE)
try:
message = get_service().reply_to_message(
session,
tenant_id=principal.tenant_id,
message_id=message_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
request=_authoring_request(payload),
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
session.commit()
return _message_item(message)
@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.get(
"/messages/{message_id}/attachment-resolutions",
response_model=PostboxAttachmentResolutionResponse,
)
def api_resolve_postbox_message_attachments(
message_id: str,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxAttachmentResolutionResponse:
_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.",
)
provider = postbox_file_reference_provider(get_registry())
file_types = {
"file",
"file_asset",
"files:file",
"file_version",
"files:file_version",
}
requests = tuple(
PostboxFileReferenceRequest(
reference_type=attachment.reference_type,
reference_id=attachment.reference_id,
postbox_id=message.postbox_id,
message_id=message.id,
)
for attachment in message.attachments
if attachment.reference_type.strip().casefold() in file_types
)
resolved = (
provider.resolve_postbox_references(
session,
principal,
tenant_id=principal.tenant_id,
requests=requests,
)
if provider is not None and requests
else ()
)
by_reference = {(item.reference_type, item.reference_id): item for item in resolved}
items: list[PostboxAttachmentResolutionItem] = []
for attachment in message.attachments:
resolution = by_reference.get(
(attachment.reference_type, attachment.reference_id)
)
is_file = attachment.reference_type.strip().casefold() in file_types
attachment_payload = asdict(attachment)
attachment_payload.update(
{
"name": (
resolution.filename
if resolution and resolution.filename
else attachment.name
),
"media_type": (
resolution.content_type
if resolution and resolution.content_type
else attachment.media_type
),
"size_bytes": (
resolution.size_bytes
if resolution and resolution.size_bytes is not None
else attachment.size_bytes
),
"digest": (
resolution.sha256
if resolution and resolution.sha256
else attachment.digest
),
}
)
items.append(
PostboxAttachmentResolutionItem(
**attachment_payload,
available=bool(resolution and resolution.available),
reason_code=(
resolution.reason_code
if resolution is not None
else (
"files_provider_unavailable"
if is_file
else "reference_provider_unavailable"
)
),
file_asset_id=resolution.file_asset_id if resolution else None,
file_version_id=resolution.file_version_id if resolution else None,
download_path=resolution.download_path if resolution else None,
provenance=dict(resolution.provenance) if resolution else {},
)
)
session.commit()
return PostboxAttachmentResolutionResponse(attachments=items)
@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,
ciphertext_ref=payload.ciphertext_ref,
signed_manifest_ref=payload.signed_manifest_ref,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
),
external_recipient_tokens=tuple(
PostboxExternalRecipientTokenRef(**item.model_dump())
for item in payload.external_recipient_tokens
),
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.post(
"/routing/dry-run",
response_model=PostboxRouteDryRunResponse,
)
def api_preview_postbox_routing(
payload: PostboxRouteDryRunRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxRouteDryRunResponse:
_require_any(principal, DELIVERY_SCOPE, TEMPLATE_ADMIN_SCOPE)
try:
result = get_service().preview_hierarchy_routes(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(**payload.target.model_dump()),
producer_module=payload.producer_module,
classification=payload.classification,
expires_at=payload.expires_at,
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxRouteDryRunResponse.model_validate(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,
)
counts_by_postbox = get_service().message_counts_by_postbox(
session,
tenant_id=principal.tenant_id,
postbox_ids=tuple(visible_ids),
actor=actor,
)
return PostboxGroupingListResponse(
groupings=[
_grouping_item(
grouping,
visible_ids=visible_ids,
counts_by_postbox=counts_by_postbox,
)
for grouping in groupings
]
)
@router.post(
"/groupings",
response_model=PostboxGroupingItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_grouping(
payload: PostboxGroupingPayload,
response: Response,
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()
item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
_set_etag(response, item.etag)
return item
@router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem)
def api_update_postbox_grouping(
grouping_id: str,
payload: PostboxGroupingUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingItem:
_require(principal, READ_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_grouping",
resource_id=grouping_id,
base_revision=payload.base_revision,
)
actor = _actor(principal)
try:
grouping = get_service().save_grouping(
session,
tenant_id=principal.tenant_id,
actor=actor,
grouping_id=grouping_id,
expected_revision=payload.base_revision,
**payload.model_dump(exclude={"base_revision"}),
)
except PostboxError as exc:
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
_set_etag(response, item.etag)
return item
@router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_postbox_grouping(
grouping_id: str,
payload: PostboxMutationRequest,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> None:
_require(principal, READ_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_grouping",
resource_id=grouping_id,
base_revision=payload.base_revision,
)
try:
get_service().delete_grouping(
session,
tenant_id=principal.tenant_id,
actor=_actor(principal),
grouping_id=grouping_id,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_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)),
structures=list(
get_service().organization_hierarchy_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.get(
"/admin/protection-profiles",
response_model=PostboxProtectionProfileListResponse,
)
def api_postbox_protection_profiles(
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionProfileListResponse:
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
managed_available = encryption_content_cipher(get_registry()) is not None
return PostboxProtectionProfileListResponse(
standard_profile=POSTBOX_STANDARD_PROFILE,
profiles=[
PostboxProtectionProfileItem(
**asdict(profile),
available=(
managed_available if profile.requires_encryption_module else True
),
)
for profile in POSTBOX_PROTECTION_PROFILE_DEFINITIONS
],
)
@router.get(
"/admin/postboxes/{postbox_id}/protection-transitions",
response_model=PostboxProtectionTransitionListResponse,
)
def api_list_postbox_protection_transitions(
postbox_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionListResponse:
_require(principal, BINDING_ADMIN_SCOPE)
try:
values = get_service().list_protection_transitions(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxProtectionTransitionListResponse(
transitions=[_protection_transition_item(value) for value in values]
)
@router.put(
"/admin/postboxes/{postbox_id}/protection-policy",
response_model=PostboxDirectoryItem,
)
def api_update_postbox_protection_policy(
postbox_id: str,
payload: PostboxProtectionPolicyUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
get_service().update_protection_policy(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
protection_policy=payload.protection_policy.model_dump(),
actor_id=principal.account_id,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox_id),
)
)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes/{postbox_id}/protection-transitions",
response_model=PostboxProtectionTransitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_protection_transition(
postbox_id: str,
payload: PostboxProtectionTransitionCreateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionResponse:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
value = get_service().create_protection_transition(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
expected_revision=payload.base_revision,
actor_id=principal.account_id,
**payload.model_dump(
exclude={"base_revision", "acknowledge_irreversibility"}
),
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _protection_transition_item(value)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes/{postbox_id}/protection-transitions/{transition_id}/transform",
response_model=PostboxProtectionTransitionResponse,
)
def api_apply_postbox_protection_transform(
postbox_id: str,
transition_id: str,
payload: PostboxProtectionTransformRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionResponse:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_protection_transition",
resource_id=transition_id,
base_revision=payload.base_revision,
)
try:
value = get_service().apply_client_protection_transform(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
transition_id=transition_id,
expected_revision=payload.base_revision,
actor_id=principal.account_id,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump())
for item in payload.wrapped_keys
),
**payload.model_dump(exclude={"base_revision", "wrapped_keys"}),
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _protection_transition_item(value)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes",
response_model=PostboxDirectoryItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_exact_postbox(
payload: PostboxExactCreateRequest,
response: Response,
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()
item = _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id),
)
)
_set_etag(response, item.etag)
return item
@router.delete(
"/admin/postboxes/{postbox_id}",
response_model=PostboxDirectoryItem,
)
def api_archive_postbox(
postbox_id: str,
payload: PostboxMutationRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
postbox = get_service().archive_postbox(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
actor_id=principal.account_id,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id),
)
)
_set_etag(response, item.etag)
return item
@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,
response: Response,
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()
item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/templates/preview",
response_model=PostboxTemplatePreviewResponse,
)
def api_preview_postbox_template(
payload: PostboxTemplatePreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplatePreviewResponse:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
preview = get_service().preview_template_targets(
session,
tenant_id=principal.tenant_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxTemplatePreviewResponse.model_validate(preview)
@router.post(
"/admin/templates/{template_id}/revisions",
response_model=PostboxTemplateItem,
)
def api_revise_postbox_template(
template_id: str,
payload: PostboxTemplateReviseRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_template",
resource_id=template_id,
base_revision=payload.base_revision,
)
try:
template = get_service().revise_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
actor_id=principal.account_id,
expected_revision=payload.base_revision,
**payload.model_dump(exclude={"base_revision"}),
)
except PostboxError as exc:
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/templates/{template_id}/publish",
response_model=PostboxTemplateItem,
)
def api_publish_postbox_template(
template_id: str,
payload: PostboxTemplatePublishRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_template",
resource_id=template_id,
base_revision=payload.base_revision,
)
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,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/templates/{template_id}/retire",
response_model=PostboxTemplateItem,
)
def api_retire_postbox_template(
template_id: str,
payload: PostboxMutationRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_template",
resource_id=template_id,
base_revision=payload.base_revision,
)
try:
template = get_service().retire_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
actor_id=principal.account_id,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _template_item(template)
_set_etag(response, item.etag)
return item
@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)