From 174ee97719eefd558c83bb78260f43bc34347f73 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 03:42:58 +0200 Subject: [PATCH] feat(postbox): add governed content protection profiles --- README.md | 20 +- docs/POSTBOX_CONCEPT.md | 123 +- .../backend/content_protection.py | 6 +- src/govoplan_postbox/backend/db/__init__.py | 4 + src/govoplan_postbox/backend/db/models.py | 128 +- src/govoplan_postbox/backend/manifest.py | 105 +- ...7c1e4f8b2d6_v016_protection_transitions.py | 112 ++ .../backend/protection_profiles.py | 106 ++ src/govoplan_postbox/backend/router.py | 271 ++- src/govoplan_postbox/backend/schemas.py | 322 +++- src/govoplan_postbox/backend/service.py | 1593 +++++++++++++++-- tests/test_migration.py | 19 +- tests/test_service.py | 419 ++++- webui/src/api/postbox.ts | 146 +- .../features/postbox/PostboxAdminPanel.tsx | 882 ++++++++- webui/src/features/postbox/PostboxPage.tsx | 44 +- 16 files changed, 3917 insertions(+), 383 deletions(-) create mode 100644 src/govoplan_postbox/backend/migrations/versions/a7c1e4f8b2d6_v016_protection_transitions.py create mode 100644 src/govoplan_postbox/backend/protection_profiles.py diff --git a/README.md b/README.md index 89c69f3..153acc2 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,22 @@ an independently readable copy in the next frozen function Postbox. The `govoplan.postbox.dispatch_routes` periodic Core worker drains due routes when Celery beat and a worker consuming the `postbox` queue are enabled. -Postboxes support `plaintext_v1` and an optional `server_envelope_v1` profile. -The latter stores message bodies as ciphertext through the Encryption -capability and fails closed on reads if that capability or key is unavailable. +Postboxes expose three configurable content-protection profiles. The recommended +`server_envelope_v1` profile stores message bodies as ciphertext through an +institution-controlled Encryption vault and fails closed if its capability or +key is unavailable. `external_e2ee_v1` accepts only ciphertext, a signed +manifest, wrapped recipient keys, and a verified content digest produced by an +approved external client; GovOPlaN cannot decrypt that content. `plaintext_v1` +keeps content unencrypted for deployments that explicitly accept that boundary. Subjects, participants, routing, attachment references, and lifecycle metadata -remain observable. Existing externally produced ciphertext references remain -supported, but neither path is described as end-to-end encryption. +remain observable in every profile. + +Administrators may govern future-only changes or migrate retained history. +Transitions record user-consent and/or institutional key-holder evidence, +quorum, reason, per-message digest continuity, and completion state. Managed +envelope changes use the Encryption migration ledger. Any transition to or from +E2EE waits for client-supplied transforms for historical messages; the module +does not claim or silently simulate native browser/device key custody. Run focused checks with: diff --git a/docs/POSTBOX_CONCEPT.md b/docs/POSTBOX_CONCEPT.md index e4fefd3..602094a 100644 --- a/docs/POSTBOX_CONCEPT.md +++ b/docs/POSTBOX_CONCEPT.md @@ -12,14 +12,16 @@ needed a role, process, portal, campaign, or service responsibility. It may look like an inbox for a message task or like a vault for content shared with the current holders of that responsibility; neither form is owned by one account. -The strategic target is an encrypted administrative postbox. The current -implementation supports ordinary persisted messages and an optional -server-readable Encryption envelope for message bodies. The model also retains -external ciphertext, wrapped-key, signed-manifest, external-recipient-token, -and key-epoch metadata needed for later independently reviewed E2EE profiles. -The server-envelope profile is not E2EE, and subjects, routing, participants, -and attachment references remain visible. The -cross-module target architecture is recorded in +The strategic target is a policy-selectable administrative postbox. The current +implementation offers unencrypted content, an institution-managed Encryption +envelope, and a strict external E2EE boundary. E2EE messages contain only an +external ciphertext reference, signed-manifest reference, wrapped recipient +keys, and a verified plaintext digest; an approved producer or client owns the +actual cryptographic operation and private-key custody. GovOPlaN cannot decrypt +that profile. The institution-managed envelope remains server-readable by +authorized institutional key holders. Subjects, routing, participants, and +attachment references remain visible in every profile. The cross-module target +architecture is recorded in `govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`. ## Function-Organization-Bound Access @@ -356,47 +358,80 @@ The WebUI should start as an administration and inbox surface: Campaign, files, portal, and mail behavior should arrive as optional integrations after the core postbox model is stable. -### Current content-protection profile +### Configurable content-protection profiles -An exact Postbox or template revision may select `server_envelope_v1` and an -Encryption vault. New locally authored and delivered message bodies are then -stored in `body_ciphertext` with an owner-bound envelope reference; clear body -text is not persisted. Reads ask the optional `encryption.content_cipher` -capability to open the exact tenant, message, and envelope tuple. Missing -Encryption, a lost deployment key, a destroyed vault key, ciphertext tampering, -or a mismatched resource causes a fail-closed read. +An exact Postbox or template revision selects one profile. The administration +surface recommends the managed profile and requires its vault explicitly; the +API retains the legacy plaintext default when an older integration omits these +new fields so an upgrade cannot make an unavailable Encryption module block +existing automation. -Plaintext Postboxes continue to work without Encryption. A protected Postbox -cannot silently fall back to plaintext. Database recovery of protected messages -requires Postbox and Encryption tables from the same consistency point plus the -matching provider/deployment key. Hierarchy-routed copies retain the source -envelope reference rather than decrypting and re-encrypting during routing. +- `server_envelope_v1` is the recommended standard. New local message bodies + are encrypted through the optional `encryption.content_cipher` capability, + stored in `body_ciphertext`, and linked to an owner-bound envelope in the + institution's selected vault. Authorized reads ask that capability to open + the exact tenant, message, and envelope tuple. Missing Encryption, unavailable + or destroyed keys, tampering, and resource mismatch fail closed. +- `external_e2ee_v1` is a server-blind storage contract. Clear bodies are + rejected. A producer must provide a ciphertext reference, signed manifest, + wrapped recipient keys for the current key epoch, and `sha256` content digest. + The server retains and authorizes those artifacts but has no private key with + which to decrypt them. +- `plaintext_v1` stores the body without content encryption. It remains + available for deployments that explicitly choose transport and + infrastructure controls only. -## E2EE Readiness Checklist +No profile hides operational metadata. Subjects, senders, participants, +routing, timestamps, classifications, attachment references, receipts, +retention state, and access evidence remain server-visible. Native browser or +device enrollment, private-key custody, offline recovery, and independently +reviewed cryptographic clients are not bundled by Postbox; an institution that +selects E2EE must provide and govern that client/provider boundary. -Before the data model is considered stable, verify that it can represent: +### Protection and hand-over policy -- message or attachment ciphertext references -- signed manifest references -- recipient, role, or function key wrapping records -- key epoch and device-key references -- key-fetch/access audit events -- external recipient token state -- expiry and withdrawal state separate from deletion -- retention state that can operate without decrypting content +Each Postbox snapshots policy for the choices that cannot safely be inferred: -## E2EE decisions still to settle before implementation +- a new incumbent sees all retained history, content since assignment, or a + bounded look-back period; +- ordinary and compromise rotations select key rewrapping or full content + re-encryption; +- recovery, hand-over, emergency access, export, and destruction name the + required user-consent, institutional key-holder, or dual-control authority + and quorum; +- external retrieval requires strong identity, email plus a one-time code, or + may be disabled; and +- vacancy escalation is always metadata-only and never gives an unrelated + personal account content access. -The product direction above is selected, but the first trusted profile still -needs bounded decisions on: +The defaults are deliberately conservative: history since assignment, +ordinary rewrapping, re-encryption after compromise, two-person institutional +recovery, dual-control hand-over/emergency/export/destruction, strong external +identity, and metadata-only vacancy escalation. These are product defaults, not +hard-coded policy decisions; administrators can change them per template or +exact Postbox. -- whether a new incumbent receives all retained history, history from a - policy-defined date, or only content delivered during the assignment; -- organizational recovery/escrow and the authority required when every holder - loses all registered device keys; -- whether ordinary rotation only rewraps per-content keys or also re-encrypts - ciphertext, and which events require the stronger path; -- assurance and quorum requirements for delegation, hand-over, emergency - access, export, and destructive retention; and -- how attention/escalation works during a vacancy without granting plaintext - access to an unrelated personal account. +### Governed profile transitions + +A profile change increments the Postbox key epoch and applies immediately to +new messages. The administrator chooses whether retained history stays under +its existing profile or is migrated. Every transition records an idempotency +key, source and target profiles/vaults, user-consent and/or institutional +authorization evidence, quorum, reason, immutable configuration snapshot, +message digests, and per-message outcome. + +Plaintext-to-managed and managed-to-plaintext migrations can complete through +the configured Encryption capability. Managed decrypt, export, and +re-encryption operations are also written to the Encryption migration ledger; +old envelopes are not merely orphaned. A transition to or from E2EE pauses each +historical message until an approved external client supplies the ciphertext or +plaintext transform and evidence. Postbox checks the immutable SHA-256 digest +before committing the new representation. Leaving E2EE requires user-consent +evidence; changing institution-managed history requires institutional +key-holder evidence; dual control can require both. Previously viewed, copied, +printed, or exported cleartext cannot be recalled and must be acknowledged. + +Database recovery of managed messages requires Postbox and Encryption tables +from the same consistency point plus the provider/deployment key. Recovery of +E2EE content additionally depends on the institution's external private-key +custody and client procedures. diff --git a/src/govoplan_postbox/backend/content_protection.py b/src/govoplan_postbox/backend/content_protection.py index 970f608..5ef0340 100644 --- a/src/govoplan_postbox/backend/content_protection.py +++ b/src/govoplan_postbox/backend/content_protection.py @@ -25,6 +25,8 @@ def protect_message_body( vault_id: str, plaintext: str, actor_id: str, + operation_ref: str = "v1", + policy_decision_ref: str = "postbox:configured-server-envelope:v1", ) -> ProtectedContent: capability = encryption_content_cipher(get_registry()) if capability is None: @@ -43,8 +45,8 @@ def protect_message_body( vault_id=vault_id, ciphertext_ref=f"postbox-db://messages/{message_id}/body", plaintext=plaintext.encode("utf-8"), - policy_decision_ref="postbox:configured-server-envelope:v1", - idempotency_key=f"postbox-message:{message_id}:body:v1", + policy_decision_ref=policy_decision_ref, + idempotency_key=f"postbox-message:{message_id}:body:{operation_ref}", actor_id=actor_id, metadata={"content_type": "text/plain;charset=utf-8"}, ), diff --git a/src/govoplan_postbox/backend/db/__init__.py b/src/govoplan_postbox/backend/db/__init__.py index 5c0daac..81fb1c4 100644 --- a/src/govoplan_postbox/backend/db/__init__.py +++ b/src/govoplan_postbox/backend/db/__init__.py @@ -10,6 +10,8 @@ from govoplan_postbox.backend.db.models import ( PostboxMessage, PostboxMessageReceipt, PostboxParticipant, + PostboxProtectionTransition, + PostboxProtectionTransitionItem, PostboxRoute, PostboxTemplate, PostboxTemplateRevision, @@ -27,6 +29,8 @@ __all__ = [ "PostboxMessage", "PostboxMessageReceipt", "PostboxParticipant", + "PostboxProtectionTransition", + "PostboxProtectionTransitionItem", "PostboxRoute", "PostboxTemplate", "PostboxTemplateRevision", diff --git a/src/govoplan_postbox/backend/db/models.py b/src/govoplan_postbox/backend/db/models.py index d96e76f..a8ed106 100644 --- a/src/govoplan_postbox/backend/db/models.py +++ b/src/govoplan_postbox/backend/db/models.py @@ -161,9 +161,7 @@ class PostboxTemplateRevision(Base, TimestampMixin): default="plaintext_v1", nullable=False, ) - encryption_vault_id: Mapped[str | None] = mapped_column( - String(255), nullable=True - ) + encryption_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True) history_policy: Mapped[dict[str, Any]] = mapped_column( JSON, default=dict, @@ -323,6 +321,11 @@ class Postbox(Base, TimestampMixin): back_populates="postbox", cascade="all, delete-orphan", ) + protection_transitions: Mapped[list["PostboxProtectionTransition"]] = relationship( + back_populates="postbox", + cascade="all, delete-orphan", + order_by="PostboxProtectionTransition.created_at", + ) @property def strong_etag(self) -> str: @@ -439,9 +442,7 @@ class PostboxMessage(Base, TimestampMixin): ) subject: Mapped[str] = mapped_column(String(1000), nullable=False) body_text: Mapped[str | None] = mapped_column(Text, nullable=True) - body_ciphertext: Mapped[bytes | None] = mapped_column( - LargeBinary, nullable=True - ) + body_ciphertext: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) status: Mapped[str] = mapped_column( String(30), default="delivered", @@ -558,6 +559,121 @@ class PostboxMessage(Base, TimestampMixin): ) +class PostboxProtectionTransition(Base, TimestampMixin): + __tablename__ = "postbox_protection_transitions" + __table_args__ = ( + Index( + "ix_postbox_protection_transition_state", + "tenant_id", + "postbox_id", + "state", + ), + UniqueConstraint( + "tenant_id", + "postbox_id", + "idempotency_key", + name="uq_postbox_protection_transition_idem", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + postbox_id: Mapped[str] = mapped_column( + ForeignKey("postboxes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + source_profile: Mapped[str] = mapped_column(String(80), nullable=False) + target_profile: Mapped[str] = mapped_column(String(80), nullable=False) + source_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + target_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + history_mode: Mapped[str] = mapped_column(String(30), nullable=False) + authority_mode: Mapped[str] = mapped_column(String(40), nullable=False) + required_quorum: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + evidence_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + reason: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column( + String(30), default="pending", nullable=False, index=True + ) + message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + completed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + failed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + requested_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + activated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + configuration_snapshot: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + + postbox: Mapped[Postbox] = relationship(back_populates="protection_transitions") + items: Mapped[list["PostboxProtectionTransitionItem"]] = relationship( + back_populates="transition", + cascade="all, delete-orphan", + order_by="PostboxProtectionTransitionItem.created_at", + ) + + @property + def strong_etag(self) -> str: + return strong_resource_etag( + "postbox_protection_transition", + self.id, + self.resource_revision, + ) + + +class PostboxProtectionTransitionItem(Base, TimestampMixin): + __tablename__ = "postbox_protection_transition_items" + __table_args__ = ( + UniqueConstraint( + "transition_id", + "message_id", + name="uq_postbox_protection_transition_message", + ), + Index( + "ix_postbox_protection_transition_item_state", + "tenant_id", + "transition_id", + "state", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + transition_id: Mapped[str] = mapped_column( + ForeignKey("postbox_protection_transitions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + message_id: Mapped[str] = mapped_column( + ForeignKey("postbox_messages.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + source_profile: Mapped[str] = mapped_column(String(80), nullable=False) + target_profile: Mapped[str] = mapped_column(String(80), nullable=False) + state: Mapped[str] = mapped_column( + String(30), default="pending", nullable=False, index=True + ) + source_digest: Mapped[str | None] = mapped_column(String(255), nullable=True) + target_digest: Mapped[str | None] = mapped_column(String(255), nullable=True) + completed_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + error_code: Mapped[str | None] = mapped_column(String(100), nullable=True) + evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + transition: Mapped[PostboxProtectionTransition] = relationship( + back_populates="items" + ) + + class PostboxParticipant(Base, TimestampMixin): __tablename__ = "postbox_participants" __table_args__ = ( diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index 0a64cee..bf55073 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -71,6 +71,7 @@ MODULE_ID = "postbox" MODULE_NAME = "Postbox" MODULE_VERSION = "0.1.18" + def _permission(scope: str, label: str, description: str) -> PermissionDefinition: module_id, resource, action = scope.split(":", 2) return PermissionDefinition( @@ -209,6 +210,8 @@ _OWNED_TABLES = ( postbox_models.PostboxDelivery, postbox_models.PostboxAttachmentReference, postbox_models.PostboxParticipant, + postbox_models.PostboxProtectionTransitionItem, + postbox_models.PostboxProtectionTransition, postbox_models.PostboxMessage, postbox_models.PostboxBinding, postbox_models.Postbox, @@ -505,7 +508,10 @@ manifest = ModuleManifest( ), } }, - metadata={"kind": "reference", "help_contexts": ["postbox.quick_access.messages"]}, + metadata={ + "kind": "reference", + "help_contexts": ["postbox.quick_access.messages"], + }, order=33, ), DocumentationTopic( @@ -525,6 +531,70 @@ manifest = ModuleManifest( related_modules=("search", "idm", "encryption"), order=34, ), + DocumentationTopic( + id="postbox.content-protection-policy", + title="Choose and change Postbox content protection", + summary="Configure plaintext, institution-managed envelope, or externally managed E2EE content with governed hand-over and migration evidence.", + body=( + "Every exact Postbox and immutable template revision selects a content-protection profile. " + "Institution-managed server envelopes are the recommended standard and require an Encryption vault; authorized institutional key holders can decrypt them. " + "External E2EE rejects clear message bodies and requires an approved producer or client to supply ciphertext, a signed manifest, wrapped recipient keys, and a verified digest; GovOPlaN cannot decrypt that content. " + "Plaintext remains an explicit deployment choice. Subjects, routing, participants, attachment references, lifecycle state, and other operational metadata remain visible in every profile. " + "The accompanying policy selects history for new incumbents, ordinary and compromise rotation, recovery, hand-over, emergency access, export, destruction, external-recipient assurance, and metadata-only vacancy escalation. " + "A profile transition applies to future messages immediately and may retain or migrate history. It records user-consent and/or institutional key-holder evidence, quorum, reason, digest continuity, and per-message outcome. " + "Transitions to or from E2EE wait for approved client transformations; native device key custody and cryptographic clients are not supplied by Postbox. Previously decrypted, copied, printed, or exported content cannot be recalled." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("administrator", "user", "auditor"), + related_modules=("encryption", "identity_trust", "policy", "audit"), + links=( + DocumentationLink( + label="Postbox administration", + href="/admin?section=postbox", + kind="runtime", + ), + DocumentationLink( + label="Protection profile catalog API", + href="/api/v1/postbox/admin/protection-profiles", + kind="api", + ), + DocumentationLink( + label="Postbox protection concept", + href="docs/POSTBOX_CONCEPT.md", + kind="source", + ), + ), + translations={ + "de": { + "title": "Inhaltsschutz für Postfächer wählen und ändern", + "summary": "Klartext, institutionell verwaltete Umschlagverschlüsselung oder extern verwaltete Ende-zu-Ende-Verschlüsselung mit geregelten Übergabe- und Migrationsnachweisen konfigurieren.", + "body": ( + "Jedes exakte Postfach und jede unveränderliche Vorlagenrevision wählt ein Inhaltsschutzprofil. " + "Institutionell verwaltete Server-Umschläge sind der empfohlene Standard und benötigen einen Encryption-Tresor; berechtigte institutionelle Schlüsselverantwortliche können sie entschlüsseln. " + "Externe Ende-zu-Ende-Verschlüsselung weist Klartextnachrichten ab und verlangt von einem zugelassenen Erzeuger oder Client Chiffrat, signiertes Manifest, umhüllte Empfängerschlüssel und einen geprüften Digest; GovOPlaN kann diesen Inhalt nicht entschlüsseln. " + "Klartext bleibt eine ausdrückliche Wahl. Betreff, Routing, Beteiligte, Anlagenverweise, Lebenszyklus und weitere Betriebsmetadaten bleiben bei allen Profilen sichtbar. " + "Die begleitende Richtlinie regelt den Verlauf für neue Stelleninhaber, normale Rotation und Kompromittierung, Wiederherstellung, Übergabe, Notfallzugriff, Export, Vernichtung, externe Empfängerprüfung und rein metadatenbasierte Vakanzeskalation. " + "Ein Profilwechsel gilt sofort für neue Nachrichten und kann den Bestand beibehalten oder migrieren. Er protokolliert Einwilligungen und/oder institutionelle Freigaben, Quorum, Grund, Digest-Kontinuität und Ergebnis je Nachricht. " + "Wechsel zu oder von E2EE warten auf freigegebene Client-Transformationen; Geräte-Schlüsselverwahrung und Kryptografie-Clients liefert Postbox nicht mit. Bereits entschlüsselte, kopierte, gedruckte oder exportierte Inhalte können nicht zurückgerufen werden." + ), + } + }, + metadata={ + "kind": "guide", + "help_contexts": [ + "postbox.admin.templates", + "postbox.field.protection-profile", + "postbox.action.protection-transition", + ], + "privacy_notes": [ + "E2EE protects content, not operational metadata.", + "Managed envelopes are decryptable by authorized institutional key holders.", + "Profile transitions preserve authority evidence and content-digest continuity.", + ], + }, + order=35, + ), DocumentationTopic( id="postbox.function-bound-containers", title="Function-bound Postboxes", @@ -538,12 +608,11 @@ manifest = ModuleManifest( "reassignment. Current access combines a generic Postbox " "permission with effective IDM assignment context. Templates " "can lazily materialize unit-specific addresses, while exact " - "postboxes cover exceptional responsibilities. Plaintext " - "Postboxes remain available without Encryption. A " - "server-envelope profile stores message bodies as ciphertext and " - "uses the optional Encryption capability for authorized reads. " - "External ciphertext profiles retain producer-managed references " - "and keys; neither profile is described as end-to-end encryption. " + "postboxes cover exceptional responsibilities. Administrators " + "choose plaintext, the recommended institution-managed Encryption " + "envelope, or a strict externally produced E2EE contract. Managed " + "envelopes remain decryptable by authorized institutional key holders; " + "E2EE rejects plaintext and GovOPlaN has no private decryption key. " "When Tasks is enabled, currently readable unread messages also appear " "in the common work inbox and disappear when the personal read receipt is recorded." ), @@ -587,7 +656,7 @@ manifest = ModuleManifest( "Subjects, participants, routing facts, and attachment references remain observable metadata.", ], }, - order=35, + order=36, ), DocumentationTopic( id="postbox.reference.fields-and-consequences", @@ -660,7 +729,7 @@ manifest = ModuleManifest( "withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.", }, }, - order=36, + order=37, ), ), architecture=declared_module_architecture( @@ -672,10 +741,22 @@ manifest = ModuleManifest( known_limits=( "Subjects, routing metadata, participants, and attachment references remain plaintext metadata.", "Server-envelope protection is server-decryptable and is not end-to-end encryption.", - "External ciphertext profiles require a separately governed producer and client key-custody profile.", + "External E2EE requires a separately governed producer/client, private-key custody, device enrollment, and independent cryptographic review.", + ), + owned_concepts=( + "postbox", + "postbox address", + "postbox message", + "delivery receipt", + "access event", + "postbox protection transition", + ), + non_owned_concepts=( + "identity", + "function assignment", + "campaign", + "cryptographic key custody", ), - owned_concepts=("postbox", "postbox address", "postbox message", "delivery receipt", "access event"), - non_owned_concepts=("identity", "function assignment", "campaign", "cryptographic key custody"), recovery_docs=("docs/POSTBOX_CONCEPT.md",), security_docs=("docs/POSTBOX_CONCEPT.md",), operations_docs=("README.md",), diff --git a/src/govoplan_postbox/backend/migrations/versions/a7c1e4f8b2d6_v016_protection_transitions.py b/src/govoplan_postbox/backend/migrations/versions/a7c1e4f8b2d6_v016_protection_transitions.py new file mode 100644 index 0000000..60dd613 --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/a7c1e4f8b2d6_v016_protection_transitions.py @@ -0,0 +1,112 @@ +"""v0.1.18 governed Postbox protection transitions. + +Revision ID: a7c1e4f8b2d6 +Revises: f2a5c8e1b4d7 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "a7c1e4f8b2d6" +down_revision = "f2a5c8e1b4d7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "postbox_protection_transitions", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("tenant_id", sa.String(36), nullable=False), + sa.Column("postbox_id", sa.String(36), nullable=False), + sa.Column("idempotency_key", sa.String(255), nullable=False), + sa.Column("source_profile", sa.String(80), nullable=False), + sa.Column("target_profile", sa.String(80), nullable=False), + sa.Column("source_vault_id", sa.String(255), nullable=True), + sa.Column("target_vault_id", sa.String(255), nullable=True), + sa.Column("history_mode", sa.String(30), nullable=False), + sa.Column("authority_mode", sa.String(40), nullable=False), + sa.Column("required_quorum", sa.Integer(), nullable=False), + sa.Column("evidence_refs", sa.JSON(), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("state", sa.String(30), nullable=False), + sa.Column("message_count", sa.Integer(), nullable=False), + sa.Column("completed_count", sa.Integer(), nullable=False), + sa.Column("failed_count", sa.Integer(), nullable=False), + sa.Column("requested_by", sa.String(255), nullable=True), + sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resource_revision", sa.Integer(), nullable=False), + sa.Column("configuration_snapshot", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["postbox_id"], ["postboxes.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "postbox_id", + "idempotency_key", + name="uq_postbox_protection_transition_idem", + ), + ) + op.create_index( + "ix_postbox_protection_transition_state", + "postbox_protection_transitions", + ["tenant_id", "postbox_id", "state"], + ) + for column in ("tenant_id", "postbox_id", "state"): + op.create_index( + f"ix_postbox_protection_transitions_{column}", + "postbox_protection_transitions", + [column], + ) + + op.create_table( + "postbox_protection_transition_items", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("tenant_id", sa.String(36), nullable=False), + sa.Column("transition_id", sa.String(36), nullable=False), + sa.Column("message_id", sa.String(36), nullable=False), + sa.Column("source_profile", sa.String(80), nullable=False), + sa.Column("target_profile", sa.String(80), nullable=False), + sa.Column("state", sa.String(30), nullable=False), + sa.Column("source_digest", sa.String(255), nullable=True), + sa.Column("target_digest", sa.String(255), nullable=True), + sa.Column("completed_by", sa.String(255), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_code", sa.String(100), nullable=True), + sa.Column("evidence", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["transition_id"], + ["postbox_protection_transitions.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["message_id"], ["postbox_messages.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "transition_id", + "message_id", + name="uq_postbox_protection_transition_message", + ), + ) + op.create_index( + "ix_postbox_protection_transition_item_state", + "postbox_protection_transition_items", + ["tenant_id", "transition_id", "state"], + ) + for column in ("tenant_id", "transition_id", "message_id", "state"): + op.create_index( + f"ix_postbox_protection_transition_items_{column}", + "postbox_protection_transition_items", + [column], + ) + + +def downgrade() -> None: + op.drop_table("postbox_protection_transition_items") + op.drop_table("postbox_protection_transitions") diff --git a/src/govoplan_postbox/backend/protection_profiles.py b/src/govoplan_postbox/backend/protection_profiles.py new file mode 100644 index 0000000..cc8a9df --- /dev/null +++ b/src/govoplan_postbox/backend/protection_profiles.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +POSTBOX_PLAINTEXT_PROFILE = "plaintext_v1" +POSTBOX_MANAGED_ENVELOPE_PROFILE = "server_envelope_v1" +POSTBOX_EXTERNAL_E2EE_PROFILE = "external_e2ee_v1" +POSTBOX_LEGACY_EXTERNAL_ENVELOPE_PROFILE = "external_envelope_v1" +POSTBOX_STANDARD_PROFILE = POSTBOX_MANAGED_ENVELOPE_PROFILE + +PostboxProtectionProfile = Literal[ + "plaintext_v1", + "server_envelope_v1", + "external_e2ee_v1", +] + +SUPPORTED_POSTBOX_PROTECTION_PROFILES = frozenset( + { + POSTBOX_PLAINTEXT_PROFILE, + POSTBOX_MANAGED_ENVELOPE_PROFILE, + POSTBOX_EXTERNAL_E2EE_PROFILE, + } +) + + +def normalize_postbox_protection_policy( + policy: dict[str, object] | None = None, +) -> dict[str, object]: + return { + "new_incumbent_history": "since_assignment", + "history_days": None, + "ordinary_rotation": "rewrap", + "compromise_rotation": "reencrypt", + "recovery_authority": "institutional_key_holders", + "recovery_quorum": 2, + "handover_authority": "dual_control", + "handover_quorum": 2, + "emergency_access": "dual_control", + "emergency_quorum": 2, + "export_authority": "dual_control", + "export_quorum": 2, + "destruction_authority": "dual_control", + "destruction_quorum": 2, + "external_recipient_assurance": "strong_identity", + "vacancy_escalation_content_access": "metadata_only", + **(policy or {}), + } + + +@dataclass(frozen=True, slots=True) +class PostboxProtectionProfileDefinition: + id: PostboxProtectionProfile + label: str + description: str + server_can_decrypt: bool + requires_encryption_module: bool + requires_external_client: bool + standard: bool = False + + +POSTBOX_PROTECTION_PROFILE_DEFINITIONS = ( + PostboxProtectionProfileDefinition( + id=POSTBOX_MANAGED_ENVELOPE_PROFILE, + label="Institution-managed envelope", + description=( + "The Encryption provider protects content and authorized institutional " + "key holders can govern recovery. This is the standard profile." + ), + server_can_decrypt=True, + requires_encryption_module=True, + requires_external_client=False, + standard=True, + ), + PostboxProtectionProfileDefinition( + id=POSTBOX_EXTERNAL_E2EE_PROFILE, + label="External end-to-end envelope", + description=( + "A reviewed client or producer supplies ciphertext, a signed manifest, " + "and recipient-wrapped keys. GovOPlaN stores and routes them but cannot " + "decrypt the content." + ), + server_can_decrypt=False, + requires_encryption_module=False, + requires_external_client=True, + ), + PostboxProtectionProfileDefinition( + id=POSTBOX_PLAINTEXT_PROFILE, + label="No application-layer encryption", + description=( + "Postbox stores readable message content. Transport and storage controls " + "may still apply, but this profile is not encrypted by Postbox." + ), + server_can_decrypt=True, + requires_encryption_module=False, + requires_external_client=False, + ), +) + + +def is_e2ee_profile(profile: str) -> bool: + return profile in { + POSTBOX_EXTERNAL_E2EE_PROFILE, + POSTBOX_LEGACY_EXTERNAL_ENVELOPE_PROFILE, + } diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py index f85b655..64d9af8 100644 --- a/src/govoplan_postbox/backend/router.py +++ b/src/govoplan_postbox/backend/router.py @@ -28,6 +28,7 @@ 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, @@ -60,6 +61,14 @@ from govoplan_postbox.backend.schemas import ( PostboxMessageStateRequest, PostboxOrganizationTargetsResponse, PostboxMutationRequest, + PostboxProtectionProfileItem, + PostboxProtectionProfileListResponse, + PostboxProtectionPolicyUpdateRequest, + PostboxProtectionTransformRequest, + PostboxProtectionTransitionCreateRequest, + PostboxProtectionTransitionItemResponse, + PostboxProtectionTransitionListResponse, + PostboxProtectionTransitionResponse, PostboxRouteDryRunRequest, PostboxRouteDryRunResponse, PostboxTemplateCreateRequest, @@ -71,6 +80,10 @@ from govoplan_postbox.backend.schemas import ( 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, @@ -122,6 +135,8 @@ def _http_error(exc: PostboxError) -> HTTPException: "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"}: @@ -195,6 +210,11 @@ def _authoring_request( 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()) @@ -236,9 +256,7 @@ def _template_item(template) -> PostboxTemplateItem: "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 [] - ), + "scope_relation_type_ids": list(revision.scope_relation_type_ids or []), "name_pattern": revision.name_pattern, "address_pattern": revision.address_pattern, "classification": revision.classification, @@ -246,6 +264,7 @@ def _template_item(template) -> PostboxTemplateItem: "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 {}), @@ -291,6 +310,48 @@ def _grouping_item( ) +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, @@ -523,7 +584,13 @@ def api_resolve_postbox_message_attachments( ) provider = postbox_file_reference_provider(get_registry()) - file_types = {"file", "file_asset", "files:file", "file_version", "files:file_version"} + file_types = { + "file", + "file_asset", + "files:file", + "file_version", + "files:file_version", + } requests = tuple( PostboxFileReferenceRequest( reference_type=attachment.reference_type, @@ -544,9 +611,7 @@ def api_resolve_postbox_message_attachments( if provider is not None and requests else () ) - by_reference = { - (item.reference_type, item.reference_id): item for item in resolved - } + 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( @@ -667,8 +732,7 @@ def api_deliver_to_postbox( 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 + PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys ), external_recipient_tokens=tuple( PostboxExternalRecipientTokenRef(**item.model_dump()) @@ -853,13 +917,9 @@ def api_postbox_organization_targets( ) -> PostboxOrganizationTargetsResponse: _require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE) return PostboxOrganizationTargetsResponse( - units=list( - get_service().organization_targets(tenant_id=principal.tenant_id) - ), + units=list(get_service().organization_targets(tenant_id=principal.tenant_id)), structures=list( - get_service().organization_hierarchy_targets( - tenant_id=principal.tenant_id - ) + get_service().organization_hierarchy_targets(tenant_id=principal.tenant_id) ), ) @@ -881,6 +941,187 @@ def api_admin_postboxes( ) +@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, diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py index 087fa99..977b157 100644 --- a/src/govoplan_postbox/backend/schemas.py +++ b/src/govoplan_postbox/backend/schemas.py @@ -5,6 +5,12 @@ from typing import Any, Literal from pydantic import BaseModel, Field, model_validator +from govoplan_postbox.backend.protection_profiles import ( + POSTBOX_MANAGED_ENVELOPE_PROFILE, + POSTBOX_PLAINTEXT_PROFILE, + PostboxProtectionProfile, +) + PostboxClassification = Literal[ "public", @@ -48,6 +54,10 @@ class PostboxDirectoryItem(BaseModel): template_revision_id: str | None = None holder_count: int = 0 vacant: bool = True + encryption_profile: str = POSTBOX_PLAINTEXT_PROFILE + key_epoch: int = Field(default=1, ge=1) + encryption_vault_id: str | None = None + protection_policy: dict[str, Any] = Field(default_factory=dict) access: PostboxAccessDecisionResponse | None = None resource_revision: int = Field(default=1, ge=1) etag: str | None = None @@ -156,11 +166,32 @@ class PostboxMessageAuthoringPayload(BaseModel): idempotency_key: str = Field(min_length=1, max_length=255) subject: str = Field(min_length=1, max_length=1000) body_text: str | 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) classification: PostboxClassification = "internal" participants: list[PostboxParticipantPayload] = Field(default_factory=list) attachments: list[PostboxAttachmentPayload] = Field(default_factory=list) metadata: dict[str, Any] = Field(default_factory=dict) + @model_validator(mode="after") + def validate_content_source(self) -> "PostboxMessageAuthoringPayload": + if self.body_text is not None and self.ciphertext_ref: + raise ValueError( + "Provide plaintext or an external ciphertext envelope, not both." + ) + if self.ciphertext_ref and ( + not self.signed_manifest_ref or not self.wrapped_keys + ): + raise ValueError( + "External E2EE content requires a signed manifest and wrapped keys." + ) + if not self.ciphertext_ref and (self.signed_manifest_ref or self.wrapped_keys): + raise ValueError( + "A signed manifest and wrapped keys require an external ciphertext reference." + ) + return self + class PostboxMessageCreateRequest(PostboxMessageAuthoringPayload): postbox_id: str = Field(min_length=1, max_length=36) @@ -178,9 +209,7 @@ class PostboxTargetPayload(BaseModel): def validate_target(self) -> "PostboxTargetPayload": direct = bool(self.postbox_id or self.address_key) templated = bool( - self.template_id - and self.organization_unit_id - and self.function_id + self.template_id and self.organization_unit_id and self.function_id ) if direct == templated: raise ValueError( @@ -210,6 +239,24 @@ class PostboxDeliveryCreateRequest(BaseModel): ) metadata: dict[str, Any] = Field(default_factory=dict) + @model_validator(mode="after") + def validate_content_source(self) -> "PostboxDeliveryCreateRequest": + if self.body_text is not None and self.ciphertext_ref: + raise ValueError( + "Provide plaintext or an external ciphertext envelope, not both." + ) + if self.ciphertext_ref and ( + not self.signed_manifest_ref or not self.wrapped_keys + ): + raise ValueError( + "External E2EE content requires a signed manifest and wrapped keys." + ) + if not self.ciphertext_ref and (self.signed_manifest_ref or self.wrapped_keys): + raise ValueError( + "A signed manifest and wrapped keys require an external ciphertext reference." + ) + return self + class PostboxDeliveryResponse(BaseModel): delivery_id: str @@ -248,14 +295,10 @@ class PostboxLinkedCopyPolicyPayload(BaseModel): def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload": self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids)) self.allowed_classifications = list( - dict.fromkeys( - value.strip() for value in self.allowed_classifications - ) + dict.fromkeys(value.strip() for value in self.allowed_classifications) ) self.allowed_producer_modules = list( - dict.fromkeys( - value.strip() for value in self.allowed_producer_modules - ) + dict.fromkeys(value.strip() for value in self.allowed_producer_modules) ) if any(not value for value in self.relation_type_ids): raise ValueError("Relation type IDs must not be empty.") @@ -316,16 +359,10 @@ class PostboxRoutingPolicyPayload(BaseModel): @model_validator(mode="after") def validate_semantics(self) -> "PostboxRoutingPolicyPayload": - if ( - self.attention.mode == "vacancy_escalation" - and ( - not self.linked_copy.enabled - or self.linked_copy.fanout != "nearest" - ) + if self.attention.mode == "vacancy_escalation" and ( + not self.linked_copy.enabled or self.linked_copy.fanout != "nearest" ): - raise ValueError( - "Vacancy escalation requires nearest linked-copy routing." - ) + raise ValueError("Vacancy escalation requires nearest linked-copy routing.") return self @@ -361,6 +398,73 @@ class PostboxRouteDryRunResponse(BaseModel): diagnostics: list[str] = Field(default_factory=list) +class PostboxProtectionPolicyPayload(BaseModel): + new_incumbent_history: Literal[ + "all_retained", + "since_assignment", + "bounded_days", + ] = "since_assignment" + history_days: int | None = Field(default=None, ge=1, le=36500) + ordinary_rotation: Literal["rewrap", "reencrypt"] = "rewrap" + compromise_rotation: Literal["rewrap", "reencrypt"] = "reencrypt" + recovery_authority: Literal[ + "disabled", + "user_consent", + "institutional_key_holders", + "dual_control", + ] = "institutional_key_holders" + recovery_quorum: int = Field(default=2, ge=1, le=20) + handover_authority: Literal[ + "user_consent", + "institutional_key_holders", + "dual_control", + ] = "dual_control" + handover_quorum: int = Field(default=2, ge=1, le=20) + emergency_access: Literal["disabled", "dual_control"] = "dual_control" + emergency_quorum: int = Field(default=2, ge=1, le=20) + export_authority: Literal[ + "user_consent", + "institutional_key_holders", + "dual_control", + ] = "dual_control" + export_quorum: int = Field(default=2, ge=1, le=20) + destruction_authority: Literal[ + "institutional_key_holders", + "dual_control", + ] = "dual_control" + destruction_quorum: int = Field(default=2, ge=1, le=20) + external_recipient_assurance: Literal[ + "disabled", + "email_otp", + "strong_identity", + ] = "strong_identity" + vacancy_escalation_content_access: Literal["metadata_only"] = "metadata_only" + + @model_validator(mode="after") + def validate_history_policy(self) -> "PostboxProtectionPolicyPayload": + if self.new_incumbent_history == "bounded_days" and self.history_days is None: + raise ValueError("Bounded incumbent history requires a day limit.") + if self.new_incumbent_history != "bounded_days": + self.history_days = None + if self.handover_authority == "dual_control" and self.handover_quorum < 2: + raise ValueError( + "Dual-control hand-over requires a quorum of at least two." + ) + if self.emergency_access == "dual_control" and self.emergency_quorum < 2: + raise ValueError( + "Emergency dual control requires a quorum of at least two." + ) + if self.recovery_authority == "dual_control" and self.recovery_quorum < 2: + raise ValueError("Dual-control recovery requires a quorum of at least two.") + if self.export_authority == "dual_control" and self.export_quorum < 2: + raise ValueError("Dual-control export requires a quorum of at least two.") + if self.destruction_authority == "dual_control" and self.destruction_quorum < 2: + raise ValueError( + "Dual-control destruction requires a quorum of at least two." + ) + return self + + class PostboxExactCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=500) description: str | None = None @@ -369,25 +473,31 @@ class PostboxExactCreateRequest(BaseModel): address_key: str | None = Field(default=None, max_length=120) classification: PostboxClassification = "internal" portal_visible: bool = False - encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( - "plaintext_v1" - ) + encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE encryption_vault_id: str | None = Field(default=None, max_length=255) + protection_policy: PostboxProtectionPolicyPayload = Field( + default_factory=PostboxProtectionPolicyPayload + ) @model_validator(mode="after") def validate_encryption(self) -> "PostboxExactCreateRequest": - if self.encryption_profile == "server_envelope_v1": + if self.encryption_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: if not str(self.encryption_vault_id or "").strip(): raise ValueError( "Server-envelope Postboxes require an encryption vault." ) elif self.encryption_vault_id: raise ValueError( - "A plaintext Postbox cannot select an encryption vault." + "Only an institution-managed Postbox can select an encryption vault." ) return self +class PostboxProtectionPolicyUpdateRequest(BaseModel): + base_revision: int = Field(ge=1) + protection_policy: PostboxProtectionPolicyPayload + + class PostboxTemplateRevisionPayload(BaseModel): function_type_id: str | None = Field(default=None, max_length=36) scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant" @@ -407,10 +517,11 @@ class PostboxTemplateRevisionPayload(BaseModel): classification: PostboxClassification = "internal" allow_vacant_delivery: bool = True portal_visible: bool = False - encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( - "plaintext_v1" - ) + encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE encryption_vault_id: str | None = Field(default=None, max_length=255) + protection_policy: PostboxProtectionPolicyPayload = Field( + default_factory=PostboxProtectionPolicyPayload + ) routing_policy: PostboxRoutingPolicyPayload = Field( default_factory=PostboxRoutingPolicyPayload ) @@ -426,18 +537,169 @@ class PostboxTemplateRevisionPayload(BaseModel): @model_validator(mode="after") def validate_encryption(self) -> "PostboxTemplateRevisionPayload": - if self.encryption_profile == "server_envelope_v1": + if self.encryption_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: if not str(self.encryption_vault_id or "").strip(): raise ValueError( "Server-envelope Postbox templates require an encryption vault." ) elif self.encryption_vault_id: raise ValueError( - "A plaintext Postbox template cannot select an encryption vault." + "Only an institution-managed Postbox template can select an encryption vault." ) return self +class PostboxProtectionProfileItem(BaseModel): + id: PostboxProtectionProfile + label: str + description: str + server_can_decrypt: bool + requires_encryption_module: bool + requires_external_client: bool + available: bool + standard: bool = False + + +class PostboxProtectionProfileListResponse(BaseModel): + standard_profile: PostboxProtectionProfile + profiles: list[PostboxProtectionProfileItem] + + +class PostboxProtectionTransitionCreateRequest(BaseModel): + idempotency_key: str = Field(min_length=1, max_length=255) + base_revision: int = Field(ge=1) + target_profile: PostboxProtectionProfile + target_vault_id: str | None = Field(default=None, max_length=255) + history_mode: Literal["future_only", "migrate_history"] = "future_only" + authority_mode: Literal[ + "user_consent", + "institutional_key_holders", + "dual_control", + ] + required_quorum: int = Field(default=1, ge=1, le=20) + user_consent_refs: list[str] = Field(default_factory=list, max_length=50) + institutional_authorization_refs: list[str] = Field( + default_factory=list, max_length=50 + ) + reason: str = Field(min_length=1, max_length=2000) + acknowledge_irreversibility: bool + + @model_validator(mode="after") + def validate_transition(self) -> "PostboxProtectionTransitionCreateRequest": + self.user_consent_refs = list( + dict.fromkeys( + item.strip() for item in self.user_consent_refs if item.strip() + ) + ) + self.institutional_authorization_refs = list( + dict.fromkeys( + item.strip() + for item in self.institutional_authorization_refs + if item.strip() + ) + ) + evidence_count = len( + set(self.user_consent_refs + self.institutional_authorization_refs) + ) + if evidence_count < self.required_quorum: + raise ValueError("The evidence set does not satisfy the selected quorum.") + if self.authority_mode in {"user_consent", "dual_control"} and not ( + self.user_consent_refs + ): + raise ValueError( + "The selected authority mode requires user consent evidence." + ) + if ( + self.authority_mode + in { + "institutional_key_holders", + "dual_control", + } + and not self.institutional_authorization_refs + ): + raise ValueError( + "The selected authority mode requires institutional authorization evidence." + ) + if self.authority_mode == "dual_control" and self.required_quorum < 2: + raise ValueError("Dual control requires a quorum of at least two.") + if not self.acknowledge_irreversibility: + raise ValueError( + "Confirm that previously decrypted, copied, or exported content cannot be recalled." + ) + if self.target_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: + if not str(self.target_vault_id or "").strip(): + raise ValueError("Institution-managed envelopes require a vault.") + elif self.target_vault_id: + raise ValueError("Only institution-managed envelopes select a vault.") + return self + + +class PostboxProtectionTransformRequest(BaseModel): + base_revision: int = Field(ge=1) + message_id: str = Field(min_length=1, max_length=36) + plaintext: str | 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) + content_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + transformation_evidence_ref: str = Field(min_length=1, max_length=1000) + + @model_validator(mode="after") + def validate_target_payload(self) -> "PostboxProtectionTransformRequest": + if self.plaintext is not None and self.ciphertext_ref: + raise ValueError("Provide transformed plaintext or ciphertext, not both.") + if self.ciphertext_ref and ( + not self.signed_manifest_ref or not self.wrapped_keys + ): + raise ValueError( + "E2EE transformation requires a signed manifest and wrapped keys." + ) + return self + + +class PostboxProtectionTransitionItemResponse(BaseModel): + id: str + message_id: str + source_profile: str + target_profile: str + state: str + source_digest: str | None = None + target_digest: str | None = None + completed_by: str | None = None + completed_at: datetime | None = None + error_code: str | None = None + evidence: dict[str, Any] = Field(default_factory=dict) + + +class PostboxProtectionTransitionResponse(BaseModel): + id: str + postbox_id: str + source_profile: str + target_profile: str + source_vault_id: str | None = None + target_vault_id: str | None = None + history_mode: str + authority_mode: str + required_quorum: int + evidence_refs: list[str] + reason: str + state: str + message_count: int + completed_count: int + failed_count: int + requested_by: str | None = None + activated_at: datetime | None = None + completed_at: datetime | None = None + resource_revision: int = Field(ge=1) + etag: str + configuration_snapshot: dict[str, Any] = Field(default_factory=dict) + items: list[PostboxProtectionTransitionItemResponse] = Field(default_factory=list) + + +class PostboxProtectionTransitionListResponse(BaseModel): + transitions: list[PostboxProtectionTransitionResponse] + + def _validate_template_write_scope( payload: PostboxTemplateRevisionPayload, ) -> None: @@ -586,9 +848,7 @@ class PostboxOrganizationStructureItem(BaseModel): class PostboxOrganizationTargetsResponse(BaseModel): units: list[PostboxOrganizationUnitItem] - structures: list[PostboxOrganizationStructureItem] = Field( - default_factory=list - ) + structures: list[PostboxOrganizationStructureItem] = Field(default_factory=list) class PostboxGroupingPayload(BaseModel): diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index 0aa32ac..9b5af0d 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -6,8 +6,9 @@ import logging import re from collections import Counter from collections.abc import Mapping, Sequence -from dataclasses import asdict +from dataclasses import asdict, replace from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from typing import Any, Literal from sqlalchemy import and_, case, func, or_ @@ -21,6 +22,12 @@ from govoplan_core.core.events import ( PlatformEvent, emit_platform_event, ) +from govoplan_core.core.encryption import ( + ProtectionMigrationRequest, + content_protection_registry, + encryption_content_cipher, + encryption_key_vault, +) from govoplan_core.core.concurrency import MissingPreconditionError, claim_revision from govoplan_core.core.identity import ( CAPABILITY_IDENTITY_DIRECTORY, @@ -86,6 +93,8 @@ from govoplan_postbox.backend.db.models import ( PostboxMessage, PostboxMessageReceipt, PostboxParticipant, + PostboxProtectionTransition, + PostboxProtectionTransitionItem, PostboxRoute, PostboxTemplate, PostboxTemplateRevision, @@ -98,6 +107,14 @@ from govoplan_postbox.backend.hierarchy_routing import ( plan_hierarchy_routes, ) from govoplan_postbox.backend.access_decisions import evaluate_postbox_access +from govoplan_postbox.backend.protection_profiles import ( + POSTBOX_EXTERNAL_E2EE_PROFILE, + POSTBOX_MANAGED_ENVELOPE_PROFILE, + POSTBOX_PLAINTEXT_PROFILE, + SUPPORTED_POSTBOX_PROTECTION_PROFILES, + is_e2ee_profile, + normalize_postbox_protection_policy, +) logger = logging.getLogger(__name__) @@ -131,25 +148,42 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]: return dict(value or {}) +def _plaintext_digest(value: str | None) -> str: + return "sha256:" + hashlib.sha256((value or "").encode("utf-8")).hexdigest() + + +def _validated_content_digest(value: object) -> str: + candidate = str(value or "").strip().casefold() + if not re.fullmatch(r"sha256:[0-9a-f]{64}", candidate): + raise PostboxError( + "content_digest_required", + "External E2EE content requires a sha256 content digest.", + ) + return candidate + + def _validate_encryption_configuration( profile: str, vault_id: str | None, ) -> None: normalized = str(profile or "").strip() - if normalized not in {"plaintext_v1", "server_envelope_v1"}: + if normalized not in SUPPORTED_POSTBOX_PROTECTION_PROFILES: raise PostboxError( "unsupported_encryption_profile", "Unsupported Postbox encryption profile.", ) - if normalized == "server_envelope_v1" and not str(vault_id or "").strip(): + if ( + normalized == POSTBOX_MANAGED_ENVELOPE_PROFILE + and not str(vault_id or "").strip() + ): raise PostboxError( "encryption_vault_missing", "Server-envelope Postboxes require an encryption vault.", ) - if normalized == "plaintext_v1" and vault_id: + if normalized != POSTBOX_MANAGED_ENVELOPE_PROFILE and vault_id: raise PostboxError( "encryption_profile_mismatch", - "A plaintext Postbox cannot select an encryption vault.", + "Only institution-managed Postboxes can select an encryption vault.", ) @@ -189,6 +223,9 @@ def _authoring_digest( payload = { "subject": request.subject.strip() or "(No subject)", "body_text": request.body_text, + "ciphertext_ref": request.ciphertext_ref, + "signed_manifest_ref": request.signed_manifest_ref, + "wrapped_keys": [asdict(item) for item in request.wrapped_keys], "classification": request.classification.strip().casefold(), "in_reply_to_message_id": in_reply_to.id if in_reply_to else None, "participants": [ @@ -270,20 +307,14 @@ def _publish_postbox_event( type=event_type, module_id="postbox", payload=dict(payload or {}), - actor=( - EventActorRef(type=actor_type, id=actor_id) - if actor_type - else None - ), + actor=(EventActorRef(type=actor_type, id=actor_id) if actor_type else None), tenant=EventTenantRef(id=tenant_id), subject=( - EventObjectRef(type="postbox", id=postbox_id) - if postbox_id - else None + EventObjectRef(type="postbox", id=postbox_id) if postbox_id else None ), resource=EventObjectRef(type=resource_type, id=resource_id), classification="internal", - ) + ), ) @@ -314,10 +345,27 @@ class PostboxService: body_text: str | None, actor_id: str, external_ciphertext_ref: str | None = None, + external_signed_manifest_ref: str | None = None, external_wrapped_keys: Sequence[object] = (), + external_content_digest: object = None, ) -> dict[str, object]: - profile = str(postbox.encryption_profile or "plaintext_v1").strip() - if external_ciphertext_ref: + profile = str(postbox.encryption_profile or POSTBOX_PLAINTEXT_PROFILE).strip() + if profile == POSTBOX_EXTERNAL_E2EE_PROFILE: + if body_text is not None: + raise PostboxError( + "e2ee_plaintext_forbidden", + "E2EE Postboxes do not accept plaintext message content.", + ) + if not external_ciphertext_ref: + raise PostboxError( + "e2ee_payload_required", + "This Postbox requires ciphertext from an approved E2EE client.", + ) + if not external_signed_manifest_ref or not external_wrapped_keys: + raise PostboxError( + "e2ee_envelope_incomplete", + "E2EE content requires a signed manifest and recipient-wrapped keys.", + ) return { "body_text": None, "body_ciphertext": None, @@ -325,8 +373,18 @@ class PostboxService: "encryption_envelope_id": None, "encryption_resource_id": None, "wrapped_keys": [asdict(item) for item in external_wrapped_keys], + "content_digest": _validated_content_digest(external_content_digest), } - if body_text is None or profile == "plaintext_v1": + if ( + external_ciphertext_ref + or external_signed_manifest_ref + or external_wrapped_keys + ): + raise PostboxError( + "encryption_profile_mismatch", + "External E2EE envelopes require an E2EE Postbox profile.", + ) + if body_text is None or profile == POSTBOX_PLAINTEXT_PROFILE: return { "body_text": body_text, "body_ciphertext": None, @@ -334,8 +392,9 @@ class PostboxService: "encryption_envelope_id": None, "encryption_resource_id": None, "wrapped_keys": [], + "content_digest": _plaintext_digest(body_text), } - if profile != "server_envelope_v1": + if profile != POSTBOX_MANAGED_ENVELOPE_PROFILE: raise PostboxError( "unsupported_encryption_profile", "This Postbox protection profile requires an external content producer.", @@ -381,25 +440,50 @@ class PostboxService: } for wrapped_key_ref in envelope.wrapped_key_refs ], + "content_digest": _plaintext_digest(body_text), } + @staticmethod + def _validate_external_recipient_tokens( + postbox: Postbox, + *, + tokens: Sequence[PostboxExternalRecipientTokenRef], + ) -> None: + if not tokens: + return + settings = postbox.settings if isinstance(postbox.settings, Mapping) else {} + policy = normalize_postbox_protection_policy( + _mapping(settings.get("protection_policy")) + ) + required = str(policy["external_recipient_assurance"]) + if required == "disabled": + raise PostboxError( + "external_recipient_access_disabled", + "This Postbox does not permit external recipient retrieval.", + ) + accepted = ( + {"strong_identity"} + if required == "strong_identity" + else {"email_otp", "email-otp", "strong_identity"} + ) + for token in tokens: + if str(token.assurance_profile or "") not in accepted: + raise PostboxError( + "external_recipient_assurance_insufficient", + "An external recipient token does not satisfy the Postbox assurance policy.", + ) + @classmethod def from_registry(cls, registry: PlatformRegistry) -> "PostboxService": identities = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY) idm = registry.require_capability(CAPABILITY_IDM_DIRECTORY) - incumbencies = registry.require_capability( - CAPABILITY_IDM_FUNCTION_ASSIGNMENTS - ) - organizations = registry.require_capability( - CAPABILITY_ORGANIZATION_DIRECTORY - ) + incumbencies = registry.require_capability(CAPABILITY_IDM_FUNCTION_ASSIGNMENTS) + organizations = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY) hierarchy = registry.require_capability( CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY ) if not isinstance(identities, IdentityDirectory): - raise RuntimeError( - f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}" - ) + raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}") if not isinstance(idm, IdmDirectory): raise RuntimeError(f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}") if not isinstance(incumbencies, IdmFunctionAssignmentDirectory): @@ -412,8 +496,7 @@ class PostboxService: ) if not isinstance(hierarchy, OrganizationHierarchyDirectory): raise RuntimeError( - "Invalid capability: " - f"{CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY}" + f"Invalid capability: {CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY}" ) return cls( identities=identities, @@ -501,11 +584,7 @@ class PostboxService: ) .one_or_none() ) - elif ( - target.template_id - and target.organization_unit_id - and target.function_id - ): + elif target.template_id and target.organization_unit_id and target.function_id: address_key = self._template_address_key( target.template_id, target.organization_unit_id, @@ -667,20 +746,20 @@ class PostboxService: state: PostboxMessageListState = "all", ) -> tuple[PostboxMessageRef, ...]: db = _session(session) - allowed_ids = self._allowed_postbox_ids( + history_cutoffs = self._allowed_postbox_history_cutoffs( db, tenant_id=tenant_id, postbox_ids=postbox_ids, actor=actor, action="read", ) - if not allowed_ids: + if not history_cutoffs: return () messages = ( self._messages_query( db, tenant_id=tenant_id, - allowed_ids=allowed_ids, + history_cutoffs=history_cutoffs, account_id=actor.account_id, allowed_classifications=tuple(actor.authorized_classifications), query=query, @@ -715,20 +794,20 @@ class PostboxService: state: PostboxMessageListState = "all", ) -> int: db = _session(session) - allowed_ids = self._allowed_postbox_ids( + history_cutoffs = self._allowed_postbox_history_cutoffs( db, tenant_id=tenant_id, postbox_ids=postbox_ids, actor=actor, action="read", ) - if not allowed_ids: + if not history_cutoffs: return 0 return int( self._messages_query( db, tenant_id=tenant_id, - allowed_ids=allowed_ids, + history_cutoffs=history_cutoffs, account_id=actor.account_id, allowed_classifications=tuple(actor.authorized_classifications), query=query, @@ -752,20 +831,20 @@ class PostboxService: """Return unread messages that can still be acted on by this actor.""" db = _session(session) - allowed_ids = self._allowed_postbox_ids( + history_cutoffs = self._allowed_postbox_history_cutoffs( db, tenant_id=tenant_id, postbox_ids=postbox_ids, actor=actor, action="read", ) - if not allowed_ids: + if not history_cutoffs: return (), 0 now = utc_now() messages = self._messages_query( db, tenant_id=tenant_id, - allowed_ids=allowed_ids, + history_cutoffs=history_cutoffs, account_id=actor.account_id, allowed_classifications=tuple(actor.authorized_classifications), query=query, @@ -815,14 +894,14 @@ class PostboxService: """Return one access-filtered count projection for multiple inboxes.""" db = _session(session) - allowed_ids = self._allowed_postbox_ids( + history_cutoffs = self._allowed_postbox_history_cutoffs( db, tenant_id=tenant_id, postbox_ids=postbox_ids, actor=actor, action="read", ) - if not allowed_ids: + if not history_cutoffs: return {} rows = ( db.query( @@ -845,7 +924,7 @@ class PostboxService: ) .filter( PostboxMessage.tenant_id == tenant_id, - PostboxMessage.postbox_id.in_(allowed_ids), + self._history_visibility_predicate(history_cutoffs), PostboxMessage.classification.in_( tuple(actor.authorized_classifications) ), @@ -866,7 +945,7 @@ class PostboxService: session: Session, *, tenant_id: str, - allowed_ids: Sequence[str], + history_cutoffs: Mapping[str, datetime | None], account_id: str, allowed_classifications: Sequence[str], query: str | None, @@ -874,7 +953,7 @@ class PostboxService: ): result = session.query(PostboxMessage).filter( PostboxMessage.tenant_id == tenant_id, - PostboxMessage.postbox_id.in_(allowed_ids), + self._history_visibility_predicate(history_cutoffs), PostboxMessage.classification.in_(allowed_classifications), ) needle = (query or "").strip().casefold() @@ -883,9 +962,9 @@ class PostboxService: result = result.filter( or_( func.lower(PostboxMessage.subject).like(pattern), - func.lower( - func.coalesce(PostboxMessage.sender_label, "") - ).like(pattern), + func.lower(func.coalesce(PostboxMessage.sender_label, "")).like( + pattern + ), PostboxMessage.participants.any( or_( func.lower( @@ -1008,9 +1087,7 @@ class PostboxService: tenant_id=tenant_id, message_id=message_id, ) - action: PostboxAction = ( - "acknowledge" if state == "acknowledged" else "read" - ) + action: PostboxAction = "acknowledge" if state == "acknowledged" else "read" decision = self._message_access_decision( db, message=message, @@ -1137,6 +1214,14 @@ class PostboxService: tenant_id=tenant_id, message_id=message_id, ) + parent_decision = self._message_access_decision( + db, + message=parent, + actor=actor, + action="read", + ) + if not parent_decision.allowed: + raise PostboxError("access_denied", parent_decision.explanation) availability = _message_availability(parent) if availability != "available": raise PostboxError( @@ -1270,7 +1355,12 @@ class PostboxService: message_id=message_id, body_text=request.body_text, actor_id=actor.account_id, + external_ciphertext_ref=request.ciphertext_ref, + external_signed_manifest_ref=request.signed_manifest_ref, + external_wrapped_keys=request.wrapped_keys, + external_content_digest=metadata.get("content_digest"), ) + metadata["content_digest"] = body_storage["content_digest"] message = PostboxMessage( id=message_id, tenant_id=postbox.tenant_id, @@ -1291,6 +1381,7 @@ class PostboxService: ciphertext_ref=body_storage["ciphertext_ref"], encryption_envelope_id=body_storage["encryption_envelope_id"], encryption_resource_id=body_storage["encryption_resource_id"], + signed_manifest_ref=request.signed_manifest_ref, wrapped_keys=body_storage["wrapped_keys"], delivered_at=utc_now(), metadata_=metadata, @@ -1436,6 +1527,10 @@ class PostboxService: "vacant_delivery_blocked", "The target function is vacant and its template blocks vacant delivery.", ) + self._validate_external_recipient_tokens( + postbox, + tokens=request.external_recipient_tokens, + ) now = utc_now() message_id = new_uuid() @@ -1446,8 +1541,12 @@ class PostboxService: body_text=request.body_text, actor_id=f"module:{request.producer_module}", external_ciphertext_ref=request.ciphertext_ref, + external_signed_manifest_ref=request.signed_manifest_ref, external_wrapped_keys=request.wrapped_keys, + external_content_digest=request.metadata.get("content_digest"), ) + message_metadata = _mapping(request.metadata) + message_metadata["content_digest"] = body_storage["content_digest"] message = PostboxMessage( id=message_id, tenant_id=request.tenant_id, @@ -1461,11 +1560,7 @@ class PostboxService: producer_module=request.producer_module, producer_resource_type=request.producer_resource_type, producer_resource_id=request.producer_resource_id, - encryption_profile=( - "external_envelope_v1" - if request.ciphertext_ref - else postbox.encryption_profile - ), + encryption_profile=postbox.encryption_profile, key_epoch=postbox.key_epoch, ciphertext_ref=body_storage["ciphertext_ref"], encryption_envelope_id=body_storage["encryption_envelope_id"], @@ -1478,7 +1573,7 @@ class PostboxService: ], delivered_at=now, expires_at=request.expires_at, - metadata_=_mapping(request.metadata), + metadata_=message_metadata, ) for position, participant in enumerate(request.participants): message.participants.append( @@ -1783,10 +1878,14 @@ class PostboxService: linked_copy = _mapping(plan.policy.get("linked_copy")) template_id = str(linked_copy.get("target_template_id") or "") if not template_id: - return None, None, ( - ("target_template_missing",) - if required or plan.status == "planned" - else () + return ( + None, + None, + ( + ("target_template_missing",) + if required or plan.status == "planned" + else () + ), ) try: template = self._get_template( @@ -1855,19 +1954,11 @@ class PostboxService: "depth": candidate.depth, "organization_unit_id": candidate.unit.id, "organization_unit_name": candidate.unit.name, - "function_id": ( - candidate.function.id if candidate.function else None - ), - "function_name": ( - candidate.function.name if candidate.function else None - ), - "target_postbox_id": ( - target_postbox.id if target_postbox else None - ), + "function_id": (candidate.function.id if candidate.function else None), + "function_name": (candidate.function.name if candidate.function else None), + "target_postbox_id": (target_postbox.id if target_postbox else None), "target_address": ( - target_postbox.address_record.address - if target_postbox - else None + target_postbox.address_record.address if target_postbox else None ), "status": status, "vacant": candidate.holder_count == 0, @@ -1925,15 +2016,13 @@ class PostboxService: ): return () - materialized: list[ - tuple[HierarchyRouteCandidate, Postbox] - ] = [] + materialized: list[tuple[HierarchyRouteCandidate, Postbox]] = [] seen_postbox_ids = {source_postbox.id} for candidate in plan.candidates: - if ( - candidate.function is None - or candidate.status not in {"available", "vacant"} - ): + if candidate.function is None or candidate.status not in { + "available", + "vacant", + }: continue if ( candidate.status == "vacant" @@ -2012,9 +2101,7 @@ class PostboxService: ) routes.append(pending) - routing_snapshot["routes"] = [ - self._route_evidence(route) for route in routes - ] + routing_snapshot["routes"] = [self._route_evidence(route) for route in routes] routing_snapshot["status"] = "routed" if routes else "no_route" delivery.target_snapshot = { **dict(delivery.target_snapshot or {}), @@ -2076,9 +2163,7 @@ class PostboxService: ) route.target_message_id = message.id route.processed_at = now - route.status = ( - "accepted" if candidate.holders else "accepted_vacant" - ) + route.status = "accepted" if candidate.holders else "accepted_vacant" session.flush() self._record_access_event( session, @@ -2313,10 +2398,7 @@ class PostboxService: PostboxMessageReceipt.acknowledged_at.is_not(None), ) .count() - if ( - previous_route is not None - and previous_route.target_message_id - ) + if (previous_route is not None and previous_route.target_message_id) else 0 ) if previous_holders or acknowledged: @@ -2483,24 +2565,16 @@ class PostboxService: "organization_unit_id": candidate.unit.id, "organization_unit_name": candidate.unit.name, "organization_unit_type_id": candidate.unit.unit_type_id, - "function_id": ( - candidate.function.id if candidate.function else None - ), - "function_name": ( - candidate.function.name if candidate.function else None - ), + "function_id": (candidate.function.id if candidate.function else None), + "function_name": (candidate.function.name if candidate.function else None), "function_type_id": ( - candidate.function.function_type_id - if candidate.function - else None + candidate.function.function_type_id if candidate.function else None ), "target_postbox_id": postbox.id, "target_address": postbox.address_record.address, "vacant_at_evaluation": candidate.holder_count == 0, "holder_count_at_evaluation": candidate.holder_count, - "holder_assignment_ids": [ - holder.id for holder in candidate.holders - ], + "holder_assignment_ids": [holder.id for holder in candidate.holders], "holder_assignment_sources": [ holder.source for holder in candidate.holders ], @@ -2520,14 +2594,10 @@ class PostboxService: "target_postbox_id": route.target_postbox_id, "target_message_id": route.target_message_id, "execute_after": ( - route.execute_after.isoformat() - if route.execute_after - else None + route.execute_after.isoformat() if route.execute_after else None ), "processed_at": ( - route.processed_at.isoformat() - if route.processed_at - else None + route.processed_at.isoformat() if route.processed_at else None ), "target_snapshot": snapshot.get("target", {}), "policy_snapshot": snapshot.get("policy", {}), @@ -2786,9 +2856,7 @@ class PostboxService: 1 for message in delivery_messages if availabilities[message.id] == "available" - and ( - postbox := postboxes_by_id.get(message.postbox_id) - ) is not None + and (postbox := postboxes_by_id.get(message.postbox_id)) is not None and postbox.status == "active" and holder_counts.get(postbox.id, 0) > 0 ) @@ -2873,6 +2941,7 @@ class PostboxService: actor_id: str | None, encryption_profile: str = "plaintext_v1", encryption_vault_id: str | None = None, + protection_policy: Mapping[str, object] | None = None, ) -> Postbox: classification = self._validate_classification(classification) _validate_encryption_configuration( @@ -2917,6 +2986,7 @@ class PostboxService: actor_id=actor_id, encryption_profile=encryption_profile, encryption_vault_id=encryption_vault_id, + protection_policy=protection_policy, portal_visible=portal_visible, ) @@ -2976,6 +3046,1038 @@ class PostboxService: ) return postbox + def update_protection_policy( + self, + session: Session, + *, + tenant_id: str, + postbox_id: str, + protection_policy: Mapping[str, object], + actor_id: str | None, + expected_revision: int, + ) -> Postbox: + postbox = self._get_postbox( + session, + tenant_id=tenant_id, + postbox_id=postbox_id, + ) + self._claim_resource_revision( + session, + model=Postbox, + resource=postbox, + resource_type="postbox", + tenant_id=tenant_id, + expected_revision=expected_revision, + ) + settings = dict(postbox.settings or {}) + previous = normalize_postbox_protection_policy( + _mapping(settings.get("protection_policy")) + ) + current = normalize_postbox_protection_policy(dict(protection_policy)) + settings["protection_policy"] = current + postbox.settings = settings + session.flush() + self._record_access_event( + session, + tenant_id=tenant_id, + postbox_id=postbox.id, + actor=( + PostboxActorRef( + account_id=actor_id, + authorized_actions=frozenset({"administer"}), + ) + if actor_id + else None + ), + action="postbox.protection.policy.update", + outcome="allowed", + reason_code="administrator", + details={"previous": previous, "current": current}, + ) + _publish_postbox_event( + session, + "postbox.protection.policy.updated.v1", + tenant_id=tenant_id, + resource_type="postbox", + resource_id=postbox.id, + postbox_id=postbox.id, + actor_type="user", + actor_id=actor_id, + payload={"previous": previous, "current": current}, + ) + return postbox + + def list_protection_transitions( + self, + session: Session, + *, + tenant_id: str, + postbox_id: str, + ) -> tuple[PostboxProtectionTransition, ...]: + self._get_postbox( + session, + tenant_id=tenant_id, + postbox_id=postbox_id, + ) + return tuple( + session.query(PostboxProtectionTransition) + .options(selectinload(PostboxProtectionTransition.items)) + .filter( + PostboxProtectionTransition.tenant_id == tenant_id, + PostboxProtectionTransition.postbox_id == postbox_id, + ) + .order_by( + PostboxProtectionTransition.created_at.desc(), + PostboxProtectionTransition.id.desc(), + ) + .all() + ) + + def create_protection_transition( + self, + session: Session, + *, + tenant_id: str, + postbox_id: str, + idempotency_key: str, + expected_revision: int, + target_profile: str, + target_vault_id: str | None, + history_mode: str, + authority_mode: str, + required_quorum: int, + user_consent_refs: Sequence[str], + institutional_authorization_refs: Sequence[str], + reason: str, + actor_id: str | None, + ) -> PostboxProtectionTransition: + postbox = self._get_postbox( + session, + tenant_id=tenant_id, + postbox_id=postbox_id, + ) + clean_idempotency_key = idempotency_key.strip() + evidence_refs = list( + dict.fromkeys( + [ + *(item.strip() for item in user_consent_refs if item.strip()), + *( + item.strip() + for item in institutional_authorization_refs + if item.strip() + ), + ] + ) + ) + request_snapshot = { + "target_profile": target_profile, + "target_vault_id": target_vault_id, + "history_mode": history_mode, + "authority_mode": authority_mode, + "required_quorum": required_quorum, + "user_consent_refs": list(user_consent_refs), + "institutional_authorization_refs": list(institutional_authorization_refs), + "reason": reason.strip(), + } + replay = ( + session.query(PostboxProtectionTransition) + .options(selectinload(PostboxProtectionTransition.items)) + .filter( + PostboxProtectionTransition.tenant_id == tenant_id, + PostboxProtectionTransition.postbox_id == postbox_id, + PostboxProtectionTransition.idempotency_key == clean_idempotency_key, + ) + .one_or_none() + ) + if replay is not None: + if replay.configuration_snapshot.get("request") != request_snapshot: + raise PostboxError( + "idempotency_conflict", + "The transition idempotency key was already used for another request.", + ) + return replay + + _validate_encryption_configuration(target_profile, target_vault_id) + source_profile = str(postbox.encryption_profile) + source_vault_id = ( + str(postbox.settings.get("encryption_vault_id")) + if isinstance(postbox.settings, Mapping) + and postbox.settings.get("encryption_vault_id") + else None + ) + if source_profile == target_profile and source_vault_id == target_vault_id: + raise PostboxError( + "protection_unchanged", + "The Postbox already uses this protection configuration.", + ) + self._validate_transition_authority( + source_profile=source_profile, + target_profile=target_profile, + authority_mode=authority_mode, + required_quorum=required_quorum, + user_consent_refs=user_consent_refs, + institutional_authorization_refs=institutional_authorization_refs, + ) + self._validate_configured_handover_authority( + postbox, + authority_mode=authority_mode, + required_quorum=required_quorum, + ) + pending = ( + session.query(PostboxProtectionTransition.id) + .filter( + PostboxProtectionTransition.tenant_id == tenant_id, + PostboxProtectionTransition.postbox_id == postbox_id, + PostboxProtectionTransition.state.in_( + ("pending", "processing", "awaiting_client") + ), + ) + .first() + ) + if pending is not None: + raise PostboxError( + "protection_transition_active", + "Complete the active protection transition before starting another.", + ) + + self._claim_resource_revision( + session, + model=Postbox, + resource=postbox, + resource_type="postbox", + tenant_id=tenant_id, + expected_revision=expected_revision, + ) + now = utc_now() + transition = PostboxProtectionTransition( + tenant_id=tenant_id, + postbox_id=postbox_id, + idempotency_key=clean_idempotency_key, + source_profile=source_profile, + target_profile=target_profile, + source_vault_id=source_vault_id, + target_vault_id=(target_vault_id.strip() if target_vault_id else None), + history_mode=history_mode, + authority_mode=authority_mode, + required_quorum=required_quorum, + evidence_refs=evidence_refs, + reason=reason.strip(), + state="processing", + requested_by=actor_id, + activated_at=now, + configuration_snapshot={ + "request": request_snapshot, + "irreversibility_acknowledged": True, + "source_key_epoch": postbox.key_epoch, + }, + ) + session.add(transition) + session.flush() + + if history_mode == "migrate_history": + messages = ( + session.query(PostboxMessage) + .filter( + PostboxMessage.tenant_id == tenant_id, + PostboxMessage.postbox_id == postbox_id, + ) + .order_by(PostboxMessage.delivered_at, PostboxMessage.id) + .all() + ) + for message in messages: + needs_migration = message.encryption_profile != target_profile + if ( + target_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE + and source_profile == target_profile + and source_vault_id != target_vault_id + ): + needs_migration = True + if not needs_migration: + continue + source_digest = self._message_transition_digest( + session, + message=message, + ) + transition.items.append( + PostboxProtectionTransitionItem( + tenant_id=tenant_id, + message_id=message.id, + source_profile=message.encryption_profile, + target_profile=target_profile, + state="pending", + source_digest=source_digest, + evidence={}, + ) + ) + transition.message_count = len(transition.items) + + settings = dict(postbox.settings or {}) + if target_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: + settings["encryption_vault_id"] = str(target_vault_id) + else: + settings.pop("encryption_vault_id", None) + postbox.settings = settings + postbox.encryption_profile = target_profile + postbox.key_epoch += 1 + session.flush() + + for item in transition.items: + if is_e2ee_profile(item.source_profile) or is_e2ee_profile( + item.target_profile + ): + item.state = "awaiting_client" + continue + self._apply_automatic_transition_item( + session, + postbox=postbox, + transition=transition, + item=item, + actor_id=actor_id, + ) + self._refresh_transition_state(transition, now=now) + session.flush() + self._record_access_event( + session, + tenant_id=tenant_id, + postbox_id=postbox_id, + actor=( + PostboxActorRef( + account_id=actor_id, + authorized_actions=frozenset({"administer"}), + ) + if actor_id + else None + ), + action="postbox.protection.transition", + outcome="allowed", + reason_code=transition.state, + details={ + "transition_id": transition.id, + "source_profile": source_profile, + "target_profile": target_profile, + "history_mode": history_mode, + "authority_mode": authority_mode, + "required_quorum": required_quorum, + }, + ) + _publish_postbox_event( + session, + "postbox.protection.transitioned.v1", + tenant_id=tenant_id, + resource_type="postbox_protection_transition", + resource_id=transition.id, + postbox_id=postbox_id, + actor_type="user", + actor_id=actor_id, + payload={ + "source_profile": source_profile, + "target_profile": target_profile, + "history_mode": history_mode, + "state": transition.state, + "message_count": transition.message_count, + }, + ) + return transition + + def apply_client_protection_transform( + self, + session: Session, + *, + tenant_id: str, + postbox_id: str, + transition_id: str, + message_id: str, + expected_revision: int, + plaintext: str | None, + ciphertext_ref: str | None, + signed_manifest_ref: str | None, + wrapped_keys: Sequence[PostboxWrappedKeyRef], + content_digest: str, + transformation_evidence_ref: str, + actor_id: str, + ) -> PostboxProtectionTransition: + transition = self._get_protection_transition( + session, + tenant_id=tenant_id, + postbox_id=postbox_id, + transition_id=transition_id, + ) + if transition.state not in {"awaiting_client", "processing"}: + raise PostboxError( + "transition_not_actionable", + "This protection transition no longer accepts client transformations.", + ) + self._claim_resource_revision( + session, + model=PostboxProtectionTransition, + resource=transition, + resource_type="postbox_protection_transition", + tenant_id=tenant_id, + expected_revision=expected_revision, + ) + item = next( + ( + candidate + for candidate in transition.items + if candidate.message_id == message_id + ), + None, + ) + if item is None: + raise PostboxError( + "transition_item_not_found", + "The message is not part of this protection transition.", + ) + if item.state == "completed": + if item.target_digest != content_digest: + raise PostboxError( + "transformation_conflict", + "This message was already transformed with another digest.", + ) + return transition + if item.state != "awaiting_client": + raise PostboxError( + "transition_item_not_actionable", + "The transition item is not waiting for a client transformation.", + ) + message = self._get_message( + session, + tenant_id=tenant_id, + message_id=message_id, + ) + normalized_digest = _validated_content_digest(content_digest) + if item.source_digest and item.source_digest != normalized_digest: + raise PostboxError( + "content_digest_mismatch", + "The transformed payload does not match the immutable source digest.", + ) + if is_e2ee_profile(item.target_profile): + if plaintext is not None or not ( + ciphertext_ref and signed_manifest_ref and wrapped_keys + ): + raise PostboxError( + "e2ee_transform_incomplete", + "The target E2EE profile requires ciphertext, a signed manifest, and wrapped keys.", + ) + if item.source_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: + self._complete_managed_disposition( + session, + message=message, + transition=transition, + mode="export", + actor_id=actor_id, + ) + message.body_text = None + message.body_ciphertext = None + message.ciphertext_ref = ciphertext_ref + message.encryption_envelope_id = None + message.encryption_resource_id = None + message.signed_manifest_ref = signed_manifest_ref + message.wrapped_keys = [asdict(value) for value in wrapped_keys] + else: + if ( + plaintext is None + or ciphertext_ref + or signed_manifest_ref + or wrapped_keys + ): + raise PostboxError( + "plaintext_transform_required", + "Leaving E2EE requires client-supplied plaintext for this message.", + ) + if _plaintext_digest(plaintext) != normalized_digest: + raise PostboxError( + "content_digest_mismatch", + "The supplied plaintext does not match its declared digest.", + ) + if item.target_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: + protected = self._protect_transition_plaintext( + session, + postbox=transition.postbox, + transition=transition, + message=message, + plaintext=plaintext, + actor_id=actor_id, + ) + self._assign_managed_protection(message, protected) + else: + self._assign_plaintext_protection(message, plaintext) + self._complete_transition_item( + message=message, + transition=transition, + item=item, + content_digest=normalized_digest, + actor_id=actor_id, + evidence_ref=transformation_evidence_ref, + ) + self._refresh_transition_state(transition, now=utc_now()) + session.flush() + _publish_postbox_event( + session, + "postbox.protection.message_transformed.v1", + tenant_id=tenant_id, + resource_type="postbox_message", + resource_id=message.id, + postbox_id=postbox_id, + actor_type="user", + actor_id=actor_id, + payload={ + "transition_id": transition.id, + "target_profile": transition.target_profile, + "state": transition.state, + }, + ) + return transition + + @staticmethod + def _validate_transition_authority( + *, + source_profile: str, + target_profile: str, + authority_mode: str, + required_quorum: int, + user_consent_refs: Sequence[str], + institutional_authorization_refs: Sequence[str], + ) -> None: + if required_quorum < 1: + raise PostboxError( + "transition_quorum_invalid", + "A protection transition requires a positive quorum.", + ) + evidence_count = len( + { + *(item.strip() for item in user_consent_refs if item.strip()), + *( + item.strip() + for item in institutional_authorization_refs + if item.strip() + ), + } + ) + if evidence_count < required_quorum: + raise PostboxError( + "transition_quorum_unsatisfied", + "The supplied transition evidence does not satisfy its quorum.", + ) + if is_e2ee_profile(source_profile) and authority_mode not in { + "user_consent", + "dual_control", + }: + raise PostboxError( + "user_consent_required", + "Leaving or rewrapping E2EE history requires user consent evidence.", + ) + if ( + source_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE + and authority_mode + not in { + "institutional_key_holders", + "dual_control", + } + ): + raise PostboxError( + "institutional_authority_required", + "Changing institution-managed history requires key-holder authorization.", + ) + if authority_mode in {"user_consent", "dual_control"} and not any( + item.strip() for item in user_consent_refs + ): + raise PostboxError( + "user_consent_required", + "The transition requires at least one user consent reference.", + ) + if authority_mode in {"institutional_key_holders", "dual_control"} and not any( + item.strip() for item in institutional_authorization_refs + ): + raise PostboxError( + "institutional_authority_required", + "The transition requires institutional authorization evidence.", + ) + if authority_mode == "dual_control" and required_quorum < 2: + raise PostboxError( + "dual_control_quorum_invalid", + "Dual-control transitions require a quorum of at least two.", + ) + if target_profile not in SUPPORTED_POSTBOX_PROTECTION_PROFILES: + raise PostboxError( + "unsupported_encryption_profile", + "Unsupported Postbox protection profile.", + ) + + @staticmethod + def _validate_configured_handover_authority( + postbox: Postbox, + *, + authority_mode: str, + required_quorum: int, + ) -> None: + settings = postbox.settings if isinstance(postbox.settings, Mapping) else {} + policy = normalize_postbox_protection_policy( + _mapping(settings.get("protection_policy")) + ) + configured_authority = str(policy["handover_authority"]) + accepted = { + "user_consent": {"user_consent", "dual_control"}, + "institutional_key_holders": { + "institutional_key_holders", + "dual_control", + }, + "dual_control": {"dual_control"}, + }.get(configured_authority, {"dual_control"}) + if authority_mode not in accepted: + raise PostboxError( + "handover_authority_insufficient", + "The transition authority does not satisfy the configured Postbox hand-over policy.", + ) + configured_quorum = max(1, int(policy.get("handover_quorum") or 1)) + if required_quorum < configured_quorum: + raise PostboxError( + "handover_quorum_unsatisfied", + "The transition quorum is lower than the configured Postbox hand-over quorum.", + ) + + def _get_protection_transition( + self, + session: Session, + *, + tenant_id: str, + postbox_id: str, + transition_id: str, + ) -> PostboxProtectionTransition: + transition = ( + session.query(PostboxProtectionTransition) + .options( + selectinload(PostboxProtectionTransition.items), + selectinload(PostboxProtectionTransition.postbox), + ) + .filter( + PostboxProtectionTransition.id == transition_id, + PostboxProtectionTransition.tenant_id == tenant_id, + PostboxProtectionTransition.postbox_id == postbox_id, + ) + .one_or_none() + ) + if transition is None: + raise PostboxError( + "protection_transition_not_found", + "Postbox protection transition not found.", + ) + return transition + + def _message_transition_digest( + self, + session: Session, + *, + message: PostboxMessage, + ) -> str: + metadata = _mapping(message.metadata_) + candidate = metadata.get("content_digest") + if candidate: + return _validated_content_digest(candidate) + if is_e2ee_profile(message.encryption_profile): + raise PostboxError( + "content_digest_required", + "Legacy E2EE content needs a verified digest before it can be migrated.", + ) + return _plaintext_digest( + self._message_plaintext_for_transition(session, message=message) + ) + + @staticmethod + def _message_plaintext_for_transition( + session: Session, + *, + message: PostboxMessage, + ) -> str | None: + if is_e2ee_profile(message.encryption_profile): + return None + if ( + message.encryption_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE + and message.encryption_envelope_id + and message.body_ciphertext is not None + ): + from govoplan_postbox.backend.content_protection import ( + PostboxContentProtectionError, + unprotect_message_body, + ) + + try: + return unprotect_message_body( + session, + tenant_id=message.tenant_id, + message_id=message.encryption_resource_id or message.id, + envelope_id=message.encryption_envelope_id, + ciphertext=message.body_ciphertext, + ) + except PostboxContentProtectionError as exc: + raise PostboxError( + "managed_content_unavailable", + str(exc), + ) from exc + return message.body_text + + def _apply_automatic_transition_item( + self, + session: Session, + *, + postbox: Postbox, + transition: PostboxProtectionTransition, + item: PostboxProtectionTransitionItem, + actor_id: str | None, + ) -> None: + message = self._get_message( + session, + tenant_id=transition.tenant_id, + message_id=item.message_id, + ) + plaintext = self._message_plaintext_for_transition( + session, + message=message, + ) + digest = _plaintext_digest(plaintext) + if item.source_digest and digest != item.source_digest: + raise PostboxError( + "content_digest_mismatch", + "The stored message no longer matches its immutable transition digest.", + ) + if item.target_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: + if item.source_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: + protected = self._reencrypt_managed_message( + session, + message=message, + transition=transition, + actor_id=actor_id, + ) + else: + protected = self._protect_transition_plaintext( + session, + postbox=postbox, + transition=transition, + message=message, + plaintext=plaintext, + actor_id=actor_id, + ) + self._assign_managed_protection(message, protected) + else: + if item.source_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE: + self._complete_managed_disposition( + session, + message=message, + transition=transition, + mode="decrypt", + actor_id=actor_id, + ) + self._assign_plaintext_protection(message, plaintext) + self._complete_transition_item( + message=message, + transition=transition, + item=item, + content_digest=digest, + actor_id=actor_id, + evidence_ref=f"postbox-transition:{transition.id}:automatic", + ) + + @staticmethod + def _assign_plaintext_protection( + message: PostboxMessage, + plaintext: str | None, + ) -> None: + message.body_text = plaintext + message.body_ciphertext = None + message.ciphertext_ref = None + message.encryption_envelope_id = None + message.encryption_resource_id = None + message.signed_manifest_ref = None + message.wrapped_keys = [] + + @staticmethod + def _assign_managed_protection(message: PostboxMessage, protected: object) -> None: + envelope = protected.envelope + message.body_text = None + message.body_ciphertext = protected.ciphertext + message.ciphertext_ref = envelope.ciphertext_ref + message.encryption_envelope_id = envelope.envelope_id + message.encryption_resource_id = message.id + message.signed_manifest_ref = None + message.wrapped_keys = [ + { + "recipient_type": "vault", + "recipient_id": envelope.vault_id, + "key_epoch": envelope.key_version, + "wrapped_key_ref": wrapped_key_ref, + "algorithm": envelope.algorithm_suite, + "metadata": {"envelope_id": envelope.envelope_id}, + } + for wrapped_key_ref in envelope.wrapped_key_refs + ] + + @staticmethod + def _protect_transition_plaintext( + session: Session, + *, + postbox: Postbox, + transition: PostboxProtectionTransition, + message: PostboxMessage, + plaintext: str | None, + actor_id: str | None, + ) -> object: + if plaintext is None: + raise PostboxError( + "transition_plaintext_unavailable", + "This message has no plaintext payload to protect.", + ) + vault_id = str(transition.target_vault_id or "").strip() + if not vault_id: + raise PostboxError( + "encryption_vault_missing", + "The target institution-managed profile has no vault.", + ) + from govoplan_postbox.backend.content_protection import ( + PostboxContentProtectionError, + protect_message_body, + ) + + try: + return protect_message_body( + session, + tenant_id=postbox.tenant_id, + message_id=message.id, + vault_id=vault_id, + plaintext=plaintext, + actor_id=actor_id or "module:postbox", + operation_ref=f"transition-{transition.id}", + policy_decision_ref=f"postbox:transition:{transition.id}", + ) + except PostboxContentProtectionError as exc: + raise PostboxError("managed_protection_failed", str(exc)) from exc + + @staticmethod + def _transition_principal( + transition: PostboxProtectionTransition, + actor_id: str | None, + ) -> object: + return SimpleNamespace( + tenant_id=transition.tenant_id, + account_id=actor_id or "module:postbox", + ) + + @staticmethod + def _institutional_evidence_ref( + transition: PostboxProtectionTransition, + ) -> str: + request = transition.configuration_snapshot.get("request", {}) + refs = ( + request.get("institutional_authorization_refs", []) + if isinstance(request, Mapping) + else [] + ) + candidate = next( + (str(value).strip() for value in refs if str(value).strip()), + None, + ) + if not candidate: + raise PostboxError( + "institutional_authority_required", + "Encryption requires institutional authorization evidence for this transition.", + ) + return candidate + + @staticmethod + def _encryption_migration_request( + session: Session, + *, + message: PostboxMessage, + transition: PostboxProtectionTransition, + mode: Literal["reencrypt", "decrypt", "export"], + actor_id: str | None, + ) -> tuple[object, object, object]: + from govoplan_postbox.backend.runtime import get_registry + + registry = get_registry() + protections = content_protection_registry(registry) + vaults = encryption_key_vault(registry) + if protections is None or vaults is None: + raise PostboxError( + "encryption_migration_unavailable", + "Encryption migration capabilities are unavailable.", + ) + if not message.encryption_envelope_id: + raise PostboxError( + "encryption_envelope_missing", + "The managed message has no registered protection envelope.", + ) + principal = PostboxService._transition_principal(transition, actor_id) + envelope = protections.get_envelope( + session, + principal, + tenant_id=transition.tenant_id, + envelope_id=message.encryption_envelope_id, + ) + if envelope is None: + raise PostboxError( + "encryption_envelope_missing", + "The managed protection envelope is no longer registered.", + ) + target_vault_id = ( + str(transition.target_vault_id) + if mode == "reencrypt" + else envelope.vault_id + ) + target_vault = vaults.get_vault( + session, + tenant_id=transition.tenant_id, + vault_id=target_vault_id, + ) + if target_vault is None or target_vault.current_key is None: + raise PostboxError( + "encryption_vault_unavailable", + "The transition target vault has no active key.", + ) + migration = protections.request_migration( + session, + principal, + request=ProtectionMigrationRequest( + tenant_id=transition.tenant_id, + envelope_id=envelope.envelope_id, + target_provider_id=target_vault.provider_id, + target_vault_id=target_vault.vault_id, + target_key_version=target_vault.current_key.version, + target_algorithm_suite=target_vault.current_key.algorithm_suite, + mode=mode, + policy_decision_ref=f"postbox:transition:{transition.id}", + assurance_evidence_ref=PostboxService._institutional_evidence_ref( + transition + ), + idempotency_key=( + f"postbox-transition:{transition.id}:{message.id}:{mode}" + ), + ), + ) + return protections, principal, migration + + @staticmethod + def _complete_managed_disposition( + session: Session, + *, + message: PostboxMessage, + transition: PostboxProtectionTransition, + mode: Literal["decrypt", "export"], + actor_id: str | None, + ) -> None: + protections, principal, migration = ( + PostboxService._encryption_migration_request( + session, + message=message, + transition=transition, + mode=mode, + actor_id=actor_id, + ) + ) + protections.record_migration_outcome( + session, + principal, + migration_id=migration.migration_id, + state="succeeded", + evidence_refs=tuple(transition.evidence_refs), + ) + + @staticmethod + def _reencrypt_managed_message( + session: Session, + *, + message: PostboxMessage, + transition: PostboxProtectionTransition, + actor_id: str | None, + ) -> object: + from govoplan_postbox.backend.runtime import get_registry + + protections, principal, migration = ( + PostboxService._encryption_migration_request( + session, + message=message, + transition=transition, + mode="reencrypt", + actor_id=actor_id, + ) + ) + cipher = encryption_content_cipher(get_registry()) + if cipher is None or message.body_ciphertext is None: + raise PostboxError( + "encryption_migration_unavailable", + "Managed re-encryption cannot access the configured content cipher.", + ) + protected = cipher.prepare_reencryption( + session, + principal, + migration_id=migration.migration_id, + source_ciphertext=message.body_ciphertext, + target_ciphertext_ref=f"postbox-db://messages/{message.id}/body", + ) + protections.record_migration_outcome( + session, + principal, + migration_id=migration.migration_id, + state="succeeded", + evidence_refs=tuple(transition.evidence_refs), + target_envelope=protected.envelope, + ) + return protected + + @staticmethod + def _complete_transition_item( + *, + message: PostboxMessage, + transition: PostboxProtectionTransition, + item: PostboxProtectionTransitionItem, + content_digest: str, + actor_id: str | None, + evidence_ref: str, + ) -> None: + metadata = _mapping(message.metadata_) + metadata["content_digest"] = content_digest + metadata["protection_transition"] = { + "transition_id": transition.id, + "source_profile": item.source_profile, + "target_profile": item.target_profile, + "completed_at": utc_now().isoformat(), + "evidence_ref": evidence_ref, + } + message.metadata_ = metadata + message.encryption_profile = item.target_profile + message.key_epoch = transition.postbox.key_epoch + now = utc_now() + item.state = "completed" + item.target_digest = content_digest + item.completed_by = actor_id + item.completed_at = now + item.error_code = None + item.evidence = { + "transformation_evidence_ref": evidence_ref, + "authority_evidence_refs": list(transition.evidence_refs), + } + + @staticmethod + def _refresh_transition_state( + transition: PostboxProtectionTransition, + *, + now: datetime, + ) -> None: + transition.completed_count = sum( + item.state == "completed" for item in transition.items + ) + transition.failed_count = sum( + item.state == "failed" for item in transition.items + ) + if transition.completed_count == transition.message_count: + transition.state = "completed" + transition.completed_at = now + elif any(item.state == "awaiting_client" for item in transition.items): + transition.state = "awaiting_client" + elif transition.failed_count: + transition.state = "failed" + else: + transition.state = "processing" + def list_templates( self, session: Session, @@ -3011,11 +4113,12 @@ class PostboxService: routing_policy: Mapping[str, object] | None, encryption_profile: str, encryption_vault_id: str | None, + protection_policy: Mapping[str, object] | None, template_id: str | None, context_key: str | None, limit: int, ) -> dict[str, object]: - del description, portal_visible + del description, portal_visible, protection_policy self._validate_classification(classification) _validate_encryption_configuration( encryption_profile, @@ -3037,14 +4140,12 @@ class PostboxService: template_id=template_id, ) - units, diagnostics_by_unit, diagnostics = ( - self._template_preview_scope_units( - tenant_id=tenant_id, - scope_kind=scope_kind, - scope_id=scope_id, - scope_structure_id=scope_structure_id, - scope_relation_type_ids=scope_relation_type_ids, - ) + units, diagnostics_by_unit, diagnostics = self._template_preview_scope_units( + tenant_id=tenant_id, + scope_kind=scope_kind, + scope_id=scope_id, + scope_structure_id=scope_structure_id, + scope_relation_type_ids=scope_relation_type_ids, ) unit_ids = tuple(unit.id for unit in units) functions = self._template_preview_functions( @@ -3089,9 +4190,7 @@ class PostboxService: ), "address": rendered_address, "name": rendered_name, - "diagnostics": list( - diagnostics_by_unit.get(unit.id, ()) - ), + "diagnostics": list(diagnostics_by_unit.get(unit.id, ())), } ) @@ -3122,12 +4221,8 @@ class PostboxService: ): existing_by_id[item.id] = item existing_addresses = tuple(existing_by_id.values()) - existing_by_key = { - item.address_key: item for item in existing_addresses - } - existing_by_address = { - item.address: item for item in existing_addresses - } + existing_by_key = {item.address_key: item for item in existing_addresses} + existing_by_address = {item.address: item for item in existing_addresses} generated_address_counts = Counter(addresses) if any(count > 1 for count in generated_address_counts.values()): diagnostics.append("duplicate_generated_address") @@ -3215,6 +4310,7 @@ class PostboxService: routing_policy: Mapping[str, object] | None = None, encryption_profile: str = "plaintext_v1", encryption_vault_id: str | None = None, + protection_policy: Mapping[str, object] | None = None, ) -> PostboxTemplate: classification = self._validate_classification(classification) _validate_encryption_configuration( @@ -3267,7 +4363,9 @@ class PostboxService: portal_visible=portal_visible, encryption_profile=encryption_profile, encryption_vault_id=encryption_vault_id, - history_policy={}, + history_policy=normalize_postbox_protection_policy( + dict(protection_policy or {}) + ), routing_policy=normalized_routing_policy(routing_policy), retention_policy={}, created_by=actor_id, @@ -3308,6 +4406,7 @@ class PostboxService: routing_policy: Mapping[str, object] | None = None, encryption_profile: str = "plaintext_v1", encryption_vault_id: str | None = None, + protection_policy: Mapping[str, object] | None = None, ) -> PostboxTemplate: classification = self._validate_classification(classification) _validate_encryption_configuration( @@ -3340,10 +4439,13 @@ class PostboxService: scope_relation_type_ids=scope_relation_type_ids, ) self._validate_patterns(name_pattern, address_pattern) - next_revision = max( - (revision.revision for revision in template.revisions), - default=0, - ) + 1 + next_revision = ( + max( + (revision.revision for revision in template.revisions), + default=0, + ) + + 1 + ) template.revisions.append( PostboxTemplateRevision( tenant_id=tenant_id, @@ -3360,7 +4462,9 @@ class PostboxService: portal_visible=portal_visible, encryption_profile=encryption_profile, encryption_vault_id=encryption_vault_id, - history_policy={}, + history_policy=normalize_postbox_protection_policy( + dict(protection_policy or {}) + ), routing_policy=normalized_routing_policy(routing_policy), retention_policy={}, created_by=actor_id, @@ -3716,9 +4820,11 @@ class PostboxService: expected_revision=expected_revision, account_id=actor.account_id, ) - existing_ids = tuple( - source.postbox_id for source in grouping.sources - ) if grouping is not None else () + existing_ids = ( + tuple(source.postbox_id for source in grouping.sources) + if grouping is not None + else () + ) requested = tuple(dict.fromkeys(postbox_ids)) visibility_candidates = tuple(dict.fromkeys((*requested, *existing_ids))) allowed = set( @@ -3913,17 +5019,43 @@ class PostboxService: tenant_id=message.tenant_id, postbox_id=message.postbox_id, ) - return self._access_decision( + assignments = self._assignments_for_actor( + actor, + tenant_id=message.tenant_id, + ) + decision = self._access_decision( postbox, actor=actor, action=action, - assignments=self._assignments_for_actor( - actor, - tenant_id=message.tenant_id, - ), + assignments=assignments, holder_cache={}, classification=message.classification, ) + if not decision.allowed: + return decision + selected_assignment = next( + ( + assignment + for assignment in assignments + if assignment.id == decision.selected_assignment_id + ), + None, + ) + cutoff = self._history_cutoff_for_assignment( + postbox, + assignment=selected_assignment, + ) + if cutoff is not None and _as_utc(message.delivered_at) < cutoff: + return replace( + decision, + allowed=False, + reason_code="incumbent_history_not_permitted", + explanation=( + "The Postbox history policy does not grant this assignment " + "access to content delivered before its history boundary." + ), + ) + return decision def _get_template( self, @@ -4036,18 +5168,14 @@ class PostboxService: if not function_ids: return {} try: - incumbencies = ( - self._incumbencies.organization_function_incumbencies( - function_ids, - tenant_id=tenant_id, - ) + incumbencies = self._incumbencies.organization_function_incumbencies( + function_ids, + tenant_id=tenant_id, ) except ValueError: return {} return { - function_id: tuple( - incumbencies[function_id].assignments - ) + function_id: tuple(incumbencies[function_id].assignments) for function_id in function_ids if function_id in incumbencies } @@ -4158,9 +5286,7 @@ class PostboxService: return True if not assignment.applies_to_subunits or not binding.function_type_id: return False - assigned_function = self._organizations.get_function( - assignment.function_id - ) + assigned_function = self._organizations.get_function(assignment.function_id) target_function = ( self._organizations.get_function(binding.function_id) if binding.function_id @@ -4199,9 +5325,28 @@ class PostboxService: actor: PostboxActorRef, action: PostboxAction, ) -> tuple[str, ...]: + return tuple( + self._allowed_postbox_history_cutoffs( + session, + tenant_id=tenant_id, + postbox_ids=postbox_ids, + actor=actor, + action=action, + ) + ) + + def _allowed_postbox_history_cutoffs( + self, + session: Session, + *, + tenant_id: str, + postbox_ids: Sequence[str], + actor: PostboxActorRef, + action: PostboxAction, + ) -> dict[str, datetime | None]: requested = tuple(dict.fromkeys(postbox_ids)) if not requested: - return () + return {} postboxes = ( session.query(Postbox) .options( @@ -4216,17 +5361,74 @@ class PostboxService: ) assignments = self._assignments_for_actor(actor, tenant_id=tenant_id) holder_cache: dict[str, tuple[OrganizationFunctionAssignmentRef, ...]] = {} - return tuple( - postbox.id - for postbox in postboxes - if self._access_decision( + result: dict[str, datetime | None] = {} + for postbox in postboxes: + decision = self._access_decision( postbox, actor=actor, action=action, assignments=assignments, holder_cache=holder_cache, - ).allowed + ) + if not decision.allowed: + continue + selected_assignment = next( + ( + assignment + for assignment in assignments + if assignment.id == decision.selected_assignment_id + ), + None, + ) + result[postbox.id] = self._history_cutoff_for_assignment( + postbox, + assignment=selected_assignment, + ) + return result + + @staticmethod + def _history_cutoff_for_assignment( + postbox: Postbox, + *, + assignment: OrganizationFunctionAssignmentRef | None, + ) -> datetime | None: + settings = postbox.settings if isinstance(postbox.settings, Mapping) else {} + policy = settings.get("protection_policy") + configured = policy if isinstance(policy, Mapping) else {} + mode = str(configured.get("new_incumbent_history") or "all_retained") + if mode == "since_assignment": + return ( + _as_utc(assignment.valid_from) + if assignment and assignment.valid_from + else None + ) + if mode == "bounded_days": + try: + days = max(1, min(36500, int(configured.get("history_days") or 1))) + except (TypeError, ValueError): + days = 1 + return utc_now() - timedelta(days=days) + return None + + @staticmethod + def _history_visibility_predicate( + cutoffs: Mapping[str, datetime | None], + ) -> object: + unrestricted = [ + postbox_id for postbox_id, cutoff in cutoffs.items() if cutoff is None + ] + predicates: list[object] = [] + if unrestricted: + predicates.append(PostboxMessage.postbox_id.in_(unrestricted)) + predicates.extend( + and_( + PostboxMessage.postbox_id == postbox_id, + PostboxMessage.delivered_at >= cutoff, + ) + for postbox_id, cutoff in cutoffs.items() + if cutoff is not None ) + return or_(*predicates) def _directory_entry( self, @@ -4262,6 +5464,22 @@ class PostboxService: template_revision_id=address.template_revision_id, holder_count=holder_count, vacant=holder_count == 0, + encryption_profile=postbox.encryption_profile, + key_epoch=postbox.key_epoch, + encryption_vault_id=( + str(postbox.settings.get("encryption_vault_id")) + if isinstance(postbox.settings, Mapping) + and postbox.settings.get("encryption_vault_id") + else None + ), + protection_policy=( + normalize_postbox_protection_policy( + _mapping(postbox.settings.get("protection_policy")) + ) + if isinstance(postbox.settings, Mapping) + and isinstance(postbox.settings.get("protection_policy"), Mapping) + else normalize_postbox_protection_policy() + ), access=decision, resource_revision=postbox.resource_revision, etag=postbox.strong_etag, @@ -4274,11 +5492,7 @@ class PostboxService: account_id: str, ) -> PostboxMessageRef: receipt = next( - ( - item - for item in message.receipts - if item.account_id == account_id - ), + (item for item in message.receipts if item.account_id == account_id), None, ) availability = _message_availability(message) @@ -4499,12 +5713,13 @@ class PostboxService: actor_id: str | None, encryption_profile: str | None = None, encryption_vault_id: str | None = None, + protection_policy: Mapping[str, object] | None = None, portal_visible: bool = False, ) -> Postbox: effective_profile = ( revision.encryption_profile if revision is not None - else str(encryption_profile or "plaintext_v1") + else str(encryption_profile or POSTBOX_PLAINTEXT_PROFILE) ) effective_vault_id = ( revision.encryption_vault_id @@ -4516,14 +5731,20 @@ class PostboxService: if revision is not None else bool(portal_visible) ) - if effective_profile == "server_envelope_v1" and not str( - effective_vault_id or "" - ).strip(): + effective_protection_policy = ( + normalize_postbox_protection_policy(dict(revision.history_policy or {})) + if revision is not None + else normalize_postbox_protection_policy(dict(protection_policy or {})) + ) + if ( + effective_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE + and not str(effective_vault_id or "").strip() + ): raise PostboxError( "encryption_vault_missing", "Server-envelope Postboxes require an encryption vault.", ) - if effective_profile not in {"plaintext_v1", "server_envelope_v1"}: + if effective_profile not in SUPPORTED_POSTBOX_PROTECTION_PROFILES: raise PostboxError( "unsupported_encryption_profile", "Unsupported Postbox encryption profile.", @@ -4558,6 +5779,7 @@ class PostboxService: else {} ), "portal_visible": effective_portal_visible, + "protection_policy": effective_protection_policy, }, ) postbox.bindings.append( @@ -4807,9 +6029,7 @@ class PostboxService: ]: all_units = tuple( unit - for unit in self._organizations.organization_units_for_tenant( - tenant_id - ) + for unit in self._organizations.organization_units_for_tenant(tenant_id) if unit.tenant_id == tenant_id and unit.status == "active" ) diagnostics: list[str] = [] @@ -4842,9 +6062,7 @@ class PostboxService: "active", "inactive", }: - reason = ( - resolution.status if resolution is not None else "missing" - ) + reason = resolution.status if resolution is not None else "missing" raise PostboxError( "organization_hierarchy_unavailable", f"The hierarchy scope could not be resolved ({reason}).", @@ -4862,10 +6080,7 @@ class PostboxService: if duplicate_ids: diagnostics.append("ambiguous_scope_paths") diagnostics_by_unit.update( - { - unit_id: ("ambiguous_scope_path",) - for unit_id in duplicate_ids - } + {unit_id: ("ambiguous_scope_path",) for unit_id in duplicate_ids} ) if resolution.cycle_detected: diagnostics.append("hierarchy_cycle_detected") @@ -4902,9 +6117,7 @@ class PostboxService: resolution = hierarchy.resolve_functions_by_type( tenant_id, function_type_id, - organization_unit_ids=tuple( - unit_ids[offset : offset + 400] - ), + organization_unit_ids=tuple(unit_ids[offset : offset + 400]), ) if resolution.status in {"missing", "invalid"}: raise PostboxError( @@ -4946,11 +6159,9 @@ class PostboxService: counts: dict[str, int] = {} try: for offset in range(0, len(function_ids), 400): - incumbencies = ( - self._incumbencies.organization_function_incumbencies( - tuple(function_ids[offset : offset + 400]), - tenant_id=tenant_id, - ) + incumbencies = self._incumbencies.organization_function_incumbencies( + tuple(function_ids[offset : offset + 400]), + tenant_id=tenant_id, ) counts.update( { diff --git a/tests/test_migration.py b/tests/test_migration.py index e768d56..8f914bd 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -38,6 +38,10 @@ class PostboxMigrationTests(unittest.TestCase): "govoplan_postbox.backend.migrations.versions." "f2a5c8e1b4d7_v015_portal_visibility" ) + transition_migration = importlib.import_module( + "govoplan_postbox.backend.migrations.versions." + "a7c1e4f8b2d6_v016_protection_transitions" + ) engine = create_engine("sqlite:///:memory:") try: with engine.begin() as connection: @@ -49,6 +53,7 @@ class PostboxMigrationTests(unittest.TestCase): protection_original = protection_migration.op scope_original = scope_migration.op portal_original = portal_migration.op + transition_original = transition_migration.op migration.op = operations route_migration.op = operations occ_migration.op = operations @@ -56,6 +61,7 @@ class PostboxMigrationTests(unittest.TestCase): protection_migration.op = operations scope_migration.op = operations portal_migration.op = operations + transition_migration.op = operations try: migration.upgrade() route_migration.upgrade() @@ -64,11 +70,14 @@ class PostboxMigrationTests(unittest.TestCase): protection_migration.upgrade() scope_migration.upgrade() portal_migration.upgrade() + transition_migration.upgrade() tables = set(inspect(connection).get_table_names()) self.assertIn("postboxes", tables) self.assertIn("postbox_messages", tables) self.assertIn("postbox_deliveries", tables) self.assertIn("postbox_access_events", tables) + self.assertIn("postbox_protection_transitions", tables) + self.assertIn("postbox_protection_transition_items", tables) message_columns = { column["name"] for column in inspect(connection).get_columns( @@ -120,15 +129,12 @@ class PostboxMigrationTests(unittest.TestCase): ) route_columns = { column["name"] - for column in inspect(connection).get_columns( - "postbox_routes" - ) + for column in inspect(connection).get_columns("postbox_routes") } self.assertTrue( - {"execute_after", "processed_at"}.issubset( - route_columns - ) + {"execute_after", "processed_at"}.issubset(route_columns) ) + transition_migration.downgrade() portal_migration.downgrade() scope_migration.downgrade() protection_migration.downgrade() @@ -151,6 +157,7 @@ class PostboxMigrationTests(unittest.TestCase): protection_migration.op = protection_original scope_migration.op = scope_original portal_migration.op = portal_original + transition_migration.op = transition_original finally: engine.dispose() diff --git a/tests/test_service.py b/tests/test_service.py index 8f8a1a2..37cbfac 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,6 +1,8 @@ from __future__ import annotations +import hashlib import unittest +from dataclasses import replace from datetime import timedelta from types import SimpleNamespace from unittest.mock import patch @@ -49,6 +51,8 @@ from govoplan_postbox.backend.db.models import ( PostboxMessage, PostboxMessageReceipt, PostboxParticipant, + PostboxProtectionTransition, + PostboxProtectionTransitionItem, PostboxRoute, PostboxTemplate, PostboxTemplateRevision, @@ -64,6 +68,8 @@ POSTBOX_TABLES = ( Postbox.__table__, PostboxBinding.__table__, PostboxMessage.__table__, + PostboxProtectionTransition.__table__, + PostboxProtectionTransitionItem.__table__, PostboxParticipant.__table__, PostboxAttachmentReference.__table__, PostboxDelivery.__table__, @@ -87,7 +93,9 @@ class FakeIdentityDirectory: ) def identities_for_accounts(self, account_ids): - return tuple(self.identity_for_account(account_id) for account_id in account_ids) + return tuple( + self.identity_for_account(account_id) for account_id in account_ids + ) def accounts_for_identity(self, identity_id: str): return () @@ -335,8 +343,7 @@ class FakeOrganizationDirectory: if function.function_type_id == function_type_id and ( not organization_unit_ids - or function.organization_unit_id - in organization_unit_ids + or function.organization_unit_id in organization_unit_ids ) ), ) @@ -355,8 +362,7 @@ class FakeOrganizationDirectory: matches=tuple( unit for unit in self.units.values() - if unit.tenant_id == tenant_id - and unit.unit_type_id == unit_type_id + if unit.tenant_id == tenant_id and unit.unit_type_id == unit_type_id ), ) @@ -457,9 +463,7 @@ class FakeOrganizationDirectory: root=root, matches=matches, cycle_detected=self.cycle_detected, - depth_limited=( - structure_id == self.structure.id and max_depth < 2 - ), + depth_limited=(structure_id == self.structure.id and max_depth < 2), ) ) return tuple(results) @@ -529,15 +533,20 @@ class PostboxServiceTests(unittest.TestCase): self.actor = PostboxActorRef( account_id="account-1", identity_id="identity-1", - authorized_actions=frozenset( - {"discover", "read", "send", "acknowledge"} - ), + authorized_actions=frozenset({"discover", "read", "send", "acknowledge"}), ) def tearDown(self) -> None: self.engine.dispose() - def _create_exact(self, session: Session) -> Postbox: + def _create_exact( + self, + session: Session, + *, + encryption_profile: str = "plaintext_v1", + encryption_vault_id: str | None = None, + protection_policy: dict[str, object] | None = None, + ) -> Postbox: return self.service.create_exact_postbox( session, tenant_id="tenant-1", @@ -548,6 +557,9 @@ class PostboxServiceTests(unittest.TestCase): description=None, classification="internal", actor_id="admin-1", + encryption_profile=encryption_profile, + encryption_vault_id=encryption_vault_id, + protection_policy=protection_policy, ) def _routing_policy( @@ -575,11 +587,7 @@ class PostboxServiceTests(unittest.TestCase): "max_retention_days": 30, }, "attention": { - "mode": ( - "vacancy_escalation" - if vacancy_escalation - else "none" - ), + "mode": ("vacancy_escalation" if vacancy_escalation else "none"), "delay_minutes": 1 if vacancy_escalation else None, }, "shared_visibility": {"mode": "none"}, @@ -830,7 +838,9 @@ class PostboxServiceTests(unittest.TestCase): ), ) - def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None: + def test_template_revision_is_immutable_and_materialization_idempotent( + self, + ) -> None: with Session(self.engine) as session: template = self.service.create_template( session, @@ -957,6 +967,7 @@ class PostboxServiceTests(unittest.TestCase): routing_policy={}, encryption_profile="plaintext_v1", encryption_vault_id=None, + protection_policy={}, context_key=None, limit=200, ) @@ -985,6 +996,7 @@ class PostboxServiceTests(unittest.TestCase): routing_policy={}, encryption_profile="plaintext_v1", encryption_vault_id=None, + protection_policy={}, context_key=None, limit=200, ) @@ -1014,6 +1026,7 @@ class PostboxServiceTests(unittest.TestCase): routing_policy={}, encryption_profile="plaintext_v1", encryption_vault_id=None, + protection_policy={}, context_key=None, limit=200, ) @@ -1067,9 +1080,7 @@ class PostboxServiceTests(unittest.TestCase): [item.slug for item in catalog.templates], ) north = next( - unit - for unit in catalog.organization_units - if unit.id == "unit-1" + unit for unit in catalog.organization_units if unit.id == "unit-1" ) self.assertEqual( ["function-1"], @@ -1309,7 +1320,11 @@ class PostboxServiceTests(unittest.TestCase): self.idm.assignments.append(self.assignment) expires_at = utc_now() + timedelta(days=1) with Session(self.engine) as session: - postbox = self._create_exact(session) + postbox = self._create_exact( + session, + encryption_profile="external_e2ee_v1", + protection_policy={"external_recipient_assurance": "email_otp"}, + ) delivered = self.service.deliver( session, PostboxDeliveryRequest( @@ -1340,6 +1355,7 @@ class PostboxServiceTests(unittest.TestCase): assurance_profile="email-otp", ), ), + metadata={"content_digest": "sha256:" + "a" * 64}, ), ) @@ -1361,9 +1377,7 @@ class PostboxServiceTests(unittest.TestCase): 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 - ), + message.external_recipient_tokens[0].expires_at.replace(microsecond=0), ) def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed( @@ -1445,14 +1459,330 @@ class PostboxServiceTests(unittest.TestCase): actor=self.actor, ) + def test_future_only_protection_transition_changes_new_message_policy(self) -> None: + with Session(self.engine) as session: + postbox = self._create_exact( + session, + protection_policy={ + "handover_authority": "user_consent", + "handover_quorum": 1, + }, + ) + transition = self.service.create_protection_transition( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + idempotency_key="future-e2ee", + expected_revision=postbox.resource_revision, + target_profile="external_e2ee_v1", + target_vault_id=None, + history_mode="future_only", + authority_mode="user_consent", + required_quorum=1, + user_consent_refs=("consent:user-1",), + institutional_authorization_refs=(), + reason="Use client-held keys for future messages.", + actor_id="admin-1", + ) + + self.assertEqual("completed", transition.state) + self.assertEqual(0, transition.message_count) + self.assertEqual("external_e2ee_v1", postbox.encryption_profile) + self.assertEqual(2, postbox.key_epoch) + + def test_client_transform_completes_plaintext_to_e2ee_history(self) -> None: + self.idm.assignments.append(self.assignment) + plaintext = "A governed message" + digest = "sha256:" + hashlib.sha256(plaintext.encode()).hexdigest() + with Session(self.engine) as session: + postbox = self._create_exact( + session, + protection_policy={ + "handover_authority": "user_consent", + "handover_quorum": 1, + }, + ) + 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-1", + idempotency_key="plaintext-history", + subject="Governed history", + body_text=plaintext, + ), + ) + transition = self.service.create_protection_transition( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + idempotency_key="migrate-e2ee", + expected_revision=postbox.resource_revision, + target_profile="external_e2ee_v1", + target_vault_id=None, + history_mode="migrate_history", + authority_mode="user_consent", + required_quorum=1, + user_consent_refs=("consent:user-1",), + institutional_authorization_refs=(), + reason="Move retained content to client-held keys.", + actor_id="admin-1", + ) + + self.assertEqual("awaiting_client", transition.state) + self.assertEqual(1, transition.message_count) + completed = self.service.apply_client_protection_transform( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + transition_id=transition.id, + message_id=delivered.message_id, + expected_revision=transition.resource_revision, + plaintext=None, + ciphertext_ref="files:ciphertext-transition-1", + signed_manifest_ref="files:manifest-transition-1", + wrapped_keys=( + PostboxWrappedKeyRef( + recipient_type="function_postbox", + recipient_id=postbox.id, + key_epoch=postbox.key_epoch, + wrapped_key_ref="trust:wrapped-transition-1", + algorithm="HPKE-v1", + ), + ), + content_digest=digest, + transformation_evidence_ref="client:evidence-1", + actor_id="admin-1", + ) + + self.assertEqual("completed", completed.state) + self.assertEqual(1, completed.completed_count) + stored = session.get(PostboxMessage, delivered.message_id) + assert stored is not None + self.assertIsNone(stored.body_text) + self.assertEqual("external_e2ee_v1", stored.encryption_profile) + self.assertEqual("files:ciphertext-transition-1", stored.ciphertext_ref) + self.assertEqual(digest, stored.metadata_["content_digest"]) + + def test_plaintext_history_migrates_to_managed_envelopes_automatically( + self, + ) -> None: + protected = SimpleNamespace( + ciphertext=b"managed-history", + envelope=SimpleNamespace( + envelope_id="transition-envelope-1", + ciphertext_ref="postbox-db://messages/history/body", + algorithm_suite="AES-256-GCM", + wrapped_key_refs=("wrapped-history-1",), + vault_id="vault-1", + key_version=4, + ), + ) + 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-managed-history", + idempotency_key="managed-history", + subject="Managed history", + body_text="Move this content", + ), + ) + with patch( + "govoplan_postbox.backend.content_protection.protect_message_body", + return_value=protected, + ): + transition = self.service.create_protection_transition( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + idempotency_key="migrate-managed", + expected_revision=postbox.resource_revision, + target_profile="server_envelope_v1", + target_vault_id="vault-1", + history_mode="migrate_history", + authority_mode="dual_control", + required_quorum=2, + user_consent_refs=("consent:incumbent-1",), + institutional_authorization_refs=("approval:key-holder-1",), + reason="Adopt the managed standard.", + actor_id="admin-1", + ) + + self.assertEqual("completed", transition.state) + stored = session.get(PostboxMessage, delivered.message_id) + assert stored is not None + self.assertIsNone(stored.body_text) + self.assertEqual(b"managed-history", stored.body_ciphertext) + self.assertEqual("transition-envelope-1", stored.encryption_envelope_id) + self.assertEqual("server_envelope_v1", stored.encryption_profile) + self.assertEqual("vault-1", stored.wrapped_keys[0]["recipient_id"]) + + def test_new_incumbent_history_policy_filters_lists_counts_and_direct_reads( + self, + ) -> None: + boundary = utc_now() - timedelta(days=1) + self.idm.assignments.append(replace(self.assignment, valid_from=boundary)) + with Session(self.engine) as session: + postbox = self._create_exact( + session, + protection_policy={"new_incumbent_history": "since_assignment"}, + ) + old_delivery = 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-old", + idempotency_key="history-old", + subject="Before assignment", + body_text="Old content", + ), + ) + recent_delivery = 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-recent", + idempotency_key="history-recent", + subject="After assignment", + body_text="Recent content", + ), + ) + old = session.get(PostboxMessage, old_delivery.message_id) + recent = session.get(PostboxMessage, recent_delivery.message_id) + assert old is not None and recent is not None + old.delivered_at = boundary - timedelta(hours=1) + recent.delivered_at = boundary + timedelta(hours=1) + session.flush() + + messages = self.service.list_messages( + session, + tenant_id="tenant-1", + postbox_ids=(postbox.id,), + actor=self.actor, + ) + + self.assertEqual([recent.id], [message.id for message in messages]) + self.assertEqual( + 1, + self.service.count_messages( + session, + tenant_id="tenant-1", + postbox_ids=(postbox.id,), + actor=self.actor, + ), + ) + self.assertIsNone( + self.service.get_message( + session, + tenant_id="tenant-1", + message_id=old.id, + actor=self.actor, + ) + ) + self.assertFalse( + self.service.can_read_message( + session, + tenant_id="tenant-1", + message_id=old.id, + actor=self.actor, + ) + ) + + def test_leaving_e2ee_history_requires_user_consent_authority(self) -> None: + with Session(self.engine) as session: + postbox = self._create_exact( + session, + encryption_profile="external_e2ee_v1", + ) + with self.assertRaisesRegex(PostboxError, "user consent"): + self.service.create_protection_transition( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + idempotency_key="leave-e2ee-without-user", + expected_revision=postbox.resource_revision, + target_profile="plaintext_v1", + target_vault_id=None, + history_mode="future_only", + authority_mode="institutional_key_holders", + required_quorum=1, + user_consent_refs=(), + institutional_authorization_refs=("approval:key-holder-1",), + reason="Leave E2EE without holder consent.", + actor_id="admin-1", + ) + + def test_protection_policy_update_is_revisioned_and_enforces_assurance( + self, + ) -> None: + with Session(self.engine) as session: + postbox = self._create_exact(session) + original_revision = postbox.resource_revision + updated = self.service.update_protection_policy( + session, + tenant_id="tenant-1", + postbox_id=postbox.id, + protection_policy={ + "new_incumbent_history": "all_retained", + "external_recipient_assurance": "disabled", + }, + actor_id="admin-1", + expected_revision=original_revision, + ) + + self.assertEqual(original_revision + 1, updated.resource_revision) + self.assertEqual( + "disabled", + updated.settings["protection_policy"]["external_recipient_assurance"], + ) + self.assertEqual( + "rewrap", + updated.settings["protection_policy"]["ordinary_rotation"], + ) + with self.assertRaisesRegex(PostboxError, "does not permit"): + 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-external-disabled", + idempotency_key="external-disabled", + subject="External retrieval", + body_text="Content", + external_recipient_tokens=( + PostboxExternalRecipientTokenRef( + token_id="grant-disabled", + state="available", + one_time=True, + assurance_profile="strong_identity", + ), + ), + ), + ) + def test_hierarchy_linked_copy_snapshots_path_and_independent_state( self, ) -> None: self.idm.assignments.append(self.assignment) with Session(self.engine) as session: - service, source, _target_template = ( - self._create_routing_source(session) - ) + service, source, _target_template = self._create_routing_source(session) result = service.deliver( session, PostboxDeliveryRequest( @@ -1478,10 +1808,7 @@ class PostboxServiceTests(unittest.TestCase): self.assertNotEqual(result.message_id, route.target_message_id) self.assertEqual( ["edge-child-parent"], - [ - edge["edge_id"] - for edge in route.policy_snapshot["target"]["path"] - ], + [edge["edge_id"] for edge in route.policy_snapshot["target"]["path"]], ) self.assertEqual( route.id, @@ -1496,7 +1823,9 @@ class PostboxServiceTests(unittest.TestCase): ) session.commit() receipts = session.query(PostboxMessageReceipt).all() - self.assertEqual([route.target_message_id], [item.message_id for item in receipts]) + self.assertEqual( + [route.target_message_id], [item.message_id for item in receipts] + ) summary = service.delivery_receipt_summaries( session, tenant_id="tenant-1", @@ -1512,8 +1841,8 @@ class PostboxServiceTests(unittest.TestCase): self, ) -> None: with Session(self.engine) as session: - service, source, _target_template = ( - self._create_routing_source(session, max_depth=1) + service, source, _target_template = self._create_routing_source( + session, max_depth=1 ) blocked = service.preview_hierarchy_routes( session, @@ -1573,9 +1902,7 @@ class PostboxServiceTests(unittest.TestCase): self.organizations.duplicate_parent_match = True self.organizations.cycle_detected = True with Session(self.engine) as session: - service, source, _target_template = ( - self._create_routing_source(session) - ) + service, source, _target_template = self._create_routing_source(session) preview = service.preview_hierarchy_routes( session, tenant_id="tenant-1", @@ -1588,10 +1915,7 @@ class PostboxServiceTests(unittest.TestCase): self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"]) self.assertEqual( 1, - sum( - route["status"] == "duplicate" - for route in preview["routes"] - ), + sum(route["status"] == "duplicate" for route in preview["routes"]), ) with self.assertRaisesRegex( ValueError, @@ -1639,11 +1963,7 @@ class PostboxServiceTests(unittest.TestCase): ), ) session.commit() - routes = ( - session.query(PostboxRoute) - .order_by(PostboxRoute.depth) - .all() - ) + routes = session.query(PostboxRoute).order_by(PostboxRoute.depth).all() self.assertEqual( ["accepted_vacant", "pending_vacancy_escalation"], [route.status for route in routes], @@ -1687,10 +2007,7 @@ class PostboxServiceTests(unittest.TestCase): session.commit() pending = ( session.query(PostboxRoute) - .filter( - PostboxRoute.status - == "pending_vacancy_escalation" - ) + .filter(PostboxRoute.status == "pending_vacancy_escalation") .one() ) pending.execute_after = utc_now() - timedelta(seconds=1) diff --git a/webui/src/api/postbox.ts b/webui/src/api/postbox.ts index e9a323e..b3c6636 100644 --- a/webui/src/api/postbox.ts +++ b/webui/src/api/postbox.ts @@ -41,11 +41,84 @@ export type PostboxDirectoryItem = { template_revision_id?: string | null; holder_count: number; vacant: boolean; + encryption_profile: PostboxProtectionProfileId; + key_epoch: number; + encryption_vault_id?: string | null; + protection_policy: PostboxProtectionPolicy; access?: PostboxAccessDecision | null; resource_revision: number; etag: string; }; +export type PostboxProtectionProfileId = + | "plaintext_v1" + | "server_envelope_v1" + | "external_e2ee_v1"; + +export type PostboxProtectionPolicy = { + new_incumbent_history: "all_retained" | "since_assignment" | "bounded_days"; + history_days?: number | null; + ordinary_rotation: "rewrap" | "reencrypt"; + compromise_rotation: "rewrap" | "reencrypt"; + recovery_authority: "disabled" | "user_consent" | "institutional_key_holders" | "dual_control"; + recovery_quorum: number; + handover_authority: "user_consent" | "institutional_key_holders" | "dual_control"; + handover_quorum: number; + emergency_access: "disabled" | "dual_control"; + emergency_quorum: number; + export_authority: "user_consent" | "institutional_key_holders" | "dual_control"; + export_quorum: number; + destruction_authority: "institutional_key_holders" | "dual_control"; + destruction_quorum: number; + external_recipient_assurance: "disabled" | "email_otp" | "strong_identity"; + vacancy_escalation_content_access: "metadata_only"; +}; + +export type PostboxProtectionProfile = { + id: PostboxProtectionProfileId; + label: string; + description: string; + server_can_decrypt: boolean; + requires_encryption_module: boolean; + requires_external_client: boolean; + available: boolean; + standard: boolean; +}; + +export type PostboxProtectionTransition = { + id: string; + postbox_id: string; + source_profile: string; + target_profile: string; + source_vault_id?: string | null; + target_vault_id?: string | null; + history_mode: string; + authority_mode: string; + required_quorum: number; + evidence_refs: string[]; + reason: string; + state: string; + message_count: number; + completed_count: number; + failed_count: number; + requested_by?: string | null; + activated_at?: string | null; + completed_at?: string | null; + resource_revision: number; + etag: string; + configuration_snapshot: Record; + items: Array<{ + id: string; + message_id: string; + source_profile: string; + target_profile: string; + state: string; + source_digest?: string | null; + target_digest?: string | null; + error_code?: string | null; + }>; +}; + export type PostboxParticipant = { kind: string; reference_type: string; @@ -93,7 +166,7 @@ export type PostboxMessage = { producer_resource_id?: string | null; in_reply_to_message_id?: string | null; replaces_message_id?: string | null; - encryption_profile: string; + encryption_profile: PostboxProtectionProfileId; key_epoch: number; ciphertext_ref?: string | null; signed_manifest_ref?: string | null; @@ -213,6 +286,8 @@ export type PostboxTemplateRevision = { allow_vacant_delivery: boolean; portal_visible: boolean; encryption_profile: string; + encryption_vault_id?: string | null; + protection_policy: PostboxProtectionPolicy; history_policy: Record; routing_policy: PostboxRoutingPolicy; retention_policy: Record; @@ -248,6 +323,9 @@ export type PostboxTemplateRevisionPayload = Pick< | "classification" | "allow_vacant_delivery" | "portal_visible" + | "encryption_profile" + | "encryption_vault_id" + | "protection_policy" | "routing_policy" >; @@ -290,12 +368,18 @@ export type PostboxExactCreatePayload = { address_key?: string | null; classification: string; portal_visible: boolean; + encryption_profile: PostboxProtectionProfileId; + encryption_vault_id?: string | null; + protection_policy: PostboxProtectionPolicy; }; export type PostboxMessageAuthoringPayload = { idempotency_key: string; subject: string; body_text?: string | null; + ciphertext_ref?: string | null; + signed_manifest_ref?: string | null; + wrapped_keys?: PostboxMessage["wrapped_keys"]; classification: string; participants: PostboxParticipant[]; attachments: PostboxAttachment[]; @@ -469,6 +553,66 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/postbox/admin/protection-profiles"); +} + +export async function listPostboxProtectionTransitions( + settings: ApiSettings, + postboxId: string +): Promise { + const response = await apiFetch<{ transitions: PostboxProtectionTransition[] }>( + settings, + `/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}/protection-transitions` + ); + return response.transitions; +} + +export function createPostboxProtectionTransition( + settings: ApiSettings, + postbox: PostboxDirectoryItem, + payload: { + idempotency_key: string; + target_profile: PostboxProtectionProfileId; + target_vault_id?: string | null; + history_mode: "future_only" | "migrate_history"; + authority_mode: "user_consent" | "institutional_key_holders" | "dual_control"; + required_quorum: number; + user_consent_refs: string[]; + institutional_authorization_refs: string[]; + reason: string; + acknowledge_irreversibility: boolean; + } +): Promise { + return apiPostJson( + settings, + `/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/protection-transitions`, + { ...payload, base_revision: postbox.resource_revision }, + { headers: { "If-Match": postbox.etag } } + ); +} + +export function updatePostboxProtectionPolicy( + settings: ApiSettings, + postbox: PostboxDirectoryItem, + protectionPolicy: PostboxProtectionPolicy +): Promise { + return apiFetch( + settings, + `/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/protection-policy`, + { + method: "PUT", + headers: { "If-Match": postbox.etag }, + body: JSON.stringify({ + base_revision: postbox.resource_revision, + protection_policy: protectionPolicy + }) + } + ); +} + export async function listPostboxOrganizationTargets( settings: ApiSettings ): Promise { diff --git a/webui/src/features/postbox/PostboxAdminPanel.tsx b/webui/src/features/postbox/PostboxAdminPanel.tsx index 3318169..7efc88e 100644 --- a/webui/src/features/postbox/PostboxAdminPanel.tsx +++ b/webui/src/features/postbox/PostboxAdminPanel.tsx @@ -4,6 +4,7 @@ import { Archive, Boxes, Building2, + KeyRound, Eye, Inbox, Pencil, @@ -36,9 +37,12 @@ import { FormGrid, } from "@govoplan/core-webui"; import { archivePostbox, + createPostboxProtectionTransition, createExactPostbox, createPostboxTemplate, listAdminPostboxes, + listPostboxProtectionProfiles, + listPostboxProtectionTransitions, listPostboxOrganizationTargets, listPostboxTemplates, materializePostboxTemplate, @@ -46,11 +50,16 @@ import { publishPostboxTemplate, retirePostboxTemplate, revisePostboxTemplate, + updatePostboxProtectionPolicy, type PostboxDirectoryItem, type PostboxExactCreatePayload, type PostboxOrganizationFunction, type PostboxOrganizationStructure, type PostboxOrganizationUnit, + type PostboxProtectionPolicy, + type PostboxProtectionProfile, + type PostboxProtectionProfileId, + type PostboxProtectionTransition, type PostboxRoutingPolicy, type PostboxTemplate, type PostboxTemplateCreatePayload, @@ -74,6 +83,36 @@ type MaterializeDraft = { function_id: string; context_key: string; }; +type ProtectionTransitionDraft = { + target_profile: PostboxProtectionProfileId; + target_vault_id: string; + history_mode: "future_only" | "migrate_history"; + authority_mode: "user_consent" | "institutional_key_holders" | "dual_control"; + required_quorum: number; + user_consent_refs: string; + institutional_authorization_refs: string; + reason: string; + acknowledge_irreversibility: boolean; +}; + +const protectionPolicyDefaults = (): PostboxProtectionPolicy => ({ + new_incumbent_history: "since_assignment", + history_days: null, + ordinary_rotation: "rewrap", + compromise_rotation: "reencrypt", + recovery_authority: "institutional_key_holders", + recovery_quorum: 2, + handover_authority: "dual_control", + handover_quorum: 2, + emergency_access: "dual_control", + emergency_quorum: 2, + export_authority: "dual_control", + export_quorum: 2, + destruction_authority: "dual_control", + destruction_quorum: 2, + external_recipient_assurance: "strong_identity", + vacancy_escalation_content_access: "metadata_only" +}); const routingDefaults = (): PostboxRoutingPolicy => ({ linked_copy: { @@ -115,6 +154,9 @@ const templateDefaults = (): TemplateDraft => ({ classification: "internal", allow_vacant_delivery: true, portal_visible: false, + encryption_profile: "server_envelope_v1", + encryption_vault_id: "", + protection_policy: protectionPolicyDefaults(), routing_policy: routingDefaults() }); @@ -125,9 +167,34 @@ const exactDefaults = (): ExactDraft => ({ function_id: "", address_key: "", classification: "internal", - portal_visible: false + portal_visible: false, + encryption_profile: "server_envelope_v1", + encryption_vault_id: "", + protection_policy: protectionPolicyDefaults() }); +const protectionTransitionDefaults = ( + postbox?: PostboxDirectoryItem | null +): ProtectionTransitionDraft => { + const authority = postbox?.protection_policy?.handover_authority || "dual_control"; + return { + target_profile: postbox?.encryption_profile === "server_envelope_v1" + ? "external_e2ee_v1" + : "server_envelope_v1", + target_vault_id: postbox?.encryption_vault_id || "", + history_mode: "future_only", + authority_mode: authority, + required_quorum: Math.max( + authority === "dual_control" ? 2 : 1, + postbox?.protection_policy?.handover_quorum || 1 + ), + user_consent_refs: "", + institutional_authorization_refs: "", + reason: "", + acknowledge_irreversibility: false + }; +}; + export default function PostboxAdminPanel({ settings, canManageBindings, @@ -144,6 +211,8 @@ export default function PostboxAdminPanel({ const [postboxes, setPostboxes] = useState([]); const [units, setUnits] = useState([]); const [structures, setStructures] = useState([]); + const [protectionProfiles, setProtectionProfiles] = useState([]); + const [protectionTransitions, setProtectionTransitions] = useState([]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [selectedPostboxId, setSelectedPostboxId] = useState(""); const [loading, setLoading] = useState(true); @@ -173,6 +242,12 @@ export default function PostboxAdminPanel({ }); const [archiveTarget, setArchiveTarget] = useState(null); const [retireTarget, setRetireTarget] = useState(null); + const [protectionDialogOpen, setProtectionDialogOpen] = useState(false); + const [protectionDraft, setProtectionDraft] = useState(protectionTransitionDefaults); + const [protectionBaseline, setProtectionBaseline] = useState(protectionTransitionDefaults); + const [policyDialogOpen, setPolicyDialogOpen] = useState(false); + const [policyDraft, setPolicyDraft] = useState(protectionPolicyDefaults); + const [policyBaseline, setPolicyBaseline] = useState(protectionPolicyDefaults); const { requestDiscard } = useUnsavedChanges(); const selectedTemplate = useMemo( @@ -206,9 +281,11 @@ export default function PostboxAdminPanel({ const templateDirty = templateDialogOpen && draftKey(templateDraft) !== draftKey(templateBaseline); const exactDirty = exactDialogOpen && draftKey(exactDraft) !== draftKey(exactBaseline); const materializeDirty = materializeDialogOpen && draftKey(materializeDraft) !== draftKey(materializeBaseline); + const protectionDirty = protectionDialogOpen && draftKey(protectionDraft) !== draftKey(protectionBaseline); + const policyDirty = policyDialogOpen && draftKey(policyDraft) !== draftKey(policyBaseline); useUnsavedDraftGuard({ - dirty: templateDirty || exactDirty || materializeDirty, + dirty: templateDirty || exactDirty || materializeDirty || protectionDirty || policyDirty, title: "Unsaved Postbox administration draft", message: "Save or discard the open Postbox administration draft before leaving this surface.", onSave: saveActiveDraft, @@ -219,15 +296,17 @@ export default function PostboxAdminPanel({ setLoading(true); setError(""); try { - const [nextTemplates, nextPostboxes, organizationTargets] = await Promise.all([ + const [nextTemplates, nextPostboxes, organizationTargets, profileCatalog] = await Promise.all([ canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]), canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]), - listPostboxOrganizationTargets(settings) + listPostboxOrganizationTargets(settings), + listPostboxProtectionProfiles(settings) ]); setTemplates(nextTemplates); setPostboxes(nextPostboxes); setUnits(organizationTargets.units); setStructures(organizationTargets.structures); + setProtectionProfiles(profileCatalog.profiles); setSelectedTemplateId((current) => current && nextTemplates.some((template) => template.id === current) ? current @@ -249,6 +328,24 @@ export default function PostboxAdminPanel({ void load(); }, [load]); + useEffect(() => { + if (!canManageBindings || !selectedPostboxId) { + setProtectionTransitions([]); + return; + } + let cancelled = false; + void listPostboxProtectionTransitions(settings, selectedPostboxId) + .then((values) => { + if (!cancelled) setProtectionTransitions(values); + }) + .catch((transitionError) => { + if (!cancelled) setError(errorMessage(transitionError)); + }); + return () => { + cancelled = true; + }; + }, [canManageBindings, selectedPostboxId, settings]); + function openNewTemplate() { const next = templateDefaults(); setTemplatePreview(null); @@ -275,6 +372,9 @@ export default function PostboxAdminPanel({ 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: revision.protection_policy ?? protectionPolicyDefaults(), routing_policy: revision.routing_policy ?? routingDefaults() }; setTemplatePreview(null); @@ -398,6 +498,89 @@ export default function PostboxAdminPanel({ } } + function openProtectionTransition() { + if (!selectedPostbox) return; + const next = protectionTransitionDefaults(selectedPostbox); + setProtectionDraft(next); + setProtectionBaseline(next); + setProtectionDialogOpen(true); + } + + async function saveProtectionTransition(): Promise { + if (!selectedPostbox) return false; + setBusy(true); + setError(""); + setSuccess(""); + try { + const transition = await createPostboxProtectionTransition( + settings, + selectedPostbox, + { + idempotency_key: crypto.randomUUID(), + target_profile: protectionDraft.target_profile, + target_vault_id: protectionDraft.target_profile === "server_envelope_v1" + ? protectionDraft.target_vault_id.trim() || null + : null, + history_mode: protectionDraft.history_mode, + authority_mode: protectionDraft.authority_mode, + required_quorum: protectionDraft.required_quorum, + user_consent_refs: lineSeparated(protectionDraft.user_consent_refs), + institutional_authorization_refs: lineSeparated( + protectionDraft.institutional_authorization_refs + ), + reason: protectionDraft.reason.trim(), + acknowledge_irreversibility: protectionDraft.acknowledge_irreversibility + } + ); + setProtectionTransitions((current) => [ + transition, + ...current.filter((item) => item.id !== transition.id) + ]); + setProtectionBaseline(protectionDraft); + setProtectionDialogOpen(false); + setSuccess( + transition.state === "completed" + ? "Postbox protection profile changed and historical content migration completed." + : "Postbox protection profile changed for new messages. Historical content is awaiting approved client transformations." + ); + await load(); + return true; + } catch (actionError) { + setError(errorMessage(actionError)); + return false; + } finally { + setBusy(false); + } + } + + function openProtectionPolicy() { + if (!selectedPostbox) return; + const next = selectedPostbox.protection_policy || protectionPolicyDefaults(); + setPolicyDraft(next); + setPolicyBaseline(next); + setPolicyDialogOpen(true); + } + + async function saveProtectionPolicy(): Promise { + if (!selectedPostbox) return false; + setBusy(true); + setError(""); + setSuccess(""); + try { + await updatePostboxProtectionPolicy(settings, selectedPostbox, policyDraft); + setPolicyBaseline(policyDraft); + setPolicyDialogOpen(false); + setSuccess("Postbox protection and hand-over policy updated."); + await load(); + return true; + } catch (actionError) { + setError(errorMessage(actionError)); + return false; + } finally { + setBusy(false); + } + } + function openMaterialize(template: PostboxTemplate) { const revision = currentRevision(template); const compatible = compatibleTargets(units, revision?.function_type_id); @@ -475,6 +658,8 @@ export default function PostboxAdminPanel({ if (templateDirty) return saveTemplate(); if (exactDirty) return saveExact(); if (materializeDirty) return materialize(); + if (protectionDirty) return saveProtectionTransition(); + if (policyDirty) return saveProtectionPolicy(); return Promise.resolve(true); } @@ -491,6 +676,14 @@ export default function PostboxAdminPanel({ setMaterializeDraft(materializeBaseline); setMaterializeDialogOpen(false); } + if (protectionDialogOpen) { + setProtectionDraft(protectionBaseline); + setProtectionDialogOpen(false); + } + if (policyDialogOpen) { + setPolicyDraft(policyBaseline); + setPolicyDialogOpen(false); + } } function closeTemplateDialog() { @@ -520,6 +713,24 @@ export default function PostboxAdminPanel({ else close(); } + function closeProtectionDialog() { + const close = () => { + setProtectionDraft(protectionBaseline); + setProtectionDialogOpen(false); + }; + if (protectionDirty) requestDiscard(close); + else close(); + } + + function closePolicyDialog() { + const close = () => { + setPolicyDraft(policyBaseline); + setPolicyDialogOpen(false); + }; + if (policyDirty) requestDiscard(close); + else close(); + } + return ( @@ -604,6 +818,7 @@ export default function PostboxAdminPanel({ units={units} structures={structures} templates={templates} + protectionProfiles={protectionProfiles} functionTypes={functionTypes} unitTypes={unitTypes} busy={busy} @@ -621,6 +836,7 @@ export default function PostboxAdminPanel({ open={exactDialogOpen} draft={exactDraft} units={units} + protectionProfiles={protectionProfiles} busy={busy} onChange={setExactDraft} onClose={closeExactDialog} @@ -636,6 +852,26 @@ export default function PostboxAdminPanel({ onClose={closeMaterializeDialog} onSave={() => void materialize()} /> + void saveProtectionTransition()} + /> + void saveProtectionPolicy()} + /> void; + onChangeProtection: () => void; + onEditPolicy: () => void; onArchive: (postbox: PostboxDirectoryItem) => void; busy: boolean; }) { @@ -854,14 +1096,30 @@ function PostboxWorkspace({

{selected.name}

{selected.address}

- +
+ + + +
Organization unit
{selected.organization_unit_name || "None"}
@@ -872,7 +1130,25 @@ function PostboxWorkspace({
Vacancy
{selected.vacant ? "Vacant" : "Staffed"}
Context
{selected.context_key || "None"}
Template revision
{selected.template_revision_id || "Exact Postbox"}
+
Protection
{protectionProfileLabel(selected.encryption_profile)}
+
Key epoch
{selected.key_epoch}
+
New incumbent history
{selected.protection_policy.new_incumbent_history.replaceAll("_", " ")}
+
External recipient assurance
{selected.protection_policy.external_recipient_assurance.replaceAll("_", " ")}
+ {transitions.length ? ( +
+

Protection transitions

+ {transitions.slice(0, 5).map((transition) => ( +
+ {protectionProfileLabel(transition.target_profile)} + + {transition.history_mode.replaceAll("_", " ")} · {transition.completed_count}/{transition.message_count} messages + + +
+ ))} +
+ ) : null} ) : ( } title="Select a Postbox" description="Materialized Postboxes remain durable through vacancy and reassignment." /> @@ -888,6 +1164,7 @@ function TemplateDialog({ units, structures, templates, + protectionProfiles, functionTypes, unitTypes, busy, @@ -903,6 +1180,7 @@ function TemplateDialog({ units: PostboxOrganizationUnit[]; structures: PostboxOrganizationStructure[]; templates: PostboxTemplate[]; + protectionProfiles: PostboxProtectionProfile[]; functionTypes: Array<{ id: string; name: string }>; unitTypes: Array<{ id: string; example: string }>; busy: boolean; @@ -925,6 +1203,9 @@ function TemplateDialog({ const selectedStructure = structures.find( (item) => item.id === linkedCopy.structure_id ); + const selectedProtectionProfile = protectionProfiles.find( + (item) => item.id === draft.encryption_profile + ); const updateRouting = (routing_policy: PostboxRoutingPolicy) => { onChange({ ...draft, routing_policy }); }; @@ -946,6 +1227,8 @@ function TemplateDialog({ draft.address_pattern.trim() && (draft.scope_kind === "tenant" || Boolean(draft.scope_id)) && (draft.scope_kind !== "subtree" || Boolean(draft.scope_structure_id)) && + selectedProtectionProfile?.available && + (draft.encryption_profile !== "server_envelope_v1" || Boolean(draft.encryption_vault_id?.trim())) && ( !linkedCopy.enabled || Boolean( @@ -1131,6 +1414,27 @@ function TemplateDialog({ onChange={(checked) => onChange({ ...draft, portal_visible: checked })} /> + onChange({ + ...draft, + encryption_profile, + encryption_vault_id: encryption_profile === "server_envelope_v1" + ? draft.encryption_vault_id + : null + })} + onVaultChange={(encryption_vault_id) => onChange({ + ...draft, + encryption_vault_id + })} + onPolicyChange={(protection_policy) => onChange({ + ...draft, + protection_policy + })} + />
@@ -1403,6 +1707,7 @@ function ExactPostboxDialog({ open, draft, units, + protectionProfiles, busy, onChange, onClose, @@ -1411,12 +1716,26 @@ function ExactPostboxDialog({ open: boolean; draft: ExactDraft; units: PostboxOrganizationUnit[]; + protectionProfiles: PostboxProtectionProfile[]; busy: boolean; onChange: (draft: ExactDraft) => void; onClose: () => void; onSave: () => void; }) { const unit = units.find((item) => item.id === draft.organization_unit_id); + const selectedProtectionProfile = protectionProfiles.find( + (item) => item.id === draft.encryption_profile + ); + const valid = Boolean( + draft.name.trim() + && draft.organization_unit_id + && draft.function_id + && selectedProtectionProfile?.available + && ( + draft.encryption_profile !== "server_envelope_v1" + || draft.encryption_vault_id?.trim() + ) + ); return ( Create Postbox @@ -1485,11 +1804,530 @@ function ExactPostboxDialog({ onChange={(checked) => onChange({ ...draft, portal_visible: checked })} />
+ onChange({ + ...draft, + encryption_profile, + encryption_vault_id: encryption_profile === "server_envelope_v1" + ? draft.encryption_vault_id + : null + })} + onVaultChange={(encryption_vault_id) => onChange({ + ...draft, + encryption_vault_id + })} + onPolicyChange={(protection_policy) => onChange({ + ...draft, + protection_policy + })} + /> ); } +function ProtectionConfigurationFields({ + profile, + vaultId, + policy, + profiles, + profileLocked = false, + onProfileChange, + onVaultChange, + onPolicyChange +}: { + profile: PostboxProtectionProfileId; + vaultId: string; + policy: PostboxProtectionPolicy; + profiles: PostboxProtectionProfile[]; + profileLocked?: boolean; + onProfileChange: (profile: PostboxProtectionProfileId) => void; + onVaultChange: (vaultId: string) => void; + onPolicyChange: (policy: PostboxProtectionPolicy) => void; +}) { + const selected = profiles.find((item) => item.id === profile); + const updatePolicy = (next: Partial) => { + onPolicyChange({ ...policy, ...next }); + }; + return ( +
+
+
+ Content protection and hand-over policy + + The institution chooses the protection boundary. Managed envelope encryption is the recommended standard. + + +
+ {selected?.standard ? : null} +
+ + + + {selected?.description} + + {profile === "server_envelope_v1" ? ( + + onVaultChange(event.target.value)} + /> + + ) : ( +
+ {profile === "external_e2ee_v1" + ? "An approved external client or producer must supply ciphertext, a signed manifest, wrapped keys, and a digest. GovOPlaN cannot decrypt message content." + : "Content is stored without encryption. Transport and infrastructure controls still apply."} +
+ )} + + + + {policy.new_incumbent_history === "bounded_days" ? ( + + updatePolicy({ + history_days: Math.max(1, Math.min(36500, Number(event.target.value) || 1)) + })} + /> + + ) :
} + + + + + + + updatePolicy({ recovery_authority })} + /> + updatePolicy({ recovery_quorum })} + /> + updatePolicy({ + handover_authority: handover_authority as PostboxProtectionPolicy["handover_authority"] + })} + /> + updatePolicy({ handover_quorum })} + /> + + + + updatePolicy({ emergency_quorum })} + /> + updatePolicy({ + export_authority: export_authority as PostboxProtectionPolicy["export_authority"] + })} + /> + updatePolicy({ export_quorum })} + /> + + + + updatePolicy({ destruction_quorum })} + /> + + + + + + + +
+ ); +} + +function AuthorityField({ + label, + value, + includeDisabled = false, + onChange +}: { + label: string; + value: "disabled" | "user_consent" | "institutional_key_holders" | "dual_control"; + includeDisabled?: boolean; + onChange: (value: "disabled" | "user_consent" | "institutional_key_holders" | "dual_control") => void; +}) { + return ( + + + + ); +} + +function QuorumField({ + label, + value, + onChange +}: { + label: string; + value: number; + onChange: (value: number) => void; +}) { + return ( + + onChange(Math.max(1, Math.min(20, Number(event.target.value) || 1)))} + /> + + ); +} + +function ProtectionPolicyDialog({ + open, + postbox, + draft, + profiles, + busy, + onChange, + onClose, + onSave +}: { + open: boolean; + postbox: PostboxDirectoryItem | null; + draft: PostboxProtectionPolicy; + profiles: PostboxProtectionProfile[]; + busy: boolean; + onChange: (policy: PostboxProtectionPolicy) => void; + onClose: () => void; + onSave: () => void; +}) { + return ( + + + +
+ } + > + {postbox ? ( + undefined} + onVaultChange={() => undefined} + onPolicyChange={onChange} + /> + ) : null} +

+ Changing this policy affects future access and governance decisions. It does not change the content-protection profile or rewrite retained messages. +

+ + ); +} + +function ProtectionTransitionDialog({ + open, + postbox, + draft, + profiles, + busy, + onChange, + onClose, + onSave +}: { + open: boolean; + postbox: PostboxDirectoryItem | null; + draft: ProtectionTransitionDraft; + profiles: PostboxProtectionProfile[]; + busy: boolean; + onChange: (draft: ProtectionTransitionDraft) => void; + onClose: () => void; + onSave: () => void; +}) { + const selected = profiles.find((item) => item.id === draft.target_profile); + const userEvidence = lineSeparated(draft.user_consent_refs); + const institutionalEvidence = lineSeparated(draft.institutional_authorization_refs); + const evidenceCount = new Set([...userEvidence, ...institutionalEvidence]).size; + const requiresUser = ["user_consent", "dual_control"].includes(draft.authority_mode); + const requiresInstitution = ["institutional_key_holders", "dual_control"].includes( + draft.authority_mode + ); + const configuredAuthority = postbox?.protection_policy?.handover_authority || "dual_control"; + const authoritySatisfiesPolicy = configuredAuthority === "dual_control" + ? draft.authority_mode === "dual_control" + : configuredAuthority === "user_consent" + ? ["user_consent", "dual_control"].includes(draft.authority_mode) + : ["institutional_key_holders", "dual_control"].includes(draft.authority_mode); + const authoritySatisfiesSource = postbox?.encryption_profile === "external_e2ee_v1" + ? ["user_consent", "dual_control"].includes(draft.authority_mode) + : postbox?.encryption_profile === "server_envelope_v1" + ? ["institutional_key_holders", "dual_control"].includes(draft.authority_mode) + : true; + const valid = Boolean( + postbox + && selected?.available + && draft.target_profile !== postbox.encryption_profile + && (draft.target_profile !== "server_envelope_v1" || draft.target_vault_id.trim()) + && draft.reason.trim() + && draft.acknowledge_irreversibility + && evidenceCount >= draft.required_quorum + && (!requiresUser || userEvidence.length) + && (!requiresInstitution || institutionalEvidence.length) + && (draft.authority_mode !== "dual_control" || draft.required_quorum >= 2) + && authoritySatisfiesPolicy + && authoritySatisfiesSource + && draft.required_quorum >= (postbox?.protection_policy?.handover_quorum || 1) + ); + return ( + + + +
+ } + > +

+ New messages switch immediately. Historical migration is separately tracked so interrupted work is visible and resumable. +

+ + + + + + + {selected?.description} + + {draft.target_profile === "server_envelope_v1" ? ( + + onChange({ ...draft, target_vault_id: event.target.value })} + /> + + ) :
} + + + + + + + onChange({ ...draft, required_quorum })} + /> + +