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
@@ -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,
Integer,
JSON,
LargeBinary,
String,
Text,
UniqueConstraint,
@@ -145,6 +146,9 @@ class PostboxTemplateRevision(Base, TimestampMixin):
default="plaintext_v1",
nullable=False,
)
encryption_vault_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
history_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
@@ -420,6 +424,9 @@ class PostboxMessage(Base, TimestampMixin):
)
subject: Mapped[str] = mapped_column(String(1000), nullable=False)
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_ciphertext: Mapped[bytes | None] = mapped_column(
LargeBinary, nullable=True
)
status: Mapped[str] = mapped_column(
String(30),
default="delivered",
@@ -473,6 +480,12 @@ class PostboxMessage(Base, TimestampMixin):
String(1000),
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(
String(1000),
nullable=True,
+18 -4
View File
@@ -7,6 +7,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
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 (
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
@@ -212,6 +213,7 @@ manifest = ModuleManifest(
"access",
"audit",
"campaigns",
"encryption",
"files",
"mail",
"notifications",
@@ -257,6 +259,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -368,9 +376,11 @@ manifest = ModuleManifest(
"permission with effective IDM assignment context. Templates "
"can lazily materialize unit-specific addresses, while exact "
"postboxes cover exceptional responsibilities. The first "
"implementation persists plaintext but reserves explicit "
"ciphertext, signed-manifest, and key-epoch fields; it does not "
"claim end-to-end encryption until a trusted policy profile is selected."
"Plaintext Postboxes remain available without Encryption. A "
"server-envelope profile stores message bodies as ciphertext and "
"uses the optional Encryption capability for authorized reads. "
"External ciphertext profiles retain producer-managed references "
"and keys; neither profile is described as end-to-end encryption."
),
layer="available",
documentation_types=("admin", "user"),
@@ -392,7 +402,11 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/POSTBOX_CONCEPT.md",
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"),
non_owned_concepts=("identity", "function assignment", "campaign", "cryptographic key custody"),
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,
"allow_vacant_delivery": revision.allow_vacant_delivery,
"encryption_profile": revision.encryption_profile,
"encryption_vault_id": revision.encryption_vault_id,
"history_policy": dict(revision.history_policy or {}),
"routing_policy": dict(revision.routing_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)
address_key: str | None = Field(default=None, max_length=120)
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):
@@ -373,10 +390,27 @@ class PostboxTemplateRevisionPayload(BaseModel):
)
classification: PostboxClassification = "internal"
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(
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):
slug: str = Field(min_length=1, max_length=120)
@@ -391,7 +425,6 @@ class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
id: str
revision: int
encryption_profile: str
history_policy: dict[str, Any] = Field(default_factory=dict)
retention_policy: dict[str, Any] = Field(default_factory=dict)
published_at: datetime | None = None
+253 -16
View File
@@ -89,6 +89,7 @@ from govoplan_postbox.backend.db.models import (
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
new_uuid,
)
from govoplan_postbox.backend.hierarchy_routing import (
HierarchyRouteCandidate,
@@ -130,6 +131,28 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
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:
if isinstance(value, datetime):
return value
@@ -282,6 +305,84 @@ class PostboxService:
self._hierarchy = hierarchy
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
def from_registry(cls, registry: PlatformRegistry) -> "PostboxService":
identities = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
@@ -1014,11 +1115,21 @@ class PostboxService:
"assignment_id": decision.selected_assignment_id,
"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(
id=message_id,
tenant_id=postbox.tenant_id,
postbox_id=postbox.id,
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",
classification=classification,
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),
encryption_profile=postbox.encryption_profile,
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(),
metadata_=metadata,
)
@@ -1175,22 +1290,40 @@ class PostboxService:
)
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(
id=message_id,
tenant_id=request.tenant_id,
postbox_id=postbox.id,
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",
classification=classification,
sender_label=request.sender_label,
producer_module=request.producer_module,
producer_resource_type=request.producer_resource_type,
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,
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,
wrapped_keys=[asdict(item) for item in request.wrapped_keys],
wrapped_keys=body_storage["wrapped_keys"],
external_recipient_tokens=[
_external_token_record(item)
for item in request.external_recipient_tokens
@@ -1845,22 +1978,48 @@ class PostboxService:
route: PostboxRoute,
delivered_at: datetime,
) -> 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(
id=message_id,
tenant_id=source_message.tenant_id,
postbox_id=target_postbox.id,
subject=source_message.subject,
body_text=source_message.body_text,
body_text=body_storage["body_text"],
body_ciphertext=body_storage["body_ciphertext"],
status="delivered",
classification=source_message.classification,
sender_label=source_message.sender_label,
producer_module=source_message.producer_module,
producer_resource_type=source_message.producer_resource_type,
producer_resource_id=source_message.producer_resource_id,
encryption_profile=target_postbox.encryption_profile,
encryption_profile=encryption_profile,
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,
wrapped_keys=list(source_message.wrapped_keys or []),
wrapped_keys=body_storage["wrapped_keys"],
external_recipient_tokens=list(
source_message.external_recipient_tokens or []
),
@@ -2563,8 +2722,14 @@ class PostboxService:
description: str | None,
classification: str,
actor_id: str | None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> Postbox:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
unit, function = self._validate_function_target(
tenant_id=tenant_id,
organization_unit_id=organization_unit_id,
@@ -2601,6 +2766,8 @@ class PostboxService:
revision=None,
source="exact",
actor_id=actor_id,
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
)
def archive_postbox(
@@ -2690,8 +2857,14 @@ class PostboxService:
allow_vacant_delivery: bool,
actor_id: str | None,
routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> PostboxTemplate:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
clean_slug = _slug(slug or name, fallback="template")
if (
session.query(PostboxTemplate)
@@ -2731,7 +2904,8 @@ class PostboxService:
address_pattern=address_pattern,
classification=classification,
allow_vacant_delivery=allow_vacant_delivery,
encryption_profile="plaintext_v1",
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
history_policy={},
routing_policy=normalized_routing_policy(routing_policy),
retention_policy={},
@@ -2768,8 +2942,14 @@ class PostboxService:
actor_id: str | None,
expected_revision: int,
routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> PostboxTemplate:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
template = self._get_template(
session,
tenant_id=tenant_id,
@@ -2809,7 +2989,8 @@ class PostboxService:
address_pattern=address_pattern,
classification=classification,
allow_vacant_delivery=allow_vacant_delivery,
encryption_profile="plaintext_v1",
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
history_policy={},
routing_policy=normalized_routing_policy(routing_policy),
retention_policy={},
@@ -3754,12 +3935,42 @@ class PostboxService:
)
availability = _message_availability(message)
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(
id=message.id,
tenant_id=message.tenant_id,
postbox_id=message.postbox_id,
subject=message.subject,
body_text=message.body_text if content_available else None,
body_text=body_text,
status=message.status,
availability=availability,
classification=message.classification,
@@ -3938,7 +4149,31 @@ class PostboxService:
revision: PostboxTemplateRevision | None,
source: str,
actor_id: str | None,
encryption_profile: str | None = None,
encryption_vault_id: str | None = None,
) -> 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(
tenant_id=tenant_id,
address_key=address_key,
@@ -3960,11 +4195,13 @@ class PostboxService:
description=description,
status="active",
classification=classification,
encryption_profile=(
revision.encryption_profile if revision else "plaintext_v1"
),
encryption_profile=effective_profile,
key_epoch=1,
settings={},
settings=(
{"encryption_vault_id": str(effective_vault_id)}
if effective_vault_id
else {}
),
)
postbox.bindings.append(
PostboxBinding(