feat: protect postbox message content
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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",),
|
||||
|
||||
+55
@@ -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")
|
||||
@@ -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 {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user