Files
certctl/internal/service/intermediate_ca_metrics.go
T
shankar0123 fb54ebcb62 service: IntermediateCAService + IntermediateCAMetrics + RFC 5280 enforcement
Rank 8 of the 2026-05-03 deep-research deliverable, commit 2 of 5.
Service-layer wiring for first-class N-level CA hierarchy management.
The connector rewrite that activates this surface lands in commit 3.

Files added:
  internal/service/intermediate_ca.go          — IntermediateCAService
                                                  with 6 methods:
                                                    CreateRoot:
                                                      registers operator-
                                                      supplied root cert+key
                                                      reference. Validates
                                                      RFC 5280 §3.2 self-
                                                      signed (subject ==
                                                      issuer + signature
                                                      verifies). Cross-
                                                      checks the supplied
                                                      keyDriverID resolves
                                                      to a signer whose
                                                      public key matches
                                                      the cert (rejects
                                                      mismatched bundles
                                                      at registration
                                                      time, not at first
                                                      CreateChild — the
                                                      ErrCAKeyMismatch
                                                      sentinel).
                                                    CreateChild:
                                                      generates child key
                                                      via signer.Driver,
                                                      signs the cert via
                                                      the parent's signer.
                                                      Enforces RFC 5280
                                                      §4.2.1.9 (path-len
                                                      tightening) +
                                                      §4.2.1.10
                                                      (NameConstraints
                                                      subset semantics) at
                                                      service layer fail-
                                                      closed. Defaults
                                                      child path-len to
                                                      parent-1 when
                                                      unset; caps child
                                                      validity at parent's
                                                      not_after (RFC 5280
                                                      §4.1.2.5).
                                                    Retire: two-phase
                                                      drain — first call
                                                      active → retiring,
                                                      second call (with
                                                      confirm=true)
                                                      retiring → retired.
                                                      Refuses retired
                                                      transition if active
                                                      children still exist
                                                      (the
                                                      ErrCAStillHasActiveChildren
                                                      sentinel — drain-
                                                      first semantics).
                                                    Get / LoadHierarchy:
                                                      thin repo wrappers.
                                                    AssembleChain: walks
                                                      WalkAncestry (the
                                                      recursive CTE
                                                      shipped in commit 1)
                                                      and returns the
                                                      leaf-to-root PEM
                                                      bundle for the
                                                      local connector to
                                                      attach to
                                                      IssuanceResult.

  internal/service/intermediate_ca_metrics.go  — IntermediateCAMetrics:
                                                  per-(issuer_id, kind)
                                                  counter, mirrors the
                                                  ApprovalMetrics +
                                                  ExpiryAlertMetrics
                                                  pattern. RecordCreate
                                                  (root/child) +
                                                  RecordRetire
                                                  (retiring/retired).
                                                  SnapshotIntermediateCA
                                                  for the Prometheus
                                                  exposer.

Defense in depth retained:
  - NEVER persist CA private key bytes in the row. KeyDriverID is the
    only key reference; signer.Driver.Load resolves it at signing time.
  - The Driver interface has 3 methods (Load/Generate/Name) — no
    Import surface. CreateRoot accepts a pre-positioned KeyDriverID
    rather than raw key bytes; the operator owns where the root key
    physically lives. Future PKCS11Driver / CloudKMSDriver close the
    file-on-disk leg without touching this service.

Verified locally:
  gofmt: clean.
  go vet ./internal/service/...: exit 0.
  go build ./internal/service/...: exit 0.

Deferred to commit 2.5 (or fold into commit 3, operator's call):
  - 9 service-level tests including:
    * TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
    * TestIntermediateCA_CreateRoot_RejectsNonSelfSigned
    * TestIntermediateCA_CreateRoot_RejectsKeyMismatch
    * TestIntermediateCA_CreateChild_PathLenTighteningEnforced
    * TestIntermediateCA_CreateChild_NameConstraintsSubset
    * TestIntermediateCA_AssembleChain_4DeepHierarchy ← LOAD-BEARING
    * TestIntermediateCA_Retire_RefusesIfActiveChildren
    * TestIntermediateCA_Retire_TwoPhaseConfirm
    * TestIntermediateCA_MetricsRecordedPerOutcome

  Test setup needs: in-memory IntermediateCARepository fake +
  signer.MemoryDriver (already exists) + helper to generate test root
  cert+key. Fake repo's WalkAncestry implementation needs to mirror
  the recursive-CTE semantics for the AssembleChain pin to be
  meaningful. Total ~500 lines of test code; non-trivial setup.

Out of scope of THIS commit (commits 3-5):
  - Local connector rewrite + byte-equivalence pin
    (TestLocal_HierarchyMode_SingleVsTree_ByteIdentical).
  - 4 admin-gated handler endpoints + OpenAPI extension.
  - web/src/pages/IssuerHierarchyPage.tsx.
  - docs/intermediate-ca-hierarchy.md sysadmin runbook.
  - cmd/server/main.go wiring.

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md.
2026-05-04 01:58:26 +00:00

105 lines
2.7 KiB
Go

package service
import (
"sort"
"sync"
"sync/atomic"
)
// IntermediateCAMetrics is a thread-safe counter table for the CA-
// hierarchy management surface (Rank 8). Mirrors the
// ApprovalMetrics + ExpiryAlertMetrics shape: cmd/server/main.go
// constructs ONE instance, passes it to IntermediateCAService
// (recording side) AND metricsHandler (exposing side) so the
// snapshotter is the single source of truth.
//
// Dimensions:
//
// issuer_id — owning issuer (bounded cardinality; operators have
// <100 issuers in production).
// kind — closed enum:
// "create_root" — CreateRoot succeeded.
// "create_child" — CreateChild succeeded.
// "retire_<state>" — Retire transitioned state.
type IntermediateCAMetrics struct {
mu sync.RWMutex
counters map[intermediateCAKey]*atomic.Uint64
}
type intermediateCAKey struct {
IssuerID string
Kind string
}
// NewIntermediateCAMetrics returns a zero-value instance ready for
// concurrent use.
func NewIntermediateCAMetrics() *IntermediateCAMetrics {
return &IntermediateCAMetrics{
counters: make(map[intermediateCAKey]*atomic.Uint64),
}
}
// RecordCreate bumps the create-counter. role ∈ {"root", "child"}.
func (m *IntermediateCAMetrics) RecordCreate(issuerID, role string) {
m.bump(issuerID, "create_"+role)
}
// RecordRetire bumps the retire-counter. newState ∈
// {"retiring", "retired"}.
func (m *IntermediateCAMetrics) RecordRetire(issuerID, newState string) {
m.bump(issuerID, "retire_"+newState)
}
func (m *IntermediateCAMetrics) bump(issuerID, kind string) {
if m == nil {
return
}
key := intermediateCAKey{IssuerID: issuerID, Kind: kind}
m.mu.RLock()
c, ok := m.counters[key]
m.mu.RUnlock()
if !ok {
m.mu.Lock()
c, ok = m.counters[key]
if !ok {
c = &atomic.Uint64{}
m.counters[key] = c
}
m.mu.Unlock()
}
c.Add(1)
}
// IntermediateCAEntry is a single row of the SnapshotIntermediateCA
// output.
type IntermediateCAEntry struct {
IssuerID string
Kind string
Count uint64
}
// SnapshotIntermediateCA returns the current counter table sorted by
// (issuer_id, kind) for deterministic Prometheus exposition.
func (m *IntermediateCAMetrics) SnapshotIntermediateCA() []IntermediateCAEntry {
if m == nil {
return nil
}
m.mu.RLock()
out := make([]IntermediateCAEntry, 0, len(m.counters))
for k, c := range m.counters {
out = append(out, IntermediateCAEntry{
IssuerID: k.IssuerID,
Kind: k.Kind,
Count: c.Load(),
})
}
m.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].IssuerID != out[j].IssuerID {
return out[i].IssuerID < out[j].IssuerID
}
return out[i].Kind < out[j].Kind
})
return out
}