Add encrypted envelope recipient state
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
+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,
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user