334 lines
20 KiB
TypeScript
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);
|
|
}
|