Files
govoplan-identity-trust/webui/src/features/IdentityTrustPanel.tsx
T
zemion 909e41b7ec
Module Package Release / publish-packages (push) Successful in 11s
fix(webui): bind key revocation to help
2026-08-24 11:36:40 +02:00

334 lines
20 KiB
TypeScript

import { MetricGrid } from "@govoplan/core-webui";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Eye, RefreshCw, RotateCw, ShieldOff } from "lucide-react";
import {
AdminPageLayout,
Button,
Card,
DataGrid,
Dialog,
DismissibleAlert,
FormField,
LoadingFrame,
MetricCard,
ReferenceSelect,
StatusBadge,
TableActionGroup,
ToggleSwitch,
hasScope,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
identityTrustAccountProvider,
listAssuranceEvidence,
listDeviceKeys,
listEpochs,
listKeyAccessDecisions,
revokeDeviceKey,
rotateEpoch,
type AssuranceEvidence,
type DeviceKey,
type KeyAccessDecision,
type KeyEpoch
} from "../api/identityTrust";
type IdentityTrustPanelProps = {
settings: ApiSettings;
auth: AuthInfo;
administrative?: boolean;
};
type EpochDraft = {
subjectKind: "identity" | "account" | "function" | "postbox" | "external_recipient";
subjectId: string;
reason: string;
accessDecisionRef: string;
};
const EMPTY_EPOCH_DRAFT: EpochDraft = {
subjectKind: "postbox",
subjectId: "",
reason: "",
accessDecisionRef: ""
};
export default function IdentityTrustPanel({ settings, auth, administrative = false }: IdentityTrustPanelProps) {
const ownAccountId = auth.principal?.account_id || auth.user.account_id;
const canRevokeDevice = hasScope(auth, "identity_trust:device:write")
|| hasScope(auth, "identity_trust:device:admin");
const [accountId, setAccountId] = useState(ownAccountId);
const [keys, setKeys] = useState<DeviceKey[]>([]);
const [evidence, setEvidence] = useState<AssuranceEvidence[]>([]);
const [decisions, setDecisions] = useState<KeyAccessDecision[]>([]);
const [epochs, setEpochs] = useState<KeyEpoch[]>([]);
const [showRevoked, setShowRevoked] = useState(false);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [revoking, setRevoking] = useState<DeviceKey | null>(null);
const [revocationReason, setRevocationReason] = useState("");
const [selectedEvidence, setSelectedEvidence] = useState<AssuranceEvidence | null>(null);
const [selectedDecision, setSelectedDecision] = useState<KeyAccessDecision | null>(null);
const [epochDraft, setEpochDraft] = useState<EpochDraft>(EMPTY_EPOCH_DRAFT);
const accountProvider = useMemo(() => identityTrustAccountProvider(settings), [settings]);
const loadAccount = useCallback(async () => {
if (!accountId) return;
setLoading(true);
setError("");
try {
const [nextKeys, nextEvidence, nextDecisions] = await Promise.all([
listDeviceKeys(settings, accountId, false),
listAssuranceEvidence(settings, accountId, false),
administrative ? listKeyAccessDecisions(settings, accountId) : Promise.resolve([])
]);
setKeys(nextKeys);
setEvidence(nextEvidence);
setDecisions(nextDecisions);
} catch (caught) {
setError(errorMessage(caught));
setKeys([]);
setEvidence([]);
setDecisions([]);
} finally {
setLoading(false);
}
}, [accountId, administrative, settings]);
useEffect(() => {
void loadAccount();
}, [loadAccount]);
const visibleKeys = showRevoked ? keys : keys.filter((key) => key.status === "active");
const activeEvidence = evidence.filter((item) => item.active);
const highestAssurance = activeEvidence
.map((item) => item.assurance_level)
.sort((left, right) => assuranceRank(right) - assuranceRank(left))[0] ?? "None";
const keyColumns = useMemo<DataGridColumn<DeviceKey>[]>(() => [
{ id: "device", header: "Device", width: 180, sortable: true, filterable: true, render: (row) => row.device_id, value: (row) => row.device_id },
{ id: "key", header: "Public key", width: 220, sortable: true, filterable: true, render: (row) => row.key_id, value: (row) => row.key_id },
{ id: "purpose", header: "Purpose", width: 170, sortable: true, filterable: true, render: (row) => humanize(row.purpose), value: (row) => row.purpose },
{ id: "algorithm", header: "Algorithm", width: 130, sortable: true, filterable: true, render: (row) => row.algorithm, value: (row) => row.algorithm },
{ id: "assurance", header: "Assurance", width: 140, sortable: true, filterable: true, render: (row) => humanize(row.assurance_level), value: (row) => row.assurance_level },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status },
{ id: "epoch", header: "Revision", width: 100, sortable: true, filterable: true, filterType: "integer", render: (row) => row.epoch, value: (row) => row.epoch },
{ id: "expiry", header: "Expiry", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at ?? "" },
{
id: "actions",
header: "Actions",
width: 90,
sticky: "end",
align: "right",
render: (row) => <TableActionGroup actions={[
{
id: "revoke",
label: "Revoke device key",
icon: <ShieldOff aria-hidden="true" />,
variant: "danger",
applicable: row.status === "active",
disabled: !canRevokeDevice,
disabledReason: !canRevokeDevice
? "Device-key write permission is required."
: undefined,
onClick: () => {
setRevocationReason("");
setRevoking(row);
}
}
]} />
}
], [canRevokeDevice]);
const evidenceColumns = useMemo<DataGridColumn<AssuranceEvidence>[]>(() => [
{ id: "level", header: "Level", width: 130, sortable: true, filterable: true, render: (row) => humanize(row.assurance_level), value: (row) => row.assurance_level },
{ id: "provider", header: "Provider", width: 160, sortable: true, filterable: true, render: (row) => row.provider_id, value: (row) => row.provider_id },
{ id: "evidence", header: "Evidence reference", width: 260, sortable: true, filterable: true, render: (row) => row.evidence_ref, value: (row) => row.evidence_ref },
{ id: "device", header: "Device key", width: 190, sortable: true, filterable: true, render: (row) => row.device_key_id || "Any registered device", value: (row) => row.device_key_id ?? "" },
{ id: "verified", header: "Verified", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.verified_at), value: (row) => row.verified_at },
{ id: "expires", header: "Expires", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at },
{ id: "state", header: "State", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.active ? "active" : "expired"} />, value: (row) => row.active ? "active" : "expired" },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "details", label: "View provenance", icon: <Eye aria-hidden="true" />, onClick: () => setSelectedEvidence(row) }]} /> }
], []);
const decisionColumns = useMemo<DataGridColumn<KeyAccessDecision>[]>(() => [
{ id: "time", header: "Recorded", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.created_at), value: (row) => row.created_at },
{ id: "purpose", header: "Purpose", width: 220, sortable: true, filterable: true, render: (row) => row.purpose, value: (row) => row.purpose },
{ id: "subject", header: "Subject", width: 230, sortable: true, filterable: true, render: (row) => `${humanize(row.subject_kind)}: ${row.subject_id}`, value: (row) => `${row.subject_kind}:${row.subject_id}` },
{ id: "device", header: "Device key", width: 180, sortable: true, filterable: true, render: (row) => row.device_key_id, value: (row) => row.device_key_id },
{ id: "decision", header: "Decision", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.allowed ? "allowed" : "denied"} />, value: (row) => row.allowed ? "allowed" : "denied" },
{ id: "reason", header: "Reason", width: 320, render: (row) => row.reason, value: (row) => row.reason },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "details", label: "View decision provenance", icon: <Eye aria-hidden="true" />, onClick: () => setSelectedDecision(row) }]} /> }
], []);
const epochColumns = useMemo<DataGridColumn<KeyEpoch>[]>(() => [
{ id: "epoch", header: "Epoch", width: 90, sortable: true, filterable: true, filterType: "integer", render: (row) => row.epoch, value: (row) => row.epoch },
{ id: "state", header: "State", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "history", header: "History access", width: 170, sortable: true, filterable: true, render: (row) => humanize(row.history_policy), value: (row) => row.history_policy },
{ id: "effective", header: "Effective", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.effective_at), value: (row) => row.effective_at },
{ id: "access", header: "Access decision", width: 260, render: (row) => row.access_decision_ref || "-", value: (row) => row.access_decision_ref ?? "" },
{ id: "reason", header: "Reason", width: 320, render: (row) => row.reason || "-", value: (row) => row.reason ?? "" }
], []);
async function applyRevoke() {
if (!revoking || !revocationReason.trim() || busy) return;
setBusy(true);
setError("");
try {
await revokeDeviceKey(settings, revoking.key_id, revoking.epoch, revocationReason.trim());
setRevoking(null);
setSuccess("The device key was revoked. Existing plaintext or exported keys cannot be recalled.");
await loadAccount();
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
}
async function loadEpochHistory() {
if (!epochDraft.subjectId.trim()) return;
setBusy(true);
setError("");
try {
setEpochs(await listEpochs(settings, epochDraft.subjectKind, epochDraft.subjectId.trim()));
} catch (caught) {
setError(errorMessage(caught));
setEpochs([]);
} finally {
setBusy(false);
}
}
async function applyEpochRotation() {
if (!epochDraft.subjectId.trim() || !epochDraft.reason.trim() || !epochDraft.accessDecisionRef.trim() || busy) return;
setBusy(true);
setError("");
try {
const current = epochs.find((epoch) => epoch.state === "active");
await rotateEpoch(settings, {
subject_kind: epochDraft.subjectKind,
subject_id: epochDraft.subjectId.trim(),
reason: epochDraft.reason.trim(),
access_decision_ref: epochDraft.accessDecisionRef.trim(),
idempotency_key: crypto.randomUUID(),
history_policy: "all_retained",
previous_epoch: current?.epoch ?? null
});
setSuccess("The key epoch was rotated. Existing device copies and previously obtained plaintext cannot be revoked retroactively.");
setEpochDraft((currentDraft) => ({ ...currentDraft, reason: "", accessDecisionRef: "" }));
await loadEpochHistory();
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
}
const content = <>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
{administrative && <Card title="Account" compact>
<div className="identity-trust-account-selector">
<FormField label="Account">
<ReferenceSelect
value={accountId}
provider={accountProvider}
onChange={(value) => setAccountId(value)}
createCustomOption={(value) => value.trim() ? { value: value.trim(), label: value.trim(), description: "Explicit account reference" } : null}
placeholder="Select or enter an account"
searchPlaceholder="Search accounts" />
</FormField>
<Button onClick={() => void loadAccount()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>
</div>
</Card>}
<LoadingFrame loading={loading} label="Loading identity trust state">
<MetricGrid density="compact">
<MetricCard label="Active device keys" value={keys.filter((key) => key.status === "active").length} tone="good" />
<MetricCard label="Revoked or expired" value={keys.filter((key) => key.status !== "active").length} tone="warning" />
<MetricCard label="Active assurance evidence" value={activeEvidence.length} tone={activeEvidence.length ? "good" : "warning"} />
<MetricCard label="Highest assurance" value={humanize(highestAssurance)} tone={highestAssurance === "None" ? "warning" : "info"} />
</MetricGrid>
<Card title="Device keys" actions={<ToggleSwitch label="Show revoked and expired" checked={showRevoked} onChange={setShowRevoked} />}>
<p className="muted small-note">Only public key and trust metadata are stored. Revocation blocks future server-mediated use but cannot erase plaintext or key material already obtained by a device.</p>
<div className="admin-table-surface"><DataGrid id={`identity-trust-device-keys-${administrative ? "admin" : "self"}`} rows={visibleKeys} columns={keyColumns} initialFit="container" getRowKey={(row) => row.key_id} emptyText="No device keys found." /></div>
</Card>
<Card title="Assurance evidence">
<p className="muted small-note">Evidence is bounded by provider, assurance level, device, verification time, and expiry. It does not grant resource access on its own.</p>
<div className="admin-table-surface"><DataGrid id={`identity-trust-assurance-${administrative ? "admin" : "self"}`} rows={evidence} columns={evidenceColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No assurance evidence found." /></div>
</Card>
{administrative && <>
<Card title="Key epoch administration">
<p className="muted small-note">Rotation supersedes the active epoch and retains history for newly authorized incumbents. It does not grant Access permission, recall exported material, or transfer private keys.</p>
<div className="identity-trust-epoch-form">
<FormField label="Subject type"><select value={epochDraft.subjectKind} disabled={busy} onChange={(event) => { setEpochDraft({ ...epochDraft, subjectKind: event.target.value as EpochDraft["subjectKind"] }); setEpochs([]); }}><option value="identity">Identity</option><option value="account">Account</option><option value="function">Function</option><option value="postbox">Postbox</option><option value="external_recipient">External recipient</option></select></FormField>
<FormField label="Subject reference"><input value={epochDraft.subjectId} disabled={busy} onChange={(event) => { setEpochDraft({ ...epochDraft, subjectId: event.target.value }); setEpochs([]); }} /></FormField>
<Button onClick={() => void loadEpochHistory()} disabled={busy || !epochDraft.subjectId.trim()}><RefreshCw aria-hidden="true" /> Load history</Button>
<FormField label="History access"><input value="All retained history" disabled /></FormField>
<FormField label="Authorizing Access decision"><input value={epochDraft.accessDecisionRef} disabled={busy} onChange={(event) => setEpochDraft({ ...epochDraft, accessDecisionRef: event.target.value })} /></FormField>
<FormField label="Rotation reason"><input value={epochDraft.reason} disabled={busy} onChange={(event) => setEpochDraft({ ...epochDraft, reason: event.target.value })} /></FormField>
<Button variant="danger" onClick={() => void applyEpochRotation()} disabled={busy || !epochDraft.subjectId.trim() || !epochDraft.accessDecisionRef.trim() || !epochDraft.reason.trim()}><RotateCw aria-hidden="true" /> Rotate epoch</Button>
</div>
<div className="admin-table-surface"><DataGrid id="identity-trust-epochs-admin" rows={epochs} columns={epochColumns} initialFit="container" getRowKey={(row) => `${row.subject_kind}:${row.subject_id}:${row.epoch}`} emptyText="Load a subject to inspect its epoch history." /></div>
</Card>
<Card title="Key-access decisions">
<p className="muted small-note">These immutable decisions combine an upstream Access decision with the acting account, current public device key, active epoch, purpose, and resource reference. No cryptographic material is returned by Identity Trust.</p>
<div className="admin-table-surface"><DataGrid id="identity-trust-decisions-admin" rows={decisions} columns={decisionColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No key-access decisions found for this account." /></div>
</Card>
</>}
</LoadingFrame>
<Dialog open={Boolean(revoking)} title="Revoke device key" helpContextId="identity_trust.settings.devices" helpModuleId="identity_trust" onClose={() => !busy && setRevoking(null)} closeDisabled={busy} footer={<><Button onClick={() => setRevoking(null)} disabled={busy}>Cancel</Button><Button variant="danger" helpContextId="identity_trust.settings.devices" helpModuleId="identity_trust" onClick={() => void applyRevoke()} disabled={busy || !revocationReason.trim()}>Revoke key</Button></>}>
<p>Revoke <strong>{revoking?.key_id}</strong>? Future key-access decisions will reject this device. Plaintext, exports, and keys already obtained by the device cannot be recalled.</p>
<FormField label="Reason"><textarea rows={4} value={revocationReason} disabled={busy} onChange={(event) => setRevocationReason(event.target.value)} /></FormField>
</Dialog>
<ProvenanceDialog title="Assurance evidence provenance" value={selectedEvidence} onClose={() => setSelectedEvidence(null)} />
<ProvenanceDialog title="Key-access decision provenance" value={selectedDecision} onClose={() => setSelectedDecision(null)} />
</>;
if (administrative) {
return <AdminPageLayout title="Identity trust" description="Inspect public device trust, assurance provenance, epoch history, and immutable key-access decisions." loading={false} error="" success="" actions={<Button onClick={() => void loadAccount()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>{content}</AdminPageLayout>;
}
return <div className="identity-trust-panel">{content}</div>;
}
function ProvenanceDialog({ title, value, onClose }: { title: string; value: AssuranceEvidence | KeyAccessDecision | null; onClose: () => void }) {
return <Dialog open={Boolean(value)} title={title} onClose={onClose} footer={<Button onClick={onClose}>Close</Button>}>
{value && <div className="identity-trust-provenance">
<dl>
{"evidence_ref" in value && <><dt>Evidence reference</dt><dd>{value.evidence_ref}</dd><dt>Provider</dt><dd>{value.provider_id}</dd></>}
{"decision_ref" in value && <><dt>Decision reference</dt><dd>{value.decision_ref}</dd><dt>Upstream Access decision</dt><dd>{value.access_decision_ref}</dd><dt>Resource</dt><dd>{value.resource_ref || "Not bound"}</dd></>}
</dl>
<pre>{JSON.stringify(value.provenance, null, 2)}</pre>
</div>}
</Dialog>;
}
function assuranceRank(value: string): number {
return ({ none: 0, software: 1, mfa: 2, hardware: 3, high: 4 } as Record<string, number>)[value.toLowerCase()] ?? 0;
}
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);
}