feat(postbox): add governed content protection profiles
This commit is contained in:
@@ -25,6 +25,8 @@ def protect_message_body(
|
||||
vault_id: str,
|
||||
plaintext: str,
|
||||
actor_id: str,
|
||||
operation_ref: str = "v1",
|
||||
policy_decision_ref: str = "postbox:configured-server-envelope:v1",
|
||||
) -> ProtectedContent:
|
||||
capability = encryption_content_cipher(get_registry())
|
||||
if capability is None:
|
||||
@@ -43,8 +45,8 @@ def protect_message_body(
|
||||
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",
|
||||
policy_decision_ref=policy_decision_ref,
|
||||
idempotency_key=f"postbox-message:{message_id}:body:{operation_ref}",
|
||||
actor_id=actor_id,
|
||||
metadata={"content_type": "text/plain;charset=utf-8"},
|
||||
),
|
||||
|
||||
@@ -10,6 +10,8 @@ from govoplan_postbox.backend.db.models import (
|
||||
PostboxMessage,
|
||||
PostboxMessageReceipt,
|
||||
PostboxParticipant,
|
||||
PostboxProtectionTransition,
|
||||
PostboxProtectionTransitionItem,
|
||||
PostboxRoute,
|
||||
PostboxTemplate,
|
||||
PostboxTemplateRevision,
|
||||
@@ -27,6 +29,8 @@ __all__ = [
|
||||
"PostboxMessage",
|
||||
"PostboxMessageReceipt",
|
||||
"PostboxParticipant",
|
||||
"PostboxProtectionTransition",
|
||||
"PostboxProtectionTransitionItem",
|
||||
"PostboxRoute",
|
||||
"PostboxTemplate",
|
||||
"PostboxTemplateRevision",
|
||||
|
||||
@@ -161,9 +161,7 @@ class PostboxTemplateRevision(Base, TimestampMixin):
|
||||
default="plaintext_v1",
|
||||
nullable=False,
|
||||
)
|
||||
encryption_vault_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
encryption_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
history_policy: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
@@ -323,6 +321,11 @@ class Postbox(Base, TimestampMixin):
|
||||
back_populates="postbox",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
protection_transitions: Mapped[list["PostboxProtectionTransition"]] = relationship(
|
||||
back_populates="postbox",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PostboxProtectionTransition.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
@@ -439,9 +442,7 @@ 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
|
||||
)
|
||||
body_ciphertext: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="delivered",
|
||||
@@ -558,6 +559,121 @@ class PostboxMessage(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
|
||||
class PostboxProtectionTransition(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_protection_transitions"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_postbox_protection_transition_state",
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"state",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"idempotency_key",
|
||||
name="uq_postbox_protection_transition_idem",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
postbox_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postboxes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
source_profile: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
target_profile: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
source_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
target_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
history_mode: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
authority_mode: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
required_quorum: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
evidence_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(30), default="pending", nullable=False, index=True
|
||||
)
|
||||
message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
completed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
failed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
requested_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
activated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
configuration_snapshot: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
postbox: Mapped[Postbox] = relationship(back_populates="protection_transitions")
|
||||
items: Mapped[list["PostboxProtectionTransitionItem"]] = relationship(
|
||||
back_populates="transition",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PostboxProtectionTransitionItem.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"postbox_protection_transition",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class PostboxProtectionTransitionItem(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_protection_transition_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"transition_id",
|
||||
"message_id",
|
||||
name="uq_postbox_protection_transition_message",
|
||||
),
|
||||
Index(
|
||||
"ix_postbox_protection_transition_item_state",
|
||||
"tenant_id",
|
||||
"transition_id",
|
||||
"state",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
transition_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_protection_transitions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
message_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_profile: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
target_profile: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(30), default="pending", nullable=False, index=True
|
||||
)
|
||||
source_digest: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
target_digest: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
completed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
error_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
transition: Mapped[PostboxProtectionTransition] = relationship(
|
||||
back_populates="items"
|
||||
)
|
||||
|
||||
|
||||
class PostboxParticipant(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_participants"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -71,6 +71,7 @@ MODULE_ID = "postbox"
|
||||
MODULE_NAME = "Postbox"
|
||||
MODULE_VERSION = "0.1.18"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
@@ -209,6 +210,8 @@ _OWNED_TABLES = (
|
||||
postbox_models.PostboxDelivery,
|
||||
postbox_models.PostboxAttachmentReference,
|
||||
postbox_models.PostboxParticipant,
|
||||
postbox_models.PostboxProtectionTransitionItem,
|
||||
postbox_models.PostboxProtectionTransition,
|
||||
postbox_models.PostboxMessage,
|
||||
postbox_models.PostboxBinding,
|
||||
postbox_models.Postbox,
|
||||
@@ -505,7 +508,10 @@ manifest = ModuleManifest(
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"kind": "reference", "help_contexts": ["postbox.quick_access.messages"]},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": ["postbox.quick_access.messages"],
|
||||
},
|
||||
order=33,
|
||||
),
|
||||
DocumentationTopic(
|
||||
@@ -525,6 +531,70 @@ manifest = ModuleManifest(
|
||||
related_modules=("search", "idm", "encryption"),
|
||||
order=34,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.content-protection-policy",
|
||||
title="Choose and change Postbox content protection",
|
||||
summary="Configure plaintext, institution-managed envelope, or externally managed E2EE content with governed hand-over and migration evidence.",
|
||||
body=(
|
||||
"Every exact Postbox and immutable template revision selects a content-protection profile. "
|
||||
"Institution-managed server envelopes are the recommended standard and require an Encryption vault; authorized institutional key holders can decrypt them. "
|
||||
"External E2EE rejects clear message bodies and requires an approved producer or client to supply ciphertext, a signed manifest, wrapped recipient keys, and a verified digest; GovOPlaN cannot decrypt that content. "
|
||||
"Plaintext remains an explicit deployment choice. Subjects, routing, participants, attachment references, lifecycle state, and other operational metadata remain visible in every profile. "
|
||||
"The accompanying policy selects history for new incumbents, ordinary and compromise rotation, recovery, hand-over, emergency access, export, destruction, external-recipient assurance, and metadata-only vacancy escalation. "
|
||||
"A profile transition applies to future messages immediately and may retain or migrate history. It records user-consent and/or institutional key-holder evidence, quorum, reason, digest continuity, and per-message outcome. "
|
||||
"Transitions to or from E2EE wait for approved client transformations; native device key custody and cryptographic clients are not supplied by Postbox. Previously decrypted, copied, printed, or exported content cannot be recalled."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("administrator", "user", "auditor"),
|
||||
related_modules=("encryption", "identity_trust", "policy", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Postbox administration",
|
||||
href="/admin?section=postbox",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Protection profile catalog API",
|
||||
href="/api/v1/postbox/admin/protection-profiles",
|
||||
kind="api",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Postbox protection concept",
|
||||
href="docs/POSTBOX_CONCEPT.md",
|
||||
kind="source",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Inhaltsschutz für Postfächer wählen und ändern",
|
||||
"summary": "Klartext, institutionell verwaltete Umschlagverschlüsselung oder extern verwaltete Ende-zu-Ende-Verschlüsselung mit geregelten Übergabe- und Migrationsnachweisen konfigurieren.",
|
||||
"body": (
|
||||
"Jedes exakte Postfach und jede unveränderliche Vorlagenrevision wählt ein Inhaltsschutzprofil. "
|
||||
"Institutionell verwaltete Server-Umschläge sind der empfohlene Standard und benötigen einen Encryption-Tresor; berechtigte institutionelle Schlüsselverantwortliche können sie entschlüsseln. "
|
||||
"Externe Ende-zu-Ende-Verschlüsselung weist Klartextnachrichten ab und verlangt von einem zugelassenen Erzeuger oder Client Chiffrat, signiertes Manifest, umhüllte Empfängerschlüssel und einen geprüften Digest; GovOPlaN kann diesen Inhalt nicht entschlüsseln. "
|
||||
"Klartext bleibt eine ausdrückliche Wahl. Betreff, Routing, Beteiligte, Anlagenverweise, Lebenszyklus und weitere Betriebsmetadaten bleiben bei allen Profilen sichtbar. "
|
||||
"Die begleitende Richtlinie regelt den Verlauf für neue Stelleninhaber, normale Rotation und Kompromittierung, Wiederherstellung, Übergabe, Notfallzugriff, Export, Vernichtung, externe Empfängerprüfung und rein metadatenbasierte Vakanzeskalation. "
|
||||
"Ein Profilwechsel gilt sofort für neue Nachrichten und kann den Bestand beibehalten oder migrieren. Er protokolliert Einwilligungen und/oder institutionelle Freigaben, Quorum, Grund, Digest-Kontinuität und Ergebnis je Nachricht. "
|
||||
"Wechsel zu oder von E2EE warten auf freigegebene Client-Transformationen; Geräte-Schlüsselverwahrung und Kryptografie-Clients liefert Postbox nicht mit. Bereits entschlüsselte, kopierte, gedruckte oder exportierte Inhalte können nicht zurückgerufen werden."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"help_contexts": [
|
||||
"postbox.admin.templates",
|
||||
"postbox.field.protection-profile",
|
||||
"postbox.action.protection-transition",
|
||||
],
|
||||
"privacy_notes": [
|
||||
"E2EE protects content, not operational metadata.",
|
||||
"Managed envelopes are decryptable by authorized institutional key holders.",
|
||||
"Profile transitions preserve authority evidence and content-digest continuity.",
|
||||
],
|
||||
},
|
||||
order=35,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.function-bound-containers",
|
||||
title="Function-bound Postboxes",
|
||||
@@ -538,12 +608,11 @@ manifest = ModuleManifest(
|
||||
"reassignment. Current access combines a generic Postbox "
|
||||
"permission with effective IDM assignment context. Templates "
|
||||
"can lazily materialize unit-specific addresses, while exact "
|
||||
"postboxes cover exceptional responsibilities. 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. "
|
||||
"postboxes cover exceptional responsibilities. Administrators "
|
||||
"choose plaintext, the recommended institution-managed Encryption "
|
||||
"envelope, or a strict externally produced E2EE contract. Managed "
|
||||
"envelopes remain decryptable by authorized institutional key holders; "
|
||||
"E2EE rejects plaintext and GovOPlaN has no private decryption key. "
|
||||
"When Tasks is enabled, currently readable unread messages also appear "
|
||||
"in the common work inbox and disappear when the personal read receipt is recorded."
|
||||
),
|
||||
@@ -587,7 +656,7 @@ manifest = ModuleManifest(
|
||||
"Subjects, participants, routing facts, and attachment references remain observable metadata.",
|
||||
],
|
||||
},
|
||||
order=35,
|
||||
order=36,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.reference.fields-and-consequences",
|
||||
@@ -660,7 +729,7 @@ manifest = ModuleManifest(
|
||||
"withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.",
|
||||
},
|
||||
},
|
||||
order=36,
|
||||
order=37,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
@@ -672,10 +741,22 @@ manifest = ModuleManifest(
|
||||
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.",
|
||||
"External E2EE requires a separately governed producer/client, private-key custody, device enrollment, and independent cryptographic review.",
|
||||
),
|
||||
owned_concepts=(
|
||||
"postbox",
|
||||
"postbox address",
|
||||
"postbox message",
|
||||
"delivery receipt",
|
||||
"access event",
|
||||
"postbox protection transition",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"identity",
|
||||
"function assignment",
|
||||
"campaign",
|
||||
"cryptographic key custody",
|
||||
),
|
||||
owned_concepts=("postbox", "postbox address", "postbox message", "delivery receipt", "access event"),
|
||||
non_owned_concepts=("identity", "function assignment", "campaign", "cryptographic key custody"),
|
||||
recovery_docs=("docs/POSTBOX_CONCEPT.md",),
|
||||
security_docs=("docs/POSTBOX_CONCEPT.md",),
|
||||
operations_docs=("README.md",),
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
"""v0.1.18 governed Postbox protection transitions.
|
||||
|
||||
Revision ID: a7c1e4f8b2d6
|
||||
Revises: f2a5c8e1b4d7
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a7c1e4f8b2d6"
|
||||
down_revision = "f2a5c8e1b4d7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"postbox_protection_transitions",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("postbox_id", sa.String(36), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=False),
|
||||
sa.Column("source_profile", sa.String(80), nullable=False),
|
||||
sa.Column("target_profile", sa.String(80), nullable=False),
|
||||
sa.Column("source_vault_id", sa.String(255), nullable=True),
|
||||
sa.Column("target_vault_id", sa.String(255), nullable=True),
|
||||
sa.Column("history_mode", sa.String(30), nullable=False),
|
||||
sa.Column("authority_mode", sa.String(40), nullable=False),
|
||||
sa.Column("required_quorum", sa.Integer(), nullable=False),
|
||||
sa.Column("evidence_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("state", sa.String(30), nullable=False),
|
||||
sa.Column("message_count", sa.Integer(), nullable=False),
|
||||
sa.Column("completed_count", sa.Integer(), nullable=False),
|
||||
sa.Column("failed_count", sa.Integer(), nullable=False),
|
||||
sa.Column("requested_by", sa.String(255), nullable=True),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("configuration_snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["postbox_id"], ["postboxes.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"idempotency_key",
|
||||
name="uq_postbox_protection_transition_idem",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_protection_transition_state",
|
||||
"postbox_protection_transitions",
|
||||
["tenant_id", "postbox_id", "state"],
|
||||
)
|
||||
for column in ("tenant_id", "postbox_id", "state"):
|
||||
op.create_index(
|
||||
f"ix_postbox_protection_transitions_{column}",
|
||||
"postbox_protection_transitions",
|
||||
[column],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_protection_transition_items",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("transition_id", sa.String(36), nullable=False),
|
||||
sa.Column("message_id", sa.String(36), nullable=False),
|
||||
sa.Column("source_profile", sa.String(80), nullable=False),
|
||||
sa.Column("target_profile", sa.String(80), nullable=False),
|
||||
sa.Column("state", sa.String(30), nullable=False),
|
||||
sa.Column("source_digest", sa.String(255), nullable=True),
|
||||
sa.Column("target_digest", sa.String(255), nullable=True),
|
||||
sa.Column("completed_by", sa.String(255), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("error_code", sa.String(100), nullable=True),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["transition_id"],
|
||||
["postbox_protection_transitions.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"], ["postbox_messages.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"transition_id",
|
||||
"message_id",
|
||||
name="uq_postbox_protection_transition_message",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_protection_transition_item_state",
|
||||
"postbox_protection_transition_items",
|
||||
["tenant_id", "transition_id", "state"],
|
||||
)
|
||||
for column in ("tenant_id", "transition_id", "message_id", "state"):
|
||||
op.create_index(
|
||||
f"ix_postbox_protection_transition_items_{column}",
|
||||
"postbox_protection_transition_items",
|
||||
[column],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("postbox_protection_transition_items")
|
||||
op.drop_table("postbox_protection_transitions")
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
POSTBOX_PLAINTEXT_PROFILE = "plaintext_v1"
|
||||
POSTBOX_MANAGED_ENVELOPE_PROFILE = "server_envelope_v1"
|
||||
POSTBOX_EXTERNAL_E2EE_PROFILE = "external_e2ee_v1"
|
||||
POSTBOX_LEGACY_EXTERNAL_ENVELOPE_PROFILE = "external_envelope_v1"
|
||||
POSTBOX_STANDARD_PROFILE = POSTBOX_MANAGED_ENVELOPE_PROFILE
|
||||
|
||||
PostboxProtectionProfile = Literal[
|
||||
"plaintext_v1",
|
||||
"server_envelope_v1",
|
||||
"external_e2ee_v1",
|
||||
]
|
||||
|
||||
SUPPORTED_POSTBOX_PROTECTION_PROFILES = frozenset(
|
||||
{
|
||||
POSTBOX_PLAINTEXT_PROFILE,
|
||||
POSTBOX_MANAGED_ENVELOPE_PROFILE,
|
||||
POSTBOX_EXTERNAL_E2EE_PROFILE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_postbox_protection_policy(
|
||||
policy: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"new_incumbent_history": "since_assignment",
|
||||
"history_days": None,
|
||||
"ordinary_rotation": "rewrap",
|
||||
"compromise_rotation": "reencrypt",
|
||||
"recovery_authority": "institutional_key_holders",
|
||||
"recovery_quorum": 2,
|
||||
"handover_authority": "dual_control",
|
||||
"handover_quorum": 2,
|
||||
"emergency_access": "dual_control",
|
||||
"emergency_quorum": 2,
|
||||
"export_authority": "dual_control",
|
||||
"export_quorum": 2,
|
||||
"destruction_authority": "dual_control",
|
||||
"destruction_quorum": 2,
|
||||
"external_recipient_assurance": "strong_identity",
|
||||
"vacancy_escalation_content_access": "metadata_only",
|
||||
**(policy or {}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxProtectionProfileDefinition:
|
||||
id: PostboxProtectionProfile
|
||||
label: str
|
||||
description: str
|
||||
server_can_decrypt: bool
|
||||
requires_encryption_module: bool
|
||||
requires_external_client: bool
|
||||
standard: bool = False
|
||||
|
||||
|
||||
POSTBOX_PROTECTION_PROFILE_DEFINITIONS = (
|
||||
PostboxProtectionProfileDefinition(
|
||||
id=POSTBOX_MANAGED_ENVELOPE_PROFILE,
|
||||
label="Institution-managed envelope",
|
||||
description=(
|
||||
"The Encryption provider protects content and authorized institutional "
|
||||
"key holders can govern recovery. This is the standard profile."
|
||||
),
|
||||
server_can_decrypt=True,
|
||||
requires_encryption_module=True,
|
||||
requires_external_client=False,
|
||||
standard=True,
|
||||
),
|
||||
PostboxProtectionProfileDefinition(
|
||||
id=POSTBOX_EXTERNAL_E2EE_PROFILE,
|
||||
label="External end-to-end envelope",
|
||||
description=(
|
||||
"A reviewed client or producer supplies ciphertext, a signed manifest, "
|
||||
"and recipient-wrapped keys. GovOPlaN stores and routes them but cannot "
|
||||
"decrypt the content."
|
||||
),
|
||||
server_can_decrypt=False,
|
||||
requires_encryption_module=False,
|
||||
requires_external_client=True,
|
||||
),
|
||||
PostboxProtectionProfileDefinition(
|
||||
id=POSTBOX_PLAINTEXT_PROFILE,
|
||||
label="No application-layer encryption",
|
||||
description=(
|
||||
"Postbox stores readable message content. Transport and storage controls "
|
||||
"may still apply, but this profile is not encrypted by Postbox."
|
||||
),
|
||||
server_can_decrypt=True,
|
||||
requires_encryption_module=False,
|
||||
requires_external_client=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def is_e2ee_profile(profile: str) -> bool:
|
||||
return profile in {
|
||||
POSTBOX_EXTERNAL_E2EE_PROFILE,
|
||||
POSTBOX_LEGACY_EXTERNAL_ENVELOPE_PROFILE,
|
||||
}
|
||||
@@ -28,6 +28,7 @@ from govoplan_core.core.files import (
|
||||
PostboxFileReferenceRequest,
|
||||
postbox_file_reference_provider,
|
||||
)
|
||||
from govoplan_core.core.encryption import encryption_content_cipher
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_postbox.backend.manifest import (
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
@@ -60,6 +61,14 @@ from govoplan_postbox.backend.schemas import (
|
||||
PostboxMessageStateRequest,
|
||||
PostboxOrganizationTargetsResponse,
|
||||
PostboxMutationRequest,
|
||||
PostboxProtectionProfileItem,
|
||||
PostboxProtectionProfileListResponse,
|
||||
PostboxProtectionPolicyUpdateRequest,
|
||||
PostboxProtectionTransformRequest,
|
||||
PostboxProtectionTransitionCreateRequest,
|
||||
PostboxProtectionTransitionItemResponse,
|
||||
PostboxProtectionTransitionListResponse,
|
||||
PostboxProtectionTransitionResponse,
|
||||
PostboxRouteDryRunRequest,
|
||||
PostboxRouteDryRunResponse,
|
||||
PostboxTemplateCreateRequest,
|
||||
@@ -71,6 +80,10 @@ from govoplan_postbox.backend.schemas import (
|
||||
PostboxTemplateReviseRequest,
|
||||
)
|
||||
from govoplan_postbox.backend.service import PostboxError
|
||||
from govoplan_postbox.backend.protection_profiles import (
|
||||
POSTBOX_PROTECTION_PROFILE_DEFINITIONS,
|
||||
POSTBOX_STANDARD_PROFILE,
|
||||
)
|
||||
from govoplan_postbox.backend.principals import (
|
||||
PostboxPrincipalError,
|
||||
actor_from_principal,
|
||||
@@ -122,6 +135,8 @@ def _http_error(exc: PostboxError) -> HTTPException:
|
||||
"revision_not_found",
|
||||
"grouping_not_found",
|
||||
"target_not_found",
|
||||
"protection_transition_not_found",
|
||||
"transition_item_not_found",
|
||||
}:
|
||||
code = status.HTTP_404_NOT_FOUND
|
||||
elif exc.code in {"access_denied", "grouping_source_denied"}:
|
||||
@@ -195,6 +210,11 @@ def _authoring_request(
|
||||
idempotency_key=payload.idempotency_key,
|
||||
subject=payload.subject,
|
||||
body_text=payload.body_text,
|
||||
ciphertext_ref=payload.ciphertext_ref,
|
||||
signed_manifest_ref=payload.signed_manifest_ref,
|
||||
wrapped_keys=tuple(
|
||||
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
|
||||
),
|
||||
classification=payload.classification,
|
||||
participants=tuple(
|
||||
PostboxParticipantRef(**participant.model_dump())
|
||||
@@ -236,9 +256,7 @@ def _template_item(template) -> PostboxTemplateItem:
|
||||
"scope_kind": revision.scope_kind,
|
||||
"scope_id": revision.scope_id,
|
||||
"scope_structure_id": revision.scope_structure_id,
|
||||
"scope_relation_type_ids": list(
|
||||
revision.scope_relation_type_ids or []
|
||||
),
|
||||
"scope_relation_type_ids": list(revision.scope_relation_type_ids or []),
|
||||
"name_pattern": revision.name_pattern,
|
||||
"address_pattern": revision.address_pattern,
|
||||
"classification": revision.classification,
|
||||
@@ -246,6 +264,7 @@ def _template_item(template) -> PostboxTemplateItem:
|
||||
"portal_visible": revision.portal_visible,
|
||||
"encryption_profile": revision.encryption_profile,
|
||||
"encryption_vault_id": revision.encryption_vault_id,
|
||||
"protection_policy": dict(revision.history_policy or {}),
|
||||
"history_policy": dict(revision.history_policy or {}),
|
||||
"routing_policy": dict(revision.routing_policy or {}),
|
||||
"retention_policy": dict(revision.retention_policy or {}),
|
||||
@@ -291,6 +310,48 @@ def _grouping_item(
|
||||
)
|
||||
|
||||
|
||||
def _protection_transition_item(value) -> PostboxProtectionTransitionResponse:
|
||||
return PostboxProtectionTransitionResponse(
|
||||
id=value.id,
|
||||
postbox_id=value.postbox_id,
|
||||
source_profile=value.source_profile,
|
||||
target_profile=value.target_profile,
|
||||
source_vault_id=value.source_vault_id,
|
||||
target_vault_id=value.target_vault_id,
|
||||
history_mode=value.history_mode,
|
||||
authority_mode=value.authority_mode,
|
||||
required_quorum=value.required_quorum,
|
||||
evidence_refs=list(value.evidence_refs or []),
|
||||
reason=value.reason,
|
||||
state=value.state,
|
||||
message_count=value.message_count,
|
||||
completed_count=value.completed_count,
|
||||
failed_count=value.failed_count,
|
||||
requested_by=value.requested_by,
|
||||
activated_at=value.activated_at,
|
||||
completed_at=value.completed_at,
|
||||
resource_revision=value.resource_revision,
|
||||
etag=value.strong_etag,
|
||||
configuration_snapshot=dict(value.configuration_snapshot or {}),
|
||||
items=[
|
||||
PostboxProtectionTransitionItemResponse(
|
||||
id=item.id,
|
||||
message_id=item.message_id,
|
||||
source_profile=item.source_profile,
|
||||
target_profile=item.target_profile,
|
||||
state=item.state,
|
||||
source_digest=item.source_digest,
|
||||
target_digest=item.target_digest,
|
||||
completed_by=item.completed_by,
|
||||
completed_at=item.completed_at,
|
||||
error_code=item.error_code,
|
||||
evidence=dict(item.evidence or {}),
|
||||
)
|
||||
for item in value.items
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/directory", response_model=PostboxDirectoryResponse)
|
||||
def api_postbox_directory(
|
||||
assignment_context_id: str | None = None,
|
||||
@@ -523,7 +584,13 @@ def api_resolve_postbox_message_attachments(
|
||||
)
|
||||
|
||||
provider = postbox_file_reference_provider(get_registry())
|
||||
file_types = {"file", "file_asset", "files:file", "file_version", "files:file_version"}
|
||||
file_types = {
|
||||
"file",
|
||||
"file_asset",
|
||||
"files:file",
|
||||
"file_version",
|
||||
"files:file_version",
|
||||
}
|
||||
requests = tuple(
|
||||
PostboxFileReferenceRequest(
|
||||
reference_type=attachment.reference_type,
|
||||
@@ -544,9 +611,7 @@ def api_resolve_postbox_message_attachments(
|
||||
if provider is not None and requests
|
||||
else ()
|
||||
)
|
||||
by_reference = {
|
||||
(item.reference_type, item.reference_id): item for item in resolved
|
||||
}
|
||||
by_reference = {(item.reference_type, item.reference_id): item for item in resolved}
|
||||
items: list[PostboxAttachmentResolutionItem] = []
|
||||
for attachment in message.attachments:
|
||||
resolution = by_reference.get(
|
||||
@@ -667,8 +732,7 @@ def api_deliver_to_postbox(
|
||||
ciphertext_ref=payload.ciphertext_ref,
|
||||
signed_manifest_ref=payload.signed_manifest_ref,
|
||||
wrapped_keys=tuple(
|
||||
PostboxWrappedKeyRef(**item.model_dump())
|
||||
for item in payload.wrapped_keys
|
||||
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
|
||||
),
|
||||
external_recipient_tokens=tuple(
|
||||
PostboxExternalRecipientTokenRef(**item.model_dump())
|
||||
@@ -853,13 +917,9 @@ def api_postbox_organization_targets(
|
||||
) -> PostboxOrganizationTargetsResponse:
|
||||
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
|
||||
return PostboxOrganizationTargetsResponse(
|
||||
units=list(
|
||||
get_service().organization_targets(tenant_id=principal.tenant_id)
|
||||
),
|
||||
units=list(get_service().organization_targets(tenant_id=principal.tenant_id)),
|
||||
structures=list(
|
||||
get_service().organization_hierarchy_targets(
|
||||
tenant_id=principal.tenant_id
|
||||
)
|
||||
get_service().organization_hierarchy_targets(tenant_id=principal.tenant_id)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -881,6 +941,187 @@ def api_admin_postboxes(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/protection-profiles",
|
||||
response_model=PostboxProtectionProfileListResponse,
|
||||
)
|
||||
def api_postbox_protection_profiles(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxProtectionProfileListResponse:
|
||||
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
|
||||
managed_available = encryption_content_cipher(get_registry()) is not None
|
||||
return PostboxProtectionProfileListResponse(
|
||||
standard_profile=POSTBOX_STANDARD_PROFILE,
|
||||
profiles=[
|
||||
PostboxProtectionProfileItem(
|
||||
**asdict(profile),
|
||||
available=(
|
||||
managed_available if profile.requires_encryption_module else True
|
||||
),
|
||||
)
|
||||
for profile in POSTBOX_PROTECTION_PROFILE_DEFINITIONS
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/postboxes/{postbox_id}/protection-transitions",
|
||||
response_model=PostboxProtectionTransitionListResponse,
|
||||
)
|
||||
def api_list_postbox_protection_transitions(
|
||||
postbox_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxProtectionTransitionListResponse:
|
||||
_require(principal, BINDING_ADMIN_SCOPE)
|
||||
try:
|
||||
values = get_service().list_protection_transitions(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return PostboxProtectionTransitionListResponse(
|
||||
transitions=[_protection_transition_item(value) for value in values]
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/admin/postboxes/{postbox_id}/protection-policy",
|
||||
response_model=PostboxDirectoryItem,
|
||||
)
|
||||
def api_update_postbox_protection_policy(
|
||||
postbox_id: str,
|
||||
payload: PostboxProtectionPolicyUpdateRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxDirectoryItem:
|
||||
_require(principal, BINDING_ADMIN_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
try:
|
||||
get_service().update_protection_policy(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
protection_policy=payload.protection_policy.model_dump(),
|
||||
actor_id=principal.account_id,
|
||||
expected_revision=payload.base_revision,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
item = _directory_item(
|
||||
get_service().resolve_postbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
target=PostboxTargetRef(postbox_id=postbox_id),
|
||||
)
|
||||
)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/postboxes/{postbox_id}/protection-transitions",
|
||||
response_model=PostboxProtectionTransitionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_postbox_protection_transition(
|
||||
postbox_id: str,
|
||||
payload: PostboxProtectionTransitionCreateRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxProtectionTransitionResponse:
|
||||
_require(principal, BINDING_ADMIN_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
try:
|
||||
value = get_service().create_protection_transition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
expected_revision=payload.base_revision,
|
||||
actor_id=principal.account_id,
|
||||
**payload.model_dump(
|
||||
exclude={"base_revision", "acknowledge_irreversibility"}
|
||||
),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
item = _protection_transition_item(value)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/postboxes/{postbox_id}/protection-transitions/{transition_id}/transform",
|
||||
response_model=PostboxProtectionTransitionResponse,
|
||||
)
|
||||
def api_apply_postbox_protection_transform(
|
||||
postbox_id: str,
|
||||
transition_id: str,
|
||||
payload: PostboxProtectionTransformRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxProtectionTransitionResponse:
|
||||
_require(principal, BINDING_ADMIN_SCOPE)
|
||||
_require_mutation_precondition(
|
||||
if_match,
|
||||
resource_type="postbox_protection_transition",
|
||||
resource_id=transition_id,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
try:
|
||||
value = get_service().apply_client_protection_transform(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
transition_id=transition_id,
|
||||
expected_revision=payload.base_revision,
|
||||
actor_id=principal.account_id,
|
||||
wrapped_keys=tuple(
|
||||
PostboxWrappedKeyRef(**item.model_dump())
|
||||
for item in payload.wrapped_keys
|
||||
),
|
||||
**payload.model_dump(exclude={"base_revision", "wrapped_keys"}),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
except ConcurrencyError as exc:
|
||||
session.rollback()
|
||||
raise _concurrency_http_error(exc) from exc
|
||||
session.commit()
|
||||
item = _protection_transition_item(value)
|
||||
_set_etag(response, item.etag)
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/postboxes",
|
||||
response_model=PostboxDirectoryItem,
|
||||
|
||||
@@ -5,6 +5,12 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from govoplan_postbox.backend.protection_profiles import (
|
||||
POSTBOX_MANAGED_ENVELOPE_PROFILE,
|
||||
POSTBOX_PLAINTEXT_PROFILE,
|
||||
PostboxProtectionProfile,
|
||||
)
|
||||
|
||||
|
||||
PostboxClassification = Literal[
|
||||
"public",
|
||||
@@ -48,6 +54,10 @@ class PostboxDirectoryItem(BaseModel):
|
||||
template_revision_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
encryption_profile: str = POSTBOX_PLAINTEXT_PROFILE
|
||||
key_epoch: int = Field(default=1, ge=1)
|
||||
encryption_vault_id: str | None = None
|
||||
protection_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
access: PostboxAccessDecisionResponse | None = None
|
||||
resource_revision: int = Field(default=1, ge=1)
|
||||
etag: str | None = None
|
||||
@@ -156,11 +166,32 @@ class PostboxMessageAuthoringPayload(BaseModel):
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
subject: str = Field(min_length=1, max_length=1000)
|
||||
body_text: str | None = None
|
||||
ciphertext_ref: str | None = Field(default=None, max_length=1000)
|
||||
signed_manifest_ref: str | None = Field(default=None, max_length=1000)
|
||||
wrapped_keys: list[PostboxWrappedKeyPayload] = Field(default_factory=list)
|
||||
classification: PostboxClassification = "internal"
|
||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_content_source(self) -> "PostboxMessageAuthoringPayload":
|
||||
if self.body_text is not None and self.ciphertext_ref:
|
||||
raise ValueError(
|
||||
"Provide plaintext or an external ciphertext envelope, not both."
|
||||
)
|
||||
if self.ciphertext_ref and (
|
||||
not self.signed_manifest_ref or not self.wrapped_keys
|
||||
):
|
||||
raise ValueError(
|
||||
"External E2EE content requires a signed manifest and wrapped keys."
|
||||
)
|
||||
if not self.ciphertext_ref and (self.signed_manifest_ref or self.wrapped_keys):
|
||||
raise ValueError(
|
||||
"A signed manifest and wrapped keys require an external ciphertext reference."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxMessageCreateRequest(PostboxMessageAuthoringPayload):
|
||||
postbox_id: str = Field(min_length=1, max_length=36)
|
||||
@@ -178,9 +209,7 @@ class PostboxTargetPayload(BaseModel):
|
||||
def validate_target(self) -> "PostboxTargetPayload":
|
||||
direct = bool(self.postbox_id or self.address_key)
|
||||
templated = bool(
|
||||
self.template_id
|
||||
and self.organization_unit_id
|
||||
and self.function_id
|
||||
self.template_id and self.organization_unit_id and self.function_id
|
||||
)
|
||||
if direct == templated:
|
||||
raise ValueError(
|
||||
@@ -210,6 +239,24 @@ class PostboxDeliveryCreateRequest(BaseModel):
|
||||
)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_content_source(self) -> "PostboxDeliveryCreateRequest":
|
||||
if self.body_text is not None and self.ciphertext_ref:
|
||||
raise ValueError(
|
||||
"Provide plaintext or an external ciphertext envelope, not both."
|
||||
)
|
||||
if self.ciphertext_ref and (
|
||||
not self.signed_manifest_ref or not self.wrapped_keys
|
||||
):
|
||||
raise ValueError(
|
||||
"External E2EE content requires a signed manifest and wrapped keys."
|
||||
)
|
||||
if not self.ciphertext_ref and (self.signed_manifest_ref or self.wrapped_keys):
|
||||
raise ValueError(
|
||||
"A signed manifest and wrapped keys require an external ciphertext reference."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxDeliveryResponse(BaseModel):
|
||||
delivery_id: str
|
||||
@@ -248,14 +295,10 @@ class PostboxLinkedCopyPolicyPayload(BaseModel):
|
||||
def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload":
|
||||
self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids))
|
||||
self.allowed_classifications = list(
|
||||
dict.fromkeys(
|
||||
value.strip() for value in self.allowed_classifications
|
||||
)
|
||||
dict.fromkeys(value.strip() for value in self.allowed_classifications)
|
||||
)
|
||||
self.allowed_producer_modules = list(
|
||||
dict.fromkeys(
|
||||
value.strip() for value in self.allowed_producer_modules
|
||||
)
|
||||
dict.fromkeys(value.strip() for value in self.allowed_producer_modules)
|
||||
)
|
||||
if any(not value for value in self.relation_type_ids):
|
||||
raise ValueError("Relation type IDs must not be empty.")
|
||||
@@ -316,16 +359,10 @@ class PostboxRoutingPolicyPayload(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_semantics(self) -> "PostboxRoutingPolicyPayload":
|
||||
if (
|
||||
self.attention.mode == "vacancy_escalation"
|
||||
and (
|
||||
not self.linked_copy.enabled
|
||||
or self.linked_copy.fanout != "nearest"
|
||||
)
|
||||
if self.attention.mode == "vacancy_escalation" and (
|
||||
not self.linked_copy.enabled or self.linked_copy.fanout != "nearest"
|
||||
):
|
||||
raise ValueError(
|
||||
"Vacancy escalation requires nearest linked-copy routing."
|
||||
)
|
||||
raise ValueError("Vacancy escalation requires nearest linked-copy routing.")
|
||||
return self
|
||||
|
||||
|
||||
@@ -361,6 +398,73 @@ class PostboxRouteDryRunResponse(BaseModel):
|
||||
diagnostics: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PostboxProtectionPolicyPayload(BaseModel):
|
||||
new_incumbent_history: Literal[
|
||||
"all_retained",
|
||||
"since_assignment",
|
||||
"bounded_days",
|
||||
] = "since_assignment"
|
||||
history_days: int | None = Field(default=None, ge=1, le=36500)
|
||||
ordinary_rotation: Literal["rewrap", "reencrypt"] = "rewrap"
|
||||
compromise_rotation: Literal["rewrap", "reencrypt"] = "reencrypt"
|
||||
recovery_authority: Literal[
|
||||
"disabled",
|
||||
"user_consent",
|
||||
"institutional_key_holders",
|
||||
"dual_control",
|
||||
] = "institutional_key_holders"
|
||||
recovery_quorum: int = Field(default=2, ge=1, le=20)
|
||||
handover_authority: Literal[
|
||||
"user_consent",
|
||||
"institutional_key_holders",
|
||||
"dual_control",
|
||||
] = "dual_control"
|
||||
handover_quorum: int = Field(default=2, ge=1, le=20)
|
||||
emergency_access: Literal["disabled", "dual_control"] = "dual_control"
|
||||
emergency_quorum: int = Field(default=2, ge=1, le=20)
|
||||
export_authority: Literal[
|
||||
"user_consent",
|
||||
"institutional_key_holders",
|
||||
"dual_control",
|
||||
] = "dual_control"
|
||||
export_quorum: int = Field(default=2, ge=1, le=20)
|
||||
destruction_authority: Literal[
|
||||
"institutional_key_holders",
|
||||
"dual_control",
|
||||
] = "dual_control"
|
||||
destruction_quorum: int = Field(default=2, ge=1, le=20)
|
||||
external_recipient_assurance: Literal[
|
||||
"disabled",
|
||||
"email_otp",
|
||||
"strong_identity",
|
||||
] = "strong_identity"
|
||||
vacancy_escalation_content_access: Literal["metadata_only"] = "metadata_only"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_history_policy(self) -> "PostboxProtectionPolicyPayload":
|
||||
if self.new_incumbent_history == "bounded_days" and self.history_days is None:
|
||||
raise ValueError("Bounded incumbent history requires a day limit.")
|
||||
if self.new_incumbent_history != "bounded_days":
|
||||
self.history_days = None
|
||||
if self.handover_authority == "dual_control" and self.handover_quorum < 2:
|
||||
raise ValueError(
|
||||
"Dual-control hand-over requires a quorum of at least two."
|
||||
)
|
||||
if self.emergency_access == "dual_control" and self.emergency_quorum < 2:
|
||||
raise ValueError(
|
||||
"Emergency dual control requires a quorum of at least two."
|
||||
)
|
||||
if self.recovery_authority == "dual_control" and self.recovery_quorum < 2:
|
||||
raise ValueError("Dual-control recovery requires a quorum of at least two.")
|
||||
if self.export_authority == "dual_control" and self.export_quorum < 2:
|
||||
raise ValueError("Dual-control export requires a quorum of at least two.")
|
||||
if self.destruction_authority == "dual_control" and self.destruction_quorum < 2:
|
||||
raise ValueError(
|
||||
"Dual-control destruction requires a quorum of at least two."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxExactCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=500)
|
||||
description: str | None = None
|
||||
@@ -369,25 +473,31 @@ class PostboxExactCreateRequest(BaseModel):
|
||||
address_key: str | None = Field(default=None, max_length=120)
|
||||
classification: PostboxClassification = "internal"
|
||||
portal_visible: bool = False
|
||||
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
|
||||
"plaintext_v1"
|
||||
)
|
||||
encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE
|
||||
encryption_vault_id: str | None = Field(default=None, max_length=255)
|
||||
protection_policy: PostboxProtectionPolicyPayload = Field(
|
||||
default_factory=PostboxProtectionPolicyPayload
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_encryption(self) -> "PostboxExactCreateRequest":
|
||||
if self.encryption_profile == "server_envelope_v1":
|
||||
if self.encryption_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE:
|
||||
if not str(self.encryption_vault_id or "").strip():
|
||||
raise ValueError(
|
||||
"Server-envelope Postboxes require an encryption vault."
|
||||
)
|
||||
elif self.encryption_vault_id:
|
||||
raise ValueError(
|
||||
"A plaintext Postbox cannot select an encryption vault."
|
||||
"Only an institution-managed Postbox can select an encryption vault."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxProtectionPolicyUpdateRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
protection_policy: PostboxProtectionPolicyPayload
|
||||
|
||||
|
||||
class PostboxTemplateRevisionPayload(BaseModel):
|
||||
function_type_id: str | None = Field(default=None, max_length=36)
|
||||
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
|
||||
@@ -407,10 +517,11 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
||||
classification: PostboxClassification = "internal"
|
||||
allow_vacant_delivery: bool = True
|
||||
portal_visible: bool = False
|
||||
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
|
||||
"plaintext_v1"
|
||||
)
|
||||
encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE
|
||||
encryption_vault_id: str | None = Field(default=None, max_length=255)
|
||||
protection_policy: PostboxProtectionPolicyPayload = Field(
|
||||
default_factory=PostboxProtectionPolicyPayload
|
||||
)
|
||||
routing_policy: PostboxRoutingPolicyPayload = Field(
|
||||
default_factory=PostboxRoutingPolicyPayload
|
||||
)
|
||||
@@ -426,18 +537,169 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
|
||||
if self.encryption_profile == "server_envelope_v1":
|
||||
if self.encryption_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE:
|
||||
if not str(self.encryption_vault_id or "").strip():
|
||||
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."
|
||||
"Only an institution-managed Postbox template can select an encryption vault."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxProtectionProfileItem(BaseModel):
|
||||
id: PostboxProtectionProfile
|
||||
label: str
|
||||
description: str
|
||||
server_can_decrypt: bool
|
||||
requires_encryption_module: bool
|
||||
requires_external_client: bool
|
||||
available: bool
|
||||
standard: bool = False
|
||||
|
||||
|
||||
class PostboxProtectionProfileListResponse(BaseModel):
|
||||
standard_profile: PostboxProtectionProfile
|
||||
profiles: list[PostboxProtectionProfileItem]
|
||||
|
||||
|
||||
class PostboxProtectionTransitionCreateRequest(BaseModel):
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
base_revision: int = Field(ge=1)
|
||||
target_profile: PostboxProtectionProfile
|
||||
target_vault_id: str | None = Field(default=None, max_length=255)
|
||||
history_mode: Literal["future_only", "migrate_history"] = "future_only"
|
||||
authority_mode: Literal[
|
||||
"user_consent",
|
||||
"institutional_key_holders",
|
||||
"dual_control",
|
||||
]
|
||||
required_quorum: int = Field(default=1, ge=1, le=20)
|
||||
user_consent_refs: list[str] = Field(default_factory=list, max_length=50)
|
||||
institutional_authorization_refs: list[str] = Field(
|
||||
default_factory=list, max_length=50
|
||||
)
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
acknowledge_irreversibility: bool
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transition(self) -> "PostboxProtectionTransitionCreateRequest":
|
||||
self.user_consent_refs = list(
|
||||
dict.fromkeys(
|
||||
item.strip() for item in self.user_consent_refs if item.strip()
|
||||
)
|
||||
)
|
||||
self.institutional_authorization_refs = list(
|
||||
dict.fromkeys(
|
||||
item.strip()
|
||||
for item in self.institutional_authorization_refs
|
||||
if item.strip()
|
||||
)
|
||||
)
|
||||
evidence_count = len(
|
||||
set(self.user_consent_refs + self.institutional_authorization_refs)
|
||||
)
|
||||
if evidence_count < self.required_quorum:
|
||||
raise ValueError("The evidence set does not satisfy the selected quorum.")
|
||||
if self.authority_mode in {"user_consent", "dual_control"} and not (
|
||||
self.user_consent_refs
|
||||
):
|
||||
raise ValueError(
|
||||
"The selected authority mode requires user consent evidence."
|
||||
)
|
||||
if (
|
||||
self.authority_mode
|
||||
in {
|
||||
"institutional_key_holders",
|
||||
"dual_control",
|
||||
}
|
||||
and not self.institutional_authorization_refs
|
||||
):
|
||||
raise ValueError(
|
||||
"The selected authority mode requires institutional authorization evidence."
|
||||
)
|
||||
if self.authority_mode == "dual_control" and self.required_quorum < 2:
|
||||
raise ValueError("Dual control requires a quorum of at least two.")
|
||||
if not self.acknowledge_irreversibility:
|
||||
raise ValueError(
|
||||
"Confirm that previously decrypted, copied, or exported content cannot be recalled."
|
||||
)
|
||||
if self.target_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE:
|
||||
if not str(self.target_vault_id or "").strip():
|
||||
raise ValueError("Institution-managed envelopes require a vault.")
|
||||
elif self.target_vault_id:
|
||||
raise ValueError("Only institution-managed envelopes select a vault.")
|
||||
return self
|
||||
|
||||
|
||||
class PostboxProtectionTransformRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
message_id: str = Field(min_length=1, max_length=36)
|
||||
plaintext: str | None = None
|
||||
ciphertext_ref: str | None = Field(default=None, max_length=1000)
|
||||
signed_manifest_ref: str | None = Field(default=None, max_length=1000)
|
||||
wrapped_keys: list[PostboxWrappedKeyPayload] = Field(default_factory=list)
|
||||
content_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
transformation_evidence_ref: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_target_payload(self) -> "PostboxProtectionTransformRequest":
|
||||
if self.plaintext is not None and self.ciphertext_ref:
|
||||
raise ValueError("Provide transformed plaintext or ciphertext, not both.")
|
||||
if self.ciphertext_ref and (
|
||||
not self.signed_manifest_ref or not self.wrapped_keys
|
||||
):
|
||||
raise ValueError(
|
||||
"E2EE transformation requires a signed manifest and wrapped keys."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxProtectionTransitionItemResponse(BaseModel):
|
||||
id: str
|
||||
message_id: str
|
||||
source_profile: str
|
||||
target_profile: str
|
||||
state: str
|
||||
source_digest: str | None = None
|
||||
target_digest: str | None = None
|
||||
completed_by: str | None = None
|
||||
completed_at: datetime | None = None
|
||||
error_code: str | None = None
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxProtectionTransitionResponse(BaseModel):
|
||||
id: str
|
||||
postbox_id: str
|
||||
source_profile: str
|
||||
target_profile: str
|
||||
source_vault_id: str | None = None
|
||||
target_vault_id: str | None = None
|
||||
history_mode: str
|
||||
authority_mode: str
|
||||
required_quorum: int
|
||||
evidence_refs: list[str]
|
||||
reason: str
|
||||
state: str
|
||||
message_count: int
|
||||
completed_count: int
|
||||
failed_count: int
|
||||
requested_by: str | None = None
|
||||
activated_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
resource_revision: int = Field(ge=1)
|
||||
etag: str
|
||||
configuration_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
items: list[PostboxProtectionTransitionItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PostboxProtectionTransitionListResponse(BaseModel):
|
||||
transitions: list[PostboxProtectionTransitionResponse]
|
||||
|
||||
|
||||
def _validate_template_write_scope(
|
||||
payload: PostboxTemplateRevisionPayload,
|
||||
) -> None:
|
||||
@@ -586,9 +848,7 @@ class PostboxOrganizationStructureItem(BaseModel):
|
||||
|
||||
class PostboxOrganizationTargetsResponse(BaseModel):
|
||||
units: list[PostboxOrganizationUnitItem]
|
||||
structures: list[PostboxOrganizationStructureItem] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
structures: list[PostboxOrganizationStructureItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PostboxGroupingPayload(BaseModel):
|
||||
|
||||
+1402
-191
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user