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