diff --git a/src/govoplan_postbox/backend/db/models.py b/src/govoplan_postbox/backend/db/models.py index 9f02229..8357fc1 100644 --- a/src/govoplan_postbox/backend/db/models.py +++ b/src/govoplan_postbox/backend/db/models.py @@ -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, diff --git a/src/govoplan_postbox/backend/migrations/versions/a6d9e1f4c8b3_v013_external_recipient_tokens.py b/src/govoplan_postbox/backend/migrations/versions/a6d9e1f4c8b3_v013_external_recipient_tokens.py new file mode 100644 index 0000000..fccb788 --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/a6d9e1f4c8b3_v013_external_recipient_tokens.py @@ -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") diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py index 8b8ed3b..6304008 100644 --- a/src/govoplan_postbox/backend/router.py +++ b/src/govoplan_postbox/backend/router.py @@ -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: diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py index 3833091..e198abd 100644 --- a/src/govoplan_postbox/backend/schemas.py +++ b/src/govoplan_postbox/backend/schemas.py @@ -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) diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index acd7e68..7b6b3b2 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -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, diff --git a/tests/test_migration.py b/tests/test_migration.py index d36ded6..cb086d9 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -22,6 +22,10 @@ class PostboxMigrationTests(unittest.TestCase): "govoplan_postbox.backend.migrations.versions." "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:") try: with engine.begin() as connection: @@ -29,13 +33,16 @@ class PostboxMigrationTests(unittest.TestCase): original = migration.op route_original = route_migration.op occ_original = occ_migration.op + envelope_original = envelope_migration.op migration.op = operations route_migration.op = operations occ_migration.op = operations + envelope_migration.op = operations try: migration.upgrade() route_migration.upgrade() occ_migration.upgrade() + envelope_migration.upgrade() tables = set(inspect(connection).get_table_names()) self.assertIn("postboxes", tables) self.assertIn("postbox_messages", tables) @@ -52,6 +59,7 @@ class PostboxMigrationTests(unittest.TestCase): "ciphertext_ref", "signed_manifest_ref", "wrapped_keys", + "external_recipient_tokens", "key_epoch", "expires_at", "withdrawn_at", @@ -83,6 +91,7 @@ class PostboxMigrationTests(unittest.TestCase): route_columns ) ) + envelope_migration.downgrade() occ_migration.downgrade() route_migration.downgrade() migration.downgrade() @@ -97,6 +106,7 @@ class PostboxMigrationTests(unittest.TestCase): migration.op = original route_migration.op = route_original occ_migration.op = occ_original + envelope_migration.op = envelope_original finally: engine.dispose() diff --git a/tests/test_router.py b/tests/test_router.py index 1679fd4..2320d34 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -241,6 +241,15 @@ class PostboxRouterTests(unittest.TestCase): self.patch.stop() 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: directory = self.client.get("/api/v1/postbox/directory") self.assertEqual(200, directory.status_code, directory.text) diff --git a/tests/test_service.py b/tests/test_service.py index 40f636e..21e4228 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -29,7 +29,9 @@ from govoplan_core.core.organizations import ( from govoplan_core.core.postbox import ( PostboxActorRef, PostboxDeliveryRequest, + PostboxExternalRecipientTokenRef, PostboxTargetRef, + PostboxWrappedKeyRef, ) from govoplan_core.db.base import Base 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.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( self, ) -> None: diff --git a/webui/src/api/postbox.ts b/webui/src/api/postbox.ts index d5224b2..c3a4136 100644 --- a/webui/src/api/postbox.ts +++ b/webui/src/api/postbox.ts @@ -86,6 +86,24 @@ export type PostboxMessage = { key_epoch: number; ciphertext_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; + }>; + 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; + }>; participants: PostboxParticipant[]; attachments: PostboxAttachment[]; metadata: Record; diff --git a/webui/src/features/postbox/PostboxPage.tsx b/webui/src/features/postbox/PostboxPage.tsx index 7d61293..0144db7 100644 --- a/webui/src/features/postbox/PostboxPage.tsx +++ b/webui/src/features/postbox/PostboxPage.tsx @@ -999,7 +999,7 @@ function MessageDetail({ resetKey={`${message.id}:${message.availability}`} > {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."} ) : null} @@ -1026,6 +1026,12 @@ function MessageDetail({
Address
{postbox?.address || "Not loaded"}
Producer
{producerLabel(message)}
Encryption profile
{message.encryption_profile} ยท epoch {message.key_epoch}
+ {message.wrapped_keys.length ? ( +
Key envelopes
{message.wrapped_keys.length}
+ ) : null} + {message.external_recipient_tokens.length ? ( +
External grants
{message.external_recipient_tokens.length}
+ ) : null}