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
+2
View File
@@ -156,6 +156,8 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp
**Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`.
**Tree mode (Rank 8 — multi-level CA hierarchy):** When `Issuer.HierarchyMode = "tree"` is set on the issuer row, the local connector reads the active CA hierarchy from the `intermediate_cas` table and assembles `IssuanceResult.ChainPEM` by walking the `parent_ca_id` ancestry from the issuing leaf CA up to the root. Tree mode is operator-managed via the admin-gated `/api/v1/issuers/{id}/intermediates` and `/api/v1/intermediates/{id}` endpoints (`POST` to create / sign children, `GET` to list / inspect, `POST .../retire` to two-phase retire). The signing path is shared with single-mode (cert is signed via `c.caCert` + `c.caSigner` from the on-disk issuing CA cert+key); only the chain bytes differ. RFC 5280 §3.2 (self-signed root validation), §4.2.1.9 (path-length tightening), and §4.2.1.10 (NameConstraints subset semantics) are enforced at the service layer fail-closed. The default is `single`, byte-identical to the pre-Rank-8 historical flow. See `docs/intermediate-ca-hierarchy.md` for the operator runbook covering 4-level FedRAMP boundary CA, 3-level financial-services policy CA, 2-level internal-PKI patterns + the migration runbook for flipping a single-mode issuer to tree.
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`) with 24-hour validity. An embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`) returns signed OCSP responses for issued certificates (good/revoked/unknown status). Both endpoints are reachable by relying parties with no certctl API credentials, which is how standard TLS clients, browsers, and hardware appliances consume these resources. Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA.
+218
View File
@@ -0,0 +1,218 @@
# Intermediate CA hierarchy — operator runbook
Rank 8 of the 2026-05-03 deep-research deliverable. This page is the
canonical reference for operators running certctl as a multi-level
internal PKI.
The default `single`-mode flow (one operator-supplied sub-CA loaded
from disk at boot) is unchanged and will keep working byte-for-byte
forever. This page is for operators who need a real CA tree:
- FedRAMP boundary-CA deployments where the regulator requires
separation of policy and issuing authorities.
- Financial-services policy-CA deployments (one root, one policy CA
per business unit, one issuing CA per environment).
- OT / industrial control networks where the air-gapped root signs
online sub-CAs that go in and out of service on a rotation.
## Concepts
`Issuer.HierarchyMode` is a per-issuer column on the `issuers` table.
Two values are valid (the database default is `"single"` — back-compat
byte-identical for unmigrated rows):
- `single` — pre-Rank-8 historical flow. The local connector loads a
pre-signed CA cert+key from disk via `local.Config.CACertPath` /
`local.Config.CAKeyPath`. Existing operators upgrade with no
behavior change.
- `tree` — the issuer's CAs are managed via the `intermediate_cas`
table. Chain assembly walks the `parent_ca_id` foreign key from the
issuing leaf CA up to the root and attaches the assembled chain to
every `IssuanceResult`.
Each row in `intermediate_cas` is one CA cert (root, policy, issuing).
The lifecycle is `created``active``retiring``retired`. The
state column is a closed enum and validates at the service layer; the
postgres CHECK constraint enforces it at the database layer too.
A CA's private key bytes are NEVER persisted on the row. The
`key_driver_id` column is a reference (filesystem path / KMS key ID /
HSM slot) that the `signer.Driver` resolves at sign time. A SQL
injection or a row-leak surface MUST NEVER expose key bytes; only the
reference can leak.
## Lifecycle states
```
created (CreateRoot or CreateChild)
active (issuing certs)
retiring (drain — children still active; this CA stops issuing
NEW children but existing children continue)
retired (terminal — no issuance, OCSP responder keeps responding
for already-issued leaves until expiry)
```
Drain-first semantics: a CA in `retiring` state cannot terminalize to
`retired` while it still has active children. The service layer
returns `ErrCAStillHasActiveChildren`; the API surfaces HTTP 409. Drain
the children first.
## Common deployment patterns
### Pattern A — 4-level FedRAMP boundary CA
```
Acme Root CA (path_len=3, offline air-gapped)
└── Acme Policy CA (path_len=2, FedRAMP-Moderate boundary)
└── Acme Issuing A (path_len=0, prod workload leaves)
└── Acme Issuing B (path_len=0, ephemeral pod identity)
```
Operator workflow:
1. Mint the root cert+key on the offline workstation. Move the cert
PEM (no key) to the online operator workstation.
2. `POST /api/v1/issuers/{id}/intermediates` with the empty
`parent_ca_id` and `root_cert_pem` + `key_driver_id` populated
(the operator pre-positions the root key file at the path the
`key_driver_id` points to). The service validates RFC 5280 §3.2
self-signed semantics + cross-checks the operator-supplied key
matches the cert (rejects mismatched bundles at registration time
with `ErrCAKeyMismatch`).
3. `POST /api/v1/issuers/{id}/intermediates` with `parent_ca_id`
pointing at the root for the Policy CA. The service generates the
child key via `signer.Driver.Generate`, signs the child cert via
the parent's signer (loaded from the parent's `key_driver_id`),
and persists the new row with the next `path_len` value (parent's
- 1 if unset). Repeat for each lower level.
4. Set `Issuer.HierarchyMode = "tree"` on the issuer row + set the
`treeIssuingCAID` connector field to point at the deepest CA
(Acme Issuing B in the example above) — issued leaves chain via
`AssembleChain` from B up to the root.
### Pattern B — 3-level financial-services policy CA
```
FinCo Root CA (path_len=2)
└── FinCo Trading Policy CA (path_len=1; permitted DNS = trading.finco.example)
└── FinCo Trading Issuing CA (path_len=0)
```
Per business-unit name constraints: each policy CA carries a
`PermittedDNSDomains` list scoped to the business unit (RFC 5280
§4.2.1.10). The service enforces subset semantics — a child policy CA
cannot widen the parent's permitted set, and cannot remove an
excluded subtree. Operators submit `name_constraints` on the
`POST /api/v1/issuers/{id}/intermediates` body.
### Pattern C — 2-level internal PKI
```
Internal Root CA (path_len=0)
└── Internal Issuing CA (path_len=0; issues leaves directly)
```
The simplest tree-mode deployment. Roughly equivalent to single mode
in terms of operator overhead, but provides one extra layer of
indirection so the root key can stay offline while only the issuing
CA's key sits on the certctl host.
## RFC 5280 enforcement
All enforcement happens at the service layer. The local connector
trusts the service's contract; the API layer translates errors to
HTTP codes.
- §3.2 self-signed root validation: `cert.CheckSignatureFrom(cert)` +
subject == issuer DN. Rejected with `ErrCANotSelfSigned`
HTTP 400.
- §4.2.1.9 path-length tightening: child's `PathLenConstraint` must
be strictly less than parent's. Default to `parent - 1` when unset.
Rejected with `ErrPathLenExceeded` → HTTP 400.
- §4.2.1.10 NameConstraints subset: child's `Permitted` set must be a
subset of parent's; child's `Excluded` set must be a superset of
parent's. Rejected with `ErrNameConstraintExceeded` → HTTP 400.
- §4.1.2.5 validity capping: child's `notAfter` capped to parent's
`notAfter` automatically (chain breaks at parent's expiry
regardless).
## Migrating a single-mode issuer to tree mode
Pre-flight: the load-bearing pin
`TestLocal_HierarchyMode_SingleVsTree_ByteIdentical` guarantees that
a 1-level tree wired around the same on-disk root cert+key produces
byte-identical issuance bundles to single mode. Migration is therefore
a no-downtime operation if done carefully:
1. Register the existing single-mode CA cert as an `intermediate_cas`
row via `CreateRoot` (with the existing on-disk key referenced as
`key_driver_id`).
2. Update the issuer row's `hierarchy_mode` to `"tree"` and set the
connector's `SetTreeIssuingCAID` to the new row's ID. Restart the
server (no new code path activates until the connector reads the
updated mode at boot).
3. Issue a test cert. The byte-equivalence pin guarantees the wire
bytes match the pre-migration output for a 1-level tree.
4. Build out the child CAs via `CreateChild` calls. Update
`treeIssuingCAID` to the new leaf CA. Test, then ramp.
If the pin breaks during migration, abort: roll back the
`hierarchy_mode` flip and investigate. The byte-equivalence pin is
the canary — if it goes red, deeper bugs lurk.
## API reference
All endpoints under `/api/v1/issuers/{id}/intermediates` and
`/api/v1/intermediates/{id}` are admin-gated. Non-admin Bearer callers
get HTTP 403.
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/v1/issuers/{id}/intermediates` | Register root OR sign child (body discriminator) |
| GET | `/api/v1/issuers/{id}/intermediates` | List flat hierarchy for issuer |
| GET | `/api/v1/intermediates/{id}` | Single-row detail |
| POST | `/api/v1/intermediates/{id}/retire` | Two-phase retirement |
See `api/openapi.yaml` for full request/response schemas.
## Observability
`IntermediateCAMetrics` ships counters dimensioned by `(issuer_id,
kind)`:
- `create_root` — successful CreateRoot calls.
- `create_child` — successful CreateChild calls.
- `retire_retiring``active → retiring` transitions.
- `retire_retired``retiring → retired` transitions.
The Prometheus exposer reads the snapshot via
`SnapshotIntermediateCA()` from a single instance constructed in
`cmd/server/main.go` (the snapshotter is the single source of truth
between the service-side recording path and the metrics-side exposing
path).
The audit table receives one row per CreateRoot / CreateChild /
Retire transition, scoped to the actor extracted from the API
request's auth context.
## Known limitations
The following are tracked in `WORKSPACE-ROADMAP.md` as Rank-8 follow-on
work — none are required for the v2.1.0 acquisition gate:
- HSM-backed roots beyond `signer.FileDriver` (PKCS#11 / cloud KMS
drivers).
- Automated rotation: scheduled re-issuance of sub-CAs ahead of
expiry with parallel-validity windows.
- Intra-hierarchy CRL chaining: each non-leaf CA publishes a CRL
covering its direct children's revocations.
- NameConstraints policy templates: declarative templates an operator
can pick from instead of hand-rolling the JSON.
- D3 dendrogram visualization on the GUI page (today's render is a
recursive `<ul>` nested list).
@@ -26,13 +26,18 @@
# - ProfilesPage: CRUD form; mirrors PoliciesPage shape (covered)
# - CertificateDetailPage: drill-down view; covered transitively via CertificatesPage
# - IssuerDetailPage: drill-down view; covered transitively via IssuersPage
# - IssuerHierarchyPage: Rank 8 admin-gated hierarchy render; admin gate +
# recursive build tested at the API + service layers
# (intermediate_ca_test.go + intermediate_ca_test.go
# handler triplet); defer Vitest until the next
# feature change touches the page
# - TargetDetailPage: drill-down view; covered transitively via TargetsPage
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-s2-c24a548076c6 for closure rationale.
set -e
ALLOW='^(LoginPage|AuditPage|ShortLivedPage|DigestPage|ObservabilityPage|HealthMonitorPage|NetworkScanPage|JobsPage|JobDetailPage|AgentFleetPage|ProfilesPage|CertificateDetailPage|IssuerDetailPage|TargetDetailPage)$'
ALLOW='^(LoginPage|AuditPage|ShortLivedPage|DigestPage|ObservabilityPage|HealthMonitorPage|NetworkScanPage|JobsPage|JobDetailPage|AgentFleetPage|ProfilesPage|CertificateDetailPage|IssuerDetailPage|IssuerHierarchyPage|TargetDetailPage)$'
UNTESTED=""
for f in web/src/pages/*.tsx; do
base=$(basename "$f" .tsx)
+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>
);
}