Add encrypted envelope recipient state

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent dc6f81dd33
commit e3daf400f3
10 changed files with 272 additions and 2 deletions
@@ -482,6 +482,11 @@ class PostboxMessage(Base, TimestampMixin):
default=list, default=list,
nullable=False, nullable=False,
) )
external_recipient_tokens: Mapped[list[dict[str, Any]]] = mapped_column(
JSON,
default=list,
nullable=False,
)
delivered_at: Mapped[datetime] = mapped_column( delivered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=False, nullable=False,
@@ -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")
+21 -1
View File
@@ -17,9 +17,11 @@ from govoplan_core.core.postbox import (
PostboxActorRef, PostboxActorRef,
PostboxAttachmentRef, PostboxAttachmentRef,
PostboxDeliveryRequest, PostboxDeliveryRequest,
PostboxExternalRecipientTokenRef,
PostboxMessageAuthoringRequest, PostboxMessageAuthoringRequest,
PostboxParticipantRef, PostboxParticipantRef,
PostboxTargetRef, PostboxTargetRef,
PostboxWrappedKeyRef,
) )
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import ( from govoplan_postbox.backend.manifest import (
@@ -103,7 +105,15 @@ def _actor(
TEMPLATE_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE,
): ):
actions.add("administer") 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: if selected is None and len(principal.function_assignment_ids) == 1:
selected = next(iter(principal.function_assignment_ids)) selected = next(iter(principal.function_assignment_ids))
classifications = {"public", "internal"} classifications = {"public", "internal"}
@@ -542,6 +552,16 @@ def api_deliver_to_postbox(
for attachment in payload.attachments for attachment in payload.attachments
), ),
expires_at=payload.expires_at, 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, metadata=payload.metadata,
) )
try: try:
+30
View File
@@ -75,6 +75,26 @@ class PostboxAttachmentPayload(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict) 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): class PostboxMessageItem(BaseModel):
id: str id: str
tenant_id: str tenant_id: str
@@ -99,6 +119,10 @@ class PostboxMessageItem(BaseModel):
key_epoch: int key_epoch: int
ciphertext_ref: str | None = None ciphertext_ref: str | None = None
signed_manifest_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) participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list) attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
@@ -165,6 +189,12 @@ class PostboxDeliveryCreateRequest(BaseModel):
participants: list[PostboxParticipantPayload] = Field(default_factory=list) participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list) attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
expires_at: datetime | None = None 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) metadata: dict[str, Any] = Field(default_factory=dict)
+76
View File
@@ -6,6 +6,7 @@ import logging
import re import re
from collections import Counter from collections import Counter
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import asdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -58,6 +59,7 @@ from govoplan_core.core.postbox import (
PostboxDeliveryResult, PostboxDeliveryResult,
PostboxDeliveryTemplateRef, PostboxDeliveryTemplateRef,
PostboxDirectoryEntryRef, PostboxDirectoryEntryRef,
PostboxExternalRecipientTokenRef,
PostboxMessageAvailability, PostboxMessageAvailability,
PostboxMessageAuthoringRequest, PostboxMessageAuthoringRequest,
PostboxMessageRef, PostboxMessageRef,
@@ -66,6 +68,7 @@ from govoplan_core.core.postbox import (
PostboxOrganizationUnitTargetRef, PostboxOrganizationUnitTargetRef,
PostboxParticipantRef, PostboxParticipantRef,
PostboxTargetRef, PostboxTargetRef,
PostboxWrappedKeyRef,
normalize_postbox_classification, normalize_postbox_classification,
postbox_classification_allows, postbox_classification_allows,
) )
@@ -127,6 +130,34 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
return dict(value or {}) 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( def _authoring_digest(
request: PostboxMessageAuthoringRequest, request: PostboxMessageAuthoringRequest,
*, *,
@@ -1157,6 +1188,13 @@ class PostboxService:
producer_resource_id=request.producer_resource_id, producer_resource_id=request.producer_resource_id,
encryption_profile=postbox.encryption_profile, encryption_profile=postbox.encryption_profile,
key_epoch=postbox.key_epoch, 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, delivered_at=now,
expires_at=request.expires_at, expires_at=request.expires_at,
metadata_=_mapping(request.metadata), metadata_=_mapping(request.metadata),
@@ -1820,6 +1858,12 @@ class PostboxService:
producer_resource_id=source_message.producer_resource_id, producer_resource_id=source_message.producer_resource_id,
encryption_profile=target_postbox.encryption_profile, encryption_profile=target_postbox.encryption_profile,
key_epoch=target_postbox.key_epoch, 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, delivered_at=delivered_at,
expires_at=source_message.expires_at, expires_at=source_message.expires_at,
retention_hold_until=source_message.retention_hold_until, retention_hold_until=source_message.retention_hold_until,
@@ -3734,6 +3778,38 @@ class PostboxService:
key_epoch=message.key_epoch, key_epoch=message.key_epoch,
ciphertext_ref=message.ciphertext_ref, ciphertext_ref=message.ciphertext_ref,
signed_manifest_ref=message.signed_manifest_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( participants=tuple(
PostboxParticipantRef( PostboxParticipantRef(
kind=item.kind, kind=item.kind,
+10
View File
@@ -22,6 +22,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions." "govoplan_postbox.backend.migrations.versions."
"f5c8d0e3b7a2_v012_authoring_and_occ" "f5c8d0e3b7a2_v012_authoring_and_occ"
) )
envelope_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"a6d9e1f4c8b3_v013_external_recipient_tokens"
)
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
try: try:
with engine.begin() as connection: with engine.begin() as connection:
@@ -29,13 +33,16 @@ class PostboxMigrationTests(unittest.TestCase):
original = migration.op original = migration.op
route_original = route_migration.op route_original = route_migration.op
occ_original = occ_migration.op occ_original = occ_migration.op
envelope_original = envelope_migration.op
migration.op = operations migration.op = operations
route_migration.op = operations route_migration.op = operations
occ_migration.op = operations occ_migration.op = operations
envelope_migration.op = operations
try: try:
migration.upgrade() migration.upgrade()
route_migration.upgrade() route_migration.upgrade()
occ_migration.upgrade() occ_migration.upgrade()
envelope_migration.upgrade()
tables = set(inspect(connection).get_table_names()) tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables) self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables) self.assertIn("postbox_messages", tables)
@@ -52,6 +59,7 @@ class PostboxMigrationTests(unittest.TestCase):
"ciphertext_ref", "ciphertext_ref",
"signed_manifest_ref", "signed_manifest_ref",
"wrapped_keys", "wrapped_keys",
"external_recipient_tokens",
"key_epoch", "key_epoch",
"expires_at", "expires_at",
"withdrawn_at", "withdrawn_at",
@@ -83,6 +91,7 @@ class PostboxMigrationTests(unittest.TestCase):
route_columns route_columns
) )
) )
envelope_migration.downgrade()
occ_migration.downgrade() occ_migration.downgrade()
route_migration.downgrade() route_migration.downgrade()
migration.downgrade() migration.downgrade()
@@ -97,6 +106,7 @@ class PostboxMigrationTests(unittest.TestCase):
migration.op = original migration.op = original
route_migration.op = route_original route_migration.op = route_original
occ_migration.op = occ_original occ_migration.op = occ_original
envelope_migration.op = envelope_original
finally: finally:
engine.dispose() engine.dispose()
+9
View File
@@ -241,6 +241,15 @@ class PostboxRouterTests(unittest.TestCase):
self.patch.stop() self.patch.stop()
self.engine.dispose() self.engine.dispose()
def test_assignment_context_override_must_belong_to_principal(self) -> None:
response = self.client.get(
"/api/v1/postbox/directory",
params={"assignment_context_id": "assignment-not-granted"},
)
self.assertEqual(response.status_code, 403)
self.assertIn("not active for this principal", response.text)
def test_directory_delivery_message_and_receipt_round_trip(self) -> None: def test_directory_delivery_message_and_receipt_round_trip(self) -> None:
directory = self.client.get("/api/v1/postbox/directory") directory = self.client.get("/api/v1/postbox/directory")
self.assertEqual(200, directory.status_code, directory.text) self.assertEqual(200, directory.status_code, directory.text)
+63
View File
@@ -29,7 +29,9 @@ from govoplan_core.core.organizations import (
from govoplan_core.core.postbox import ( from govoplan_core.core.postbox import (
PostboxActorRef, PostboxActorRef,
PostboxDeliveryRequest, PostboxDeliveryRequest,
PostboxExternalRecipientTokenRef,
PostboxTargetRef, PostboxTargetRef,
PostboxWrappedKeyRef,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
@@ -1173,6 +1175,67 @@ class PostboxServiceTests(unittest.TestCase):
self.assertFalse(summaries[expired.delivery_id].currently_readable) self.assertFalse(summaries[expired.delivery_id].currently_readable)
self.assertEqual(1, summaries[expired.delivery_id].expired_message_count) self.assertEqual(1, summaries[expired.delivery_id].expired_message_count)
def test_encrypted_envelope_and_external_grant_state_cross_capability(self) -> None:
self.idm.assignments.append(self.assignment)
expires_at = utc_now() + timedelta(days=1)
with Session(self.engine) as session:
postbox = self._create_exact(session)
delivered = self.service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-encrypted",
idempotency_key="encrypted-message",
subject="Encrypted decision",
ciphertext_ref="files:ciphertext-1",
signed_manifest_ref="files:manifest-1",
wrapped_keys=(
PostboxWrappedKeyRef(
recipient_type="function_postbox",
recipient_id=postbox.id,
key_epoch=postbox.key_epoch,
wrapped_key_ref="trust:wrapped-key-1",
algorithm="HPKE-v1",
),
),
external_recipient_tokens=(
PostboxExternalRecipientTokenRef(
token_id="grant-1",
state="available",
expires_at=expires_at,
one_time=True,
assurance_profile="email-otp",
),
),
),
)
message = self.service.get_message(
session,
tenant_id="tenant-1",
message_id=delivered.message_id,
actor=self.actor,
)
assert message is not None
self.assertEqual("files:ciphertext-1", message.ciphertext_ref)
self.assertEqual(
"trust:wrapped-key-1",
message.wrapped_keys[0].wrapped_key_ref,
)
self.assertEqual("grant-1", message.external_recipient_tokens[0].token_id)
self.assertEqual("available", message.external_recipient_tokens[0].state)
assert message.external_recipient_tokens[0].expires_at is not None
self.assertEqual(
expires_at.replace(microsecond=0),
message.external_recipient_tokens[0].expires_at.replace(
microsecond=0
),
)
def test_hierarchy_linked_copy_snapshots_path_and_independent_state( def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
self, self,
) -> None: ) -> None:
+18
View File
@@ -86,6 +86,24 @@ export type PostboxMessage = {
key_epoch: number; key_epoch: number;
ciphertext_ref?: string | null; ciphertext_ref?: string | null;
signed_manifest_ref?: string | null; signed_manifest_ref?: string | null;
wrapped_keys: Array<{
recipient_type: string;
recipient_id: string;
key_epoch: number;
wrapped_key_ref: string;
algorithm?: string | null;
metadata: Record<string, unknown>;
}>;
external_recipient_tokens: Array<{
token_id: string;
state: "pending" | "available" | "fetched" | "expired" | "revoked";
expires_at?: string | null;
one_time: boolean;
key_fetched_at?: string | null;
revoked_at?: string | null;
assurance_profile?: string | null;
metadata: Record<string, unknown>;
}>;
participants: PostboxParticipant[]; participants: PostboxParticipant[];
attachments: PostboxAttachment[]; attachments: PostboxAttachment[];
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
+7 -1
View File
@@ -999,7 +999,7 @@ function MessageDetail({
resetKey={`${message.id}:${message.availability}`} resetKey={`${message.id}:${message.availability}`}
> >
{message.availability === "withdrawn" {message.availability === "withdrawn"
? "This message was withdrawn. Its audit metadata remains visible, but its content and actions are unavailable." ? "This message was withdrawn. Future access is blocked and audit metadata remains visible. Plaintext already decrypted, copied, exported, or printed cannot be retracted."
: "This message has expired. Its audit metadata remains visible, but its content and actions are unavailable."} : "This message has expired. Its audit metadata remains visible, but its content and actions are unavailable."}
</DismissibleAlert> </DismissibleAlert>
) : null} ) : null}
@@ -1026,6 +1026,12 @@ function MessageDetail({
<div><dt>Address</dt><dd>{postbox?.address || "Not loaded"}</dd></div> <div><dt>Address</dt><dd>{postbox?.address || "Not loaded"}</dd></div>
<div><dt>Producer</dt><dd>{producerLabel(message)}</dd></div> <div><dt>Producer</dt><dd>{producerLabel(message)}</dd></div>
<div><dt>Encryption profile</dt><dd>{message.encryption_profile} · epoch {message.key_epoch}</dd></div> <div><dt>Encryption profile</dt><dd>{message.encryption_profile} · epoch {message.key_epoch}</dd></div>
{message.wrapped_keys.length ? (
<div><dt>Key envelopes</dt><dd>{message.wrapped_keys.length}</dd></div>
) : null}
{message.external_recipient_tokens.length ? (
<div><dt>External grants</dt><dd>{message.external_recipient_tokens.length}</dd></div>
) : null}
</dl> </dl>
</section> </section>
<section className="postbox-body"> <section className="postbox-body">