feat(postbox): add governed content protection profiles
This commit is contained in:
@@ -103,12 +103,22 @@ an independently readable copy in the next frozen function Postbox. The
|
||||
`govoplan.postbox.dispatch_routes` periodic Core worker drains due routes when
|
||||
Celery beat and a worker consuming the `postbox` queue are enabled.
|
||||
|
||||
Postboxes support `plaintext_v1` and an optional `server_envelope_v1` profile.
|
||||
The latter stores message bodies as ciphertext through the Encryption
|
||||
capability and fails closed on reads if that capability or key is unavailable.
|
||||
Postboxes expose three configurable content-protection profiles. The recommended
|
||||
`server_envelope_v1` profile stores message bodies as ciphertext through an
|
||||
institution-controlled Encryption vault and fails closed if its capability or
|
||||
key is unavailable. `external_e2ee_v1` accepts only ciphertext, a signed
|
||||
manifest, wrapped recipient keys, and a verified content digest produced by an
|
||||
approved external client; GovOPlaN cannot decrypt that content. `plaintext_v1`
|
||||
keeps content unencrypted for deployments that explicitly accept that boundary.
|
||||
Subjects, participants, routing, attachment references, and lifecycle metadata
|
||||
remain observable. Existing externally produced ciphertext references remain
|
||||
supported, but neither path is described as end-to-end encryption.
|
||||
remain observable in every profile.
|
||||
|
||||
Administrators may govern future-only changes or migrate retained history.
|
||||
Transitions record user-consent and/or institutional key-holder evidence,
|
||||
quorum, reason, per-message digest continuity, and completion state. Managed
|
||||
envelope changes use the Encryption migration ledger. Any transition to or from
|
||||
E2EE waits for client-supplied transforms for historical messages; the module
|
||||
does not claim or silently simulate native browser/device key custody.
|
||||
|
||||
Run focused checks with:
|
||||
|
||||
|
||||
+79
-44
@@ -12,14 +12,16 @@ needed a role, process, portal, campaign, or service responsibility. It may look
|
||||
like an inbox for a message task or like a vault for content shared with the
|
||||
current holders of that responsibility; neither form is owned by one account.
|
||||
|
||||
The strategic target is an encrypted administrative postbox. The current
|
||||
implementation supports ordinary persisted messages and an optional
|
||||
server-readable Encryption envelope for message bodies. The model also retains
|
||||
external ciphertext, wrapped-key, signed-manifest, external-recipient-token,
|
||||
and key-epoch metadata needed for later independently reviewed E2EE profiles.
|
||||
The server-envelope profile is not E2EE, and subjects, routing, participants,
|
||||
and attachment references remain visible. The
|
||||
cross-module target architecture is recorded in
|
||||
The strategic target is a policy-selectable administrative postbox. The current
|
||||
implementation offers unencrypted content, an institution-managed Encryption
|
||||
envelope, and a strict external E2EE boundary. E2EE messages contain only an
|
||||
external ciphertext reference, signed-manifest reference, wrapped recipient
|
||||
keys, and a verified plaintext digest; an approved producer or client owns the
|
||||
actual cryptographic operation and private-key custody. GovOPlaN cannot decrypt
|
||||
that profile. The institution-managed envelope remains server-readable by
|
||||
authorized institutional key holders. Subjects, routing, participants, and
|
||||
attachment references remain visible in every profile. The cross-module target
|
||||
architecture is recorded in
|
||||
`govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`.
|
||||
|
||||
## Function-Organization-Bound Access
|
||||
@@ -356,47 +358,80 @@ The WebUI should start as an administration and inbox surface:
|
||||
|
||||
Campaign, files, portal, and mail behavior should arrive as optional integrations after the core postbox model is stable.
|
||||
|
||||
### Current content-protection profile
|
||||
### Configurable content-protection profiles
|
||||
|
||||
An exact Postbox or template revision may select `server_envelope_v1` and an
|
||||
Encryption vault. New locally authored and delivered message bodies are then
|
||||
stored in `body_ciphertext` with an owner-bound envelope reference; clear body
|
||||
text is not persisted. Reads ask the optional `encryption.content_cipher`
|
||||
capability to open the exact tenant, message, and envelope tuple. Missing
|
||||
Encryption, a lost deployment key, a destroyed vault key, ciphertext tampering,
|
||||
or a mismatched resource causes a fail-closed read.
|
||||
An exact Postbox or template revision selects one profile. The administration
|
||||
surface recommends the managed profile and requires its vault explicitly; the
|
||||
API retains the legacy plaintext default when an older integration omits these
|
||||
new fields so an upgrade cannot make an unavailable Encryption module block
|
||||
existing automation.
|
||||
|
||||
Plaintext Postboxes continue to work without Encryption. A protected Postbox
|
||||
cannot silently fall back to plaintext. Database recovery of protected messages
|
||||
requires Postbox and Encryption tables from the same consistency point plus the
|
||||
matching provider/deployment key. Hierarchy-routed copies retain the source
|
||||
envelope reference rather than decrypting and re-encrypting during routing.
|
||||
- `server_envelope_v1` is the recommended standard. New local message bodies
|
||||
are encrypted through the optional `encryption.content_cipher` capability,
|
||||
stored in `body_ciphertext`, and linked to an owner-bound envelope in the
|
||||
institution's selected vault. Authorized reads ask that capability to open
|
||||
the exact tenant, message, and envelope tuple. Missing Encryption, unavailable
|
||||
or destroyed keys, tampering, and resource mismatch fail closed.
|
||||
- `external_e2ee_v1` is a server-blind storage contract. Clear bodies are
|
||||
rejected. A producer must provide a ciphertext reference, signed manifest,
|
||||
wrapped recipient keys for the current key epoch, and `sha256` content digest.
|
||||
The server retains and authorizes those artifacts but has no private key with
|
||||
which to decrypt them.
|
||||
- `plaintext_v1` stores the body without content encryption. It remains
|
||||
available for deployments that explicitly choose transport and
|
||||
infrastructure controls only.
|
||||
|
||||
## E2EE Readiness Checklist
|
||||
No profile hides operational metadata. Subjects, senders, participants,
|
||||
routing, timestamps, classifications, attachment references, receipts,
|
||||
retention state, and access evidence remain server-visible. Native browser or
|
||||
device enrollment, private-key custody, offline recovery, and independently
|
||||
reviewed cryptographic clients are not bundled by Postbox; an institution that
|
||||
selects E2EE must provide and govern that client/provider boundary.
|
||||
|
||||
Before the data model is considered stable, verify that it can represent:
|
||||
### Protection and hand-over policy
|
||||
|
||||
- message or attachment ciphertext references
|
||||
- signed manifest references
|
||||
- recipient, role, or function key wrapping records
|
||||
- key epoch and device-key references
|
||||
- key-fetch/access audit events
|
||||
- external recipient token state
|
||||
- expiry and withdrawal state separate from deletion
|
||||
- retention state that can operate without decrypting content
|
||||
Each Postbox snapshots policy for the choices that cannot safely be inferred:
|
||||
|
||||
## E2EE decisions still to settle before implementation
|
||||
- a new incumbent sees all retained history, content since assignment, or a
|
||||
bounded look-back period;
|
||||
- ordinary and compromise rotations select key rewrapping or full content
|
||||
re-encryption;
|
||||
- recovery, hand-over, emergency access, export, and destruction name the
|
||||
required user-consent, institutional key-holder, or dual-control authority
|
||||
and quorum;
|
||||
- external retrieval requires strong identity, email plus a one-time code, or
|
||||
may be disabled; and
|
||||
- vacancy escalation is always metadata-only and never gives an unrelated
|
||||
personal account content access.
|
||||
|
||||
The product direction above is selected, but the first trusted profile still
|
||||
needs bounded decisions on:
|
||||
The defaults are deliberately conservative: history since assignment,
|
||||
ordinary rewrapping, re-encryption after compromise, two-person institutional
|
||||
recovery, dual-control hand-over/emergency/export/destruction, strong external
|
||||
identity, and metadata-only vacancy escalation. These are product defaults, not
|
||||
hard-coded policy decisions; administrators can change them per template or
|
||||
exact Postbox.
|
||||
|
||||
- whether a new incumbent receives all retained history, history from a
|
||||
policy-defined date, or only content delivered during the assignment;
|
||||
- organizational recovery/escrow and the authority required when every holder
|
||||
loses all registered device keys;
|
||||
- whether ordinary rotation only rewraps per-content keys or also re-encrypts
|
||||
ciphertext, and which events require the stronger path;
|
||||
- assurance and quorum requirements for delegation, hand-over, emergency
|
||||
access, export, and destructive retention; and
|
||||
- how attention/escalation works during a vacancy without granting plaintext
|
||||
access to an unrelated personal account.
|
||||
### Governed profile transitions
|
||||
|
||||
A profile change increments the Postbox key epoch and applies immediately to
|
||||
new messages. The administrator chooses whether retained history stays under
|
||||
its existing profile or is migrated. Every transition records an idempotency
|
||||
key, source and target profiles/vaults, user-consent and/or institutional
|
||||
authorization evidence, quorum, reason, immutable configuration snapshot,
|
||||
message digests, and per-message outcome.
|
||||
|
||||
Plaintext-to-managed and managed-to-plaintext migrations can complete through
|
||||
the configured Encryption capability. Managed decrypt, export, and
|
||||
re-encryption operations are also written to the Encryption migration ledger;
|
||||
old envelopes are not merely orphaned. A transition to or from E2EE pauses each
|
||||
historical message until an approved external client supplies the ciphertext or
|
||||
plaintext transform and evidence. Postbox checks the immutable SHA-256 digest
|
||||
before committing the new representation. Leaving E2EE requires user-consent
|
||||
evidence; changing institution-managed history requires institutional
|
||||
key-holder evidence; dual control can require both. Previously viewed, copied,
|
||||
printed, or exported cleartext cannot be recalled and must be acknowledged.
|
||||
|
||||
Database recovery of managed messages requires Postbox and Encryption tables
|
||||
from the same consistency point plus the provider/deployment key. Recovery of
|
||||
E2EE content additionally depends on the institution's external private-key
|
||||
custody and client procedures.
|
||||
|
||||
@@ -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
+13
-6
@@ -38,6 +38,10 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
"govoplan_postbox.backend.migrations.versions."
|
||||
"f2a5c8e1b4d7_v015_portal_visibility"
|
||||
)
|
||||
transition_migration = importlib.import_module(
|
||||
"govoplan_postbox.backend.migrations.versions."
|
||||
"a7c1e4f8b2d6_v016_protection_transitions"
|
||||
)
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
@@ -49,6 +53,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
protection_original = protection_migration.op
|
||||
scope_original = scope_migration.op
|
||||
portal_original = portal_migration.op
|
||||
transition_original = transition_migration.op
|
||||
migration.op = operations
|
||||
route_migration.op = operations
|
||||
occ_migration.op = operations
|
||||
@@ -56,6 +61,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
protection_migration.op = operations
|
||||
scope_migration.op = operations
|
||||
portal_migration.op = operations
|
||||
transition_migration.op = operations
|
||||
try:
|
||||
migration.upgrade()
|
||||
route_migration.upgrade()
|
||||
@@ -64,11 +70,14 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
protection_migration.upgrade()
|
||||
scope_migration.upgrade()
|
||||
portal_migration.upgrade()
|
||||
transition_migration.upgrade()
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
self.assertIn("postboxes", tables)
|
||||
self.assertIn("postbox_messages", tables)
|
||||
self.assertIn("postbox_deliveries", tables)
|
||||
self.assertIn("postbox_access_events", tables)
|
||||
self.assertIn("postbox_protection_transitions", tables)
|
||||
self.assertIn("postbox_protection_transition_items", tables)
|
||||
message_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns(
|
||||
@@ -120,15 +129,12 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
)
|
||||
route_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns(
|
||||
"postbox_routes"
|
||||
)
|
||||
for column in inspect(connection).get_columns("postbox_routes")
|
||||
}
|
||||
self.assertTrue(
|
||||
{"execute_after", "processed_at"}.issubset(
|
||||
route_columns
|
||||
)
|
||||
{"execute_after", "processed_at"}.issubset(route_columns)
|
||||
)
|
||||
transition_migration.downgrade()
|
||||
portal_migration.downgrade()
|
||||
scope_migration.downgrade()
|
||||
protection_migration.downgrade()
|
||||
@@ -151,6 +157,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
protection_migration.op = protection_original
|
||||
scope_migration.op = scope_original
|
||||
portal_migration.op = portal_original
|
||||
transition_migration.op = transition_original
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
+368
-51
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
@@ -49,6 +51,8 @@ from govoplan_postbox.backend.db.models import (
|
||||
PostboxMessage,
|
||||
PostboxMessageReceipt,
|
||||
PostboxParticipant,
|
||||
PostboxProtectionTransition,
|
||||
PostboxProtectionTransitionItem,
|
||||
PostboxRoute,
|
||||
PostboxTemplate,
|
||||
PostboxTemplateRevision,
|
||||
@@ -64,6 +68,8 @@ POSTBOX_TABLES = (
|
||||
Postbox.__table__,
|
||||
PostboxBinding.__table__,
|
||||
PostboxMessage.__table__,
|
||||
PostboxProtectionTransition.__table__,
|
||||
PostboxProtectionTransitionItem.__table__,
|
||||
PostboxParticipant.__table__,
|
||||
PostboxAttachmentReference.__table__,
|
||||
PostboxDelivery.__table__,
|
||||
@@ -87,7 +93,9 @@ class FakeIdentityDirectory:
|
||||
)
|
||||
|
||||
def identities_for_accounts(self, account_ids):
|
||||
return tuple(self.identity_for_account(account_id) for account_id in account_ids)
|
||||
return tuple(
|
||||
self.identity_for_account(account_id) for account_id in account_ids
|
||||
)
|
||||
|
||||
def accounts_for_identity(self, identity_id: str):
|
||||
return ()
|
||||
@@ -335,8 +343,7 @@ class FakeOrganizationDirectory:
|
||||
if function.function_type_id == function_type_id
|
||||
and (
|
||||
not organization_unit_ids
|
||||
or function.organization_unit_id
|
||||
in organization_unit_ids
|
||||
or function.organization_unit_id in organization_unit_ids
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -355,8 +362,7 @@ class FakeOrganizationDirectory:
|
||||
matches=tuple(
|
||||
unit
|
||||
for unit in self.units.values()
|
||||
if unit.tenant_id == tenant_id
|
||||
and unit.unit_type_id == unit_type_id
|
||||
if unit.tenant_id == tenant_id and unit.unit_type_id == unit_type_id
|
||||
),
|
||||
)
|
||||
|
||||
@@ -457,9 +463,7 @@ class FakeOrganizationDirectory:
|
||||
root=root,
|
||||
matches=matches,
|
||||
cycle_detected=self.cycle_detected,
|
||||
depth_limited=(
|
||||
structure_id == self.structure.id and max_depth < 2
|
||||
),
|
||||
depth_limited=(structure_id == self.structure.id and max_depth < 2),
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
@@ -529,15 +533,20 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
self.actor = PostboxActorRef(
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
authorized_actions=frozenset(
|
||||
{"discover", "read", "send", "acknowledge"}
|
||||
),
|
||||
authorized_actions=frozenset({"discover", "read", "send", "acknowledge"}),
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
def _create_exact(self, session: Session) -> Postbox:
|
||||
def _create_exact(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
protection_policy: dict[str, object] | None = None,
|
||||
) -> Postbox:
|
||||
return self.service.create_exact_postbox(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
@@ -548,6 +557,9 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
description=None,
|
||||
classification="internal",
|
||||
actor_id="admin-1",
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
protection_policy=protection_policy,
|
||||
)
|
||||
|
||||
def _routing_policy(
|
||||
@@ -575,11 +587,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
"max_retention_days": 30,
|
||||
},
|
||||
"attention": {
|
||||
"mode": (
|
||||
"vacancy_escalation"
|
||||
if vacancy_escalation
|
||||
else "none"
|
||||
),
|
||||
"mode": ("vacancy_escalation" if vacancy_escalation else "none"),
|
||||
"delay_minutes": 1 if vacancy_escalation else None,
|
||||
},
|
||||
"shared_visibility": {"mode": "none"},
|
||||
@@ -830,7 +838,9 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None:
|
||||
def test_template_revision_is_immutable_and_materialization_idempotent(
|
||||
self,
|
||||
) -> None:
|
||||
with Session(self.engine) as session:
|
||||
template = self.service.create_template(
|
||||
session,
|
||||
@@ -957,6 +967,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
routing_policy={},
|
||||
encryption_profile="plaintext_v1",
|
||||
encryption_vault_id=None,
|
||||
protection_policy={},
|
||||
context_key=None,
|
||||
limit=200,
|
||||
)
|
||||
@@ -985,6 +996,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
routing_policy={},
|
||||
encryption_profile="plaintext_v1",
|
||||
encryption_vault_id=None,
|
||||
protection_policy={},
|
||||
context_key=None,
|
||||
limit=200,
|
||||
)
|
||||
@@ -1014,6 +1026,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
routing_policy={},
|
||||
encryption_profile="plaintext_v1",
|
||||
encryption_vault_id=None,
|
||||
protection_policy={},
|
||||
context_key=None,
|
||||
limit=200,
|
||||
)
|
||||
@@ -1067,9 +1080,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
[item.slug for item in catalog.templates],
|
||||
)
|
||||
north = next(
|
||||
unit
|
||||
for unit in catalog.organization_units
|
||||
if unit.id == "unit-1"
|
||||
unit for unit in catalog.organization_units if unit.id == "unit-1"
|
||||
)
|
||||
self.assertEqual(
|
||||
["function-1"],
|
||||
@@ -1309,7 +1320,11 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
self.idm.assignments.append(self.assignment)
|
||||
expires_at = utc_now() + timedelta(days=1)
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(session)
|
||||
postbox = self._create_exact(
|
||||
session,
|
||||
encryption_profile="external_e2ee_v1",
|
||||
protection_policy={"external_recipient_assurance": "email_otp"},
|
||||
)
|
||||
delivered = self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
@@ -1340,6 +1355,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
assurance_profile="email-otp",
|
||||
),
|
||||
),
|
||||
metadata={"content_digest": "sha256:" + "a" * 64},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1361,9 +1377,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
assert message.external_recipient_tokens[0].expires_at is not None
|
||||
self.assertEqual(
|
||||
expires_at.replace(microsecond=0),
|
||||
message.external_recipient_tokens[0].expires_at.replace(
|
||||
microsecond=0
|
||||
),
|
||||
message.external_recipient_tokens[0].expires_at.replace(microsecond=0),
|
||||
)
|
||||
|
||||
def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed(
|
||||
@@ -1445,14 +1459,330 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
actor=self.actor,
|
||||
)
|
||||
|
||||
def test_future_only_protection_transition_changes_new_message_policy(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(
|
||||
session,
|
||||
protection_policy={
|
||||
"handover_authority": "user_consent",
|
||||
"handover_quorum": 1,
|
||||
},
|
||||
)
|
||||
transition = self.service.create_protection_transition(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
idempotency_key="future-e2ee",
|
||||
expected_revision=postbox.resource_revision,
|
||||
target_profile="external_e2ee_v1",
|
||||
target_vault_id=None,
|
||||
history_mode="future_only",
|
||||
authority_mode="user_consent",
|
||||
required_quorum=1,
|
||||
user_consent_refs=("consent:user-1",),
|
||||
institutional_authorization_refs=(),
|
||||
reason="Use client-held keys for future messages.",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
|
||||
self.assertEqual("completed", transition.state)
|
||||
self.assertEqual(0, transition.message_count)
|
||||
self.assertEqual("external_e2ee_v1", postbox.encryption_profile)
|
||||
self.assertEqual(2, postbox.key_epoch)
|
||||
|
||||
def test_client_transform_completes_plaintext_to_e2ee_history(self) -> None:
|
||||
self.idm.assignments.append(self.assignment)
|
||||
plaintext = "A governed message"
|
||||
digest = "sha256:" + hashlib.sha256(plaintext.encode()).hexdigest()
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(
|
||||
session,
|
||||
protection_policy={
|
||||
"handover_authority": "user_consent",
|
||||
"handover_quorum": 1,
|
||||
},
|
||||
)
|
||||
delivered = self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_recipient",
|
||||
producer_resource_id="recipient-1",
|
||||
idempotency_key="plaintext-history",
|
||||
subject="Governed history",
|
||||
body_text=plaintext,
|
||||
),
|
||||
)
|
||||
transition = self.service.create_protection_transition(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
idempotency_key="migrate-e2ee",
|
||||
expected_revision=postbox.resource_revision,
|
||||
target_profile="external_e2ee_v1",
|
||||
target_vault_id=None,
|
||||
history_mode="migrate_history",
|
||||
authority_mode="user_consent",
|
||||
required_quorum=1,
|
||||
user_consent_refs=("consent:user-1",),
|
||||
institutional_authorization_refs=(),
|
||||
reason="Move retained content to client-held keys.",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
|
||||
self.assertEqual("awaiting_client", transition.state)
|
||||
self.assertEqual(1, transition.message_count)
|
||||
completed = self.service.apply_client_protection_transform(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
transition_id=transition.id,
|
||||
message_id=delivered.message_id,
|
||||
expected_revision=transition.resource_revision,
|
||||
plaintext=None,
|
||||
ciphertext_ref="files:ciphertext-transition-1",
|
||||
signed_manifest_ref="files:manifest-transition-1",
|
||||
wrapped_keys=(
|
||||
PostboxWrappedKeyRef(
|
||||
recipient_type="function_postbox",
|
||||
recipient_id=postbox.id,
|
||||
key_epoch=postbox.key_epoch,
|
||||
wrapped_key_ref="trust:wrapped-transition-1",
|
||||
algorithm="HPKE-v1",
|
||||
),
|
||||
),
|
||||
content_digest=digest,
|
||||
transformation_evidence_ref="client:evidence-1",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
|
||||
self.assertEqual("completed", completed.state)
|
||||
self.assertEqual(1, completed.completed_count)
|
||||
stored = session.get(PostboxMessage, delivered.message_id)
|
||||
assert stored is not None
|
||||
self.assertIsNone(stored.body_text)
|
||||
self.assertEqual("external_e2ee_v1", stored.encryption_profile)
|
||||
self.assertEqual("files:ciphertext-transition-1", stored.ciphertext_ref)
|
||||
self.assertEqual(digest, stored.metadata_["content_digest"])
|
||||
|
||||
def test_plaintext_history_migrates_to_managed_envelopes_automatically(
|
||||
self,
|
||||
) -> None:
|
||||
protected = SimpleNamespace(
|
||||
ciphertext=b"managed-history",
|
||||
envelope=SimpleNamespace(
|
||||
envelope_id="transition-envelope-1",
|
||||
ciphertext_ref="postbox-db://messages/history/body",
|
||||
algorithm_suite="AES-256-GCM",
|
||||
wrapped_key_refs=("wrapped-history-1",),
|
||||
vault_id="vault-1",
|
||||
key_version=4,
|
||||
),
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(session)
|
||||
delivered = self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_recipient",
|
||||
producer_resource_id="recipient-managed-history",
|
||||
idempotency_key="managed-history",
|
||||
subject="Managed history",
|
||||
body_text="Move this content",
|
||||
),
|
||||
)
|
||||
with patch(
|
||||
"govoplan_postbox.backend.content_protection.protect_message_body",
|
||||
return_value=protected,
|
||||
):
|
||||
transition = self.service.create_protection_transition(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
idempotency_key="migrate-managed",
|
||||
expected_revision=postbox.resource_revision,
|
||||
target_profile="server_envelope_v1",
|
||||
target_vault_id="vault-1",
|
||||
history_mode="migrate_history",
|
||||
authority_mode="dual_control",
|
||||
required_quorum=2,
|
||||
user_consent_refs=("consent:incumbent-1",),
|
||||
institutional_authorization_refs=("approval:key-holder-1",),
|
||||
reason="Adopt the managed standard.",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
|
||||
self.assertEqual("completed", transition.state)
|
||||
stored = session.get(PostboxMessage, delivered.message_id)
|
||||
assert stored is not None
|
||||
self.assertIsNone(stored.body_text)
|
||||
self.assertEqual(b"managed-history", stored.body_ciphertext)
|
||||
self.assertEqual("transition-envelope-1", stored.encryption_envelope_id)
|
||||
self.assertEqual("server_envelope_v1", stored.encryption_profile)
|
||||
self.assertEqual("vault-1", stored.wrapped_keys[0]["recipient_id"])
|
||||
|
||||
def test_new_incumbent_history_policy_filters_lists_counts_and_direct_reads(
|
||||
self,
|
||||
) -> None:
|
||||
boundary = utc_now() - timedelta(days=1)
|
||||
self.idm.assignments.append(replace(self.assignment, valid_from=boundary))
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(
|
||||
session,
|
||||
protection_policy={"new_incumbent_history": "since_assignment"},
|
||||
)
|
||||
old_delivery = self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_recipient",
|
||||
producer_resource_id="recipient-old",
|
||||
idempotency_key="history-old",
|
||||
subject="Before assignment",
|
||||
body_text="Old content",
|
||||
),
|
||||
)
|
||||
recent_delivery = self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_recipient",
|
||||
producer_resource_id="recipient-recent",
|
||||
idempotency_key="history-recent",
|
||||
subject="After assignment",
|
||||
body_text="Recent content",
|
||||
),
|
||||
)
|
||||
old = session.get(PostboxMessage, old_delivery.message_id)
|
||||
recent = session.get(PostboxMessage, recent_delivery.message_id)
|
||||
assert old is not None and recent is not None
|
||||
old.delivered_at = boundary - timedelta(hours=1)
|
||||
recent.delivered_at = boundary + timedelta(hours=1)
|
||||
session.flush()
|
||||
|
||||
messages = self.service.list_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(postbox.id,),
|
||||
actor=self.actor,
|
||||
)
|
||||
|
||||
self.assertEqual([recent.id], [message.id for message in messages])
|
||||
self.assertEqual(
|
||||
1,
|
||||
self.service.count_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(postbox.id,),
|
||||
actor=self.actor,
|
||||
),
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.service.get_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=old.id,
|
||||
actor=self.actor,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
self.service.can_read_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=old.id,
|
||||
actor=self.actor,
|
||||
)
|
||||
)
|
||||
|
||||
def test_leaving_e2ee_history_requires_user_consent_authority(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(
|
||||
session,
|
||||
encryption_profile="external_e2ee_v1",
|
||||
)
|
||||
with self.assertRaisesRegex(PostboxError, "user consent"):
|
||||
self.service.create_protection_transition(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
idempotency_key="leave-e2ee-without-user",
|
||||
expected_revision=postbox.resource_revision,
|
||||
target_profile="plaintext_v1",
|
||||
target_vault_id=None,
|
||||
history_mode="future_only",
|
||||
authority_mode="institutional_key_holders",
|
||||
required_quorum=1,
|
||||
user_consent_refs=(),
|
||||
institutional_authorization_refs=("approval:key-holder-1",),
|
||||
reason="Leave E2EE without holder consent.",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
|
||||
def test_protection_policy_update_is_revisioned_and_enforces_assurance(
|
||||
self,
|
||||
) -> None:
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(session)
|
||||
original_revision = postbox.resource_revision
|
||||
updated = self.service.update_protection_policy(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
protection_policy={
|
||||
"new_incumbent_history": "all_retained",
|
||||
"external_recipient_assurance": "disabled",
|
||||
},
|
||||
actor_id="admin-1",
|
||||
expected_revision=original_revision,
|
||||
)
|
||||
|
||||
self.assertEqual(original_revision + 1, updated.resource_revision)
|
||||
self.assertEqual(
|
||||
"disabled",
|
||||
updated.settings["protection_policy"]["external_recipient_assurance"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"rewrap",
|
||||
updated.settings["protection_policy"]["ordinary_rotation"],
|
||||
)
|
||||
with self.assertRaisesRegex(PostboxError, "does not permit"):
|
||||
self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_recipient",
|
||||
producer_resource_id="recipient-external-disabled",
|
||||
idempotency_key="external-disabled",
|
||||
subject="External retrieval",
|
||||
body_text="Content",
|
||||
external_recipient_tokens=(
|
||||
PostboxExternalRecipientTokenRef(
|
||||
token_id="grant-disabled",
|
||||
state="available",
|
||||
one_time=True,
|
||||
assurance_profile="strong_identity",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
|
||||
self,
|
||||
) -> None:
|
||||
self.idm.assignments.append(self.assignment)
|
||||
with Session(self.engine) as session:
|
||||
service, source, _target_template = (
|
||||
self._create_routing_source(session)
|
||||
)
|
||||
service, source, _target_template = self._create_routing_source(session)
|
||||
result = service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
@@ -1478,10 +1808,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
self.assertNotEqual(result.message_id, route.target_message_id)
|
||||
self.assertEqual(
|
||||
["edge-child-parent"],
|
||||
[
|
||||
edge["edge_id"]
|
||||
for edge in route.policy_snapshot["target"]["path"]
|
||||
],
|
||||
[edge["edge_id"] for edge in route.policy_snapshot["target"]["path"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
route.id,
|
||||
@@ -1496,7 +1823,9 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
)
|
||||
session.commit()
|
||||
receipts = session.query(PostboxMessageReceipt).all()
|
||||
self.assertEqual([route.target_message_id], [item.message_id for item in receipts])
|
||||
self.assertEqual(
|
||||
[route.target_message_id], [item.message_id for item in receipts]
|
||||
)
|
||||
summary = service.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
@@ -1512,8 +1841,8 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
self,
|
||||
) -> None:
|
||||
with Session(self.engine) as session:
|
||||
service, source, _target_template = (
|
||||
self._create_routing_source(session, max_depth=1)
|
||||
service, source, _target_template = self._create_routing_source(
|
||||
session, max_depth=1
|
||||
)
|
||||
blocked = service.preview_hierarchy_routes(
|
||||
session,
|
||||
@@ -1573,9 +1902,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
self.organizations.duplicate_parent_match = True
|
||||
self.organizations.cycle_detected = True
|
||||
with Session(self.engine) as session:
|
||||
service, source, _target_template = (
|
||||
self._create_routing_source(session)
|
||||
)
|
||||
service, source, _target_template = self._create_routing_source(session)
|
||||
preview = service.preview_hierarchy_routes(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
@@ -1588,10 +1915,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"])
|
||||
self.assertEqual(
|
||||
1,
|
||||
sum(
|
||||
route["status"] == "duplicate"
|
||||
for route in preview["routes"]
|
||||
),
|
||||
sum(route["status"] == "duplicate" for route in preview["routes"]),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
@@ -1639,11 +1963,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
routes = (
|
||||
session.query(PostboxRoute)
|
||||
.order_by(PostboxRoute.depth)
|
||||
.all()
|
||||
)
|
||||
routes = session.query(PostboxRoute).order_by(PostboxRoute.depth).all()
|
||||
self.assertEqual(
|
||||
["accepted_vacant", "pending_vacancy_escalation"],
|
||||
[route.status for route in routes],
|
||||
@@ -1687,10 +2007,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
session.commit()
|
||||
pending = (
|
||||
session.query(PostboxRoute)
|
||||
.filter(
|
||||
PostboxRoute.status
|
||||
== "pending_vacancy_escalation"
|
||||
)
|
||||
.filter(PostboxRoute.status == "pending_vacancy_escalation")
|
||||
.one()
|
||||
)
|
||||
pending.execute_after = utc_now() - timedelta(seconds=1)
|
||||
|
||||
+145
-1
@@ -41,11 +41,84 @@ export type PostboxDirectoryItem = {
|
||||
template_revision_id?: string | null;
|
||||
holder_count: number;
|
||||
vacant: boolean;
|
||||
encryption_profile: PostboxProtectionProfileId;
|
||||
key_epoch: number;
|
||||
encryption_vault_id?: string | null;
|
||||
protection_policy: PostboxProtectionPolicy;
|
||||
access?: PostboxAccessDecision | null;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
};
|
||||
|
||||
export type PostboxProtectionProfileId =
|
||||
| "plaintext_v1"
|
||||
| "server_envelope_v1"
|
||||
| "external_e2ee_v1";
|
||||
|
||||
export type PostboxProtectionPolicy = {
|
||||
new_incumbent_history: "all_retained" | "since_assignment" | "bounded_days";
|
||||
history_days?: number | null;
|
||||
ordinary_rotation: "rewrap" | "reencrypt";
|
||||
compromise_rotation: "rewrap" | "reencrypt";
|
||||
recovery_authority: "disabled" | "user_consent" | "institutional_key_holders" | "dual_control";
|
||||
recovery_quorum: number;
|
||||
handover_authority: "user_consent" | "institutional_key_holders" | "dual_control";
|
||||
handover_quorum: number;
|
||||
emergency_access: "disabled" | "dual_control";
|
||||
emergency_quorum: number;
|
||||
export_authority: "user_consent" | "institutional_key_holders" | "dual_control";
|
||||
export_quorum: number;
|
||||
destruction_authority: "institutional_key_holders" | "dual_control";
|
||||
destruction_quorum: number;
|
||||
external_recipient_assurance: "disabled" | "email_otp" | "strong_identity";
|
||||
vacancy_escalation_content_access: "metadata_only";
|
||||
};
|
||||
|
||||
export type PostboxProtectionProfile = {
|
||||
id: PostboxProtectionProfileId;
|
||||
label: string;
|
||||
description: string;
|
||||
server_can_decrypt: boolean;
|
||||
requires_encryption_module: boolean;
|
||||
requires_external_client: boolean;
|
||||
available: boolean;
|
||||
standard: boolean;
|
||||
};
|
||||
|
||||
export type PostboxProtectionTransition = {
|
||||
id: string;
|
||||
postbox_id: string;
|
||||
source_profile: string;
|
||||
target_profile: string;
|
||||
source_vault_id?: string | null;
|
||||
target_vault_id?: string | null;
|
||||
history_mode: string;
|
||||
authority_mode: string;
|
||||
required_quorum: number;
|
||||
evidence_refs: string[];
|
||||
reason: string;
|
||||
state: string;
|
||||
message_count: number;
|
||||
completed_count: number;
|
||||
failed_count: number;
|
||||
requested_by?: string | null;
|
||||
activated_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
configuration_snapshot: Record<string, unknown>;
|
||||
items: Array<{
|
||||
id: string;
|
||||
message_id: string;
|
||||
source_profile: string;
|
||||
target_profile: string;
|
||||
state: string;
|
||||
source_digest?: string | null;
|
||||
target_digest?: string | null;
|
||||
error_code?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PostboxParticipant = {
|
||||
kind: string;
|
||||
reference_type: string;
|
||||
@@ -93,7 +166,7 @@ export type PostboxMessage = {
|
||||
producer_resource_id?: string | null;
|
||||
in_reply_to_message_id?: string | null;
|
||||
replaces_message_id?: string | null;
|
||||
encryption_profile: string;
|
||||
encryption_profile: PostboxProtectionProfileId;
|
||||
key_epoch: number;
|
||||
ciphertext_ref?: string | null;
|
||||
signed_manifest_ref?: string | null;
|
||||
@@ -213,6 +286,8 @@ export type PostboxTemplateRevision = {
|
||||
allow_vacant_delivery: boolean;
|
||||
portal_visible: boolean;
|
||||
encryption_profile: string;
|
||||
encryption_vault_id?: string | null;
|
||||
protection_policy: PostboxProtectionPolicy;
|
||||
history_policy: Record<string, unknown>;
|
||||
routing_policy: PostboxRoutingPolicy;
|
||||
retention_policy: Record<string, unknown>;
|
||||
@@ -248,6 +323,9 @@ export type PostboxTemplateRevisionPayload = Pick<
|
||||
| "classification"
|
||||
| "allow_vacant_delivery"
|
||||
| "portal_visible"
|
||||
| "encryption_profile"
|
||||
| "encryption_vault_id"
|
||||
| "protection_policy"
|
||||
| "routing_policy"
|
||||
>;
|
||||
|
||||
@@ -290,12 +368,18 @@ export type PostboxExactCreatePayload = {
|
||||
address_key?: string | null;
|
||||
classification: string;
|
||||
portal_visible: boolean;
|
||||
encryption_profile: PostboxProtectionProfileId;
|
||||
encryption_vault_id?: string | null;
|
||||
protection_policy: PostboxProtectionPolicy;
|
||||
};
|
||||
|
||||
export type PostboxMessageAuthoringPayload = {
|
||||
idempotency_key: string;
|
||||
subject: string;
|
||||
body_text?: string | null;
|
||||
ciphertext_ref?: string | null;
|
||||
signed_manifest_ref?: string | null;
|
||||
wrapped_keys?: PostboxMessage["wrapped_keys"];
|
||||
classification: string;
|
||||
participants: PostboxParticipant[];
|
||||
attachments: PostboxAttachment[];
|
||||
@@ -469,6 +553,66 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise<Postbox
|
||||
return response.postboxes;
|
||||
}
|
||||
|
||||
export async function listPostboxProtectionProfiles(
|
||||
settings: ApiSettings
|
||||
): Promise<{ standard_profile: PostboxProtectionProfileId; profiles: PostboxProtectionProfile[] }> {
|
||||
return apiFetch(settings, "/api/v1/postbox/admin/protection-profiles");
|
||||
}
|
||||
|
||||
export async function listPostboxProtectionTransitions(
|
||||
settings: ApiSettings,
|
||||
postboxId: string
|
||||
): Promise<PostboxProtectionTransition[]> {
|
||||
const response = await apiFetch<{ transitions: PostboxProtectionTransition[] }>(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}/protection-transitions`
|
||||
);
|
||||
return response.transitions;
|
||||
}
|
||||
|
||||
export function createPostboxProtectionTransition(
|
||||
settings: ApiSettings,
|
||||
postbox: PostboxDirectoryItem,
|
||||
payload: {
|
||||
idempotency_key: string;
|
||||
target_profile: PostboxProtectionProfileId;
|
||||
target_vault_id?: string | null;
|
||||
history_mode: "future_only" | "migrate_history";
|
||||
authority_mode: "user_consent" | "institutional_key_holders" | "dual_control";
|
||||
required_quorum: number;
|
||||
user_consent_refs: string[];
|
||||
institutional_authorization_refs: string[];
|
||||
reason: string;
|
||||
acknowledge_irreversibility: boolean;
|
||||
}
|
||||
): Promise<PostboxProtectionTransition> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/protection-transitions`,
|
||||
{ ...payload, base_revision: postbox.resource_revision },
|
||||
{ headers: { "If-Match": postbox.etag } }
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePostboxProtectionPolicy(
|
||||
settings: ApiSettings,
|
||||
postbox: PostboxDirectoryItem,
|
||||
protectionPolicy: PostboxProtectionPolicy
|
||||
): Promise<PostboxDirectoryItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/protection-policy`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "If-Match": postbox.etag },
|
||||
body: JSON.stringify({
|
||||
base_revision: postbox.resource_revision,
|
||||
protection_policy: protectionPolicy
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPostboxOrganizationTargets(
|
||||
settings: ApiSettings
|
||||
): Promise<PostboxOrganizationTargets> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -164,6 +164,10 @@ export default function PostboxPage({
|
||||
() => groupings.find((grouping) => grouping.id === selectedScope) ?? null,
|
||||
[groupings, selectedScope]
|
||||
);
|
||||
const composeTarget = useMemo(
|
||||
() => postboxes.find((postbox) => postbox.id === messageDraft.postbox_id) ?? null,
|
||||
[messageDraft.postbox_id, postboxes]
|
||||
);
|
||||
const scopePostboxIds = useMemo(() => {
|
||||
if (selectedPostboxId) return [selectedPostboxId];
|
||||
if (selectedGrouping) {
|
||||
@@ -179,6 +183,9 @@ export default function PostboxPage({
|
||||
const replyDisabledReason = postboxBusyReason(false, busy)
|
||||
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
|
||||
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
|
||||
?? (selectedMessage?.encryption_profile === "external_e2ee_v1"
|
||||
? "Replies to E2EE messages must be created by an approved encryption client."
|
||||
: undefined)
|
||||
?? (selectedMessage?.availability !== "available"
|
||||
? POSTBOX_INTERFACE_I18N.unavailableMessage
|
||||
: undefined);
|
||||
@@ -509,7 +516,10 @@ export default function PostboxPage({
|
||||
}
|
||||
|
||||
function openCompose() {
|
||||
const postbox = selectedPostbox ?? postboxes[0] ?? null;
|
||||
const postbox = selectedPostbox
|
||||
?? postboxes.find((item) => item.encryption_profile !== "external_e2ee_v1")
|
||||
?? postboxes[0]
|
||||
?? null;
|
||||
if (!postbox) return;
|
||||
openComposeFor(postbox);
|
||||
}
|
||||
@@ -543,7 +553,11 @@ export default function PostboxPage({
|
||||
}
|
||||
|
||||
async function submitMessage(): Promise<boolean> {
|
||||
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return false;
|
||||
if (
|
||||
!messageDraft.postbox_id
|
||||
|| !messageDraft.subject.trim()
|
||||
|| composeTarget?.encryption_profile === "external_e2ee_v1"
|
||||
) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const participants = messageDraft.recipients
|
||||
@@ -1034,9 +1048,10 @@ export default function PostboxPage({
|
||||
disabled={
|
||||
busy ||
|
||||
!messageDraft.postbox_id ||
|
||||
!messageDraft.subject.trim()
|
||||
!messageDraft.subject.trim() ||
|
||||
composeTarget?.encryption_profile === "external_e2ee_v1"
|
||||
}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (composeTarget?.encryption_profile === "external_e2ee_v1" ? "This browser editor has no E2EE private-key custody. Use an approved encryption client for this Postbox." : ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined))}
|
||||
>
|
||||
<Send size={16} /> Send
|
||||
</Button>
|
||||
@@ -1044,6 +1059,13 @@ export default function PostboxPage({
|
||||
}
|
||||
>
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-compose-grid">
|
||||
{composeTarget?.encryption_profile === "external_e2ee_v1" ? (
|
||||
<div className="postbox-compose-wide">
|
||||
<DismissibleAlert tone="info" compact resetKey={composeTarget.id}>
|
||||
This Postbox requires externally produced E2EE. Use an approved client that supplies ciphertext, a signed manifest, wrapped keys, and a verified content digest; this browser editor never asks for or stores the private key.
|
||||
</DismissibleAlert>
|
||||
</div>
|
||||
) : null}
|
||||
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={messageDraft.postbox_id}
|
||||
@@ -1204,7 +1226,19 @@ function MessageDetail({
|
||||
</dl>
|
||||
</section>
|
||||
<section className="postbox-body">
|
||||
<p>{message.body_text || "No plaintext body is available for this message."}</p>
|
||||
{message.encryption_profile === "external_e2ee_v1" ? (
|
||||
<>
|
||||
<DismissibleAlert tone="info" compact resetKey={message.id}>
|
||||
This message is end-to-end encrypted. GovOPlaN stores and authorizes its envelope but cannot decrypt the content; open it with the institution's approved client.
|
||||
</DismissibleAlert>
|
||||
<dl className="postbox-provenance">
|
||||
<div><dt>Ciphertext</dt><dd>{message.ciphertext_ref || "Not recorded"}</dd></div>
|
||||
<div><dt>Signed manifest</dt><dd>{message.signed_manifest_ref || "Not recorded"}</dd></div>
|
||||
</dl>
|
||||
</>
|
||||
) : (
|
||||
<p>{message.body_text || "No plaintext body is available for this message."}</p>
|
||||
)}
|
||||
</section>
|
||||
{message.participants.length ? (
|
||||
<section className="postbox-participants">
|
||||
|
||||
Reference in New Issue
Block a user