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