feat(postbox): add governed content protection profiles

This commit is contained in:
2026-08-20 03:42:58 +02:00
parent 15d93aaa25
commit 174ee97719
16 changed files with 3917 additions and 383 deletions
+15 -5
View File
@@ -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 `govoplan.postbox.dispatch_routes` periodic Core worker drains due routes when
Celery beat and a worker consuming the `postbox` queue are enabled. Celery beat and a worker consuming the `postbox` queue are enabled.
Postboxes support `plaintext_v1` and an optional `server_envelope_v1` profile. Postboxes expose three configurable content-protection profiles. The recommended
The latter stores message bodies as ciphertext through the Encryption `server_envelope_v1` profile stores message bodies as ciphertext through an
capability and fails closed on reads if that capability or key is unavailable. 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 Subjects, participants, routing, attachment references, and lifecycle metadata
remain observable. Existing externally produced ciphertext references remain remain observable in every profile.
supported, but neither path is described as end-to-end encryption.
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: Run focused checks with:
+79 -44
View File
@@ -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 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. current holders of that responsibility; neither form is owned by one account.
The strategic target is an encrypted administrative postbox. The current The strategic target is a policy-selectable administrative postbox. The current
implementation supports ordinary persisted messages and an optional implementation offers unencrypted content, an institution-managed Encryption
server-readable Encryption envelope for message bodies. The model also retains envelope, and a strict external E2EE boundary. E2EE messages contain only an
external ciphertext, wrapped-key, signed-manifest, external-recipient-token, external ciphertext reference, signed-manifest reference, wrapped recipient
and key-epoch metadata needed for later independently reviewed E2EE profiles. keys, and a verified plaintext digest; an approved producer or client owns the
The server-envelope profile is not E2EE, and subjects, routing, participants, actual cryptographic operation and private-key custody. GovOPlaN cannot decrypt
and attachment references remain visible. The that profile. The institution-managed envelope remains server-readable by
cross-module target architecture is recorded in 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`. `govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`.
## Function-Organization-Bound Access ## 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. 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 An exact Postbox or template revision selects one profile. The administration
Encryption vault. New locally authored and delivered message bodies are then surface recommends the managed profile and requires its vault explicitly; the
stored in `body_ciphertext` with an owner-bound envelope reference; clear body API retains the legacy plaintext default when an older integration omits these
text is not persisted. Reads ask the optional `encryption.content_cipher` new fields so an upgrade cannot make an unavailable Encryption module block
capability to open the exact tenant, message, and envelope tuple. Missing existing automation.
Encryption, a lost deployment key, a destroyed vault key, ciphertext tampering,
or a mismatched resource causes a fail-closed read.
Plaintext Postboxes continue to work without Encryption. A protected Postbox - `server_envelope_v1` is the recommended standard. New local message bodies
cannot silently fall back to plaintext. Database recovery of protected messages are encrypted through the optional `encryption.content_cipher` capability,
requires Postbox and Encryption tables from the same consistency point plus the stored in `body_ciphertext`, and linked to an owner-bound envelope in the
matching provider/deployment key. Hierarchy-routed copies retain the source institution's selected vault. Authorized reads ask that capability to open
envelope reference rather than decrypting and re-encrypting during routing. 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 Each Postbox snapshots policy for the choices that cannot safely be inferred:
- 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
## 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 The defaults are deliberately conservative: history since assignment,
needs bounded decisions on: 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 ### Governed profile transitions
policy-defined date, or only content delivered during the assignment;
- organizational recovery/escrow and the authority required when every holder A profile change increments the Postbox key epoch and applies immediately to
loses all registered device keys; new messages. The administrator chooses whether retained history stays under
- whether ordinary rotation only rewraps per-content keys or also re-encrypts its existing profile or is migrated. Every transition records an idempotency
ciphertext, and which events require the stronger path; key, source and target profiles/vaults, user-consent and/or institutional
- assurance and quorum requirements for delegation, hand-over, emergency authorization evidence, quorum, reason, immutable configuration snapshot,
access, export, and destructive retention; and message digests, and per-message outcome.
- how attention/escalation works during a vacancy without granting plaintext
access to an unrelated personal account. 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.
@@ -25,6 +25,8 @@ def protect_message_body(
vault_id: str, vault_id: str,
plaintext: str, plaintext: str,
actor_id: str, actor_id: str,
operation_ref: str = "v1",
policy_decision_ref: str = "postbox:configured-server-envelope:v1",
) -> ProtectedContent: ) -> ProtectedContent:
capability = encryption_content_cipher(get_registry()) capability = encryption_content_cipher(get_registry())
if capability is None: if capability is None:
@@ -43,8 +45,8 @@ def protect_message_body(
vault_id=vault_id, vault_id=vault_id,
ciphertext_ref=f"postbox-db://messages/{message_id}/body", ciphertext_ref=f"postbox-db://messages/{message_id}/body",
plaintext=plaintext.encode("utf-8"), plaintext=plaintext.encode("utf-8"),
policy_decision_ref="postbox:configured-server-envelope:v1", policy_decision_ref=policy_decision_ref,
idempotency_key=f"postbox-message:{message_id}:body:v1", idempotency_key=f"postbox-message:{message_id}:body:{operation_ref}",
actor_id=actor_id, actor_id=actor_id,
metadata={"content_type": "text/plain;charset=utf-8"}, metadata={"content_type": "text/plain;charset=utf-8"},
), ),
@@ -10,6 +10,8 @@ from govoplan_postbox.backend.db.models import (
PostboxMessage, PostboxMessage,
PostboxMessageReceipt, PostboxMessageReceipt,
PostboxParticipant, PostboxParticipant,
PostboxProtectionTransition,
PostboxProtectionTransitionItem,
PostboxRoute, PostboxRoute,
PostboxTemplate, PostboxTemplate,
PostboxTemplateRevision, PostboxTemplateRevision,
@@ -27,6 +29,8 @@ __all__ = [
"PostboxMessage", "PostboxMessage",
"PostboxMessageReceipt", "PostboxMessageReceipt",
"PostboxParticipant", "PostboxParticipant",
"PostboxProtectionTransition",
"PostboxProtectionTransitionItem",
"PostboxRoute", "PostboxRoute",
"PostboxTemplate", "PostboxTemplate",
"PostboxTemplateRevision", "PostboxTemplateRevision",
+122 -6
View File
@@ -161,9 +161,7 @@ class PostboxTemplateRevision(Base, TimestampMixin):
default="plaintext_v1", default="plaintext_v1",
nullable=False, nullable=False,
) )
encryption_vault_id: Mapped[str | None] = mapped_column( encryption_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
String(255), nullable=True
)
history_policy: Mapped[dict[str, Any]] = mapped_column( history_policy: Mapped[dict[str, Any]] = mapped_column(
JSON, JSON,
default=dict, default=dict,
@@ -323,6 +321,11 @@ class Postbox(Base, TimestampMixin):
back_populates="postbox", back_populates="postbox",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
protection_transitions: Mapped[list["PostboxProtectionTransition"]] = relationship(
back_populates="postbox",
cascade="all, delete-orphan",
order_by="PostboxProtectionTransition.created_at",
)
@property @property
def strong_etag(self) -> str: def strong_etag(self) -> str:
@@ -439,9 +442,7 @@ class PostboxMessage(Base, TimestampMixin):
) )
subject: Mapped[str] = mapped_column(String(1000), nullable=False) subject: Mapped[str] = mapped_column(String(1000), nullable=False)
body_text: Mapped[str | None] = mapped_column(Text, nullable=True) body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_ciphertext: Mapped[bytes | None] = mapped_column( body_ciphertext: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
LargeBinary, nullable=True
)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(30), String(30),
default="delivered", 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): class PostboxParticipant(Base, TimestampMixin):
__tablename__ = "postbox_participants" __tablename__ = "postbox_participants"
__table_args__ = ( __table_args__ = (
+93 -12
View File
@@ -71,6 +71,7 @@ MODULE_ID = "postbox"
MODULE_NAME = "Postbox" MODULE_NAME = "Postbox"
MODULE_VERSION = "0.1.18" MODULE_VERSION = "0.1.18"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(
@@ -209,6 +210,8 @@ _OWNED_TABLES = (
postbox_models.PostboxDelivery, postbox_models.PostboxDelivery,
postbox_models.PostboxAttachmentReference, postbox_models.PostboxAttachmentReference,
postbox_models.PostboxParticipant, postbox_models.PostboxParticipant,
postbox_models.PostboxProtectionTransitionItem,
postbox_models.PostboxProtectionTransition,
postbox_models.PostboxMessage, postbox_models.PostboxMessage,
postbox_models.PostboxBinding, postbox_models.PostboxBinding,
postbox_models.Postbox, 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, order=33,
), ),
DocumentationTopic( DocumentationTopic(
@@ -525,6 +531,70 @@ manifest = ModuleManifest(
related_modules=("search", "idm", "encryption"), related_modules=("search", "idm", "encryption"),
order=34, 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( DocumentationTopic(
id="postbox.function-bound-containers", id="postbox.function-bound-containers",
title="Function-bound Postboxes", title="Function-bound Postboxes",
@@ -538,12 +608,11 @@ manifest = ModuleManifest(
"reassignment. Current access combines a generic Postbox " "reassignment. Current access combines a generic Postbox "
"permission with effective IDM assignment context. Templates " "permission with effective IDM assignment context. Templates "
"can lazily materialize unit-specific addresses, while exact " "can lazily materialize unit-specific addresses, while exact "
"postboxes cover exceptional responsibilities. Plaintext " "postboxes cover exceptional responsibilities. Administrators "
"Postboxes remain available without Encryption. A " "choose plaintext, the recommended institution-managed Encryption "
"server-envelope profile stores message bodies as ciphertext and " "envelope, or a strict externally produced E2EE contract. Managed "
"uses the optional Encryption capability for authorized reads. " "envelopes remain decryptable by authorized institutional key holders; "
"External ciphertext profiles retain producer-managed references " "E2EE rejects plaintext and GovOPlaN has no private decryption key. "
"and keys; neither profile is described as end-to-end encryption. "
"When Tasks is enabled, currently readable unread messages also appear " "When Tasks is enabled, currently readable unread messages also appear "
"in the common work inbox and disappear when the personal read receipt is recorded." "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.", "Subjects, participants, routing facts, and attachment references remain observable metadata.",
], ],
}, },
order=35, order=36,
), ),
DocumentationTopic( DocumentationTopic(
id="postbox.reference.fields-and-consequences", id="postbox.reference.fields-and-consequences",
@@ -660,7 +729,7 @@ manifest = ModuleManifest(
"withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.", "withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.",
}, },
}, },
order=36, order=37,
), ),
), ),
architecture=declared_module_architecture( architecture=declared_module_architecture(
@@ -672,10 +741,22 @@ manifest = ModuleManifest(
known_limits=( known_limits=(
"Subjects, routing metadata, participants, and attachment references remain plaintext metadata.", "Subjects, routing metadata, participants, and attachment references remain plaintext metadata.",
"Server-envelope protection is server-decryptable and is not end-to-end encryption.", "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",), recovery_docs=("docs/POSTBOX_CONCEPT.md",),
security_docs=("docs/POSTBOX_CONCEPT.md",), security_docs=("docs/POSTBOX_CONCEPT.md",),
operations_docs=("README.md",), operations_docs=("README.md",),
@@ -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")
@@ -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,
}
+256 -15
View File
@@ -28,6 +28,7 @@ from govoplan_core.core.files import (
PostboxFileReferenceRequest, PostboxFileReferenceRequest,
postbox_file_reference_provider, postbox_file_reference_provider,
) )
from govoplan_core.core.encryption import encryption_content_cipher
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import ( from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE, ACKNOWLEDGE_SCOPE,
@@ -60,6 +61,14 @@ from govoplan_postbox.backend.schemas import (
PostboxMessageStateRequest, PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse, PostboxOrganizationTargetsResponse,
PostboxMutationRequest, PostboxMutationRequest,
PostboxProtectionProfileItem,
PostboxProtectionProfileListResponse,
PostboxProtectionPolicyUpdateRequest,
PostboxProtectionTransformRequest,
PostboxProtectionTransitionCreateRequest,
PostboxProtectionTransitionItemResponse,
PostboxProtectionTransitionListResponse,
PostboxProtectionTransitionResponse,
PostboxRouteDryRunRequest, PostboxRouteDryRunRequest,
PostboxRouteDryRunResponse, PostboxRouteDryRunResponse,
PostboxTemplateCreateRequest, PostboxTemplateCreateRequest,
@@ -71,6 +80,10 @@ from govoplan_postbox.backend.schemas import (
PostboxTemplateReviseRequest, PostboxTemplateReviseRequest,
) )
from govoplan_postbox.backend.service import PostboxError 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 ( from govoplan_postbox.backend.principals import (
PostboxPrincipalError, PostboxPrincipalError,
actor_from_principal, actor_from_principal,
@@ -122,6 +135,8 @@ def _http_error(exc: PostboxError) -> HTTPException:
"revision_not_found", "revision_not_found",
"grouping_not_found", "grouping_not_found",
"target_not_found", "target_not_found",
"protection_transition_not_found",
"transition_item_not_found",
}: }:
code = status.HTTP_404_NOT_FOUND code = status.HTTP_404_NOT_FOUND
elif exc.code in {"access_denied", "grouping_source_denied"}: elif exc.code in {"access_denied", "grouping_source_denied"}:
@@ -195,6 +210,11 @@ def _authoring_request(
idempotency_key=payload.idempotency_key, idempotency_key=payload.idempotency_key,
subject=payload.subject, subject=payload.subject,
body_text=payload.body_text, 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, classification=payload.classification,
participants=tuple( participants=tuple(
PostboxParticipantRef(**participant.model_dump()) PostboxParticipantRef(**participant.model_dump())
@@ -236,9 +256,7 @@ def _template_item(template) -> PostboxTemplateItem:
"scope_kind": revision.scope_kind, "scope_kind": revision.scope_kind,
"scope_id": revision.scope_id, "scope_id": revision.scope_id,
"scope_structure_id": revision.scope_structure_id, "scope_structure_id": revision.scope_structure_id,
"scope_relation_type_ids": list( "scope_relation_type_ids": list(revision.scope_relation_type_ids or []),
revision.scope_relation_type_ids or []
),
"name_pattern": revision.name_pattern, "name_pattern": revision.name_pattern,
"address_pattern": revision.address_pattern, "address_pattern": revision.address_pattern,
"classification": revision.classification, "classification": revision.classification,
@@ -246,6 +264,7 @@ def _template_item(template) -> PostboxTemplateItem:
"portal_visible": revision.portal_visible, "portal_visible": revision.portal_visible,
"encryption_profile": revision.encryption_profile, "encryption_profile": revision.encryption_profile,
"encryption_vault_id": revision.encryption_vault_id, "encryption_vault_id": revision.encryption_vault_id,
"protection_policy": dict(revision.history_policy or {}),
"history_policy": dict(revision.history_policy or {}), "history_policy": dict(revision.history_policy or {}),
"routing_policy": dict(revision.routing_policy or {}), "routing_policy": dict(revision.routing_policy or {}),
"retention_policy": dict(revision.retention_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) @router.get("/directory", response_model=PostboxDirectoryResponse)
def api_postbox_directory( def api_postbox_directory(
assignment_context_id: str | None = None, assignment_context_id: str | None = None,
@@ -523,7 +584,13 @@ def api_resolve_postbox_message_attachments(
) )
provider = postbox_file_reference_provider(get_registry()) 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( requests = tuple(
PostboxFileReferenceRequest( PostboxFileReferenceRequest(
reference_type=attachment.reference_type, reference_type=attachment.reference_type,
@@ -544,9 +611,7 @@ def api_resolve_postbox_message_attachments(
if provider is not None and requests if provider is not None and requests
else () else ()
) )
by_reference = { by_reference = {(item.reference_type, item.reference_id): item for item in resolved}
(item.reference_type, item.reference_id): item for item in resolved
}
items: list[PostboxAttachmentResolutionItem] = [] items: list[PostboxAttachmentResolutionItem] = []
for attachment in message.attachments: for attachment in message.attachments:
resolution = by_reference.get( resolution = by_reference.get(
@@ -667,8 +732,7 @@ def api_deliver_to_postbox(
ciphertext_ref=payload.ciphertext_ref, ciphertext_ref=payload.ciphertext_ref,
signed_manifest_ref=payload.signed_manifest_ref, signed_manifest_ref=payload.signed_manifest_ref,
wrapped_keys=tuple( wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump()) PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
for item in payload.wrapped_keys
), ),
external_recipient_tokens=tuple( external_recipient_tokens=tuple(
PostboxExternalRecipientTokenRef(**item.model_dump()) PostboxExternalRecipientTokenRef(**item.model_dump())
@@ -853,13 +917,9 @@ def api_postbox_organization_targets(
) -> PostboxOrganizationTargetsResponse: ) -> PostboxOrganizationTargetsResponse:
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE) _require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
return PostboxOrganizationTargetsResponse( return PostboxOrganizationTargetsResponse(
units=list( units=list(get_service().organization_targets(tenant_id=principal.tenant_id)),
get_service().organization_targets(tenant_id=principal.tenant_id)
),
structures=list( structures=list(
get_service().organization_hierarchy_targets( get_service().organization_hierarchy_targets(tenant_id=principal.tenant_id)
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( @router.post(
"/admin/postboxes", "/admin/postboxes",
response_model=PostboxDirectoryItem, response_model=PostboxDirectoryItem,
+291 -31
View File
@@ -5,6 +5,12 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from govoplan_postbox.backend.protection_profiles import (
POSTBOX_MANAGED_ENVELOPE_PROFILE,
POSTBOX_PLAINTEXT_PROFILE,
PostboxProtectionProfile,
)
PostboxClassification = Literal[ PostboxClassification = Literal[
"public", "public",
@@ -48,6 +54,10 @@ class PostboxDirectoryItem(BaseModel):
template_revision_id: str | None = None template_revision_id: str | None = None
holder_count: int = 0 holder_count: int = 0
vacant: bool = True 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 access: PostboxAccessDecisionResponse | None = None
resource_revision: int = Field(default=1, ge=1) resource_revision: int = Field(default=1, ge=1)
etag: str | None = None etag: str | None = None
@@ -156,11 +166,32 @@ class PostboxMessageAuthoringPayload(BaseModel):
idempotency_key: str = Field(min_length=1, max_length=255) idempotency_key: str = Field(min_length=1, max_length=255)
subject: str = Field(min_length=1, max_length=1000) subject: str = Field(min_length=1, max_length=1000)
body_text: str | None = None 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" classification: PostboxClassification = "internal"
participants: list[PostboxParticipantPayload] = Field(default_factory=list) participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list) attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
@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): class PostboxMessageCreateRequest(PostboxMessageAuthoringPayload):
postbox_id: str = Field(min_length=1, max_length=36) postbox_id: str = Field(min_length=1, max_length=36)
@@ -178,9 +209,7 @@ class PostboxTargetPayload(BaseModel):
def validate_target(self) -> "PostboxTargetPayload": def validate_target(self) -> "PostboxTargetPayload":
direct = bool(self.postbox_id or self.address_key) direct = bool(self.postbox_id or self.address_key)
templated = bool( templated = bool(
self.template_id self.template_id and self.organization_unit_id and self.function_id
and self.organization_unit_id
and self.function_id
) )
if direct == templated: if direct == templated:
raise ValueError( raise ValueError(
@@ -210,6 +239,24 @@ class PostboxDeliveryCreateRequest(BaseModel):
) )
metadata: dict[str, Any] = Field(default_factory=dict) 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): class PostboxDeliveryResponse(BaseModel):
delivery_id: str delivery_id: str
@@ -248,14 +295,10 @@ class PostboxLinkedCopyPolicyPayload(BaseModel):
def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload": def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload":
self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids)) self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids))
self.allowed_classifications = list( self.allowed_classifications = list(
dict.fromkeys( dict.fromkeys(value.strip() for value in self.allowed_classifications)
value.strip() for value in self.allowed_classifications
)
) )
self.allowed_producer_modules = list( self.allowed_producer_modules = list(
dict.fromkeys( dict.fromkeys(value.strip() for value in self.allowed_producer_modules)
value.strip() for value in self.allowed_producer_modules
)
) )
if any(not value for value in self.relation_type_ids): if any(not value for value in self.relation_type_ids):
raise ValueError("Relation type IDs must not be empty.") raise ValueError("Relation type IDs must not be empty.")
@@ -316,16 +359,10 @@ class PostboxRoutingPolicyPayload(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_semantics(self) -> "PostboxRoutingPolicyPayload": def validate_semantics(self) -> "PostboxRoutingPolicyPayload":
if ( if self.attention.mode == "vacancy_escalation" and (
self.attention.mode == "vacancy_escalation" not self.linked_copy.enabled or self.linked_copy.fanout != "nearest"
and (
not self.linked_copy.enabled
or self.linked_copy.fanout != "nearest"
)
): ):
raise ValueError( raise ValueError("Vacancy escalation requires nearest linked-copy routing.")
"Vacancy escalation requires nearest linked-copy routing."
)
return self return self
@@ -361,6 +398,73 @@ class PostboxRouteDryRunResponse(BaseModel):
diagnostics: list[str] = Field(default_factory=list) 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): class PostboxExactCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=500) name: str = Field(min_length=1, max_length=500)
description: str | None = None description: str | None = None
@@ -369,25 +473,31 @@ class PostboxExactCreateRequest(BaseModel):
address_key: str | None = Field(default=None, max_length=120) address_key: str | None = Field(default=None, max_length=120)
classification: PostboxClassification = "internal" classification: PostboxClassification = "internal"
portal_visible: bool = False portal_visible: bool = False
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE
"plaintext_v1"
)
encryption_vault_id: str | None = Field(default=None, max_length=255) encryption_vault_id: str | None = Field(default=None, max_length=255)
protection_policy: PostboxProtectionPolicyPayload = Field(
default_factory=PostboxProtectionPolicyPayload
)
@model_validator(mode="after") @model_validator(mode="after")
def validate_encryption(self) -> "PostboxExactCreateRequest": 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(): if not str(self.encryption_vault_id or "").strip():
raise ValueError( raise ValueError(
"Server-envelope Postboxes require an encryption vault." "Server-envelope Postboxes require an encryption vault."
) )
elif self.encryption_vault_id: elif self.encryption_vault_id:
raise ValueError( raise ValueError(
"A plaintext Postbox cannot select an encryption vault." "Only an institution-managed Postbox can select an encryption vault."
) )
return self return self
class PostboxProtectionPolicyUpdateRequest(BaseModel):
base_revision: int = Field(ge=1)
protection_policy: PostboxProtectionPolicyPayload
class PostboxTemplateRevisionPayload(BaseModel): class PostboxTemplateRevisionPayload(BaseModel):
function_type_id: str | None = Field(default=None, max_length=36) function_type_id: str | None = Field(default=None, max_length=36)
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant" scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
@@ -407,10 +517,11 @@ class PostboxTemplateRevisionPayload(BaseModel):
classification: PostboxClassification = "internal" classification: PostboxClassification = "internal"
allow_vacant_delivery: bool = True allow_vacant_delivery: bool = True
portal_visible: bool = False portal_visible: bool = False
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE
"plaintext_v1"
)
encryption_vault_id: str | None = Field(default=None, max_length=255) encryption_vault_id: str | None = Field(default=None, max_length=255)
protection_policy: PostboxProtectionPolicyPayload = Field(
default_factory=PostboxProtectionPolicyPayload
)
routing_policy: PostboxRoutingPolicyPayload = Field( routing_policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload default_factory=PostboxRoutingPolicyPayload
) )
@@ -426,18 +537,169 @@ class PostboxTemplateRevisionPayload(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_encryption(self) -> "PostboxTemplateRevisionPayload": 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(): if not str(self.encryption_vault_id or "").strip():
raise ValueError( raise ValueError(
"Server-envelope Postbox templates require an encryption vault." "Server-envelope Postbox templates require an encryption vault."
) )
elif self.encryption_vault_id: elif self.encryption_vault_id:
raise ValueError( raise ValueError(
"A plaintext Postbox template cannot select an encryption vault." "Only an institution-managed Postbox template can select an encryption vault."
) )
return self 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( def _validate_template_write_scope(
payload: PostboxTemplateRevisionPayload, payload: PostboxTemplateRevisionPayload,
) -> None: ) -> None:
@@ -586,9 +848,7 @@ class PostboxOrganizationStructureItem(BaseModel):
class PostboxOrganizationTargetsResponse(BaseModel): class PostboxOrganizationTargetsResponse(BaseModel):
units: list[PostboxOrganizationUnitItem] units: list[PostboxOrganizationUnitItem]
structures: list[PostboxOrganizationStructureItem] = Field( structures: list[PostboxOrganizationStructureItem] = Field(default_factory=list)
default_factory=list
)
class PostboxGroupingPayload(BaseModel): class PostboxGroupingPayload(BaseModel):
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -38,6 +38,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions." "govoplan_postbox.backend.migrations.versions."
"f2a5c8e1b4d7_v015_portal_visibility" "f2a5c8e1b4d7_v015_portal_visibility"
) )
transition_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"a7c1e4f8b2d6_v016_protection_transitions"
)
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
try: try:
with engine.begin() as connection: with engine.begin() as connection:
@@ -49,6 +53,7 @@ class PostboxMigrationTests(unittest.TestCase):
protection_original = protection_migration.op protection_original = protection_migration.op
scope_original = scope_migration.op scope_original = scope_migration.op
portal_original = portal_migration.op portal_original = portal_migration.op
transition_original = transition_migration.op
migration.op = operations migration.op = operations
route_migration.op = operations route_migration.op = operations
occ_migration.op = operations occ_migration.op = operations
@@ -56,6 +61,7 @@ class PostboxMigrationTests(unittest.TestCase):
protection_migration.op = operations protection_migration.op = operations
scope_migration.op = operations scope_migration.op = operations
portal_migration.op = operations portal_migration.op = operations
transition_migration.op = operations
try: try:
migration.upgrade() migration.upgrade()
route_migration.upgrade() route_migration.upgrade()
@@ -64,11 +70,14 @@ class PostboxMigrationTests(unittest.TestCase):
protection_migration.upgrade() protection_migration.upgrade()
scope_migration.upgrade() scope_migration.upgrade()
portal_migration.upgrade() portal_migration.upgrade()
transition_migration.upgrade()
tables = set(inspect(connection).get_table_names()) tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables) self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables) self.assertIn("postbox_messages", tables)
self.assertIn("postbox_deliveries", tables) self.assertIn("postbox_deliveries", tables)
self.assertIn("postbox_access_events", tables) self.assertIn("postbox_access_events", tables)
self.assertIn("postbox_protection_transitions", tables)
self.assertIn("postbox_protection_transition_items", tables)
message_columns = { message_columns = {
column["name"] column["name"]
for column in inspect(connection).get_columns( for column in inspect(connection).get_columns(
@@ -120,15 +129,12 @@ class PostboxMigrationTests(unittest.TestCase):
) )
route_columns = { route_columns = {
column["name"] column["name"]
for column in inspect(connection).get_columns( for column in inspect(connection).get_columns("postbox_routes")
"postbox_routes"
)
} }
self.assertTrue( self.assertTrue(
{"execute_after", "processed_at"}.issubset( {"execute_after", "processed_at"}.issubset(route_columns)
route_columns
)
) )
transition_migration.downgrade()
portal_migration.downgrade() portal_migration.downgrade()
scope_migration.downgrade() scope_migration.downgrade()
protection_migration.downgrade() protection_migration.downgrade()
@@ -151,6 +157,7 @@ class PostboxMigrationTests(unittest.TestCase):
protection_migration.op = protection_original protection_migration.op = protection_original
scope_migration.op = scope_original scope_migration.op = scope_original
portal_migration.op = portal_original portal_migration.op = portal_original
transition_migration.op = transition_original
finally: finally:
engine.dispose() engine.dispose()
+368 -51
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import unittest import unittest
from dataclasses import replace
from datetime import timedelta from datetime import timedelta
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
@@ -49,6 +51,8 @@ from govoplan_postbox.backend.db.models import (
PostboxMessage, PostboxMessage,
PostboxMessageReceipt, PostboxMessageReceipt,
PostboxParticipant, PostboxParticipant,
PostboxProtectionTransition,
PostboxProtectionTransitionItem,
PostboxRoute, PostboxRoute,
PostboxTemplate, PostboxTemplate,
PostboxTemplateRevision, PostboxTemplateRevision,
@@ -64,6 +68,8 @@ POSTBOX_TABLES = (
Postbox.__table__, Postbox.__table__,
PostboxBinding.__table__, PostboxBinding.__table__,
PostboxMessage.__table__, PostboxMessage.__table__,
PostboxProtectionTransition.__table__,
PostboxProtectionTransitionItem.__table__,
PostboxParticipant.__table__, PostboxParticipant.__table__,
PostboxAttachmentReference.__table__, PostboxAttachmentReference.__table__,
PostboxDelivery.__table__, PostboxDelivery.__table__,
@@ -87,7 +93,9 @@ class FakeIdentityDirectory:
) )
def identities_for_accounts(self, account_ids): 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): def accounts_for_identity(self, identity_id: str):
return () return ()
@@ -335,8 +343,7 @@ class FakeOrganizationDirectory:
if function.function_type_id == function_type_id if function.function_type_id == function_type_id
and ( and (
not organization_unit_ids not organization_unit_ids
or function.organization_unit_id or function.organization_unit_id in organization_unit_ids
in organization_unit_ids
) )
), ),
) )
@@ -355,8 +362,7 @@ class FakeOrganizationDirectory:
matches=tuple( matches=tuple(
unit unit
for unit in self.units.values() for unit in self.units.values()
if unit.tenant_id == tenant_id if unit.tenant_id == tenant_id and unit.unit_type_id == unit_type_id
and unit.unit_type_id == unit_type_id
), ),
) )
@@ -457,9 +463,7 @@ class FakeOrganizationDirectory:
root=root, root=root,
matches=matches, matches=matches,
cycle_detected=self.cycle_detected, cycle_detected=self.cycle_detected,
depth_limited=( depth_limited=(structure_id == self.structure.id and max_depth < 2),
structure_id == self.structure.id and max_depth < 2
),
) )
) )
return tuple(results) return tuple(results)
@@ -529,15 +533,20 @@ class PostboxServiceTests(unittest.TestCase):
self.actor = PostboxActorRef( self.actor = PostboxActorRef(
account_id="account-1", account_id="account-1",
identity_id="identity-1", identity_id="identity-1",
authorized_actions=frozenset( authorized_actions=frozenset({"discover", "read", "send", "acknowledge"}),
{"discover", "read", "send", "acknowledge"}
),
) )
def tearDown(self) -> None: def tearDown(self) -> None:
self.engine.dispose() 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( return self.service.create_exact_postbox(
session, session,
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -548,6 +557,9 @@ class PostboxServiceTests(unittest.TestCase):
description=None, description=None,
classification="internal", classification="internal",
actor_id="admin-1", actor_id="admin-1",
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
protection_policy=protection_policy,
) )
def _routing_policy( def _routing_policy(
@@ -575,11 +587,7 @@ class PostboxServiceTests(unittest.TestCase):
"max_retention_days": 30, "max_retention_days": 30,
}, },
"attention": { "attention": {
"mode": ( "mode": ("vacancy_escalation" if vacancy_escalation else "none"),
"vacancy_escalation"
if vacancy_escalation
else "none"
),
"delay_minutes": 1 if vacancy_escalation else None, "delay_minutes": 1 if vacancy_escalation else None,
}, },
"shared_visibility": {"mode": "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: with Session(self.engine) as session:
template = self.service.create_template( template = self.service.create_template(
session, session,
@@ -957,6 +967,7 @@ class PostboxServiceTests(unittest.TestCase):
routing_policy={}, routing_policy={},
encryption_profile="plaintext_v1", encryption_profile="plaintext_v1",
encryption_vault_id=None, encryption_vault_id=None,
protection_policy={},
context_key=None, context_key=None,
limit=200, limit=200,
) )
@@ -985,6 +996,7 @@ class PostboxServiceTests(unittest.TestCase):
routing_policy={}, routing_policy={},
encryption_profile="plaintext_v1", encryption_profile="plaintext_v1",
encryption_vault_id=None, encryption_vault_id=None,
protection_policy={},
context_key=None, context_key=None,
limit=200, limit=200,
) )
@@ -1014,6 +1026,7 @@ class PostboxServiceTests(unittest.TestCase):
routing_policy={}, routing_policy={},
encryption_profile="plaintext_v1", encryption_profile="plaintext_v1",
encryption_vault_id=None, encryption_vault_id=None,
protection_policy={},
context_key=None, context_key=None,
limit=200, limit=200,
) )
@@ -1067,9 +1080,7 @@ class PostboxServiceTests(unittest.TestCase):
[item.slug for item in catalog.templates], [item.slug for item in catalog.templates],
) )
north = next( north = next(
unit unit for unit in catalog.organization_units if unit.id == "unit-1"
for unit in catalog.organization_units
if unit.id == "unit-1"
) )
self.assertEqual( self.assertEqual(
["function-1"], ["function-1"],
@@ -1309,7 +1320,11 @@ class PostboxServiceTests(unittest.TestCase):
self.idm.assignments.append(self.assignment) self.idm.assignments.append(self.assignment)
expires_at = utc_now() + timedelta(days=1) expires_at = utc_now() + timedelta(days=1)
with Session(self.engine) as session: 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( delivered = self.service.deliver(
session, session,
PostboxDeliveryRequest( PostboxDeliveryRequest(
@@ -1340,6 +1355,7 @@ class PostboxServiceTests(unittest.TestCase):
assurance_profile="email-otp", 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 assert message.external_recipient_tokens[0].expires_at is not None
self.assertEqual( self.assertEqual(
expires_at.replace(microsecond=0), expires_at.replace(microsecond=0),
message.external_recipient_tokens[0].expires_at.replace( message.external_recipient_tokens[0].expires_at.replace(microsecond=0),
microsecond=0
),
) )
def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed( def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed(
@@ -1445,14 +1459,330 @@ class PostboxServiceTests(unittest.TestCase):
actor=self.actor, 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( def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
self, self,
) -> None: ) -> None:
self.idm.assignments.append(self.assignment) self.idm.assignments.append(self.assignment)
with Session(self.engine) as session: with Session(self.engine) as session:
service, source, _target_template = ( service, source, _target_template = self._create_routing_source(session)
self._create_routing_source(session)
)
result = service.deliver( result = service.deliver(
session, session,
PostboxDeliveryRequest( PostboxDeliveryRequest(
@@ -1478,10 +1808,7 @@ class PostboxServiceTests(unittest.TestCase):
self.assertNotEqual(result.message_id, route.target_message_id) self.assertNotEqual(result.message_id, route.target_message_id)
self.assertEqual( self.assertEqual(
["edge-child-parent"], ["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( self.assertEqual(
route.id, route.id,
@@ -1496,7 +1823,9 @@ class PostboxServiceTests(unittest.TestCase):
) )
session.commit() session.commit()
receipts = session.query(PostboxMessageReceipt).all() 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( summary = service.delivery_receipt_summaries(
session, session,
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -1512,8 +1841,8 @@ class PostboxServiceTests(unittest.TestCase):
self, self,
) -> None: ) -> None:
with Session(self.engine) as session: with Session(self.engine) as session:
service, source, _target_template = ( service, source, _target_template = self._create_routing_source(
self._create_routing_source(session, max_depth=1) session, max_depth=1
) )
blocked = service.preview_hierarchy_routes( blocked = service.preview_hierarchy_routes(
session, session,
@@ -1573,9 +1902,7 @@ class PostboxServiceTests(unittest.TestCase):
self.organizations.duplicate_parent_match = True self.organizations.duplicate_parent_match = True
self.organizations.cycle_detected = True self.organizations.cycle_detected = True
with Session(self.engine) as session: with Session(self.engine) as session:
service, source, _target_template = ( service, source, _target_template = self._create_routing_source(session)
self._create_routing_source(session)
)
preview = service.preview_hierarchy_routes( preview = service.preview_hierarchy_routes(
session, session,
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -1588,10 +1915,7 @@ class PostboxServiceTests(unittest.TestCase):
self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"]) self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"])
self.assertEqual( self.assertEqual(
1, 1,
sum( sum(route["status"] == "duplicate" for route in preview["routes"]),
route["status"] == "duplicate"
for route in preview["routes"]
),
) )
with self.assertRaisesRegex( with self.assertRaisesRegex(
ValueError, ValueError,
@@ -1639,11 +1963,7 @@ class PostboxServiceTests(unittest.TestCase):
), ),
) )
session.commit() session.commit()
routes = ( routes = session.query(PostboxRoute).order_by(PostboxRoute.depth).all()
session.query(PostboxRoute)
.order_by(PostboxRoute.depth)
.all()
)
self.assertEqual( self.assertEqual(
["accepted_vacant", "pending_vacancy_escalation"], ["accepted_vacant", "pending_vacancy_escalation"],
[route.status for route in routes], [route.status for route in routes],
@@ -1687,10 +2007,7 @@ class PostboxServiceTests(unittest.TestCase):
session.commit() session.commit()
pending = ( pending = (
session.query(PostboxRoute) session.query(PostboxRoute)
.filter( .filter(PostboxRoute.status == "pending_vacancy_escalation")
PostboxRoute.status
== "pending_vacancy_escalation"
)
.one() .one()
) )
pending.execute_after = utc_now() - timedelta(seconds=1) pending.execute_after = utc_now() - timedelta(seconds=1)
+145 -1
View File
@@ -41,11 +41,84 @@ export type PostboxDirectoryItem = {
template_revision_id?: string | null; template_revision_id?: string | null;
holder_count: number; holder_count: number;
vacant: boolean; vacant: boolean;
encryption_profile: PostboxProtectionProfileId;
key_epoch: number;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
access?: PostboxAccessDecision | null; access?: PostboxAccessDecision | null;
resource_revision: number; resource_revision: number;
etag: string; 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<string, unknown>;
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 = { export type PostboxParticipant = {
kind: string; kind: string;
reference_type: string; reference_type: string;
@@ -93,7 +166,7 @@ export type PostboxMessage = {
producer_resource_id?: string | null; producer_resource_id?: string | null;
in_reply_to_message_id?: string | null; in_reply_to_message_id?: string | null;
replaces_message_id?: string | null; replaces_message_id?: string | null;
encryption_profile: string; encryption_profile: PostboxProtectionProfileId;
key_epoch: number; key_epoch: number;
ciphertext_ref?: string | null; ciphertext_ref?: string | null;
signed_manifest_ref?: string | null; signed_manifest_ref?: string | null;
@@ -213,6 +286,8 @@ export type PostboxTemplateRevision = {
allow_vacant_delivery: boolean; allow_vacant_delivery: boolean;
portal_visible: boolean; portal_visible: boolean;
encryption_profile: string; encryption_profile: string;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
history_policy: Record<string, unknown>; history_policy: Record<string, unknown>;
routing_policy: PostboxRoutingPolicy; routing_policy: PostboxRoutingPolicy;
retention_policy: Record<string, unknown>; retention_policy: Record<string, unknown>;
@@ -248,6 +323,9 @@ export type PostboxTemplateRevisionPayload = Pick<
| "classification" | "classification"
| "allow_vacant_delivery" | "allow_vacant_delivery"
| "portal_visible" | "portal_visible"
| "encryption_profile"
| "encryption_vault_id"
| "protection_policy"
| "routing_policy" | "routing_policy"
>; >;
@@ -290,12 +368,18 @@ export type PostboxExactCreatePayload = {
address_key?: string | null; address_key?: string | null;
classification: string; classification: string;
portal_visible: boolean; portal_visible: boolean;
encryption_profile: PostboxProtectionProfileId;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
}; };
export type PostboxMessageAuthoringPayload = { export type PostboxMessageAuthoringPayload = {
idempotency_key: string; idempotency_key: string;
subject: string; subject: string;
body_text?: string | null; body_text?: string | null;
ciphertext_ref?: string | null;
signed_manifest_ref?: string | null;
wrapped_keys?: PostboxMessage["wrapped_keys"];
classification: string; classification: string;
participants: PostboxParticipant[]; participants: PostboxParticipant[];
attachments: PostboxAttachment[]; attachments: PostboxAttachment[];
@@ -469,6 +553,66 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise<Postbox
return response.postboxes; return response.postboxes;
} }
export async function listPostboxProtectionProfiles(
settings: ApiSettings
): Promise<{ standard_profile: PostboxProtectionProfileId; profiles: PostboxProtectionProfile[] }> {
return apiFetch(settings, "/api/v1/postbox/admin/protection-profiles");
}
export async function listPostboxProtectionTransitions(
settings: ApiSettings,
postboxId: string
): Promise<PostboxProtectionTransition[]> {
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<PostboxProtectionTransition> {
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<PostboxDirectoryItem> {
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( export async function listPostboxOrganizationTargets(
settings: ApiSettings settings: ApiSettings
): Promise<PostboxOrganizationTargets> { ): Promise<PostboxOrganizationTargets> {
File diff suppressed because it is too large Load Diff
+38 -4
View File
@@ -164,6 +164,10 @@ export default function PostboxPage({
() => groupings.find((grouping) => grouping.id === selectedScope) ?? null, () => groupings.find((grouping) => grouping.id === selectedScope) ?? null,
[groupings, selectedScope] [groupings, selectedScope]
); );
const composeTarget = useMemo(
() => postboxes.find((postbox) => postbox.id === messageDraft.postbox_id) ?? null,
[messageDraft.postbox_id, postboxes]
);
const scopePostboxIds = useMemo(() => { const scopePostboxIds = useMemo(() => {
if (selectedPostboxId) return [selectedPostboxId]; if (selectedPostboxId) return [selectedPostboxId];
if (selectedGrouping) { if (selectedGrouping) {
@@ -179,6 +183,9 @@ export default function PostboxPage({
const replyDisabledReason = postboxBusyReason(false, busy) const replyDisabledReason = postboxBusyReason(false, busy)
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined) ?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined) ?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.encryption_profile === "external_e2ee_v1"
? "Replies to E2EE messages must be created by an approved encryption client."
: undefined)
?? (selectedMessage?.availability !== "available" ?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage ? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined); : undefined);
@@ -509,7 +516,10 @@ export default function PostboxPage({
} }
function openCompose() { function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null; const postbox = selectedPostbox
?? postboxes.find((item) => item.encryption_profile !== "external_e2ee_v1")
?? postboxes[0]
?? null;
if (!postbox) return; if (!postbox) return;
openComposeFor(postbox); openComposeFor(postbox);
} }
@@ -543,7 +553,11 @@ export default function PostboxPage({
} }
async function submitMessage(): Promise<boolean> { async function submitMessage(): Promise<boolean> {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return false; if (
!messageDraft.postbox_id
|| !messageDraft.subject.trim()
|| composeTarget?.encryption_profile === "external_e2ee_v1"
) return false;
setBusy(true); setBusy(true);
setError(""); setError("");
const participants = messageDraft.recipients const participants = messageDraft.recipients
@@ -1034,9 +1048,10 @@ export default function PostboxPage({
disabled={ disabled={
busy || busy ||
!messageDraft.postbox_id || !messageDraft.postbox_id ||
!messageDraft.subject.trim() !messageDraft.subject.trim() ||
composeTarget?.encryption_profile === "external_e2ee_v1"
} }
disabledReason={postboxBusyReason(false, busy) ?? ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)} disabledReason={postboxBusyReason(false, busy) ?? (composeTarget?.encryption_profile === "external_e2ee_v1" ? "This browser editor has no E2EE private-key custody. Use an approved encryption client for this Postbox." : ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined))}
> >
<Send size={16} /> Send <Send size={16} /> Send
</Button> </Button>
@@ -1044,6 +1059,13 @@ export default function PostboxPage({
} }
> >
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-compose-grid"> <FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-compose-grid">
{composeTarget?.encryption_profile === "external_e2ee_v1" ? (
<div className="postbox-compose-wide">
<DismissibleAlert tone="info" compact resetKey={composeTarget.id}>
This Postbox requires externally produced E2EE. Use an approved client that supplies ciphertext, a signed manifest, wrapped keys, and a verified content digest; this browser editor never asks for or stores the private key.
</DismissibleAlert>
</div>
) : null}
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}> <FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select <select
value={messageDraft.postbox_id} value={messageDraft.postbox_id}
@@ -1204,7 +1226,19 @@ function MessageDetail({
</dl> </dl>
</section> </section>
<section className="postbox-body"> <section className="postbox-body">
{message.encryption_profile === "external_e2ee_v1" ? (
<>
<DismissibleAlert tone="info" compact resetKey={message.id}>
This message is end-to-end encrypted. GovOPlaN stores and authorizes its envelope but cannot decrypt the content; open it with the institution's approved client.
</DismissibleAlert>
<dl className="postbox-provenance">
<div><dt>Ciphertext</dt><dd>{message.ciphertext_ref || "Not recorded"}</dd></div>
<div><dt>Signed manifest</dt><dd>{message.signed_manifest_ref || "Not recorded"}</dd></div>
</dl>
</>
) : (
<p>{message.body_text || "No plaintext body is available for this message."}</p> <p>{message.body_text || "No plaintext body is available for this message."}</p>
)}
</section> </section>
{message.participants.length ? ( {message.participants.length ? (
<section className="postbox-participants"> <section className="postbox-participants">