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
|
`govoplan.postbox.dispatch_routes` periodic Core worker drains due routes when
|
||||||
Celery beat and a worker consuming the `postbox` queue are enabled.
|
Celery beat and a worker consuming the `postbox` queue are enabled.
|
||||||
|
|
||||||
Postboxes support `plaintext_v1` and an optional `server_envelope_v1` profile.
|
Postboxes expose three configurable content-protection profiles. The recommended
|
||||||
The latter stores message bodies as ciphertext through the Encryption
|
`server_envelope_v1` profile stores message bodies as ciphertext through an
|
||||||
capability and fails closed on reads if that capability or key is unavailable.
|
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
|
Subjects, participants, routing, attachment references, and lifecycle metadata
|
||||||
remain observable. Existing externally produced ciphertext references remain
|
remain observable in every profile.
|
||||||
supported, but neither path is described as end-to-end encryption.
|
|
||||||
|
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:
|
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
|
like an inbox for a message task or like a vault for content shared with the
|
||||||
current holders of that responsibility; neither form is owned by one account.
|
current holders of that responsibility; neither form is owned by one account.
|
||||||
|
|
||||||
The strategic target is an encrypted administrative postbox. The current
|
The strategic target is a policy-selectable administrative postbox. The current
|
||||||
implementation supports ordinary persisted messages and an optional
|
implementation offers unencrypted content, an institution-managed Encryption
|
||||||
server-readable Encryption envelope for message bodies. The model also retains
|
envelope, and a strict external E2EE boundary. E2EE messages contain only an
|
||||||
external ciphertext, wrapped-key, signed-manifest, external-recipient-token,
|
external ciphertext reference, signed-manifest reference, wrapped recipient
|
||||||
and key-epoch metadata needed for later independently reviewed E2EE profiles.
|
keys, and a verified plaintext digest; an approved producer or client owns the
|
||||||
The server-envelope profile is not E2EE, and subjects, routing, participants,
|
actual cryptographic operation and private-key custody. GovOPlaN cannot decrypt
|
||||||
and attachment references remain visible. The
|
that profile. The institution-managed envelope remains server-readable by
|
||||||
cross-module target architecture is recorded in
|
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`.
|
`govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`.
|
||||||
|
|
||||||
## Function-Organization-Bound Access
|
## 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.
|
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
|
An exact Postbox or template revision selects one profile. The administration
|
||||||
Encryption vault. New locally authored and delivered message bodies are then
|
surface recommends the managed profile and requires its vault explicitly; the
|
||||||
stored in `body_ciphertext` with an owner-bound envelope reference; clear body
|
API retains the legacy plaintext default when an older integration omits these
|
||||||
text is not persisted. Reads ask the optional `encryption.content_cipher`
|
new fields so an upgrade cannot make an unavailable Encryption module block
|
||||||
capability to open the exact tenant, message, and envelope tuple. Missing
|
existing automation.
|
||||||
Encryption, a lost deployment key, a destroyed vault key, ciphertext tampering,
|
|
||||||
or a mismatched resource causes a fail-closed read.
|
|
||||||
|
|
||||||
Plaintext Postboxes continue to work without Encryption. A protected Postbox
|
- `server_envelope_v1` is the recommended standard. New local message bodies
|
||||||
cannot silently fall back to plaintext. Database recovery of protected messages
|
are encrypted through the optional `encryption.content_cipher` capability,
|
||||||
requires Postbox and Encryption tables from the same consistency point plus the
|
stored in `body_ciphertext`, and linked to an owner-bound envelope in the
|
||||||
matching provider/deployment key. Hierarchy-routed copies retain the source
|
institution's selected vault. Authorized reads ask that capability to open
|
||||||
envelope reference rather than decrypting and re-encrypting during routing.
|
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
|
Each Postbox snapshots policy for the choices that cannot safely be inferred:
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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
|
The defaults are deliberately conservative: history since assignment,
|
||||||
needs bounded decisions on:
|
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
|
### Governed profile transitions
|
||||||
policy-defined date, or only content delivered during the assignment;
|
|
||||||
- organizational recovery/escrow and the authority required when every holder
|
A profile change increments the Postbox key epoch and applies immediately to
|
||||||
loses all registered device keys;
|
new messages. The administrator chooses whether retained history stays under
|
||||||
- whether ordinary rotation only rewraps per-content keys or also re-encrypts
|
its existing profile or is migrated. Every transition records an idempotency
|
||||||
ciphertext, and which events require the stronger path;
|
key, source and target profiles/vaults, user-consent and/or institutional
|
||||||
- assurance and quorum requirements for delegation, hand-over, emergency
|
authorization evidence, quorum, reason, immutable configuration snapshot,
|
||||||
access, export, and destructive retention; and
|
message digests, and per-message outcome.
|
||||||
- how attention/escalation works during a vacancy without granting plaintext
|
|
||||||
access to an unrelated personal account.
|
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,
|
vault_id: str,
|
||||||
plaintext: str,
|
plaintext: str,
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
|
operation_ref: str = "v1",
|
||||||
|
policy_decision_ref: str = "postbox:configured-server-envelope:v1",
|
||||||
) -> ProtectedContent:
|
) -> ProtectedContent:
|
||||||
capability = encryption_content_cipher(get_registry())
|
capability = encryption_content_cipher(get_registry())
|
||||||
if capability is None:
|
if capability is None:
|
||||||
@@ -43,8 +45,8 @@ def protect_message_body(
|
|||||||
vault_id=vault_id,
|
vault_id=vault_id,
|
||||||
ciphertext_ref=f"postbox-db://messages/{message_id}/body",
|
ciphertext_ref=f"postbox-db://messages/{message_id}/body",
|
||||||
plaintext=plaintext.encode("utf-8"),
|
plaintext=plaintext.encode("utf-8"),
|
||||||
policy_decision_ref="postbox:configured-server-envelope:v1",
|
policy_decision_ref=policy_decision_ref,
|
||||||
idempotency_key=f"postbox-message:{message_id}:body:v1",
|
idempotency_key=f"postbox-message:{message_id}:body:{operation_ref}",
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
metadata={"content_type": "text/plain;charset=utf-8"},
|
metadata={"content_type": "text/plain;charset=utf-8"},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from govoplan_postbox.backend.db.models import (
|
|||||||
PostboxMessage,
|
PostboxMessage,
|
||||||
PostboxMessageReceipt,
|
PostboxMessageReceipt,
|
||||||
PostboxParticipant,
|
PostboxParticipant,
|
||||||
|
PostboxProtectionTransition,
|
||||||
|
PostboxProtectionTransitionItem,
|
||||||
PostboxRoute,
|
PostboxRoute,
|
||||||
PostboxTemplate,
|
PostboxTemplate,
|
||||||
PostboxTemplateRevision,
|
PostboxTemplateRevision,
|
||||||
@@ -27,6 +29,8 @@ __all__ = [
|
|||||||
"PostboxMessage",
|
"PostboxMessage",
|
||||||
"PostboxMessageReceipt",
|
"PostboxMessageReceipt",
|
||||||
"PostboxParticipant",
|
"PostboxParticipant",
|
||||||
|
"PostboxProtectionTransition",
|
||||||
|
"PostboxProtectionTransitionItem",
|
||||||
"PostboxRoute",
|
"PostboxRoute",
|
||||||
"PostboxTemplate",
|
"PostboxTemplate",
|
||||||
"PostboxTemplateRevision",
|
"PostboxTemplateRevision",
|
||||||
|
|||||||
@@ -161,9 +161,7 @@ class PostboxTemplateRevision(Base, TimestampMixin):
|
|||||||
default="plaintext_v1",
|
default="plaintext_v1",
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
encryption_vault_id: Mapped[str | None] = mapped_column(
|
encryption_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
String(255), nullable=True
|
|
||||||
)
|
|
||||||
history_policy: Mapped[dict[str, Any]] = mapped_column(
|
history_policy: Mapped[dict[str, Any]] = mapped_column(
|
||||||
JSON,
|
JSON,
|
||||||
default=dict,
|
default=dict,
|
||||||
@@ -323,6 +321,11 @@ class Postbox(Base, TimestampMixin):
|
|||||||
back_populates="postbox",
|
back_populates="postbox",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
protection_transitions: Mapped[list["PostboxProtectionTransition"]] = relationship(
|
||||||
|
back_populates="postbox",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="PostboxProtectionTransition.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strong_etag(self) -> str:
|
def strong_etag(self) -> str:
|
||||||
@@ -439,9 +442,7 @@ class PostboxMessage(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
subject: Mapped[str] = mapped_column(String(1000), nullable=False)
|
subject: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||||
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
body_ciphertext: Mapped[bytes | None] = mapped_column(
|
body_ciphertext: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||||
LargeBinary, nullable=True
|
|
||||||
)
|
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(30),
|
String(30),
|
||||||
default="delivered",
|
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):
|
class PostboxParticipant(Base, TimestampMixin):
|
||||||
__tablename__ = "postbox_participants"
|
__tablename__ = "postbox_participants"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ MODULE_ID = "postbox"
|
|||||||
MODULE_NAME = "Postbox"
|
MODULE_NAME = "Postbox"
|
||||||
MODULE_VERSION = "0.1.18"
|
MODULE_VERSION = "0.1.18"
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
@@ -209,6 +210,8 @@ _OWNED_TABLES = (
|
|||||||
postbox_models.PostboxDelivery,
|
postbox_models.PostboxDelivery,
|
||||||
postbox_models.PostboxAttachmentReference,
|
postbox_models.PostboxAttachmentReference,
|
||||||
postbox_models.PostboxParticipant,
|
postbox_models.PostboxParticipant,
|
||||||
|
postbox_models.PostboxProtectionTransitionItem,
|
||||||
|
postbox_models.PostboxProtectionTransition,
|
||||||
postbox_models.PostboxMessage,
|
postbox_models.PostboxMessage,
|
||||||
postbox_models.PostboxBinding,
|
postbox_models.PostboxBinding,
|
||||||
postbox_models.Postbox,
|
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,
|
order=33,
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -525,6 +531,70 @@ manifest = ModuleManifest(
|
|||||||
related_modules=("search", "idm", "encryption"),
|
related_modules=("search", "idm", "encryption"),
|
||||||
order=34,
|
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(
|
DocumentationTopic(
|
||||||
id="postbox.function-bound-containers",
|
id="postbox.function-bound-containers",
|
||||||
title="Function-bound Postboxes",
|
title="Function-bound Postboxes",
|
||||||
@@ -538,12 +608,11 @@ manifest = ModuleManifest(
|
|||||||
"reassignment. Current access combines a generic Postbox "
|
"reassignment. Current access combines a generic Postbox "
|
||||||
"permission with effective IDM assignment context. Templates "
|
"permission with effective IDM assignment context. Templates "
|
||||||
"can lazily materialize unit-specific addresses, while exact "
|
"can lazily materialize unit-specific addresses, while exact "
|
||||||
"postboxes cover exceptional responsibilities. Plaintext "
|
"postboxes cover exceptional responsibilities. Administrators "
|
||||||
"Postboxes remain available without Encryption. A "
|
"choose plaintext, the recommended institution-managed Encryption "
|
||||||
"server-envelope profile stores message bodies as ciphertext and "
|
"envelope, or a strict externally produced E2EE contract. Managed "
|
||||||
"uses the optional Encryption capability for authorized reads. "
|
"envelopes remain decryptable by authorized institutional key holders; "
|
||||||
"External ciphertext profiles retain producer-managed references "
|
"E2EE rejects plaintext and GovOPlaN has no private decryption key. "
|
||||||
"and keys; neither profile is described as end-to-end encryption. "
|
|
||||||
"When Tasks is enabled, currently readable unread messages also appear "
|
"When Tasks is enabled, currently readable unread messages also appear "
|
||||||
"in the common work inbox and disappear when the personal read receipt is recorded."
|
"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.",
|
"Subjects, participants, routing facts, and attachment references remain observable metadata.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
order=35,
|
order=36,
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="postbox.reference.fields-and-consequences",
|
id="postbox.reference.fields-and-consequences",
|
||||||
@@ -660,7 +729,7 @@ manifest = ModuleManifest(
|
|||||||
"withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.",
|
"withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
order=36,
|
order=37,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
architecture=declared_module_architecture(
|
architecture=declared_module_architecture(
|
||||||
@@ -672,10 +741,22 @@ manifest = ModuleManifest(
|
|||||||
known_limits=(
|
known_limits=(
|
||||||
"Subjects, routing metadata, participants, and attachment references remain plaintext metadata.",
|
"Subjects, routing metadata, participants, and attachment references remain plaintext metadata.",
|
||||||
"Server-envelope protection is server-decryptable and is not end-to-end encryption.",
|
"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",),
|
recovery_docs=("docs/POSTBOX_CONCEPT.md",),
|
||||||
security_docs=("docs/POSTBOX_CONCEPT.md",),
|
security_docs=("docs/POSTBOX_CONCEPT.md",),
|
||||||
operations_docs=("README.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,
|
PostboxFileReferenceRequest,
|
||||||
postbox_file_reference_provider,
|
postbox_file_reference_provider,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.encryption import encryption_content_cipher
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_postbox.backend.manifest import (
|
from govoplan_postbox.backend.manifest import (
|
||||||
ACKNOWLEDGE_SCOPE,
|
ACKNOWLEDGE_SCOPE,
|
||||||
@@ -60,6 +61,14 @@ from govoplan_postbox.backend.schemas import (
|
|||||||
PostboxMessageStateRequest,
|
PostboxMessageStateRequest,
|
||||||
PostboxOrganizationTargetsResponse,
|
PostboxOrganizationTargetsResponse,
|
||||||
PostboxMutationRequest,
|
PostboxMutationRequest,
|
||||||
|
PostboxProtectionProfileItem,
|
||||||
|
PostboxProtectionProfileListResponse,
|
||||||
|
PostboxProtectionPolicyUpdateRequest,
|
||||||
|
PostboxProtectionTransformRequest,
|
||||||
|
PostboxProtectionTransitionCreateRequest,
|
||||||
|
PostboxProtectionTransitionItemResponse,
|
||||||
|
PostboxProtectionTransitionListResponse,
|
||||||
|
PostboxProtectionTransitionResponse,
|
||||||
PostboxRouteDryRunRequest,
|
PostboxRouteDryRunRequest,
|
||||||
PostboxRouteDryRunResponse,
|
PostboxRouteDryRunResponse,
|
||||||
PostboxTemplateCreateRequest,
|
PostboxTemplateCreateRequest,
|
||||||
@@ -71,6 +80,10 @@ from govoplan_postbox.backend.schemas import (
|
|||||||
PostboxTemplateReviseRequest,
|
PostboxTemplateReviseRequest,
|
||||||
)
|
)
|
||||||
from govoplan_postbox.backend.service import PostboxError
|
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 (
|
from govoplan_postbox.backend.principals import (
|
||||||
PostboxPrincipalError,
|
PostboxPrincipalError,
|
||||||
actor_from_principal,
|
actor_from_principal,
|
||||||
@@ -122,6 +135,8 @@ def _http_error(exc: PostboxError) -> HTTPException:
|
|||||||
"revision_not_found",
|
"revision_not_found",
|
||||||
"grouping_not_found",
|
"grouping_not_found",
|
||||||
"target_not_found",
|
"target_not_found",
|
||||||
|
"protection_transition_not_found",
|
||||||
|
"transition_item_not_found",
|
||||||
}:
|
}:
|
||||||
code = status.HTTP_404_NOT_FOUND
|
code = status.HTTP_404_NOT_FOUND
|
||||||
elif exc.code in {"access_denied", "grouping_source_denied"}:
|
elif exc.code in {"access_denied", "grouping_source_denied"}:
|
||||||
@@ -195,6 +210,11 @@ def _authoring_request(
|
|||||||
idempotency_key=payload.idempotency_key,
|
idempotency_key=payload.idempotency_key,
|
||||||
subject=payload.subject,
|
subject=payload.subject,
|
||||||
body_text=payload.body_text,
|
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,
|
classification=payload.classification,
|
||||||
participants=tuple(
|
participants=tuple(
|
||||||
PostboxParticipantRef(**participant.model_dump())
|
PostboxParticipantRef(**participant.model_dump())
|
||||||
@@ -236,9 +256,7 @@ def _template_item(template) -> PostboxTemplateItem:
|
|||||||
"scope_kind": revision.scope_kind,
|
"scope_kind": revision.scope_kind,
|
||||||
"scope_id": revision.scope_id,
|
"scope_id": revision.scope_id,
|
||||||
"scope_structure_id": revision.scope_structure_id,
|
"scope_structure_id": revision.scope_structure_id,
|
||||||
"scope_relation_type_ids": list(
|
"scope_relation_type_ids": list(revision.scope_relation_type_ids or []),
|
||||||
revision.scope_relation_type_ids or []
|
|
||||||
),
|
|
||||||
"name_pattern": revision.name_pattern,
|
"name_pattern": revision.name_pattern,
|
||||||
"address_pattern": revision.address_pattern,
|
"address_pattern": revision.address_pattern,
|
||||||
"classification": revision.classification,
|
"classification": revision.classification,
|
||||||
@@ -246,6 +264,7 @@ def _template_item(template) -> PostboxTemplateItem:
|
|||||||
"portal_visible": revision.portal_visible,
|
"portal_visible": revision.portal_visible,
|
||||||
"encryption_profile": revision.encryption_profile,
|
"encryption_profile": revision.encryption_profile,
|
||||||
"encryption_vault_id": revision.encryption_vault_id,
|
"encryption_vault_id": revision.encryption_vault_id,
|
||||||
|
"protection_policy": dict(revision.history_policy or {}),
|
||||||
"history_policy": dict(revision.history_policy or {}),
|
"history_policy": dict(revision.history_policy or {}),
|
||||||
"routing_policy": dict(revision.routing_policy or {}),
|
"routing_policy": dict(revision.routing_policy or {}),
|
||||||
"retention_policy": dict(revision.retention_policy or {}),
|
"retention_policy": dict(revision.retention_policy or {}),
|
||||||
@@ -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)
|
@router.get("/directory", response_model=PostboxDirectoryResponse)
|
||||||
def api_postbox_directory(
|
def api_postbox_directory(
|
||||||
assignment_context_id: str | None = None,
|
assignment_context_id: str | None = None,
|
||||||
@@ -523,7 +584,13 @@ def api_resolve_postbox_message_attachments(
|
|||||||
)
|
)
|
||||||
|
|
||||||
provider = postbox_file_reference_provider(get_registry())
|
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(
|
requests = tuple(
|
||||||
PostboxFileReferenceRequest(
|
PostboxFileReferenceRequest(
|
||||||
reference_type=attachment.reference_type,
|
reference_type=attachment.reference_type,
|
||||||
@@ -544,9 +611,7 @@ def api_resolve_postbox_message_attachments(
|
|||||||
if provider is not None and requests
|
if provider is not None and requests
|
||||||
else ()
|
else ()
|
||||||
)
|
)
|
||||||
by_reference = {
|
by_reference = {(item.reference_type, item.reference_id): item for item in resolved}
|
||||||
(item.reference_type, item.reference_id): item for item in resolved
|
|
||||||
}
|
|
||||||
items: list[PostboxAttachmentResolutionItem] = []
|
items: list[PostboxAttachmentResolutionItem] = []
|
||||||
for attachment in message.attachments:
|
for attachment in message.attachments:
|
||||||
resolution = by_reference.get(
|
resolution = by_reference.get(
|
||||||
@@ -667,8 +732,7 @@ def api_deliver_to_postbox(
|
|||||||
ciphertext_ref=payload.ciphertext_ref,
|
ciphertext_ref=payload.ciphertext_ref,
|
||||||
signed_manifest_ref=payload.signed_manifest_ref,
|
signed_manifest_ref=payload.signed_manifest_ref,
|
||||||
wrapped_keys=tuple(
|
wrapped_keys=tuple(
|
||||||
PostboxWrappedKeyRef(**item.model_dump())
|
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
|
||||||
for item in payload.wrapped_keys
|
|
||||||
),
|
),
|
||||||
external_recipient_tokens=tuple(
|
external_recipient_tokens=tuple(
|
||||||
PostboxExternalRecipientTokenRef(**item.model_dump())
|
PostboxExternalRecipientTokenRef(**item.model_dump())
|
||||||
@@ -853,13 +917,9 @@ def api_postbox_organization_targets(
|
|||||||
) -> PostboxOrganizationTargetsResponse:
|
) -> PostboxOrganizationTargetsResponse:
|
||||||
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
|
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
|
||||||
return PostboxOrganizationTargetsResponse(
|
return PostboxOrganizationTargetsResponse(
|
||||||
units=list(
|
units=list(get_service().organization_targets(tenant_id=principal.tenant_id)),
|
||||||
get_service().organization_targets(tenant_id=principal.tenant_id)
|
|
||||||
),
|
|
||||||
structures=list(
|
structures=list(
|
||||||
get_service().organization_hierarchy_targets(
|
get_service().organization_hierarchy_targets(tenant_id=principal.tenant_id)
|
||||||
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(
|
@router.post(
|
||||||
"/admin/postboxes",
|
"/admin/postboxes",
|
||||||
response_model=PostboxDirectoryItem,
|
response_model=PostboxDirectoryItem,
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from govoplan_postbox.backend.protection_profiles import (
|
||||||
|
POSTBOX_MANAGED_ENVELOPE_PROFILE,
|
||||||
|
POSTBOX_PLAINTEXT_PROFILE,
|
||||||
|
PostboxProtectionProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
PostboxClassification = Literal[
|
PostboxClassification = Literal[
|
||||||
"public",
|
"public",
|
||||||
@@ -48,6 +54,10 @@ class PostboxDirectoryItem(BaseModel):
|
|||||||
template_revision_id: str | None = None
|
template_revision_id: str | None = None
|
||||||
holder_count: int = 0
|
holder_count: int = 0
|
||||||
vacant: bool = True
|
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
|
access: PostboxAccessDecisionResponse | None = None
|
||||||
resource_revision: int = Field(default=1, ge=1)
|
resource_revision: int = Field(default=1, ge=1)
|
||||||
etag: str | None = None
|
etag: str | None = None
|
||||||
@@ -156,11 +166,32 @@ class PostboxMessageAuthoringPayload(BaseModel):
|
|||||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
subject: str = Field(min_length=1, max_length=1000)
|
subject: str = Field(min_length=1, max_length=1000)
|
||||||
body_text: str | None = None
|
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"
|
classification: PostboxClassification = "internal"
|
||||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class PostboxMessageCreateRequest(PostboxMessageAuthoringPayload):
|
||||||
postbox_id: str = Field(min_length=1, max_length=36)
|
postbox_id: str = Field(min_length=1, max_length=36)
|
||||||
@@ -178,9 +209,7 @@ class PostboxTargetPayload(BaseModel):
|
|||||||
def validate_target(self) -> "PostboxTargetPayload":
|
def validate_target(self) -> "PostboxTargetPayload":
|
||||||
direct = bool(self.postbox_id or self.address_key)
|
direct = bool(self.postbox_id or self.address_key)
|
||||||
templated = bool(
|
templated = bool(
|
||||||
self.template_id
|
self.template_id and self.organization_unit_id and self.function_id
|
||||||
and self.organization_unit_id
|
|
||||||
and self.function_id
|
|
||||||
)
|
)
|
||||||
if direct == templated:
|
if direct == templated:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -210,6 +239,24 @@ class PostboxDeliveryCreateRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class PostboxDeliveryResponse(BaseModel):
|
||||||
delivery_id: str
|
delivery_id: str
|
||||||
@@ -248,14 +295,10 @@ class PostboxLinkedCopyPolicyPayload(BaseModel):
|
|||||||
def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload":
|
def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload":
|
||||||
self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids))
|
self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids))
|
||||||
self.allowed_classifications = list(
|
self.allowed_classifications = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(value.strip() for value in self.allowed_classifications)
|
||||||
value.strip() for value in self.allowed_classifications
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.allowed_producer_modules = list(
|
self.allowed_producer_modules = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(value.strip() for value in self.allowed_producer_modules)
|
||||||
value.strip() for value in self.allowed_producer_modules
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if any(not value for value in self.relation_type_ids):
|
if any(not value for value in self.relation_type_ids):
|
||||||
raise ValueError("Relation type IDs must not be empty.")
|
raise ValueError("Relation type IDs must not be empty.")
|
||||||
@@ -316,16 +359,10 @@ class PostboxRoutingPolicyPayload(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_semantics(self) -> "PostboxRoutingPolicyPayload":
|
def validate_semantics(self) -> "PostboxRoutingPolicyPayload":
|
||||||
if (
|
if self.attention.mode == "vacancy_escalation" and (
|
||||||
self.attention.mode == "vacancy_escalation"
|
not self.linked_copy.enabled or self.linked_copy.fanout != "nearest"
|
||||||
and (
|
|
||||||
not self.linked_copy.enabled
|
|
||||||
or self.linked_copy.fanout != "nearest"
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError("Vacancy escalation requires nearest linked-copy routing.")
|
||||||
"Vacancy escalation requires nearest linked-copy routing."
|
|
||||||
)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -361,6 +398,73 @@ class PostboxRouteDryRunResponse(BaseModel):
|
|||||||
diagnostics: list[str] = Field(default_factory=list)
|
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):
|
class PostboxExactCreateRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=500)
|
name: str = Field(min_length=1, max_length=500)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -369,25 +473,31 @@ class PostboxExactCreateRequest(BaseModel):
|
|||||||
address_key: str | None = Field(default=None, max_length=120)
|
address_key: str | None = Field(default=None, max_length=120)
|
||||||
classification: PostboxClassification = "internal"
|
classification: PostboxClassification = "internal"
|
||||||
portal_visible: bool = False
|
portal_visible: bool = False
|
||||||
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
|
encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE
|
||||||
"plaintext_v1"
|
|
||||||
)
|
|
||||||
encryption_vault_id: str | None = Field(default=None, max_length=255)
|
encryption_vault_id: str | None = Field(default=None, max_length=255)
|
||||||
|
protection_policy: PostboxProtectionPolicyPayload = Field(
|
||||||
|
default_factory=PostboxProtectionPolicyPayload
|
||||||
|
)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_encryption(self) -> "PostboxExactCreateRequest":
|
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():
|
if not str(self.encryption_vault_id or "").strip():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Server-envelope Postboxes require an encryption vault."
|
"Server-envelope Postboxes require an encryption vault."
|
||||||
)
|
)
|
||||||
elif self.encryption_vault_id:
|
elif self.encryption_vault_id:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"A plaintext Postbox cannot select an encryption vault."
|
"Only an institution-managed Postbox can select an encryption vault."
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxProtectionPolicyUpdateRequest(BaseModel):
|
||||||
|
base_revision: int = Field(ge=1)
|
||||||
|
protection_policy: PostboxProtectionPolicyPayload
|
||||||
|
|
||||||
|
|
||||||
class PostboxTemplateRevisionPayload(BaseModel):
|
class PostboxTemplateRevisionPayload(BaseModel):
|
||||||
function_type_id: str | None = Field(default=None, max_length=36)
|
function_type_id: str | None = Field(default=None, max_length=36)
|
||||||
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
|
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
|
||||||
@@ -407,10 +517,11 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
|||||||
classification: PostboxClassification = "internal"
|
classification: PostboxClassification = "internal"
|
||||||
allow_vacant_delivery: bool = True
|
allow_vacant_delivery: bool = True
|
||||||
portal_visible: bool = False
|
portal_visible: bool = False
|
||||||
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
|
encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE
|
||||||
"plaintext_v1"
|
|
||||||
)
|
|
||||||
encryption_vault_id: str | None = Field(default=None, max_length=255)
|
encryption_vault_id: str | None = Field(default=None, max_length=255)
|
||||||
|
protection_policy: PostboxProtectionPolicyPayload = Field(
|
||||||
|
default_factory=PostboxProtectionPolicyPayload
|
||||||
|
)
|
||||||
routing_policy: PostboxRoutingPolicyPayload = Field(
|
routing_policy: PostboxRoutingPolicyPayload = Field(
|
||||||
default_factory=PostboxRoutingPolicyPayload
|
default_factory=PostboxRoutingPolicyPayload
|
||||||
)
|
)
|
||||||
@@ -426,18 +537,169 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
|
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():
|
if not str(self.encryption_vault_id or "").strip():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Server-envelope Postbox templates require an encryption vault."
|
"Server-envelope Postbox templates require an encryption vault."
|
||||||
)
|
)
|
||||||
elif self.encryption_vault_id:
|
elif self.encryption_vault_id:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"A plaintext Postbox template cannot select an encryption vault."
|
"Only an institution-managed Postbox template can select an encryption vault."
|
||||||
)
|
)
|
||||||
return self
|
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(
|
def _validate_template_write_scope(
|
||||||
payload: PostboxTemplateRevisionPayload,
|
payload: PostboxTemplateRevisionPayload,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -586,9 +848,7 @@ class PostboxOrganizationStructureItem(BaseModel):
|
|||||||
|
|
||||||
class PostboxOrganizationTargetsResponse(BaseModel):
|
class PostboxOrganizationTargetsResponse(BaseModel):
|
||||||
units: list[PostboxOrganizationUnitItem]
|
units: list[PostboxOrganizationUnitItem]
|
||||||
structures: list[PostboxOrganizationStructureItem] = Field(
|
structures: list[PostboxOrganizationStructureItem] = Field(default_factory=list)
|
||||||
default_factory=list
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PostboxGroupingPayload(BaseModel):
|
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."
|
"govoplan_postbox.backend.migrations.versions."
|
||||||
"f2a5c8e1b4d7_v015_portal_visibility"
|
"f2a5c8e1b4d7_v015_portal_visibility"
|
||||||
)
|
)
|
||||||
|
transition_migration = importlib.import_module(
|
||||||
|
"govoplan_postbox.backend.migrations.versions."
|
||||||
|
"a7c1e4f8b2d6_v016_protection_transitions"
|
||||||
|
)
|
||||||
engine = create_engine("sqlite:///:memory:")
|
engine = create_engine("sqlite:///:memory:")
|
||||||
try:
|
try:
|
||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
@@ -49,6 +53,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
|||||||
protection_original = protection_migration.op
|
protection_original = protection_migration.op
|
||||||
scope_original = scope_migration.op
|
scope_original = scope_migration.op
|
||||||
portal_original = portal_migration.op
|
portal_original = portal_migration.op
|
||||||
|
transition_original = transition_migration.op
|
||||||
migration.op = operations
|
migration.op = operations
|
||||||
route_migration.op = operations
|
route_migration.op = operations
|
||||||
occ_migration.op = operations
|
occ_migration.op = operations
|
||||||
@@ -56,6 +61,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
|||||||
protection_migration.op = operations
|
protection_migration.op = operations
|
||||||
scope_migration.op = operations
|
scope_migration.op = operations
|
||||||
portal_migration.op = operations
|
portal_migration.op = operations
|
||||||
|
transition_migration.op = operations
|
||||||
try:
|
try:
|
||||||
migration.upgrade()
|
migration.upgrade()
|
||||||
route_migration.upgrade()
|
route_migration.upgrade()
|
||||||
@@ -64,11 +70,14 @@ class PostboxMigrationTests(unittest.TestCase):
|
|||||||
protection_migration.upgrade()
|
protection_migration.upgrade()
|
||||||
scope_migration.upgrade()
|
scope_migration.upgrade()
|
||||||
portal_migration.upgrade()
|
portal_migration.upgrade()
|
||||||
|
transition_migration.upgrade()
|
||||||
tables = set(inspect(connection).get_table_names())
|
tables = set(inspect(connection).get_table_names())
|
||||||
self.assertIn("postboxes", tables)
|
self.assertIn("postboxes", tables)
|
||||||
self.assertIn("postbox_messages", tables)
|
self.assertIn("postbox_messages", tables)
|
||||||
self.assertIn("postbox_deliveries", tables)
|
self.assertIn("postbox_deliveries", tables)
|
||||||
self.assertIn("postbox_access_events", tables)
|
self.assertIn("postbox_access_events", tables)
|
||||||
|
self.assertIn("postbox_protection_transitions", tables)
|
||||||
|
self.assertIn("postbox_protection_transition_items", tables)
|
||||||
message_columns = {
|
message_columns = {
|
||||||
column["name"]
|
column["name"]
|
||||||
for column in inspect(connection).get_columns(
|
for column in inspect(connection).get_columns(
|
||||||
@@ -120,15 +129,12 @@ class PostboxMigrationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
route_columns = {
|
route_columns = {
|
||||||
column["name"]
|
column["name"]
|
||||||
for column in inspect(connection).get_columns(
|
for column in inspect(connection).get_columns("postbox_routes")
|
||||||
"postbox_routes"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
{"execute_after", "processed_at"}.issubset(
|
{"execute_after", "processed_at"}.issubset(route_columns)
|
||||||
route_columns
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
transition_migration.downgrade()
|
||||||
portal_migration.downgrade()
|
portal_migration.downgrade()
|
||||||
scope_migration.downgrade()
|
scope_migration.downgrade()
|
||||||
protection_migration.downgrade()
|
protection_migration.downgrade()
|
||||||
@@ -151,6 +157,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
|||||||
protection_migration.op = protection_original
|
protection_migration.op = protection_original
|
||||||
scope_migration.op = scope_original
|
scope_migration.op = scope_original
|
||||||
portal_migration.op = portal_original
|
portal_migration.op = portal_original
|
||||||
|
transition_migration.op = transition_original
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|||||||
+368
-51
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import unittest
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -49,6 +51,8 @@ from govoplan_postbox.backend.db.models import (
|
|||||||
PostboxMessage,
|
PostboxMessage,
|
||||||
PostboxMessageReceipt,
|
PostboxMessageReceipt,
|
||||||
PostboxParticipant,
|
PostboxParticipant,
|
||||||
|
PostboxProtectionTransition,
|
||||||
|
PostboxProtectionTransitionItem,
|
||||||
PostboxRoute,
|
PostboxRoute,
|
||||||
PostboxTemplate,
|
PostboxTemplate,
|
||||||
PostboxTemplateRevision,
|
PostboxTemplateRevision,
|
||||||
@@ -64,6 +68,8 @@ POSTBOX_TABLES = (
|
|||||||
Postbox.__table__,
|
Postbox.__table__,
|
||||||
PostboxBinding.__table__,
|
PostboxBinding.__table__,
|
||||||
PostboxMessage.__table__,
|
PostboxMessage.__table__,
|
||||||
|
PostboxProtectionTransition.__table__,
|
||||||
|
PostboxProtectionTransitionItem.__table__,
|
||||||
PostboxParticipant.__table__,
|
PostboxParticipant.__table__,
|
||||||
PostboxAttachmentReference.__table__,
|
PostboxAttachmentReference.__table__,
|
||||||
PostboxDelivery.__table__,
|
PostboxDelivery.__table__,
|
||||||
@@ -87,7 +93,9 @@ class FakeIdentityDirectory:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def identities_for_accounts(self, account_ids):
|
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):
|
def accounts_for_identity(self, identity_id: str):
|
||||||
return ()
|
return ()
|
||||||
@@ -335,8 +343,7 @@ class FakeOrganizationDirectory:
|
|||||||
if function.function_type_id == function_type_id
|
if function.function_type_id == function_type_id
|
||||||
and (
|
and (
|
||||||
not organization_unit_ids
|
not organization_unit_ids
|
||||||
or function.organization_unit_id
|
or function.organization_unit_id in organization_unit_ids
|
||||||
in organization_unit_ids
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -355,8 +362,7 @@ class FakeOrganizationDirectory:
|
|||||||
matches=tuple(
|
matches=tuple(
|
||||||
unit
|
unit
|
||||||
for unit in self.units.values()
|
for unit in self.units.values()
|
||||||
if unit.tenant_id == tenant_id
|
if unit.tenant_id == tenant_id and unit.unit_type_id == unit_type_id
|
||||||
and unit.unit_type_id == unit_type_id
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -457,9 +463,7 @@ class FakeOrganizationDirectory:
|
|||||||
root=root,
|
root=root,
|
||||||
matches=matches,
|
matches=matches,
|
||||||
cycle_detected=self.cycle_detected,
|
cycle_detected=self.cycle_detected,
|
||||||
depth_limited=(
|
depth_limited=(structure_id == self.structure.id and max_depth < 2),
|
||||||
structure_id == self.structure.id and max_depth < 2
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return tuple(results)
|
return tuple(results)
|
||||||
@@ -529,15 +533,20 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
self.actor = PostboxActorRef(
|
self.actor = PostboxActorRef(
|
||||||
account_id="account-1",
|
account_id="account-1",
|
||||||
identity_id="identity-1",
|
identity_id="identity-1",
|
||||||
authorized_actions=frozenset(
|
authorized_actions=frozenset({"discover", "read", "send", "acknowledge"}),
|
||||||
{"discover", "read", "send", "acknowledge"}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.engine.dispose()
|
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(
|
return self.service.create_exact_postbox(
|
||||||
session,
|
session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -548,6 +557,9 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
description=None,
|
description=None,
|
||||||
classification="internal",
|
classification="internal",
|
||||||
actor_id="admin-1",
|
actor_id="admin-1",
|
||||||
|
encryption_profile=encryption_profile,
|
||||||
|
encryption_vault_id=encryption_vault_id,
|
||||||
|
protection_policy=protection_policy,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _routing_policy(
|
def _routing_policy(
|
||||||
@@ -575,11 +587,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
"max_retention_days": 30,
|
"max_retention_days": 30,
|
||||||
},
|
},
|
||||||
"attention": {
|
"attention": {
|
||||||
"mode": (
|
"mode": ("vacancy_escalation" if vacancy_escalation else "none"),
|
||||||
"vacancy_escalation"
|
|
||||||
if vacancy_escalation
|
|
||||||
else "none"
|
|
||||||
),
|
|
||||||
"delay_minutes": 1 if vacancy_escalation else None,
|
"delay_minutes": 1 if vacancy_escalation else None,
|
||||||
},
|
},
|
||||||
"shared_visibility": {"mode": "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:
|
with Session(self.engine) as session:
|
||||||
template = self.service.create_template(
|
template = self.service.create_template(
|
||||||
session,
|
session,
|
||||||
@@ -957,6 +967,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
routing_policy={},
|
routing_policy={},
|
||||||
encryption_profile="plaintext_v1",
|
encryption_profile="plaintext_v1",
|
||||||
encryption_vault_id=None,
|
encryption_vault_id=None,
|
||||||
|
protection_policy={},
|
||||||
context_key=None,
|
context_key=None,
|
||||||
limit=200,
|
limit=200,
|
||||||
)
|
)
|
||||||
@@ -985,6 +996,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
routing_policy={},
|
routing_policy={},
|
||||||
encryption_profile="plaintext_v1",
|
encryption_profile="plaintext_v1",
|
||||||
encryption_vault_id=None,
|
encryption_vault_id=None,
|
||||||
|
protection_policy={},
|
||||||
context_key=None,
|
context_key=None,
|
||||||
limit=200,
|
limit=200,
|
||||||
)
|
)
|
||||||
@@ -1014,6 +1026,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
routing_policy={},
|
routing_policy={},
|
||||||
encryption_profile="plaintext_v1",
|
encryption_profile="plaintext_v1",
|
||||||
encryption_vault_id=None,
|
encryption_vault_id=None,
|
||||||
|
protection_policy={},
|
||||||
context_key=None,
|
context_key=None,
|
||||||
limit=200,
|
limit=200,
|
||||||
)
|
)
|
||||||
@@ -1067,9 +1080,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
[item.slug for item in catalog.templates],
|
[item.slug for item in catalog.templates],
|
||||||
)
|
)
|
||||||
north = next(
|
north = next(
|
||||||
unit
|
unit for unit in catalog.organization_units if unit.id == "unit-1"
|
||||||
for unit in catalog.organization_units
|
|
||||||
if unit.id == "unit-1"
|
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["function-1"],
|
["function-1"],
|
||||||
@@ -1309,7 +1320,11 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
self.idm.assignments.append(self.assignment)
|
self.idm.assignments.append(self.assignment)
|
||||||
expires_at = utc_now() + timedelta(days=1)
|
expires_at = utc_now() + timedelta(days=1)
|
||||||
with Session(self.engine) as session:
|
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(
|
delivered = self.service.deliver(
|
||||||
session,
|
session,
|
||||||
PostboxDeliveryRequest(
|
PostboxDeliveryRequest(
|
||||||
@@ -1340,6 +1355,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
assurance_profile="email-otp",
|
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
|
assert message.external_recipient_tokens[0].expires_at is not None
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
expires_at.replace(microsecond=0),
|
expires_at.replace(microsecond=0),
|
||||||
message.external_recipient_tokens[0].expires_at.replace(
|
message.external_recipient_tokens[0].expires_at.replace(microsecond=0),
|
||||||
microsecond=0
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed(
|
def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed(
|
||||||
@@ -1445,14 +1459,330 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
actor=self.actor,
|
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(
|
def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.idm.assignments.append(self.assignment)
|
self.idm.assignments.append(self.assignment)
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
service, source, _target_template = (
|
service, source, _target_template = self._create_routing_source(session)
|
||||||
self._create_routing_source(session)
|
|
||||||
)
|
|
||||||
result = service.deliver(
|
result = service.deliver(
|
||||||
session,
|
session,
|
||||||
PostboxDeliveryRequest(
|
PostboxDeliveryRequest(
|
||||||
@@ -1478,10 +1808,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
self.assertNotEqual(result.message_id, route.target_message_id)
|
self.assertNotEqual(result.message_id, route.target_message_id)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["edge-child-parent"],
|
["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(
|
self.assertEqual(
|
||||||
route.id,
|
route.id,
|
||||||
@@ -1496,7 +1823,9 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
receipts = session.query(PostboxMessageReceipt).all()
|
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(
|
summary = service.delivery_receipt_summaries(
|
||||||
session,
|
session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -1512,8 +1841,8 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
service, source, _target_template = (
|
service, source, _target_template = self._create_routing_source(
|
||||||
self._create_routing_source(session, max_depth=1)
|
session, max_depth=1
|
||||||
)
|
)
|
||||||
blocked = service.preview_hierarchy_routes(
|
blocked = service.preview_hierarchy_routes(
|
||||||
session,
|
session,
|
||||||
@@ -1573,9 +1902,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
self.organizations.duplicate_parent_match = True
|
self.organizations.duplicate_parent_match = True
|
||||||
self.organizations.cycle_detected = True
|
self.organizations.cycle_detected = True
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
service, source, _target_template = (
|
service, source, _target_template = self._create_routing_source(session)
|
||||||
self._create_routing_source(session)
|
|
||||||
)
|
|
||||||
preview = service.preview_hierarchy_routes(
|
preview = service.preview_hierarchy_routes(
|
||||||
session,
|
session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -1588,10 +1915,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"])
|
self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
1,
|
1,
|
||||||
sum(
|
sum(route["status"] == "duplicate" for route in preview["routes"]),
|
||||||
route["status"] == "duplicate"
|
|
||||||
for route in preview["routes"]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
ValueError,
|
ValueError,
|
||||||
@@ -1639,11 +1963,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
routes = (
|
routes = session.query(PostboxRoute).order_by(PostboxRoute.depth).all()
|
||||||
session.query(PostboxRoute)
|
|
||||||
.order_by(PostboxRoute.depth)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["accepted_vacant", "pending_vacancy_escalation"],
|
["accepted_vacant", "pending_vacancy_escalation"],
|
||||||
[route.status for route in routes],
|
[route.status for route in routes],
|
||||||
@@ -1687,10 +2007,7 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
session.commit()
|
session.commit()
|
||||||
pending = (
|
pending = (
|
||||||
session.query(PostboxRoute)
|
session.query(PostboxRoute)
|
||||||
.filter(
|
.filter(PostboxRoute.status == "pending_vacancy_escalation")
|
||||||
PostboxRoute.status
|
|
||||||
== "pending_vacancy_escalation"
|
|
||||||
)
|
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
pending.execute_after = utc_now() - timedelta(seconds=1)
|
pending.execute_after = utc_now() - timedelta(seconds=1)
|
||||||
|
|||||||
+145
-1
@@ -41,11 +41,84 @@ export type PostboxDirectoryItem = {
|
|||||||
template_revision_id?: string | null;
|
template_revision_id?: string | null;
|
||||||
holder_count: number;
|
holder_count: number;
|
||||||
vacant: boolean;
|
vacant: boolean;
|
||||||
|
encryption_profile: PostboxProtectionProfileId;
|
||||||
|
key_epoch: number;
|
||||||
|
encryption_vault_id?: string | null;
|
||||||
|
protection_policy: PostboxProtectionPolicy;
|
||||||
access?: PostboxAccessDecision | null;
|
access?: PostboxAccessDecision | null;
|
||||||
resource_revision: number;
|
resource_revision: number;
|
||||||
etag: string;
|
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 = {
|
export type PostboxParticipant = {
|
||||||
kind: string;
|
kind: string;
|
||||||
reference_type: string;
|
reference_type: string;
|
||||||
@@ -93,7 +166,7 @@ export type PostboxMessage = {
|
|||||||
producer_resource_id?: string | null;
|
producer_resource_id?: string | null;
|
||||||
in_reply_to_message_id?: string | null;
|
in_reply_to_message_id?: string | null;
|
||||||
replaces_message_id?: string | null;
|
replaces_message_id?: string | null;
|
||||||
encryption_profile: string;
|
encryption_profile: PostboxProtectionProfileId;
|
||||||
key_epoch: number;
|
key_epoch: number;
|
||||||
ciphertext_ref?: string | null;
|
ciphertext_ref?: string | null;
|
||||||
signed_manifest_ref?: string | null;
|
signed_manifest_ref?: string | null;
|
||||||
@@ -213,6 +286,8 @@ export type PostboxTemplateRevision = {
|
|||||||
allow_vacant_delivery: boolean;
|
allow_vacant_delivery: boolean;
|
||||||
portal_visible: boolean;
|
portal_visible: boolean;
|
||||||
encryption_profile: string;
|
encryption_profile: string;
|
||||||
|
encryption_vault_id?: string | null;
|
||||||
|
protection_policy: PostboxProtectionPolicy;
|
||||||
history_policy: Record<string, unknown>;
|
history_policy: Record<string, unknown>;
|
||||||
routing_policy: PostboxRoutingPolicy;
|
routing_policy: PostboxRoutingPolicy;
|
||||||
retention_policy: Record<string, unknown>;
|
retention_policy: Record<string, unknown>;
|
||||||
@@ -248,6 +323,9 @@ export type PostboxTemplateRevisionPayload = Pick<
|
|||||||
| "classification"
|
| "classification"
|
||||||
| "allow_vacant_delivery"
|
| "allow_vacant_delivery"
|
||||||
| "portal_visible"
|
| "portal_visible"
|
||||||
|
| "encryption_profile"
|
||||||
|
| "encryption_vault_id"
|
||||||
|
| "protection_policy"
|
||||||
| "routing_policy"
|
| "routing_policy"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -290,12 +368,18 @@ export type PostboxExactCreatePayload = {
|
|||||||
address_key?: string | null;
|
address_key?: string | null;
|
||||||
classification: string;
|
classification: string;
|
||||||
portal_visible: boolean;
|
portal_visible: boolean;
|
||||||
|
encryption_profile: PostboxProtectionProfileId;
|
||||||
|
encryption_vault_id?: string | null;
|
||||||
|
protection_policy: PostboxProtectionPolicy;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PostboxMessageAuthoringPayload = {
|
export type PostboxMessageAuthoringPayload = {
|
||||||
idempotency_key: string;
|
idempotency_key: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body_text?: string | null;
|
body_text?: string | null;
|
||||||
|
ciphertext_ref?: string | null;
|
||||||
|
signed_manifest_ref?: string | null;
|
||||||
|
wrapped_keys?: PostboxMessage["wrapped_keys"];
|
||||||
classification: string;
|
classification: string;
|
||||||
participants: PostboxParticipant[];
|
participants: PostboxParticipant[];
|
||||||
attachments: PostboxAttachment[];
|
attachments: PostboxAttachment[];
|
||||||
@@ -469,6 +553,66 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise<Postbox
|
|||||||
return response.postboxes;
|
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(
|
export async function listPostboxOrganizationTargets(
|
||||||
settings: ApiSettings
|
settings: ApiSettings
|
||||||
): Promise<PostboxOrganizationTargets> {
|
): 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.find((grouping) => grouping.id === selectedScope) ?? null,
|
||||||
[groupings, selectedScope]
|
[groupings, selectedScope]
|
||||||
);
|
);
|
||||||
|
const composeTarget = useMemo(
|
||||||
|
() => postboxes.find((postbox) => postbox.id === messageDraft.postbox_id) ?? null,
|
||||||
|
[messageDraft.postbox_id, postboxes]
|
||||||
|
);
|
||||||
const scopePostboxIds = useMemo(() => {
|
const scopePostboxIds = useMemo(() => {
|
||||||
if (selectedPostboxId) return [selectedPostboxId];
|
if (selectedPostboxId) return [selectedPostboxId];
|
||||||
if (selectedGrouping) {
|
if (selectedGrouping) {
|
||||||
@@ -179,6 +183,9 @@ export default function PostboxPage({
|
|||||||
const replyDisabledReason = postboxBusyReason(false, busy)
|
const replyDisabledReason = postboxBusyReason(false, busy)
|
||||||
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
|
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
|
||||||
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : 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"
|
?? (selectedMessage?.availability !== "available"
|
||||||
? POSTBOX_INTERFACE_I18N.unavailableMessage
|
? POSTBOX_INTERFACE_I18N.unavailableMessage
|
||||||
: undefined);
|
: undefined);
|
||||||
@@ -509,7 +516,10 @@ export default function PostboxPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCompose() {
|
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;
|
if (!postbox) return;
|
||||||
openComposeFor(postbox);
|
openComposeFor(postbox);
|
||||||
}
|
}
|
||||||
@@ -543,7 +553,11 @@ export default function PostboxPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitMessage(): Promise<boolean> {
|
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);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
const participants = messageDraft.recipients
|
const participants = messageDraft.recipients
|
||||||
@@ -1034,9 +1048,10 @@ export default function PostboxPage({
|
|||||||
disabled={
|
disabled={
|
||||||
busy ||
|
busy ||
|
||||||
!messageDraft.postbox_id ||
|
!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
|
<Send size={16} /> Send
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1044,6 +1059,13 @@ export default function PostboxPage({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-compose-grid">
|
<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}>
|
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||||
<select
|
<select
|
||||||
value={messageDraft.postbox_id}
|
value={messageDraft.postbox_id}
|
||||||
@@ -1204,7 +1226,19 @@ function MessageDetail({
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
<section className="postbox-body">
|
<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>
|
</section>
|
||||||
{message.participants.length ? (
|
{message.participants.length ? (
|
||||||
<section className="postbox-participants">
|
<section className="postbox-participants">
|
||||||
|
|||||||
Reference in New Issue
Block a user