feat(postbox): add governed content protection profiles

This commit is contained in:
2026-08-20 03:42:58 +02:00
parent 15d93aaa25
commit 174ee97719
16 changed files with 3917 additions and 383 deletions
+145 -1
View File
@@ -41,11 +41,84 @@ export type PostboxDirectoryItem = {
template_revision_id?: string | null;
holder_count: number;
vacant: boolean;
encryption_profile: PostboxProtectionProfileId;
key_epoch: number;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
access?: PostboxAccessDecision | null;
resource_revision: number;
etag: string;
};
export type PostboxProtectionProfileId =
| "plaintext_v1"
| "server_envelope_v1"
| "external_e2ee_v1";
export type PostboxProtectionPolicy = {
new_incumbent_history: "all_retained" | "since_assignment" | "bounded_days";
history_days?: number | null;
ordinary_rotation: "rewrap" | "reencrypt";
compromise_rotation: "rewrap" | "reencrypt";
recovery_authority: "disabled" | "user_consent" | "institutional_key_holders" | "dual_control";
recovery_quorum: number;
handover_authority: "user_consent" | "institutional_key_holders" | "dual_control";
handover_quorum: number;
emergency_access: "disabled" | "dual_control";
emergency_quorum: number;
export_authority: "user_consent" | "institutional_key_holders" | "dual_control";
export_quorum: number;
destruction_authority: "institutional_key_holders" | "dual_control";
destruction_quorum: number;
external_recipient_assurance: "disabled" | "email_otp" | "strong_identity";
vacancy_escalation_content_access: "metadata_only";
};
export type PostboxProtectionProfile = {
id: PostboxProtectionProfileId;
label: string;
description: string;
server_can_decrypt: boolean;
requires_encryption_module: boolean;
requires_external_client: boolean;
available: boolean;
standard: boolean;
};
export type PostboxProtectionTransition = {
id: string;
postbox_id: string;
source_profile: string;
target_profile: string;
source_vault_id?: string | null;
target_vault_id?: string | null;
history_mode: string;
authority_mode: string;
required_quorum: number;
evidence_refs: string[];
reason: string;
state: string;
message_count: number;
completed_count: number;
failed_count: number;
requested_by?: string | null;
activated_at?: string | null;
completed_at?: string | null;
resource_revision: number;
etag: string;
configuration_snapshot: Record<string, unknown>;
items: Array<{
id: string;
message_id: string;
source_profile: string;
target_profile: string;
state: string;
source_digest?: string | null;
target_digest?: string | null;
error_code?: string | null;
}>;
};
export type PostboxParticipant = {
kind: string;
reference_type: string;
@@ -93,7 +166,7 @@ export type PostboxMessage = {
producer_resource_id?: string | null;
in_reply_to_message_id?: string | null;
replaces_message_id?: string | null;
encryption_profile: string;
encryption_profile: PostboxProtectionProfileId;
key_epoch: number;
ciphertext_ref?: string | null;
signed_manifest_ref?: string | null;
@@ -213,6 +286,8 @@ export type PostboxTemplateRevision = {
allow_vacant_delivery: boolean;
portal_visible: boolean;
encryption_profile: string;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
history_policy: Record<string, unknown>;
routing_policy: PostboxRoutingPolicy;
retention_policy: Record<string, unknown>;
@@ -248,6 +323,9 @@ export type PostboxTemplateRevisionPayload = Pick<
| "classification"
| "allow_vacant_delivery"
| "portal_visible"
| "encryption_profile"
| "encryption_vault_id"
| "protection_policy"
| "routing_policy"
>;
@@ -290,12 +368,18 @@ export type PostboxExactCreatePayload = {
address_key?: string | null;
classification: string;
portal_visible: boolean;
encryption_profile: PostboxProtectionProfileId;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
};
export type PostboxMessageAuthoringPayload = {
idempotency_key: string;
subject: string;
body_text?: string | null;
ciphertext_ref?: string | null;
signed_manifest_ref?: string | null;
wrapped_keys?: PostboxMessage["wrapped_keys"];
classification: string;
participants: PostboxParticipant[];
attachments: PostboxAttachment[];
@@ -469,6 +553,66 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise<Postbox
return response.postboxes;
}
export async function listPostboxProtectionProfiles(
settings: ApiSettings
): Promise<{ standard_profile: PostboxProtectionProfileId; profiles: PostboxProtectionProfile[] }> {
return apiFetch(settings, "/api/v1/postbox/admin/protection-profiles");
}
export async function listPostboxProtectionTransitions(
settings: ApiSettings,
postboxId: string
): Promise<PostboxProtectionTransition[]> {
const response = await apiFetch<{ transitions: PostboxProtectionTransition[] }>(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}/protection-transitions`
);
return response.transitions;
}
export function createPostboxProtectionTransition(
settings: ApiSettings,
postbox: PostboxDirectoryItem,
payload: {
idempotency_key: string;
target_profile: PostboxProtectionProfileId;
target_vault_id?: string | null;
history_mode: "future_only" | "migrate_history";
authority_mode: "user_consent" | "institutional_key_holders" | "dual_control";
required_quorum: number;
user_consent_refs: string[];
institutional_authorization_refs: string[];
reason: string;
acknowledge_irreversibility: boolean;
}
): Promise<PostboxProtectionTransition> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/protection-transitions`,
{ ...payload, base_revision: postbox.resource_revision },
{ headers: { "If-Match": postbox.etag } }
);
}
export function updatePostboxProtectionPolicy(
settings: ApiSettings,
postbox: PostboxDirectoryItem,
protectionPolicy: PostboxProtectionPolicy
): Promise<PostboxDirectoryItem> {
return apiFetch(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/protection-policy`,
{
method: "PUT",
headers: { "If-Match": postbox.etag },
body: JSON.stringify({
base_revision: postbox.resource_revision,
protection_policy: protectionPolicy
})
}
);
}
export async function listPostboxOrganizationTargets(
settings: ApiSettings
): Promise<PostboxOrganizationTargets> {
File diff suppressed because it is too large Load Diff
+39 -5
View File
@@ -164,6 +164,10 @@ export default function PostboxPage({
() => groupings.find((grouping) => grouping.id === selectedScope) ?? null,
[groupings, selectedScope]
);
const composeTarget = useMemo(
() => postboxes.find((postbox) => postbox.id === messageDraft.postbox_id) ?? null,
[messageDraft.postbox_id, postboxes]
);
const scopePostboxIds = useMemo(() => {
if (selectedPostboxId) return [selectedPostboxId];
if (selectedGrouping) {
@@ -179,6 +183,9 @@ export default function PostboxPage({
const replyDisabledReason = postboxBusyReason(false, busy)
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.encryption_profile === "external_e2ee_v1"
? "Replies to E2EE messages must be created by an approved encryption client."
: undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined);
@@ -509,7 +516,10 @@ export default function PostboxPage({
}
function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null;
const postbox = selectedPostbox
?? postboxes.find((item) => item.encryption_profile !== "external_e2ee_v1")
?? postboxes[0]
?? null;
if (!postbox) return;
openComposeFor(postbox);
}
@@ -543,7 +553,11 @@ export default function PostboxPage({
}
async function submitMessage(): Promise<boolean> {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return false;
if (
!messageDraft.postbox_id
|| !messageDraft.subject.trim()
|| composeTarget?.encryption_profile === "external_e2ee_v1"
) return false;
setBusy(true);
setError("");
const participants = messageDraft.recipients
@@ -1034,9 +1048,10 @@ export default function PostboxPage({
disabled={
busy ||
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
!messageDraft.subject.trim() ||
composeTarget?.encryption_profile === "external_e2ee_v1"
}
disabledReason={postboxBusyReason(false, busy) ?? ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
disabledReason={postboxBusyReason(false, busy) ?? (composeTarget?.encryption_profile === "external_e2ee_v1" ? "This browser editor has no E2EE private-key custody. Use an approved encryption client for this Postbox." : ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined))}
>
<Send size={16} /> Send
</Button>
@@ -1044,6 +1059,13 @@ export default function PostboxPage({
}
>
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-compose-grid">
{composeTarget?.encryption_profile === "external_e2ee_v1" ? (
<div className="postbox-compose-wide">
<DismissibleAlert tone="info" compact resetKey={composeTarget.id}>
This Postbox requires externally produced E2EE. Use an approved client that supplies ciphertext, a signed manifest, wrapped keys, and a verified content digest; this browser editor never asks for or stores the private key.
</DismissibleAlert>
</div>
) : null}
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.postbox_id}
@@ -1204,7 +1226,19 @@ function MessageDetail({
</dl>
</section>
<section className="postbox-body">
<p>{message.body_text || "No plaintext body is available for this message."}</p>
{message.encryption_profile === "external_e2ee_v1" ? (
<>
<DismissibleAlert tone="info" compact resetKey={message.id}>
This message is end-to-end encrypted. GovOPlaN stores and authorizes its envelope but cannot decrypt the content; open it with the institution's approved client.
</DismissibleAlert>
<dl className="postbox-provenance">
<div><dt>Ciphertext</dt><dd>{message.ciphertext_ref || "Not recorded"}</dd></div>
<div><dt>Signed manifest</dt><dd>{message.signed_manifest_ref || "Not recorded"}</dd></div>
</dl>
</>
) : (
<p>{message.body_text || "No plaintext body is available for this message."}</p>
)}
</section>
{message.participants.length ? (
<section className="postbox-participants">