Add governed encryption administration

This commit is contained in:
2026-08-04 01:27:52 +02:00
parent 42f35f8d00
commit ba92d8bf32
15 changed files with 1472 additions and 4 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@govoplan/encryption-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": { "types": "./src/index.ts", "import": "./src/index.ts" },
"./styles/encryption.css": "./src/styles/encryption.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": { "optional": true }
},
"scripts": {
"test:encryption-ui": "node tests/encryption-ui-structure.test.mjs"
}
}
+193
View File
@@ -0,0 +1,193 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type VaultSummary = {
tenant_id: string;
vault_id: string;
name: string;
provider_id: string;
purpose: string;
profile_kind: string;
scope_type: string;
scope_id?: string | null;
policy_ref: string;
recovery_quorum: number;
state: string;
revision: number;
current_key_version?: number | null;
current_key_state?: string | null;
algorithm_suite?: string | null;
created_at: string;
updated_at: string;
};
export type EnvelopeSummary = {
envelope_id: string;
tenant_id: string;
owner_module: string;
resource_type: string;
resource_id: string;
profile_kind: string;
provider_id: string;
vault_id: string;
key_version: number;
algorithm_suite: string;
state: string;
migration_id?: string | null;
created_at: string;
updated_at: string;
};
export type MigrationSummary = {
migration_id: string;
tenant_id: string;
source_envelope_id: string;
target_envelope_id?: string | null;
target_provider_id: string;
target_vault_id: string;
target_key_version: number;
target_algorithm_suite: string;
mode: string;
state: string;
policy_decision_ref: string;
assurance_evidence_ref: string;
evidence_refs: string[];
error_code?: string | null;
requested_by: string;
created_at: string;
updated_at: string;
completed_at?: string | null;
};
export type RecoverySummary = {
recovery_id: string;
tenant_id: string;
vault_id: string;
state: string;
requested_scope: string;
reason: string;
quorum: number;
approvals: number;
rejections: number;
revision: number;
policy_decision_ref: string;
requester_account_id: string;
requester_assurance_ref: string;
expires_at: string;
execution_ref?: string | null;
created_at: string;
updated_at: string;
};
export type DisablePreflight = {
allowed: boolean;
protected_count: number;
unresolved_count: number;
state_counts: Record<string, number>;
blocking_envelope_refs: string[];
required_actions: string[];
generated_at: string;
};
export type VaultCreatePayload = {
vault_id: string;
name: string;
provider_id: string;
purpose: string;
algorithm_suite: string;
scope_type: string;
scope_id?: string | null;
policy_ref: string;
recovery_quorum: number;
profile_kind: "server_envelope" | "tenant_held" | "end_to_end";
idempotency_key: string;
};
export type VaultLifecyclePayload = {
key_version: number;
expected_revision: number;
reason: string;
policy_decision_ref: string;
assurance_evidence_ref: string;
idempotency_key: string;
effective_at?: string | null;
};
export async function listVaults(settings: ApiSettings): Promise<VaultSummary[]> {
const value = await apiFetch<{ items: VaultSummary[] }>(settings, "/api/v1/encryption/vaults?limit=500");
return value.items;
}
export async function listEnvelopes(settings: ApiSettings): Promise<EnvelopeSummary[]> {
const value = await apiFetch<{ items: EnvelopeSummary[] }>(settings, "/api/v1/encryption/envelopes?limit=500");
return value.items;
}
export async function listMigrations(settings: ApiSettings): Promise<MigrationSummary[]> {
const value = await apiFetch<{ items: MigrationSummary[] }>(settings, "/api/v1/encryption/migrations?limit=500");
return value.items;
}
export async function listRecoveries(settings: ApiSettings): Promise<RecoverySummary[]> {
const value = await apiFetch<{ items: RecoverySummary[] }>(settings, "/api/v1/encryption/recoveries?limit=500");
return value.items;
}
export function loadDisablePreflight(settings: ApiSettings): Promise<DisablePreflight> {
return apiFetch<DisablePreflight>(settings, "/api/v1/encryption/disable-preflight");
}
export function createVault(settings: ApiSettings, payload: VaultCreatePayload): Promise<unknown> {
return apiFetch(settings, "/api/v1/encryption/vaults", { method: "POST", body: JSON.stringify(payload) });
}
export function rotateVault(settings: ApiSettings, vaultId: string, payload: Omit<VaultLifecyclePayload, "key_version" | "effective_at">): Promise<unknown> {
return apiFetch(settings, `/api/v1/encryption/vaults/${encodeURIComponent(vaultId)}/rotate`, { method: "POST", body: JSON.stringify(payload) });
}
export function changeVaultKey(settings: ApiSettings, vaultId: string, action: "revoke" | "destruction", payload: VaultLifecyclePayload): Promise<unknown> {
return apiFetch(settings, `/api/v1/encryption/vaults/${encodeURIComponent(vaultId)}/${action}`, { method: "POST", body: JSON.stringify(payload) });
}
export function reconcileVault(settings: ApiSettings, vaultId: string): Promise<unknown> {
return apiFetch(settings, `/api/v1/encryption/vaults/${encodeURIComponent(vaultId)}/reconcile`, { method: "POST" });
}
export function requestMigration(settings: ApiSettings, payload: {
envelope_id: string;
target_provider_id: string;
target_vault_id: string;
target_key_version: number;
target_algorithm_suite: string;
mode: "rewrap" | "reencrypt" | "decrypt" | "export" | "destroy";
policy_decision_ref: string;
assurance_evidence_ref: string;
idempotency_key: string;
}): Promise<unknown> {
return apiFetch(settings, "/api/v1/encryption/migrations", { method: "POST", body: JSON.stringify(payload) });
}
export function reconcileMigration(settings: ApiSettings, migrationId: string): Promise<unknown> {
return apiFetch(settings, `/api/v1/encryption/migrations/${encodeURIComponent(migrationId)}/reconcile`, { method: "POST" });
}
export function requestRecovery(settings: ApiSettings, payload: {
vault_id: string;
reason: string;
requested_scope: string;
policy_decision_ref: string;
assurance_evidence_ref: string;
idempotency_key: string;
expires_at: string;
}): Promise<unknown> {
return apiFetch(settings, "/api/v1/encryption/recoveries", { method: "POST", body: JSON.stringify(payload) });
}
export function decideRecovery(settings: ApiSettings, recoveryId: string, payload: {
decision: "approve" | "reject";
reason: string;
assurance_evidence_ref: string;
expected_revision: number;
idempotency_key: string;
}): Promise<unknown> {
return apiFetch(settings, `/api/v1/encryption/recoveries/${encodeURIComponent(recoveryId)}/decision`, { method: "POST", body: JSON.stringify(payload) });
}
+409
View File
@@ -0,0 +1,409 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowRightLeft,
Check,
Plus,
RefreshCw,
RotateCw,
ShieldAlert,
ShieldOff,
Trash2,
X
} from "lucide-react";
import {
AdminPageLayout,
Button,
Card,
DataGrid,
Dialog,
FormField,
MetricCard,
StatusBadge,
TableActionGroup,
hasScope,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
changeVaultKey,
createVault,
decideRecovery,
listEnvelopes,
listMigrations,
listRecoveries,
listVaults,
loadDisablePreflight,
reconcileMigration,
reconcileVault,
requestMigration,
requestRecovery,
rotateVault,
type DisablePreflight,
type EnvelopeSummary,
type MigrationSummary,
type RecoverySummary,
type VaultCreatePayload,
type VaultSummary
} from "../api/encryption";
type Props = { settings: ApiSettings; auth: AuthInfo };
type LifecycleAction = "rotate" | "revoke" | "destruction";
type LifecycleDraft = {
action: LifecycleAction;
vault: VaultSummary;
reason: string;
policyRef: string;
assuranceRef: string;
effectiveAt: string;
};
type MigrationDraft = {
envelope: EnvelopeSummary;
targetVaultId: string;
mode: "rewrap" | "reencrypt" | "decrypt" | "export" | "destroy";
policyRef: string;
assuranceRef: string;
};
type RecoveryDecisionDraft = {
recovery: RecoverySummary;
decision: "approve" | "reject";
reason: string;
assuranceRef: string;
};
const EMPTY_VAULT: VaultCreatePayload = {
vault_id: "",
name: "",
provider_id: "local_aesgcm",
purpose: "feature-content",
algorithm_suite: "AES-256-GCM",
scope_type: "tenant",
scope_id: null,
policy_ref: "",
recovery_quorum: 2,
profile_kind: "server_envelope",
idempotency_key: ""
};
export default function EncryptionAdminPanel({ settings, auth }: Props) {
const canAdmin = hasScope(auth, "encryption:vault:admin");
const canRecover = hasScope(auth, "encryption:recovery:approve");
const [vaults, setVaults] = useState<VaultSummary[]>([]);
const [envelopes, setEnvelopes] = useState<EnvelopeSummary[]>([]);
const [migrations, setMigrations] = useState<MigrationSummary[]>([]);
const [recoveries, setRecoveries] = useState<RecoverySummary[]>([]);
const [preflight, setPreflight] = useState<DisablePreflight | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [creating, setCreating] = useState(false);
const [vaultDraft, setVaultDraft] = useState<VaultCreatePayload>(EMPTY_VAULT);
const [lifecycle, setLifecycle] = useState<LifecycleDraft | null>(null);
const [migration, setMigration] = useState<MigrationDraft | null>(null);
const [requestingRecovery, setRequestingRecovery] = useState(false);
const [recoveryDraft, setRecoveryDraft] = useState({ vaultId: "", reason: "", requestedScope: "vault-status-and-rewrap", policyRef: "", assuranceRef: "", expiresAt: futureLocalTime(24) });
const [recoveryDecision, setRecoveryDecision] = useState<RecoveryDecisionDraft | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextVaults, nextEnvelopes, nextMigrations, nextRecoveries, nextPreflight] = await Promise.all([
listVaults(settings),
canAdmin ? listEnvelopes(settings) : Promise.resolve([]),
canAdmin ? listMigrations(settings) : Promise.resolve([]),
listRecoveries(settings),
canAdmin ? loadDisablePreflight(settings) : Promise.resolve(null)
]);
setVaults(nextVaults);
setEnvelopes(nextEnvelopes);
setMigrations(nextMigrations);
setRecoveries(nextRecoveries);
setPreflight(nextPreflight);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setLoading(false);
}
}, [canAdmin, settings]);
useEffect(() => {
void load();
}, [load]);
async function perform(operation: () => Promise<unknown>, message: string): Promise<boolean> {
if (busy) return false;
setBusy(true);
setError("");
setSuccess("");
try {
await operation();
setSuccess(message);
await load();
return true;
} catch (caught) {
setError(errorMessage(caught));
return false;
} finally {
setBusy(false);
}
}
const vaultColumns = useMemo<DataGridColumn<VaultSummary>[]>(() => [
{ id: "name", header: "Vault", width: 190, sortable: true, filterable: true, render: (row) => <><strong>{row.name}</strong><div className="muted small-note">{row.vault_id}</div></>, value: (row) => `${row.name} ${row.vault_id}` },
{ id: "provider", header: "Provider", width: 145, sortable: true, filterable: true, render: (row) => row.provider_id, value: (row) => row.provider_id },
{ id: "profile", header: "Profile", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.profile_kind), value: (row) => row.profile_kind },
{ id: "scope", header: "Scope", width: 155, sortable: true, filterable: true, render: (row) => `${humanize(row.scope_type)}${row.scope_id ? `: ${row.scope_id}` : ""}`, value: (row) => `${row.scope_type}:${row.scope_id ?? ""}` },
{ id: "key", header: "Current key", width: 145, sortable: true, filterable: true, render: (row) => row.current_key_version ? `v${row.current_key_version} · ${humanize(row.current_key_state ?? "unknown")}` : "Not provisioned", value: (row) => row.current_key_version ?? 0 },
{ id: "state", header: "State", width: 125, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "revision", header: "Revision", width: 95, sortable: true, filterable: true, filterType: "integer", render: (row) => row.revision, value: (row) => row.revision },
{
id: "actions", header: "Actions", width: 210, sticky: "end", align: "right", render: (row) => <TableActionGroup minimumSlots={4} actions={[
{ id: "reconcile", label: "Reconcile provider state", icon: <RefreshCw aria-hidden="true" />, disabled: !canAdmin, onClick: () => void perform(() => reconcileVault(settings, row.vault_id), `Vault ${row.name} was reconciled.`) },
{ id: "rotate", label: "Rotate key", icon: <RotateCw aria-hidden="true" />, disabled: !canAdmin || row.state !== "active", onClick: () => openLifecycle("rotate", row) },
{ id: "revoke", label: "Revoke current key", icon: <ShieldOff aria-hidden="true" />, variant: "danger", disabled: !canAdmin || !row.current_key_version, onClick: () => openLifecycle("revoke", row) },
{ id: "destroy", label: "Schedule key destruction", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: !canAdmin || !row.current_key_version, onClick: () => openLifecycle("destruction", row) }
]} />
}
], [canAdmin, settings]);
const envelopeColumns = useMemo<DataGridColumn<EnvelopeSummary>[]>(() => [
{ id: "owner", header: "Owner", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.owner_module), value: (row) => row.owner_module },
{ id: "resource", header: "Resource", width: 260, sortable: true, filterable: true, render: (row) => <><strong>{row.resource_type}</strong><div className="muted small-note">{row.resource_id}</div></>, value: (row) => `${row.resource_type} ${row.resource_id}` },
{ id: "vault", header: "Vault / key", width: 180, sortable: true, filterable: true, render: (row) => `${row.vault_id} · v${row.key_version}`, value: (row) => `${row.vault_id}:${row.key_version}` },
{ id: "profile", header: "Profile", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.profile_kind), value: (row) => row.profile_kind },
{ id: "state", header: "State", width: 135, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "migrate", label: "Prepare migration", icon: <ArrowRightLeft aria-hidden="true" />, disabled: row.state !== "active", onClick: () => openMigration(row) }]} /> }
], [vaults]);
const migrationColumns = useMemo<DataGridColumn<MigrationSummary>[]>(() => [
{ id: "source", header: "Source envelope", width: 245, sortable: true, filterable: true, render: (row) => row.source_envelope_id, value: (row) => row.source_envelope_id },
{ id: "mode", header: "Mode", width: 125, sortable: true, filterable: true, render: (row) => humanize(row.mode), value: (row) => row.mode },
{ id: "target", header: "Target", width: 190, sortable: true, filterable: true, render: (row) => `${row.target_vault_id} · v${row.target_key_version}`, value: (row) => `${row.target_vault_id}:${row.target_key_version}` },
{ id: "state", header: "State", width: 135, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "evidence", header: "Evidence", width: 100, sortable: true, filterable: true, filterType: "integer", render: (row) => row.evidence_refs.length, value: (row) => row.evidence_refs.length },
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "reconcile", label: "Reconcile migration state", icon: <RefreshCw aria-hidden="true" />, onClick: () => void perform(() => reconcileMigration(settings, row.migration_id), "Migration state was reconciled.") }]} /> }
], [settings]);
const recoveryColumns = useMemo<DataGridColumn<RecoverySummary>[]>(() => [
{ id: "vault", header: "Vault", width: 175, sortable: true, filterable: true, render: (row) => row.vault_id, value: (row) => row.vault_id },
{ id: "scope", header: "Requested scope", width: 220, sortable: true, filterable: true, render: (row) => humanize(row.requested_scope), value: (row) => row.requested_scope },
{ id: "requester", header: "Requester", width: 180, sortable: true, filterable: true, render: (row) => row.requester_account_id, value: (row) => row.requester_account_id },
{ id: "quorum", header: "Quorum", width: 120, sortable: true, filterable: true, render: (row) => `${row.approvals}/${row.quorum}`, value: (row) => row.approvals },
{ id: "state", header: "State", width: 125, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "expiry", header: "Expires", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at },
{ id: "actions", header: "Actions", width: 120, sticky: "end", align: "right", render: (row) => <TableActionGroup minimumSlots={2} actions={[
{ id: "approve", label: "Approve recovery", icon: <Check aria-hidden="true" />, variant: "primary", disabled: !canRecover || row.state !== "pending", onClick: () => openRecoveryDecision(row, "approve") },
{ id: "reject", label: "Reject recovery", icon: <X aria-hidden="true" />, variant: "danger", disabled: !canRecover || row.state !== "pending", onClick: () => openRecoveryDecision(row, "reject") }
]} /> }
], [canRecover]);
function openLifecycle(action: LifecycleAction, vault: VaultSummary) {
setLifecycle({ action, vault, reason: "", policyRef: vault.policy_ref, assuranceRef: "", effectiveAt: futureLocalTime(24) });
}
function openMigration(envelope: EnvelopeSummary) {
const target = vaults.find((vault) => vault.state === "active" && vault.current_key_version);
setMigration({ envelope, targetVaultId: target?.vault_id ?? "", mode: "rewrap", policyRef: "", assuranceRef: "" });
}
function openRecoveryDecision(recovery: RecoverySummary, decision: "approve" | "reject") {
setRecoveryDecision({ recovery, decision, reason: "", assuranceRef: "" });
}
async function submitVault() {
if (await perform(() => createVault(settings, { ...vaultDraft, idempotency_key: crypto.randomUUID() }), "The encryption vault was created.")) {
setCreating(false);
setVaultDraft(EMPTY_VAULT);
}
}
async function submitLifecycle() {
if (!lifecycle || !lifecycle.vault.current_key_version) return;
const common = { expected_revision: lifecycle.vault.revision, reason: lifecycle.reason.trim(), policy_decision_ref: lifecycle.policyRef.trim(), assurance_evidence_ref: lifecycle.assuranceRef.trim(), idempotency_key: crypto.randomUUID() };
const operation = lifecycle.action === "rotate"
? () => rotateVault(settings, lifecycle.vault.vault_id, common)
: () => changeVaultKey(settings, lifecycle.vault.vault_id, lifecycle.action, { ...common, key_version: lifecycle.vault.current_key_version!, effective_at: lifecycle.action === "destruction" ? new Date(lifecycle.effectiveAt).toISOString() : null });
if (await perform(operation, `Vault key ${humanize(lifecycle.action)} was recorded.`)) {
setLifecycle(null);
}
}
async function submitMigration() {
if (!migration) return;
const target = vaults.find((vault) => vault.vault_id === migration.targetVaultId);
if (!target?.current_key_version || !target.algorithm_suite) return;
if (await perform(() => requestMigration(settings, {
envelope_id: migration.envelope.envelope_id,
target_provider_id: target.provider_id,
target_vault_id: target.vault_id,
target_key_version: target.current_key_version!,
target_algorithm_suite: target.algorithm_suite!,
mode: migration.mode,
policy_decision_ref: migration.policyRef.trim(),
assurance_evidence_ref: migration.assuranceRef.trim(),
idempotency_key: crypto.randomUUID()
}), "The migration was authorized. The owning module must perform and confirm the content operation.")) {
setMigration(null);
}
}
async function submitRecovery() {
if (await perform(() => requestRecovery(settings, {
vault_id: recoveryDraft.vaultId,
reason: recoveryDraft.reason.trim(),
requested_scope: recoveryDraft.requestedScope.trim(),
policy_decision_ref: recoveryDraft.policyRef.trim(),
assurance_evidence_ref: recoveryDraft.assuranceRef.trim(),
idempotency_key: crypto.randomUUID(),
expires_at: new Date(recoveryDraft.expiresAt).toISOString()
}), "The recovery ceremony was requested. It releases no key material and changes no resource ownership.")) {
setRequestingRecovery(false);
}
}
async function submitRecoveryDecision() {
if (!recoveryDecision) return;
if (await perform(() => decideRecovery(settings, recoveryDecision.recovery.recovery_id, {
decision: recoveryDecision.decision,
reason: recoveryDecision.reason.trim(),
assurance_evidence_ref: recoveryDecision.assuranceRef.trim(),
expected_revision: recoveryDecision.recovery.revision,
idempotency_key: crypto.randomUUID()
}), `The recovery decision was recorded as ${recoveryDecision.decision}.`)) {
setRecoveryDecision(null);
}
}
const unresolved = preflight?.unresolved_count ?? envelopes.filter((item) => !["migrated", "decrypted", "exported", "destroyed"].includes(item.state)).length;
return <AdminPageLayout
title="Encryption"
description="Govern vault metadata, protection migrations, recovery quorum, and disable readiness without exposing cryptographic material."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={busy}><RefreshCw aria-hidden="true" /> Reload</Button>{canAdmin && <Button variant="primary" onClick={() => setCreating(true)} disabled={busy}><Plus aria-hidden="true" /> Add vault</Button>}</>}>
<div className="metric-grid compact">
<MetricCard label="Vaults" value={vaults.length} tone="info" />
<MetricCard label="Protected envelopes" value={preflight?.protected_count ?? envelopes.length} tone="info" />
<MetricCard label="Unresolved before disable" value={unresolved} tone={unresolved ? "warning" : "good"} />
<MetricCard label="Pending recoveries" value={recoveries.filter((item) => item.state === "pending").length} tone={recoveries.some((item) => item.state === "pending") ? "warning" : "good"} />
</div>
<Card title="Key vaults" collapsible collapseKey="encryption-vaults">
<p className="muted small-note">The operator view contains lifecycle metadata only. Provider key references and key material are intentionally excluded.</p>
<div className="encryption-table"><DataGrid id="encryption-vaults" rows={vaults} columns={vaultColumns} initialFit="container" getRowKey={(row) => row.vault_id} emptyText="No encryption vaults are configured." /></div>
</Card>
{canAdmin && <>
<Card title="Protection envelopes" collapsible collapseKey="encryption-envelopes">
<p className="muted small-note">Encryption tracks protection state; the owning module retains content authorization, retention, and migration execution.</p>
<div className="encryption-table"><DataGrid id="encryption-envelopes" rows={envelopes} columns={envelopeColumns} initialFit="container" getRowKey={(row) => row.envelope_id} emptyText="No protection envelopes were registered." /></div>
</Card>
<Card title="Protection migrations" collapsible collapseKey="encryption-migrations">
<p className="muted small-note">Rewrap and re-encryption are two-phase operations. A request is not success until the owning module records evidence for the durable content change.</p>
<div className="encryption-table"><DataGrid id="encryption-migrations" rows={migrations} columns={migrationColumns} initialFit="container" getRowKey={(row) => row.migration_id} emptyText="No protection migrations were requested." /></div>
</Card>
<Card title="Disable preflight" collapsible collapseKey="encryption-disable-preflight">
<div className={`encryption-preflight ${preflight?.allowed ? "is-ready" : "is-blocked"}`}>
<ShieldAlert aria-hidden="true" />
<div><strong>{preflight?.allowed ? "Encryption can be disabled" : "Encryption cannot be disabled"}</strong><p>{preflight?.allowed ? "Every registered envelope has a terminal, evidenced disposition." : `${unresolved} envelope(s) still require migration, authorized decryption, explicit export, or cryptographic destruction.`}</p></div>
</div>
{preflight?.blocking_envelope_refs.length ? <ul className="encryption-blockers">{preflight.blocking_envelope_refs.map((item) => <li key={item}>{item}</li>)}</ul> : null}
</Card>
</>}
<Card title="Recovery ceremonies" actions={canRecover ? <Button variant="primary" onClick={() => { setRecoveryDraft((value) => ({ ...value, vaultId: value.vaultId || vaults[0]?.vault_id || "" })); setRequestingRecovery(true); }} disabled={!vaults.length || busy}><Plus aria-hidden="true" /> Request recovery</Button> : undefined}>
<p className="muted small-note">Recovery requires recent high assurance and distinct custodians. The requester cannot approve; quorum authorization neither releases keys nor transfers ownership.</p>
<div className="encryption-table"><DataGrid id="encryption-recoveries" rows={recoveries} columns={recoveryColumns} initialFit="container" getRowKey={(row) => row.recovery_id} emptyText="No recovery ceremonies were requested." /></div>
</Card>
<Dialog open={creating} title="Add encryption vault" onClose={() => !busy && setCreating(false)} closeDisabled={busy} footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitVault()} disabled={busy || !vaultDraft.vault_id.trim() || !vaultDraft.name.trim() || !vaultDraft.policy_ref.trim()}>Create vault</Button></>}>
<div className="encryption-form-grid">
<FormField label="Vault ID"><input value={vaultDraft.vault_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, vault_id: event.target.value })} /></FormField>
<FormField label="Name"><input value={vaultDraft.name} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, name: event.target.value })} /></FormField>
<FormField label="Provider"><input value={vaultDraft.provider_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, provider_id: event.target.value })} /></FormField>
<FormField label="Protection profile"><select value={vaultDraft.profile_kind} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, profile_kind: event.target.value as VaultCreatePayload["profile_kind"] })}><option value="server_envelope">Server envelope</option><option value="tenant_held">Tenant-held</option><option value="end_to_end">End-to-end metadata only</option></select></FormField>
<FormField label="Algorithm suite"><input value={vaultDraft.algorithm_suite} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, algorithm_suite: event.target.value })} /></FormField>
<FormField label="Recovery quorum"><input type="number" min={1} max={32} value={vaultDraft.recovery_quorum} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, recovery_quorum: Number(event.target.value) })} /></FormField>
<FormField label="Purpose"><input value={vaultDraft.purpose} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, purpose: event.target.value })} /></FormField>
<FormField label="Policy reference"><input value={vaultDraft.policy_ref} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, policy_ref: event.target.value })} /></FormField>
</div>
<p className="muted small-note">Selecting end-to-end records a profile label only. It does not install or certify a client E2EE protocol.</p>
</Dialog>
<Dialog open={Boolean(lifecycle)} title={`${humanize(lifecycle?.action ?? "key")} vault key`} onClose={() => !busy && setLifecycle(null)} closeDisabled={busy} footer={<><Button onClick={() => setLifecycle(null)} disabled={busy}>Cancel</Button><Button variant={lifecycle?.action === "rotate" ? "primary" : "danger"} onClick={() => void submitLifecycle()} disabled={busy || !lifecycle?.reason.trim() || !lifecycle?.policyRef.trim() || !lifecycle?.assuranceRef.trim()}>Confirm {lifecycle?.action}</Button></>}>
{lifecycle && <>
<p>{lifecycleText(lifecycle.action)}</p>
<div className="encryption-form-grid">
<FormField label="Reason"><textarea rows={3} value={lifecycle.reason} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, reason: event.target.value })} /></FormField>
<FormField label="Policy decision reference"><input value={lifecycle.policyRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={lifecycle.assuranceRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, assuranceRef: event.target.value })} /></FormField>
{lifecycle.action === "destruction" && <FormField label="Destruction effective at"><input type="datetime-local" value={lifecycle.effectiveAt} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, effectiveAt: event.target.value })} /></FormField>}
</div>
</>}
</Dialog>
<Dialog open={Boolean(migration)} title="Prepare protection migration" onClose={() => !busy && setMigration(null)} closeDisabled={busy} footer={<><Button onClick={() => setMigration(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitMigration()} disabled={busy || !migration?.targetVaultId || !migration?.policyRef.trim() || !migration?.assuranceRef.trim()}>Authorize migration</Button></>}>
{migration && <>
<p>This authorizes a two-phase content operation. The owning module must durably update or dispose of its content and record evidence before the migration can succeed.</p>
<div className="encryption-form-grid">
<FormField label="Source envelope"><input value={migration.envelope.envelope_id} disabled /></FormField>
<FormField label="Mode"><select value={migration.mode} disabled={busy} onChange={(event) => setMigration({ ...migration, mode: event.target.value as MigrationDraft["mode"] })}><option value="rewrap">Rewrap</option><option value="reencrypt">Re-encrypt</option><option value="decrypt">Decrypt</option><option value="export">Export</option><option value="destroy">Destroy</option></select></FormField>
<FormField label="Target vault"><select value={migration.targetVaultId} disabled={busy} onChange={(event) => setMigration({ ...migration, targetVaultId: event.target.value })}>{vaults.filter((vault) => vault.current_key_version && vault.state === "active").map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name} · v{vault.current_key_version}</option>)}</select></FormField>
<FormField label="Policy decision reference"><input value={migration.policyRef} disabled={busy} onChange={(event) => setMigration({ ...migration, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={migration.assuranceRef} disabled={busy} onChange={(event) => setMigration({ ...migration, assuranceRef: event.target.value })} /></FormField>
</div>
</>}
</Dialog>
<Dialog open={requestingRecovery} title="Request recovery ceremony" onClose={() => !busy && setRequestingRecovery(false)} closeDisabled={busy} footer={<><Button onClick={() => setRequestingRecovery(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitRecovery()} disabled={busy || !recoveryDraft.vaultId || !recoveryDraft.reason.trim() || !recoveryDraft.requestedScope.trim() || !recoveryDraft.policyRef.trim() || !recoveryDraft.assuranceRef.trim()}>Request recovery</Button></>}>
<p>The request expires automatically and needs the vault's configured number of distinct custodians. You cannot approve your own request.</p>
<div className="encryption-form-grid">
<FormField label="Vault"><select value={recoveryDraft.vaultId} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, vaultId: event.target.value })}>{vaults.map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name}</option>)}</select></FormField>
<FormField label="Requested scope"><input value={recoveryDraft.requestedScope} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, requestedScope: event.target.value })} /></FormField>
<FormField label="Reason"><textarea rows={3} value={recoveryDraft.reason} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, reason: event.target.value })} /></FormField>
<FormField label="Expires at"><input type="datetime-local" value={recoveryDraft.expiresAt} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, expiresAt: event.target.value })} /></FormField>
<FormField label="Policy decision reference"><input value={recoveryDraft.policyRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={recoveryDraft.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, assuranceRef: event.target.value })} /></FormField>
</div>
</Dialog>
<Dialog open={Boolean(recoveryDecision)} title={`${humanize(recoveryDecision?.decision ?? "decide")} recovery`} onClose={() => !busy && setRecoveryDecision(null)} closeDisabled={busy} footer={<><Button onClick={() => setRecoveryDecision(null)} disabled={busy}>Cancel</Button><Button variant={recoveryDecision?.decision === "reject" ? "danger" : "primary"} onClick={() => void submitRecoveryDecision()} disabled={busy || !recoveryDecision?.reason.trim() || !recoveryDecision?.assuranceRef.trim()}>Record {recoveryDecision?.decision}</Button></>}>
{recoveryDecision && <>
<p>{recoveryDecision.decision === "approve" ? "Approval counts only if you are a distinct, high-assurance custodian. Quorum authorization still releases no key material." : "One rejection terminates this recovery ceremony. A new recovery needs a new governed request."}</p>
<FormField label="Reason"><textarea rows={4} value={recoveryDecision.reason} disabled={busy} onChange={(event) => setRecoveryDecision({ ...recoveryDecision, reason: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={recoveryDecision.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDecision({ ...recoveryDecision, assuranceRef: event.target.value })} /></FormField>
</>}
</Dialog>
</AdminPageLayout>;
}
function lifecycleText(action: LifecycleAction): string {
if (action === "rotate") return "Rotation creates a new current key version. Existing envelopes remain bound to their recorded key version until explicitly migrated.";
if (action === "revoke") return "Revocation blocks future provider use. It cannot recall plaintext or key material already obtained, and protected content may become unavailable.";
return "Destruction is irreversible once the provider performs it. Any content still bound to this key becomes permanently unreadable unless it was migrated first.";
}
function futureLocalTime(hours: number): string {
const value = new Date(Date.now() + hours * 60 * 60 * 1000);
value.setMinutes(value.getMinutes() - value.getTimezoneOffset());
return value.toISOString().slice(0, 16);
}
function humanize(value: string): string {
return value.replace(/[_-]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function formatDateTime(value?: string | null): string {
if (!value) return "-";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+1
View File
@@ -0,0 +1 @@
export { encryptionModule as default, encryptionModule } from "./module";
+38
View File
@@ -0,0 +1,38 @@
import { createElement, lazy } from "react";
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import "./styles/encryption.css";
const EncryptionAdminPanel = lazy(() => import("./features/EncryptionAdminPanel"));
const adminSections: AdminSectionsUiCapability = {
sections: [
{
id: "tenant-encryption",
moduleId: "encryption",
kind: "management",
surfaceId: "encryption.admin.operations",
label: "Encryption",
group: "TENANT",
order: 78,
anyOf: ["encryption:vault:admin", "encryption:recovery:approve"],
render: ({ settings, auth }) => createElement(EncryptionAdminPanel, { settings, auth })
}
]
};
export const encryptionModule: PlatformWebModule = {
id: "encryption",
label: "Encryption",
version: "0.1.14",
dependencies: [],
optionalDependencies: ["access", "audit", "policy", "identity_trust", "notifications"],
viewSurfaces: [
{ id: "encryption.admin.operations", moduleId: "encryption", kind: "section", label: "Encryption administration", order: 10 },
{ id: "encryption.admin.vaults", moduleId: "encryption", kind: "section", label: "Key vaults", parentId: "encryption.admin.operations", order: 20 },
{ id: "encryption.admin.migrations", moduleId: "encryption", kind: "section", label: "Protection migrations", parentId: "encryption.admin.operations", order: 30 },
{ id: "encryption.admin.recovery", moduleId: "encryption", kind: "section", label: "Recovery ceremonies", parentId: "encryption.admin.operations", order: 40 }
],
uiCapabilities: { "admin.sections": adminSections }
};
export default encryptionModule;
+56
View File
@@ -0,0 +1,56 @@
.encryption-table {
min-height: 10rem;
max-height: 25rem;
overflow: auto;
}
.encryption-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem 1rem;
}
.encryption-form-grid .form-field:has(textarea) {
grid-column: 1 / -1;
}
.encryption-preflight {
display: flex;
align-items: flex-start;
gap: 0.8rem;
padding: 0.85rem 0;
border-top: 2px solid var(--color-warning, #c38b22);
}
.encryption-preflight.is-ready {
border-top-color: var(--color-success, #43885b);
}
.encryption-preflight svg {
width: 1.35rem;
height: 1.35rem;
flex: none;
}
.encryption-preflight p {
margin: 0.25rem 0 0;
}
.encryption-blockers {
max-height: 9rem;
overflow: auto;
margin: 0;
padding-left: 1.25rem;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
}
@media (max-width: 760px) {
.encryption-form-grid {
grid-template-columns: minmax(0, 1fr);
}
.encryption-form-grid .form-field:has(textarea) {
grid-column: auto;
}
}
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const moduleSource = readFileSync("src/module.ts", "utf8");
const panel = readFileSync("src/features/EncryptionAdminPanel.tsx", "utf8");
const api = readFileSync("src/api/encryption.ts", "utf8");
assert.match(moduleSource, /"admin.sections": adminSections/);
assert.match(moduleSource, /encryption\.admin\.vaults/);
assert.match(moduleSource, /encryption\.admin\.migrations/);
assert.match(moduleSource, /encryption\.admin\.recovery/);
assert.match(panel, /<AdminPageLayout/);
assert.match(panel, /<DataGrid/);
assert.match(panel, /<StatusBadge/);
assert.match(panel, /<TableActionGroup/);
assert.match(panel, /cannot recall plaintext/);
assert.match(panel, /distinct custodians/);
assert.match(panel, /owning module must durably update/);
assert.match(panel, /Encryption cannot be disabled/);
assert.doesNotMatch(panel, /provider_key_ref/);
assert.doesNotMatch(panel, /wrapped_key_refs/);
assert.doesNotMatch(api, /provider_key_ref/);
assert.doesNotMatch(api, /wrapped_key_refs/);
assert.match(api, /expected_revision/);
assert.match(api, /disable-preflight/);
console.log("Encryption administration UI structural contract passed.");