feat(postbox): add governed content protection profiles

This commit is contained in:
2026-08-20 03:42:58 +02:00
parent 15d93aaa25
commit 174ee97719
16 changed files with 3917 additions and 383 deletions
@@ -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",
+122 -6
View File
@@ -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__ = (
+93 -12
View File
@@ -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",),
@@ -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,
}
+256 -15
View File
@@ -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,
+291 -31
View File
@@ -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):
File diff suppressed because it is too large Load Diff