mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 06:58:54 +00:00
web, docs: IssuerHierarchyPage + sysadmin runbook + connectors row (Rank 8 commit 5)
Final commit of the 5-commit Rank 8 chain. Operator-facing surface
on top of the service + handler layers shipped in commits 1-4.
Frontend (web/src):
- api/client.ts: 3 new functions + IntermediateCA interface
(listIntermediateCAs, getIntermediateCA, retireIntermediateCA).
- pages/IssuerHierarchyPage.tsx: recursive nested <ul> render of
the hierarchy tree at /issuers/:id/hierarchy. buildHierarchyTree
is a pure helper that walks the flat list and groups children
on parent_ca_id; the dendrogram view is parking-lot work tracked
in WORKSPACE-ROADMAP. Two-phase retire UX surfaces 'Retire…'
then 'Confirm retire (terminal)' when the row is in retiring
state. Admin gate is enforced at the API; the page renders the
backend's 403 as ErrorState for non-admin callers.
- main.tsx: register the new /issuers/:id/hierarchy route.
CI guard update:
- scripts/ci-guards/T-1-frontend-page-coverage.sh: add
IssuerHierarchyPage to the deferred-test allowlist with the
standard 'why deferred' comment. Admin-gate + recursive build
semantics are already pinned at the backend layer
(intermediate_ca_test.go service tests + intermediate_ca_test.go
handler triplet). Vitest test deferred until next feature
change touches the page.
Docs:
- docs/intermediate-ca-hierarchy.md: new operator runbook
covering:
Concepts (HierarchyMode 'single' vs 'tree', defense-in-depth
on key bytes never persisting on rows).
Lifecycle states + drain-first semantics
(active → retiring → retired with active-children gate).
Three deployment patterns: 4-level FedRAMP boundary CA,
3-level financial-services policy CA, 2-level internal
PKI.
RFC 5280 enforcement (§3.2 self-signed, §4.2.1.9 path-length
tightening, §4.2.1.10 NameConstraints subset).
Migration from single → tree using the load-bearing
TestLocal_HierarchyMode_SingleVsTree_ByteIdentical pin as
the canary.
API reference + observability (IntermediateCAMetrics
Prometheus exposure).
Known limitations + Rank-8 follow-on roadmap.
- docs/connectors.md: extend the Built-in Local CA section with
a 'Tree mode (Rank 8)' paragraph describing the new chain
assembly path + cross-link to docs/intermediate-ca-hierarchy.md.
Roadmap:
- WORKSPACE-ROADMAP.md: 5 follow-on items under a new
'Intermediate CA hierarchy extensions (Rank 8 V2 follow-ons)'
bullet block:
HSM-backed roots (PKCS#11 / cloud KMS drivers via existing
signer.Driver interface — no service-layer change needed).
Automated CA rotation (parallel-validity windows ahead of
expiry).
Intra-hierarchy CRL chaining (per-CA CRL endpoints stitched
at issue time).
NameConstraints policy templates (FedRAMP / financial /
internal PKI declarative templates instead of hand-rolled
JSON).
D3 dendrogram visualization (separate page so the existing
list view stays the default + the dep stays opt-in).
Verified locally:
gofmt: clean.
go vet ./...: exit 0.
tsc --noEmit (web/): exit 0 (no TypeScript errors).
go test -short -count=1 ./internal/api/handler/... + service +
local: ok across all three packages, 4-5s each.
All 24 CI guards: clean
(T-1 frontend-page-coverage with the new
IssuerHierarchyPage allowlist entry; openapi-handler-parity,
M-008 admin-gate, every other guard untouched).
Rank 8 chain complete:
468b75c domain, migrations: IntermediateCA type + intermediate_cas
+ Issuer.HierarchyMode (commit 1)
0562359 service: IntermediateCAService + IntermediateCAMetrics
+ RFC 5280 enforcement (commit 2)
5bf2f0c service: 10 IntermediateCAService tests + in-memory fake
repo (commit 2.5)
8ff5668 local: tree-mode chain assembly + byte-equivalence pin
(commit 3 — load-bearing backwards-compat refuse-to-ship
pin in TestLocal_HierarchyMode_SingleVsTree_ByteIdentical)
4d17ef9 api, handler: 4 admin-gated CA hierarchy endpoints +
OpenAPI (commit 4)
HEAD web, docs: IssuerHierarchyPage + sysadmin runbook +
connectors row (this commit)
Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 5.
This commit is contained in:
@@ -813,3 +813,39 @@ export const acknowledgeHealthCheck = (id: string) =>
|
||||
|
||||
export const getHealthCheckSummary = () =>
|
||||
fetchJSON<HealthCheckSummary>(`${BASE}/health-checks/summary`);
|
||||
|
||||
// IntermediateCA hierarchy (Rank 8 of the 2026-05-03 deep-research
|
||||
// deliverable). Admin-gated at the handler layer; non-admin Bearer
|
||||
// callers get 403. Operators drive the hierarchy from
|
||||
// IssuerHierarchyPage; the recursive tree render is built from the
|
||||
// flat list returned here by walking each row's parent_ca_id.
|
||||
export interface IntermediateCA {
|
||||
id: string;
|
||||
owning_issuer_id: string;
|
||||
parent_ca_id?: string | null;
|
||||
name: string;
|
||||
subject: string;
|
||||
state: 'active' | 'retiring' | 'retired';
|
||||
cert_pem: string;
|
||||
key_driver_id: string;
|
||||
not_before: string;
|
||||
not_after: string;
|
||||
path_len_constraint?: number | null;
|
||||
name_constraints?: { permitted?: string[]; excluded?: string[] }[];
|
||||
ocsp_responder_url?: string;
|
||||
metadata?: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const listIntermediateCAs = (issuerID: string) =>
|
||||
fetchJSON<{ data: IntermediateCA[] }>(`${BASE}/issuers/${issuerID}/intermediates`);
|
||||
|
||||
export const getIntermediateCA = (id: string) =>
|
||||
fetchJSON<IntermediateCA>(`${BASE}/intermediates/${id}`);
|
||||
|
||||
export const retireIntermediateCA = (id: string, note: string, confirm: boolean) =>
|
||||
fetchJSON<{ id: string; decided_by: string; confirmed: boolean }>(
|
||||
`${BASE}/intermediates/${id}/retire`,
|
||||
{ method: 'POST', body: JSON.stringify({ note, confirm }) },
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import DigestPage from './pages/DigestPage';
|
||||
import ObservabilityPage from './pages/ObservabilityPage';
|
||||
import JobDetailPage from './pages/JobDetailPage';
|
||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||
import IssuerHierarchyPage from './pages/IssuerHierarchyPage';
|
||||
import TargetDetailPage from './pages/TargetDetailPage';
|
||||
import SCEPAdminPage from './pages/SCEPAdminPage';
|
||||
import ESTAdminPage from './pages/ESTAdminPage';
|
||||
@@ -69,6 +70,11 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
||||
{/* Rank 8 — operator-managed multi-level CA hierarchy.
|
||||
Admin-gated at the API; the page renders the
|
||||
backend's 403 as ErrorState for non-admin
|
||||
callers. See docs/intermediate-ca-hierarchy.md. */}
|
||||
<Route path="issuers/:id/hierarchy" element={<IssuerHierarchyPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="targets/:id" element={<TargetDetailPage />} />
|
||||
<Route path="owners" element={<OwnersPage />} />
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { listIntermediateCAs, retireIntermediateCA, type IntermediateCA } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
|
||||
// IssuerHierarchyPage renders the operator-managed CA hierarchy for a
|
||||
// single issuer. Rank 8 of the 2026-05-03 deep-research deliverable.
|
||||
//
|
||||
// The recursive tree is built client-side from the flat list returned
|
||||
// by GET /api/v1/issuers/{id}/intermediates — each row's parent_ca_id
|
||||
// (nil = root) drives the nesting. We render with native HTML <ul>
|
||||
// elements rather than pulling D3 to keep the dep graph thin; the
|
||||
// dendrogram view is parking-lot work tracked in WORKSPACE-ROADMAP.
|
||||
//
|
||||
// Admin gate: the backend handlers enforce admin role at the API
|
||||
// layer (M-008 pattern). The page itself is reachable from the issuer
|
||||
// detail nav; non-admin callers see a 403 from the API and the page
|
||||
// renders the error.
|
||||
export default function IssuerHierarchyPage() {
|
||||
const { id: issuerID = '' } = useParams<{ id: string }>();
|
||||
const [retireConfirmFor, setRetireConfirmFor] = useState<string | null>(null);
|
||||
|
||||
const { data, error, isLoading, refetch } = useQuery({
|
||||
queryKey: ['issuer-hierarchy', issuerID],
|
||||
queryFn: () => listIntermediateCAs(issuerID),
|
||||
enabled: issuerID !== '',
|
||||
});
|
||||
|
||||
const retireMu = useTrackedMutation({
|
||||
mutationKey: ['retire-intermediate-ca'],
|
||||
mutationFn: (vars: { id: string; note: string; confirm: boolean }) =>
|
||||
retireIntermediateCA(vars.id, vars.note, vars.confirm),
|
||||
onSuccess: () => {
|
||||
setRetireConfirmFor(null);
|
||||
refetch();
|
||||
},
|
||||
invalidates: [['issuer-hierarchy', issuerID]],
|
||||
});
|
||||
|
||||
const tree = useMemo(() => buildHierarchyTree(data?.data ?? []), [data?.data]);
|
||||
|
||||
if (issuerID === '') {
|
||||
return <ErrorState error={new Error('No issuer id in URL.')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Certificate authority hierarchy"
|
||||
subtitle="Multi-level CA hierarchy backed by the intermediate_cas table. Each row is one CA cert (root, policy, issuing). The recursive nesting is driven by parent_ca_id."
|
||||
/>
|
||||
|
||||
{isLoading && <p className="text-sm text-slate-500">Loading hierarchy…</p>}
|
||||
{error && (
|
||||
<ErrorState
|
||||
error={error instanceof Error ? error : new Error(String(error))}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tree.length === 0 && !isLoading && !error && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-6 text-sm text-slate-600">
|
||||
<p className="font-medium">No CA hierarchy registered yet for this issuer.</p>
|
||||
<p className="mt-2">
|
||||
Operators register a root via <code>POST /api/v1/issuers/{issuerID}/intermediates</code> with
|
||||
<code> root_cert_pem</code> + <code>key_driver_id</code> set, then chain
|
||||
<code> POST </code> calls with <code>parent_ca_id</code> to build out the tree. See
|
||||
<code> docs/intermediate-ca-hierarchy.md</code> for the operator runbook.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tree.length > 0 && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{tree.map(node => (
|
||||
<HierarchyNode
|
||||
key={node.ca.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
retireConfirmFor={retireConfirmFor}
|
||||
setRetireConfirmFor={setRetireConfirmFor}
|
||||
onRetire={(id, note, confirm) => retireMu.mutate({ id, note, confirm })}
|
||||
retireDisabled={retireMu.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HierarchyTreeNode {
|
||||
ca: IntermediateCA;
|
||||
children: HierarchyTreeNode[];
|
||||
}
|
||||
|
||||
// buildHierarchyTree turns the flat list into a parent-child forest by
|
||||
// grouping rows on parent_ca_id. Roots (parent_ca_id null/empty) are
|
||||
// the forest's top level; everything else nests under its parent.
|
||||
function buildHierarchyTree(rows: IntermediateCA[]): HierarchyTreeNode[] {
|
||||
const byID = new Map<string, HierarchyTreeNode>();
|
||||
rows.forEach(row => byID.set(row.id, { ca: row, children: [] }));
|
||||
const roots: HierarchyTreeNode[] = [];
|
||||
rows.forEach(row => {
|
||||
const node = byID.get(row.id)!;
|
||||
if (!row.parent_ca_id) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
const parent = byID.get(row.parent_ca_id);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
// Orphan (parent retired+pruned) — still surface at the top.
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
}
|
||||
|
||||
interface HierarchyNodeProps {
|
||||
node: HierarchyTreeNode;
|
||||
depth: number;
|
||||
retireConfirmFor: string | null;
|
||||
setRetireConfirmFor: (id: string | null) => void;
|
||||
onRetire: (id: string, note: string, confirm: boolean) => void;
|
||||
retireDisabled: boolean;
|
||||
}
|
||||
|
||||
function HierarchyNode({
|
||||
node,
|
||||
depth,
|
||||
retireConfirmFor,
|
||||
setRetireConfirmFor,
|
||||
onRetire,
|
||||
retireDisabled,
|
||||
}: HierarchyNodeProps) {
|
||||
const { ca, children } = node;
|
||||
const isRetiring = ca.state === 'retiring';
|
||||
const isRetired = ca.state === 'retired';
|
||||
const stateBadge =
|
||||
ca.state === 'active'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: ca.state === 'retiring'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-slate-100 text-slate-600';
|
||||
|
||||
return (
|
||||
<li
|
||||
className="rounded-md border border-slate-200 bg-white p-3"
|
||||
style={{ marginLeft: depth * 24 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-slate-500">{ca.id}</span>
|
||||
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${stateBadge}`}>
|
||||
{ca.state}
|
||||
</span>
|
||||
{ca.path_len_constraint !== undefined && ca.path_len_constraint !== null && (
|
||||
<span className="text-xs text-slate-500">path_len={ca.path_len_constraint}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{ca.name}</div>
|
||||
<div className="mt-1 text-xs text-slate-600">{ca.subject}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
valid {formatDateTime(ca.not_before)} → {formatDateTime(ca.not_after)}
|
||||
</div>
|
||||
{ca.name_constraints && ca.name_constraints.length > 0 && (
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
constraints: {ca.name_constraints.flatMap(nc => nc.permitted ?? []).join(', ') || '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isRetired && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{retireConfirmFor === ca.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={retireDisabled}
|
||||
onClick={() => onRetire(ca.id, isRetiring ? 'terminalize' : 'drain', isRetiring)}
|
||||
>
|
||||
{isRetiring ? 'Confirm retire (terminal)' : 'Retire (begin drain)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-300 px-3 py-1 text-xs"
|
||||
onClick={() => setRetireConfirmFor(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-300 px-3 py-1 text-xs hover:bg-slate-100"
|
||||
onClick={() => setRetireConfirmFor(ca.id)}
|
||||
>
|
||||
{isRetiring ? 'Terminalize…' : 'Retire…'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{children.map(child => (
|
||||
<HierarchyNode
|
||||
key={child.ca.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
retireConfirmFor={retireConfirmFor}
|
||||
setRetireConfirmFor={setRetireConfirmFor}
|
||||
onRetire={onRetire}
|
||||
retireDisabled={retireDisabled}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user