Add encrypted envelope recipient state
This commit is contained in:
@@ -482,6 +482,11 @@ class PostboxMessage(Base, TimestampMixin):
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
external_recipient_tokens: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
delivered_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""v0.1.3 external recipient token state
|
||||
|
||||
Revision ID: a6d9e1f4c8b3
|
||||
Revises: f5c8d0e3b7a2
|
||||
Create Date: 2026-07-31 18:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a6d9e1f4c8b3"
|
||||
down_revision = "f5c8d0e3b7a2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("postbox_messages") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"external_recipient_tokens",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("postbox_messages") as batch_op:
|
||||
batch_op.drop_column("external_recipient_tokens")
|
||||
@@ -17,9 +17,11 @@ from govoplan_core.core.postbox import (
|
||||
PostboxActorRef,
|
||||
PostboxAttachmentRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxExternalRecipientTokenRef,
|
||||
PostboxMessageAuthoringRequest,
|
||||
PostboxParticipantRef,
|
||||
PostboxTargetRef,
|
||||
PostboxWrappedKeyRef,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_postbox.backend.manifest import (
|
||||
@@ -103,7 +105,15 @@ def _actor(
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
):
|
||||
actions.add("administer")
|
||||
selected = assignment_context_id
|
||||
if (
|
||||
assignment_context_id is not None
|
||||
and assignment_context_id not in principal.function_assignment_ids
|
||||
):
|
||||
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"}
|
||||
@@ -542,6 +552,16 @@ def api_deliver_to_postbox(
|
||||
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:
|
||||
|
||||
@@ -75,6 +75,26 @@ class PostboxAttachmentPayload(BaseModel):
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxWrappedKeyPayload(BaseModel):
|
||||
recipient_type: str = Field(min_length=1, max_length=50)
|
||||
recipient_id: str = Field(min_length=1, max_length=255)
|
||||
key_epoch: int = Field(ge=1)
|
||||
wrapped_key_ref: str = Field(min_length=1, max_length=2000)
|
||||
algorithm: str | None = Field(default=None, max_length=100)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxExternalRecipientTokenPayload(BaseModel):
|
||||
token_id: str = Field(min_length=1, max_length=255)
|
||||
state: Literal["pending", "available", "fetched", "expired", "revoked"]
|
||||
expires_at: datetime | None = None
|
||||
one_time: bool = False
|
||||
key_fetched_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
assurance_profile: str | None = Field(default=None, max_length=100)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxMessageItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
@@ -99,6 +119,10 @@ class PostboxMessageItem(BaseModel):
|
||||
key_epoch: int
|
||||
ciphertext_ref: str | None = None
|
||||
signed_manifest_ref: str | None = None
|
||||
wrapped_keys: list[PostboxWrappedKeyPayload] = Field(default_factory=list)
|
||||
external_recipient_tokens: list[PostboxExternalRecipientTokenPayload] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -165,6 +189,12 @@ class PostboxDeliveryCreateRequest(BaseModel):
|
||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
ciphertext_ref: str | None = Field(default=None, max_length=1000)
|
||||
signed_manifest_ref: str | None = Field(default=None, max_length=1000)
|
||||
wrapped_keys: list[PostboxWrappedKeyPayload] = Field(default_factory=list)
|
||||
external_recipient_tokens: list[PostboxExternalRecipientTokenPayload] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -58,6 +59,7 @@ from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryResult,
|
||||
PostboxDeliveryTemplateRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxExternalRecipientTokenRef,
|
||||
PostboxMessageAvailability,
|
||||
PostboxMessageAuthoringRequest,
|
||||
PostboxMessageRef,
|
||||
@@ -66,6 +68,7 @@ from govoplan_core.core.postbox import (
|
||||
PostboxOrganizationUnitTargetRef,
|
||||
PostboxParticipantRef,
|
||||
PostboxTargetRef,
|
||||
PostboxWrappedKeyRef,
|
||||
normalize_postbox_classification,
|
||||
postbox_classification_allows,
|
||||
)
|
||||
@@ -127,6 +130,34 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
|
||||
return dict(value or {})
|
||||
|
||||
|
||||
def _optional_datetime(value: object) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _external_token_record(
|
||||
token: PostboxExternalRecipientTokenRef,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"token_id": token.token_id,
|
||||
"state": token.state,
|
||||
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
|
||||
"one_time": token.one_time,
|
||||
"key_fetched_at": (
|
||||
token.key_fetched_at.isoformat() if token.key_fetched_at else None
|
||||
),
|
||||
"revoked_at": token.revoked_at.isoformat() if token.revoked_at else None,
|
||||
"assurance_profile": token.assurance_profile,
|
||||
"metadata": dict(token.metadata),
|
||||
}
|
||||
|
||||
|
||||
def _authoring_digest(
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
*,
|
||||
@@ -1157,6 +1188,13 @@ class PostboxService:
|
||||
producer_resource_id=request.producer_resource_id,
|
||||
encryption_profile=postbox.encryption_profile,
|
||||
key_epoch=postbox.key_epoch,
|
||||
ciphertext_ref=request.ciphertext_ref,
|
||||
signed_manifest_ref=request.signed_manifest_ref,
|
||||
wrapped_keys=[asdict(item) for item in request.wrapped_keys],
|
||||
external_recipient_tokens=[
|
||||
_external_token_record(item)
|
||||
for item in request.external_recipient_tokens
|
||||
],
|
||||
delivered_at=now,
|
||||
expires_at=request.expires_at,
|
||||
metadata_=_mapping(request.metadata),
|
||||
@@ -1820,6 +1858,12 @@ class PostboxService:
|
||||
producer_resource_id=source_message.producer_resource_id,
|
||||
encryption_profile=target_postbox.encryption_profile,
|
||||
key_epoch=target_postbox.key_epoch,
|
||||
ciphertext_ref=source_message.ciphertext_ref,
|
||||
signed_manifest_ref=source_message.signed_manifest_ref,
|
||||
wrapped_keys=list(source_message.wrapped_keys or []),
|
||||
external_recipient_tokens=list(
|
||||
source_message.external_recipient_tokens or []
|
||||
),
|
||||
delivered_at=delivered_at,
|
||||
expires_at=source_message.expires_at,
|
||||
retention_hold_until=source_message.retention_hold_until,
|
||||
@@ -3734,6 +3778,38 @@ class PostboxService:
|
||||
key_epoch=message.key_epoch,
|
||||
ciphertext_ref=message.ciphertext_ref,
|
||||
signed_manifest_ref=message.signed_manifest_ref,
|
||||
wrapped_keys=tuple(
|
||||
PostboxWrappedKeyRef(
|
||||
recipient_type=str(item.get("recipient_type") or "unknown"),
|
||||
recipient_id=str(item.get("recipient_id") or "unknown"),
|
||||
key_epoch=int(item.get("key_epoch") or message.key_epoch),
|
||||
wrapped_key_ref=str(item.get("wrapped_key_ref") or ""),
|
||||
algorithm=(
|
||||
str(item["algorithm"]) if item.get("algorithm") else None
|
||||
),
|
||||
metadata=_mapping(item.get("metadata")),
|
||||
)
|
||||
for item in message.wrapped_keys or []
|
||||
if isinstance(item, Mapping) and item.get("wrapped_key_ref")
|
||||
),
|
||||
external_recipient_tokens=tuple(
|
||||
PostboxExternalRecipientTokenRef(
|
||||
token_id=str(item.get("token_id") or ""),
|
||||
state=str(item.get("state") or "pending"),
|
||||
expires_at=_optional_datetime(item.get("expires_at")),
|
||||
one_time=bool(item.get("one_time", False)),
|
||||
key_fetched_at=_optional_datetime(item.get("key_fetched_at")),
|
||||
revoked_at=_optional_datetime(item.get("revoked_at")),
|
||||
assurance_profile=(
|
||||
str(item["assurance_profile"])
|
||||
if item.get("assurance_profile")
|
||||
else None
|
||||
),
|
||||
metadata=_mapping(item.get("metadata")),
|
||||
)
|
||||
for item in message.external_recipient_tokens or []
|
||||
if isinstance(item, Mapping) and item.get("token_id")
|
||||
),
|
||||
participants=tuple(
|
||||
PostboxParticipantRef(
|
||||
kind=item.kind,
|
||||
|
||||
Reference in New Issue
Block a user