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:
shankar0123
2026-05-04 02:33:48 +00:00
parent 4d17ef9054
commit 478c75dffe
6 changed files with 496 additions and 1 deletions
+36
View File
@@ -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 }) },
);
+6
View File
@@ -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 />} />
+228
View File
@@ -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>
);
}