feat: protect postbox message content

This commit is contained in:
2026-08-02 03:40:57 +02:00
parent 7d310d5c33
commit 8f8d259a76
12 changed files with 611 additions and 29 deletions
+6 -4
View File
@@ -92,10 +92,12 @@ 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.
The persistence model reserves ciphertext manifests, wrapped keys, key epochs, Postboxes support `plaintext_v1` and an optional `server_envelope_v1` profile.
expiry, and withdrawal state. The active profile remains `plaintext_v1`; the The latter stores message bodies as ciphertext through the Encryption
module does not claim end-to-end encryption until the history, recovery, and capability and fails closed on reads if that capability or key is unavailable.
handover policy choices in the security issues are resolved. Subjects, participants, routing, attachment references, and lifecycle metadata
remain observable. Existing externally produced ciphertext references remain
supported, but neither path is described as end-to-end encryption.
Run focused checks with: Run focused checks with:
+23 -4
View File
@@ -12,10 +12,13 @@ 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 first The strategic target is an encrypted administrative postbox. The current
implementation may start with ordinary persisted messages, but the model must implementation supports ordinary persisted messages and an optional
not prevent later end-to-end encryption, role/function key epochs, signed server-readable Encryption envelope for message bodies. The model also retains
manifests, external-recipient tokens, or honest retraction semantics. The external ciphertext, wrapped-key, signed-manifest, external-recipient-token,
and key-epoch metadata needed for later independently reviewed E2EE profiles.
The server-envelope profile is not E2EE, and subjects, routing, participants,
and attachment references remain visible. The
cross-module target architecture is recorded in cross-module target architecture is recorded in
`govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`. `govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`.
@@ -330,6 +333,22 @@ 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
An exact Postbox or template revision may select `server_envelope_v1` and an
Encryption vault. New locally authored and delivered message bodies are then
stored in `body_ciphertext` with an owner-bound envelope reference; clear body
text is not persisted. Reads ask the optional `encryption.content_cipher`
capability to open the exact tenant, message, and envelope tuple. Missing
Encryption, a lost deployment key, a destroyed vault key, ciphertext tampering,
or a mismatched resource causes a fail-closed read.
Plaintext Postboxes continue to work without Encryption. A protected Postbox
cannot silently fall back to plaintext. Database recovery of protected messages
requires Postbox and Encryption tables from the same consistency point plus the
matching provider/deployment key. Hierarchy-routed copies retain the source
envelope reference rather than decrypting and re-encrypting during routing.
## E2EE Readiness Checklist ## E2EE Readiness Checklist
Before the data model is considered stable, verify that it can represent: Before the data model is considered stable, verify that it can represent:
@@ -0,0 +1,96 @@
from __future__ import annotations
from govoplan_core.core.encryption import (
ContentProtectionRequest,
ContentUnprotectionRequest,
ProtectedContent,
encryption_content_cipher,
)
from govoplan_postbox.backend.runtime import get_registry
POSTBOX_PLAINTEXT_PROFILE = "plaintext_v1"
POSTBOX_SERVER_ENVELOPE_PROFILE = "server_envelope_v1"
class PostboxContentProtectionError(ValueError):
pass
def protect_message_body(
session: object,
*,
tenant_id: str,
message_id: str,
vault_id: str,
plaintext: str,
actor_id: str,
) -> ProtectedContent:
capability = encryption_content_cipher(get_registry())
if capability is None:
raise PostboxContentProtectionError(
"Postbox encryption was requested, but Encryption is unavailable."
)
try:
return capability.protect_content(
session,
request=ContentProtectionRequest(
tenant_id=tenant_id,
owner_module="postbox",
resource_type="postbox_message_body",
resource_id=message_id,
profile_id=POSTBOX_SERVER_ENVELOPE_PROFILE,
vault_id=vault_id,
ciphertext_ref=f"postbox-db://messages/{message_id}/body",
plaintext=plaintext.encode("utf-8"),
policy_decision_ref="postbox:configured-server-envelope:v1",
idempotency_key=f"postbox-message:{message_id}:body:v1",
actor_id=actor_id,
metadata={"content_type": "text/plain;charset=utf-8"},
),
)
except Exception as exc:
raise PostboxContentProtectionError(
"Postbox message content could not be protected by its configured vault."
) from exc
def unprotect_message_body(
session: object,
*,
tenant_id: str,
message_id: str,
envelope_id: str,
ciphertext: bytes,
) -> str:
capability = encryption_content_cipher(get_registry())
if capability is None:
raise PostboxContentProtectionError(
"This Postbox message is encrypted and Encryption is unavailable."
)
try:
plaintext = capability.unprotect_content(
session,
request=ContentUnprotectionRequest(
tenant_id=tenant_id,
owner_module="postbox",
resource_type="postbox_message_body",
resource_id=message_id,
envelope_id=envelope_id,
ciphertext=ciphertext,
),
)
return plaintext.decode("utf-8")
except Exception as exc:
raise PostboxContentProtectionError(
"Postbox message content could not be opened with its protection envelope."
) from exc
__all__ = [
"POSTBOX_PLAINTEXT_PROFILE",
"POSTBOX_SERVER_ENVELOPE_PROFILE",
"PostboxContentProtectionError",
"protect_message_body",
"unprotect_message_body",
]
+13
View File
@@ -11,6 +11,7 @@ from sqlalchemy import (
Index, Index,
Integer, Integer,
JSON, JSON,
LargeBinary,
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
@@ -145,6 +146,9 @@ class PostboxTemplateRevision(Base, TimestampMixin):
default="plaintext_v1", default="plaintext_v1",
nullable=False, nullable=False,
) )
encryption_vault_id: Mapped[str | None] = mapped_column(
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,
@@ -420,6 +424,9 @@ 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(
LargeBinary, nullable=True
)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(30), String(30),
default="delivered", default="delivered",
@@ -473,6 +480,12 @@ class PostboxMessage(Base, TimestampMixin):
String(1000), String(1000),
nullable=True, nullable=True,
) )
encryption_envelope_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
encryption_resource_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
signed_manifest_ref: Mapped[str | None] = mapped_column( signed_manifest_ref: Mapped[str | None] = mapped_column(
String(1000), String(1000),
nullable=True, nullable=True,
+18 -4
View File
@@ -7,6 +7,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
) )
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
from govoplan_core.core.idm import ( from govoplan_core.core.idm import (
CAPABILITY_IDM_DIRECTORY, CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
@@ -212,6 +213,7 @@ manifest = ModuleManifest(
"access", "access",
"audit", "audit",
"campaigns", "campaigns",
"encryption",
"files", "files",
"mail", "mail",
"notifications", "notifications",
@@ -257,6 +259,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name=CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
@@ -368,9 +376,11 @@ manifest = ModuleManifest(
"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. The first " "postboxes cover exceptional responsibilities. The first "
"implementation persists plaintext but reserves explicit " "Plaintext Postboxes remain available without Encryption. A "
"ciphertext, signed-manifest, and key-epoch fields; it does not " "server-envelope profile stores message bodies as ciphertext and "
"claim end-to-end encryption until a trusted policy profile is selected." "uses the optional Encryption capability for authorized reads. "
"External ciphertext profiles retain producer-managed references "
"and keys; neither profile is described as end-to-end encryption."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -392,7 +402,11 @@ manifest = ModuleManifest(
maturity="vertical_slice", maturity="vertical_slice",
documentation_ref="docs/POSTBOX_CONCEPT.md", documentation_ref="docs/POSTBOX_CONCEPT.md",
test_ref="tests/test_service.py", test_ref="tests/test_service.py",
known_limits=("End-to-end encryption and target deployment recovery profiles are intentionally not claimed.",), known_limits=(
"Subjects, routing metadata, participants, and attachment references remain plaintext metadata.",
"Server-envelope protection is server-decryptable and is not end-to-end encryption.",
"External ciphertext profiles require a separately governed producer and client key-custody profile.",
),
owned_concepts=("postbox", "postbox address", "postbox message", "delivery receipt", "access event"), owned_concepts=("postbox", "postbox address", "postbox message", "delivery receipt", "access event"),
non_owned_concepts=("identity", "function assignment", "campaign", "cryptographic key custody"), non_owned_concepts=("identity", "function assignment", "campaign", "cryptographic key custody"),
recovery_docs=("docs/POSTBOX_CONCEPT.md",), recovery_docs=("docs/POSTBOX_CONCEPT.md",),
@@ -0,0 +1,55 @@
"""postbox content-protection state
Revision ID: d8e3f6a9b2c5
Revises: a6d9e1f4c8b3
Create Date: 2026-08-02 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8e3f6a9b2c5"
down_revision = "a6d9e1f4c8b3"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.add_column(
sa.Column("encryption_vault_id", sa.String(length=255), nullable=True)
)
with op.batch_alter_table("postbox_messages") as batch_op:
batch_op.add_column(
sa.Column("body_ciphertext", sa.LargeBinary(), nullable=True)
)
batch_op.add_column(
sa.Column("encryption_envelope_id", sa.String(length=255), nullable=True)
)
batch_op.add_column(
sa.Column("encryption_resource_id", sa.String(length=36), nullable=True)
)
batch_op.create_index(
op.f("ix_postbox_messages_encryption_envelope_id"),
["encryption_envelope_id"],
unique=False,
)
batch_op.create_index(
op.f("ix_postbox_messages_encryption_resource_id"),
["encryption_resource_id"],
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_messages") as batch_op:
batch_op.drop_index(op.f("ix_postbox_messages_encryption_resource_id"))
batch_op.drop_index(op.f("ix_postbox_messages_encryption_envelope_id"))
batch_op.drop_column("encryption_resource_id")
batch_op.drop_column("encryption_envelope_id")
batch_op.drop_column("body_ciphertext")
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.drop_column("encryption_vault_id")
+1
View File
@@ -257,6 +257,7 @@ def _template_item(template) -> PostboxTemplateItem:
"classification": revision.classification, "classification": revision.classification,
"allow_vacant_delivery": revision.allow_vacant_delivery, "allow_vacant_delivery": revision.allow_vacant_delivery,
"encryption_profile": revision.encryption_profile, "encryption_profile": revision.encryption_profile,
"encryption_vault_id": revision.encryption_vault_id,
"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 {}),
+34 -1
View File
@@ -355,6 +355,23 @@ class PostboxExactCreateRequest(BaseModel):
function_id: str = Field(min_length=1, max_length=36) function_id: str = Field(min_length=1, max_length=36)
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"
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
"plaintext_v1"
)
encryption_vault_id: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_encryption(self) -> "PostboxExactCreateRequest":
if self.encryption_profile == "server_envelope_v1":
if not str(self.encryption_vault_id or "").strip():
raise ValueError(
"Server-envelope Postboxes require an encryption vault."
)
elif self.encryption_vault_id:
raise ValueError(
"A plaintext Postbox cannot select an encryption vault."
)
return self
class PostboxTemplateRevisionPayload(BaseModel): class PostboxTemplateRevisionPayload(BaseModel):
@@ -373,10 +390,27 @@ class PostboxTemplateRevisionPayload(BaseModel):
) )
classification: PostboxClassification = "internal" classification: PostboxClassification = "internal"
allow_vacant_delivery: bool = True allow_vacant_delivery: bool = True
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
"plaintext_v1"
)
encryption_vault_id: str | None = Field(default=None, max_length=255)
routing_policy: PostboxRoutingPolicyPayload = Field( routing_policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload default_factory=PostboxRoutingPolicyPayload
) )
@model_validator(mode="after")
def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
if self.encryption_profile == "server_envelope_v1":
if not str(self.encryption_vault_id or "").strip():
raise ValueError(
"Server-envelope Postbox templates require an encryption vault."
)
elif self.encryption_vault_id:
raise ValueError(
"A plaintext Postbox template cannot select an encryption vault."
)
return self
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload): class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
slug: str = Field(min_length=1, max_length=120) slug: str = Field(min_length=1, max_length=120)
@@ -391,7 +425,6 @@ class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload): class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
id: str id: str
revision: int revision: int
encryption_profile: str
history_policy: dict[str, Any] = Field(default_factory=dict) history_policy: dict[str, Any] = Field(default_factory=dict)
retention_policy: dict[str, Any] = Field(default_factory=dict) retention_policy: dict[str, Any] = Field(default_factory=dict)
published_at: datetime | None = None published_at: datetime | None = None
+253 -16
View File
@@ -89,6 +89,7 @@ from govoplan_postbox.backend.db.models import (
PostboxRoute, PostboxRoute,
PostboxTemplate, PostboxTemplate,
PostboxTemplateRevision, PostboxTemplateRevision,
new_uuid,
) )
from govoplan_postbox.backend.hierarchy_routing import ( from govoplan_postbox.backend.hierarchy_routing import (
HierarchyRouteCandidate, HierarchyRouteCandidate,
@@ -130,6 +131,28 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
return dict(value or {}) return dict(value or {})
def _validate_encryption_configuration(
profile: str,
vault_id: str | None,
) -> None:
normalized = str(profile or "").strip()
if normalized not in {"plaintext_v1", "server_envelope_v1"}:
raise PostboxError(
"unsupported_encryption_profile",
"Unsupported Postbox encryption profile.",
)
if normalized == "server_envelope_v1" and not str(vault_id or "").strip():
raise PostboxError(
"encryption_vault_missing",
"Server-envelope Postboxes require an encryption vault.",
)
if normalized == "plaintext_v1" and vault_id:
raise PostboxError(
"encryption_profile_mismatch",
"A plaintext Postbox cannot select an encryption vault.",
)
def _optional_datetime(value: object) -> datetime | None: def _optional_datetime(value: object) -> datetime | None:
if isinstance(value, datetime): if isinstance(value, datetime):
return value return value
@@ -282,6 +305,84 @@ class PostboxService:
self._hierarchy = hierarchy self._hierarchy = hierarchy
self._notifications = notifications self._notifications = notifications
@staticmethod
def _message_body_storage(
session: Session,
*,
postbox: Postbox,
message_id: str,
body_text: str | None,
actor_id: str,
external_ciphertext_ref: str | None = None,
external_wrapped_keys: Sequence[object] = (),
) -> dict[str, object]:
profile = str(postbox.encryption_profile or "plaintext_v1").strip()
if external_ciphertext_ref:
return {
"body_text": None,
"body_ciphertext": None,
"ciphertext_ref": external_ciphertext_ref,
"encryption_envelope_id": None,
"encryption_resource_id": None,
"wrapped_keys": [asdict(item) for item in external_wrapped_keys],
}
if body_text is None or profile == "plaintext_v1":
return {
"body_text": body_text,
"body_ciphertext": None,
"ciphertext_ref": None,
"encryption_envelope_id": None,
"encryption_resource_id": None,
"wrapped_keys": [],
}
if profile != "server_envelope_v1":
raise PostboxError(
"unsupported_encryption_profile",
"This Postbox protection profile requires an external content producer.",
)
settings = postbox.settings if isinstance(postbox.settings, Mapping) else {}
vault_id = str(settings.get("encryption_vault_id") or "").strip()
if not vault_id:
raise PostboxError(
"encryption_vault_missing",
"The Postbox server-envelope profile has no configured vault.",
)
from govoplan_postbox.backend.content_protection import (
PostboxContentProtectionError,
protect_message_body,
)
try:
protected = protect_message_body(
session,
tenant_id=postbox.tenant_id,
message_id=message_id,
vault_id=vault_id,
plaintext=body_text,
actor_id=actor_id,
)
except PostboxContentProtectionError as exc:
raise PostboxError("content_protection_failed", str(exc)) from exc
envelope = protected.envelope
return {
"body_text": None,
"body_ciphertext": protected.ciphertext,
"ciphertext_ref": envelope.ciphertext_ref,
"encryption_envelope_id": envelope.envelope_id,
"encryption_resource_id": message_id,
"wrapped_keys": [
{
"recipient_type": "vault",
"recipient_id": vault_id,
"key_epoch": postbox.key_epoch,
"wrapped_key_ref": wrapped_key_ref,
"algorithm": envelope.algorithm_suite,
"metadata": {"envelope_id": envelope.envelope_id},
}
for wrapped_key_ref in envelope.wrapped_key_refs
],
}
@classmethod @classmethod
def from_registry(cls, registry: PlatformRegistry) -> "PostboxService": def from_registry(cls, registry: PlatformRegistry) -> "PostboxService":
identities = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY) identities = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
@@ -1014,11 +1115,21 @@ class PostboxService:
"assignment_id": decision.selected_assignment_id, "assignment_id": decision.selected_assignment_id,
"action": action, "action": action,
} }
message_id = new_uuid()
body_storage = self._message_body_storage(
session,
postbox=postbox,
message_id=message_id,
body_text=request.body_text,
actor_id=actor.account_id,
)
message = PostboxMessage( message = PostboxMessage(
id=message_id,
tenant_id=postbox.tenant_id, tenant_id=postbox.tenant_id,
postbox_id=postbox.id, postbox_id=postbox.id,
subject=request.subject.strip() or "(No subject)", subject=request.subject.strip() or "(No subject)",
body_text=request.body_text, body_text=body_storage["body_text"],
body_ciphertext=body_storage["body_ciphertext"],
status="sent", status="sent",
classification=classification, classification=classification,
sender_label=sender_label, sender_label=sender_label,
@@ -1029,6 +1140,10 @@ class PostboxService:
in_reply_to_message_id=(in_reply_to.id if in_reply_to else None), in_reply_to_message_id=(in_reply_to.id if in_reply_to else None),
encryption_profile=postbox.encryption_profile, encryption_profile=postbox.encryption_profile,
key_epoch=postbox.key_epoch, key_epoch=postbox.key_epoch,
ciphertext_ref=body_storage["ciphertext_ref"],
encryption_envelope_id=body_storage["encryption_envelope_id"],
encryption_resource_id=body_storage["encryption_resource_id"],
wrapped_keys=body_storage["wrapped_keys"],
delivered_at=utc_now(), delivered_at=utc_now(),
metadata_=metadata, metadata_=metadata,
) )
@@ -1175,22 +1290,40 @@ class PostboxService:
) )
now = utc_now() now = utc_now()
message_id = new_uuid()
body_storage = self._message_body_storage(
db,
postbox=postbox,
message_id=message_id,
body_text=request.body_text,
actor_id=f"module:{request.producer_module}",
external_ciphertext_ref=request.ciphertext_ref,
external_wrapped_keys=request.wrapped_keys,
)
message = PostboxMessage( message = PostboxMessage(
id=message_id,
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
postbox_id=postbox.id, postbox_id=postbox.id,
subject=request.subject.strip() or "(No subject)", subject=request.subject.strip() or "(No subject)",
body_text=request.body_text, body_text=body_storage["body_text"],
body_ciphertext=body_storage["body_ciphertext"],
status="delivered", status="delivered",
classification=classification, classification=classification,
sender_label=request.sender_label, sender_label=request.sender_label,
producer_module=request.producer_module, producer_module=request.producer_module,
producer_resource_type=request.producer_resource_type, producer_resource_type=request.producer_resource_type,
producer_resource_id=request.producer_resource_id, producer_resource_id=request.producer_resource_id,
encryption_profile=postbox.encryption_profile, encryption_profile=(
"external_envelope_v1"
if request.ciphertext_ref
else postbox.encryption_profile
),
key_epoch=postbox.key_epoch, key_epoch=postbox.key_epoch,
ciphertext_ref=request.ciphertext_ref, ciphertext_ref=body_storage["ciphertext_ref"],
encryption_envelope_id=body_storage["encryption_envelope_id"],
encryption_resource_id=body_storage["encryption_resource_id"],
signed_manifest_ref=request.signed_manifest_ref, signed_manifest_ref=request.signed_manifest_ref,
wrapped_keys=[asdict(item) for item in request.wrapped_keys], wrapped_keys=body_storage["wrapped_keys"],
external_recipient_tokens=[ external_recipient_tokens=[
_external_token_record(item) _external_token_record(item)
for item in request.external_recipient_tokens for item in request.external_recipient_tokens
@@ -1845,22 +1978,48 @@ class PostboxService:
route: PostboxRoute, route: PostboxRoute,
delivered_at: datetime, delivered_at: datetime,
) -> PostboxMessage: ) -> PostboxMessage:
message_id = new_uuid()
if source_message.ciphertext_ref:
body_storage = {
"body_text": None,
"body_ciphertext": source_message.body_ciphertext,
"ciphertext_ref": source_message.ciphertext_ref,
"encryption_envelope_id": source_message.encryption_envelope_id,
"encryption_resource_id": (
source_message.encryption_resource_id or source_message.id
),
"wrapped_keys": list(source_message.wrapped_keys or []),
}
encryption_profile = source_message.encryption_profile
else:
body_storage = self._message_body_storage(
session,
postbox=target_postbox,
message_id=message_id,
body_text=source_message.body_text,
actor_id=f"module:{source_message.producer_module or 'postbox'}",
)
encryption_profile = target_postbox.encryption_profile
message = PostboxMessage( message = PostboxMessage(
id=message_id,
tenant_id=source_message.tenant_id, tenant_id=source_message.tenant_id,
postbox_id=target_postbox.id, postbox_id=target_postbox.id,
subject=source_message.subject, subject=source_message.subject,
body_text=source_message.body_text, body_text=body_storage["body_text"],
body_ciphertext=body_storage["body_ciphertext"],
status="delivered", status="delivered",
classification=source_message.classification, classification=source_message.classification,
sender_label=source_message.sender_label, sender_label=source_message.sender_label,
producer_module=source_message.producer_module, producer_module=source_message.producer_module,
producer_resource_type=source_message.producer_resource_type, producer_resource_type=source_message.producer_resource_type,
producer_resource_id=source_message.producer_resource_id, producer_resource_id=source_message.producer_resource_id,
encryption_profile=target_postbox.encryption_profile, encryption_profile=encryption_profile,
key_epoch=target_postbox.key_epoch, key_epoch=target_postbox.key_epoch,
ciphertext_ref=source_message.ciphertext_ref, ciphertext_ref=body_storage["ciphertext_ref"],
encryption_envelope_id=body_storage["encryption_envelope_id"],
encryption_resource_id=body_storage["encryption_resource_id"],
signed_manifest_ref=source_message.signed_manifest_ref, signed_manifest_ref=source_message.signed_manifest_ref,
wrapped_keys=list(source_message.wrapped_keys or []), wrapped_keys=body_storage["wrapped_keys"],
external_recipient_tokens=list( external_recipient_tokens=list(
source_message.external_recipient_tokens or [] source_message.external_recipient_tokens or []
), ),
@@ -2563,8 +2722,14 @@ class PostboxService:
description: str | None, description: str | None,
classification: str, classification: str,
actor_id: str | None, actor_id: str | None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> Postbox: ) -> Postbox:
classification = self._validate_classification(classification) classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
unit, function = self._validate_function_target( unit, function = self._validate_function_target(
tenant_id=tenant_id, tenant_id=tenant_id,
organization_unit_id=organization_unit_id, organization_unit_id=organization_unit_id,
@@ -2601,6 +2766,8 @@ class PostboxService:
revision=None, revision=None,
source="exact", source="exact",
actor_id=actor_id, actor_id=actor_id,
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
) )
def archive_postbox( def archive_postbox(
@@ -2690,8 +2857,14 @@ class PostboxService:
allow_vacant_delivery: bool, allow_vacant_delivery: bool,
actor_id: str | None, actor_id: str | None,
routing_policy: Mapping[str, object] | None = None, routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> PostboxTemplate: ) -> PostboxTemplate:
classification = self._validate_classification(classification) classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
clean_slug = _slug(slug or name, fallback="template") clean_slug = _slug(slug or name, fallback="template")
if ( if (
session.query(PostboxTemplate) session.query(PostboxTemplate)
@@ -2731,7 +2904,8 @@ class PostboxService:
address_pattern=address_pattern, address_pattern=address_pattern,
classification=classification, classification=classification,
allow_vacant_delivery=allow_vacant_delivery, allow_vacant_delivery=allow_vacant_delivery,
encryption_profile="plaintext_v1", encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
history_policy={}, history_policy={},
routing_policy=normalized_routing_policy(routing_policy), routing_policy=normalized_routing_policy(routing_policy),
retention_policy={}, retention_policy={},
@@ -2768,8 +2942,14 @@ class PostboxService:
actor_id: str | None, actor_id: str | None,
expected_revision: int, expected_revision: int,
routing_policy: Mapping[str, object] | None = None, routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> PostboxTemplate: ) -> PostboxTemplate:
classification = self._validate_classification(classification) classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
template = self._get_template( template = self._get_template(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -2809,7 +2989,8 @@ class PostboxService:
address_pattern=address_pattern, address_pattern=address_pattern,
classification=classification, classification=classification,
allow_vacant_delivery=allow_vacant_delivery, allow_vacant_delivery=allow_vacant_delivery,
encryption_profile="plaintext_v1", encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
history_policy={}, history_policy={},
routing_policy=normalized_routing_policy(routing_policy), routing_policy=normalized_routing_policy(routing_policy),
retention_policy={}, retention_policy={},
@@ -3754,12 +3935,42 @@ class PostboxService:
) )
availability = _message_availability(message) availability = _message_availability(message)
content_available = availability == "available" content_available = availability == "available"
body_text = message.body_text if content_available else None
if (
content_available
and message.encryption_envelope_id
and message.body_ciphertext is not None
):
session = object_session(message)
if session is None:
raise PostboxError(
"encrypted_content_unavailable",
"Encrypted Postbox content requires an attached database session.",
)
from govoplan_postbox.backend.content_protection import (
PostboxContentProtectionError,
unprotect_message_body,
)
try:
body_text = unprotect_message_body(
session,
tenant_id=message.tenant_id,
message_id=message.encryption_resource_id or message.id,
envelope_id=message.encryption_envelope_id,
ciphertext=message.body_ciphertext,
)
except PostboxContentProtectionError as exc:
raise PostboxError(
"encrypted_content_unavailable",
str(exc),
) from exc
return PostboxMessageRef( return PostboxMessageRef(
id=message.id, id=message.id,
tenant_id=message.tenant_id, tenant_id=message.tenant_id,
postbox_id=message.postbox_id, postbox_id=message.postbox_id,
subject=message.subject, subject=message.subject,
body_text=message.body_text if content_available else None, body_text=body_text,
status=message.status, status=message.status,
availability=availability, availability=availability,
classification=message.classification, classification=message.classification,
@@ -3938,7 +4149,31 @@ class PostboxService:
revision: PostboxTemplateRevision | None, revision: PostboxTemplateRevision | None,
source: str, source: str,
actor_id: str | None, actor_id: str | None,
encryption_profile: str | None = None,
encryption_vault_id: str | None = None,
) -> Postbox: ) -> Postbox:
effective_profile = (
revision.encryption_profile
if revision is not None
else str(encryption_profile or "plaintext_v1")
)
effective_vault_id = (
revision.encryption_vault_id
if revision is not None
else encryption_vault_id
)
if effective_profile == "server_envelope_v1" and not str(
effective_vault_id or ""
).strip():
raise PostboxError(
"encryption_vault_missing",
"Server-envelope Postboxes require an encryption vault.",
)
if effective_profile not in {"plaintext_v1", "server_envelope_v1"}:
raise PostboxError(
"unsupported_encryption_profile",
"Unsupported Postbox encryption profile.",
)
address_record = PostboxAddress( address_record = PostboxAddress(
tenant_id=tenant_id, tenant_id=tenant_id,
address_key=address_key, address_key=address_key,
@@ -3960,11 +4195,13 @@ class PostboxService:
description=description, description=description,
status="active", status="active",
classification=classification, classification=classification,
encryption_profile=( encryption_profile=effective_profile,
revision.encryption_profile if revision else "plaintext_v1"
),
key_epoch=1, key_epoch=1,
settings={}, settings=(
{"encryption_vault_id": str(effective_vault_id)}
if effective_vault_id
else {}
),
) )
postbox.bindings.append( postbox.bindings.append(
PostboxBinding( PostboxBinding(
+9
View File
@@ -10,6 +10,7 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_MESSAGES, CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_ROUTING, CAPABILITY_POSTBOX_ROUTING,
) )
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
from govoplan_postbox.backend.manifest import get_manifest from govoplan_postbox.backend.manifest import get_manifest
@@ -39,6 +40,14 @@ class PostboxManifestTests(unittest.TestCase):
"idm.function_assignments", "idm.function_assignments",
manifest.required_capabilities, manifest.required_capabilities,
) )
self.assertIn("encryption", manifest.optional_dependencies)
self.assertTrue(
any(
requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER
and requirement.optional
for requirement in manifest.requires_interfaces
)
)
if __name__ == "__main__": if __name__ == "__main__":
+21
View File
@@ -26,6 +26,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions." "govoplan_postbox.backend.migrations.versions."
"a6d9e1f4c8b3_v013_external_recipient_tokens" "a6d9e1f4c8b3_v013_external_recipient_tokens"
) )
protection_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"d8e3f6a9b2c5_postbox_content_protection"
)
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
try: try:
with engine.begin() as connection: with engine.begin() as connection:
@@ -34,15 +38,18 @@ class PostboxMigrationTests(unittest.TestCase):
route_original = route_migration.op route_original = route_migration.op
occ_original = occ_migration.op occ_original = occ_migration.op
envelope_original = envelope_migration.op envelope_original = envelope_migration.op
protection_original = protection_migration.op
migration.op = operations migration.op = operations
route_migration.op = operations route_migration.op = operations
occ_migration.op = operations occ_migration.op = operations
envelope_migration.op = operations envelope_migration.op = operations
protection_migration.op = operations
try: try:
migration.upgrade() migration.upgrade()
route_migration.upgrade() route_migration.upgrade()
occ_migration.upgrade() occ_migration.upgrade()
envelope_migration.upgrade() envelope_migration.upgrade()
protection_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)
@@ -63,8 +70,20 @@ class PostboxMigrationTests(unittest.TestCase):
"key_epoch", "key_epoch",
"expires_at", "expires_at",
"withdrawn_at", "withdrawn_at",
"body_ciphertext",
"encryption_envelope_id",
"encryption_resource_id",
}.issubset(message_columns) }.issubset(message_columns)
) )
self.assertIn(
"encryption_vault_id",
{
column["name"]
for column in inspect(connection).get_columns(
"postbox_template_revisions"
)
},
)
self.assertIn("authoring_key", message_columns) self.assertIn("authoring_key", message_columns)
for table_name in ( for table_name in (
"postbox_templates", "postbox_templates",
@@ -91,6 +110,7 @@ class PostboxMigrationTests(unittest.TestCase):
route_columns route_columns
) )
) )
protection_migration.downgrade()
envelope_migration.downgrade() envelope_migration.downgrade()
occ_migration.downgrade() occ_migration.downgrade()
route_migration.downgrade() route_migration.downgrade()
@@ -107,6 +127,7 @@ class PostboxMigrationTests(unittest.TestCase):
route_migration.op = route_original route_migration.op = route_original
occ_migration.op = occ_original occ_migration.op = occ_original
envelope_migration.op = envelope_original envelope_migration.op = envelope_original
protection_migration.op = protection_original
finally: finally:
engine.dispose() engine.dispose()
+82
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import unittest import unittest
from datetime import timedelta from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -51,6 +53,7 @@ from govoplan_postbox.backend.db.models import (
PostboxTemplate, PostboxTemplate,
PostboxTemplateRevision, PostboxTemplateRevision,
) )
from govoplan_postbox.backend.content_protection import PostboxContentProtectionError
from govoplan_postbox.backend.service import PostboxError, PostboxService from govoplan_postbox.backend.service import PostboxError, PostboxService
@@ -1236,6 +1239,85 @@ class PostboxServiceTests(unittest.TestCase):
), ),
) )
def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed(
self,
) -> None:
self.idm.assignments.append(self.assignment)
protected = SimpleNamespace(
ciphertext=b"encrypted-message-body",
envelope=SimpleNamespace(
envelope_id="envelope-1",
ciphertext_ref="postbox-db://message/body",
algorithm_suite="AES-256-GCM",
wrapped_key_refs=("wrapped-key-1",),
),
)
with Session(self.engine) as session:
postbox = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="Protected intake",
organization_unit_id="unit-1",
function_id="function-1",
address_key=None,
description=None,
classification="internal",
actor_id="admin-1",
encryption_profile="server_envelope_v1",
encryption_vault_id="vault-1",
)
with patch(
"govoplan_postbox.backend.content_protection.protect_message_body",
return_value=protected,
):
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-protected",
idempotency_key="protected-message",
subject="Protected notice",
body_text="clear message body",
),
)
stored = session.get(PostboxMessage, delivered.message_id)
assert stored is not None
self.assertIsNone(stored.body_text)
self.assertEqual(b"encrypted-message-body", stored.body_ciphertext)
self.assertEqual("envelope-1", stored.encryption_envelope_id)
with patch(
"govoplan_postbox.backend.content_protection.unprotect_message_body",
return_value="clear message body",
):
opened = self.service.get_message(
session,
tenant_id="tenant-1",
message_id=delivered.message_id,
actor=self.actor,
)
assert opened is not None
self.assertEqual("clear message body", opened.body_text)
with patch(
"govoplan_postbox.backend.content_protection.unprotect_message_body",
side_effect=PostboxContentProtectionError("provider unavailable"),
):
with self.assertRaisesRegex(
PostboxError,
"provider unavailable",
):
self.service.get_message(
session,
tenant_id="tenant-1",
message_id=delivered.message_id,
actor=self.actor,
)
def test_hierarchy_linked_copy_snapshots_path_and_independent_state( def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
self, self,
) -> None: ) -> None: