From 468b75c650702a61f0dbe9b4f4a735c397c5cff4 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Mon, 4 May 2026 01:53:56 +0000 Subject: [PATCH] domain, migrations: IntermediateCA type + intermediate_cas + Issuer.HierarchyMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rank 8 of the 2026-05-03 deep-research deliverable, commit 1 of 5 (cowork/rank-8-intermediate-ca-hierarchy-prompt.md). Closes the multi- level CA hierarchy gap for FedRAMP boundary-CA, financial-services policy-CA, and OT network-CA deployments where regulator-mandated certificate-policy separation requires multiple layers (root → policy → issuing). This commit lands ONLY the foundation — schema, types, repository interface, postgres implementation. No service / connector / handler wiring yet. The 5-commit chain is bisectable: this commit can ship with no operator-visible behavior change until commits 2-5 wire the service layer + the local-connector tree-mode + admin API + GUI tree view + operator runbook. The default value for issuers.hierarchy_mode is 'single' so every existing operator's behavior is byte-identical post-migration. Existing scaffolding REUSED (not redefined): - internal/crypto/signer.Driver seam — every IntermediateCA carries a key_driver_id pointing at the signer.Driver instance that owns its private key. Defense in depth: NEVER persist key bytes in a row. FileDriver is the production default; future PKCS11Driver / CloudKMSDriver close the disk-exposure leg via the same seam. - issuers.id row — the new intermediate_cas FK references it. Files added: internal/domain/intermediate_ca.go — IntermediateCA type, IntermediateCAState closed enum (active / retiring / retired), IsValidIntermediateCAState + IsTerminal helpers, NameConstraint struct (RFC 5280 §4.2.1.10 permitted+excluded subtree subset semantics for service- layer enforcement), HierarchyModeSingle / HierarchyModeTree constants. internal/repository/postgres/intermediate_ca.go — IntermediateCARepository impl: Create (ica- ID gen, JSONB + nullable-column round- trip, lib/pq 23505 → ErrAlreadyExists), Get, ListByIssuer, ListChildren, UpdateState, GetActiveRoot, WalkAncestry (recursive CTE — single SQL round-trip, O(depth) rows, leaf-first ordering). migrations/000028_intermediate_ca_hierarchy.{up,down}.sql — idempotent schema. issuers.hierarchy_mode VARCHAR(20) DEFAULT 'single'. New intermediate_cas table with FKs to issuers / self (parent_ca_id) + CHECK constraints (closed-enum state, not_after > not_before, no self- parent) + 6 indexes (partial-unique active root per issuer, partial- unique name per issuer, owning issuer, parent, state, expiring). Files modified: internal/domain/connector.go — adds Issuer.HierarchyMode field with full doc comment + JSON tag. Empty string ≡ single mode for back-compat. internal/repository/interfaces.go — adds IntermediateCARepository interface (7 methods). Verified locally: gofmt: clean. go vet ./internal/domain/... ./internal/repository/...: exit 0. go build ./internal/domain/... ./internal/repository/...: exit 0. Out of scope for this commit (lands in commits 2-5): - service/intermediate_ca.go (CreateRoot / CreateChild / Retire / LoadHierarchy / AssembleChain + RFC 5280 §4.2.1.9 path-len + §4.2.1.10 NameConstraints subset enforcement + 9 service tests). - local connector rewrite + byte-equivalence pin (TestLocal_HierarchyMode_SingleVsTree_ByteIdentical — the load- bearing backwards-compat refusal-to-ship test). - 4 admin-gated handler endpoints + OpenAPI extension + handler tests. - web/src/pages/IssuerHierarchyPage.tsx. - docs/intermediate-ca-hierarchy.md sysadmin runbook + connectors.md row + WORKSPACE-ROADMAP follow-ons. Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md. --- internal/domain/connector.go | 16 +- internal/domain/intermediate_ca.go | 115 +++++++ internal/repository/interfaces.go | 22 ++ .../repository/postgres/intermediate_ca.go | 297 ++++++++++++++++++ .../000028_intermediate_ca_hierarchy.down.sql | 15 + .../000028_intermediate_ca_hierarchy.up.sql | 68 ++++ 6 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 internal/domain/intermediate_ca.go create mode 100644 internal/repository/postgres/intermediate_ca.go create mode 100644 migrations/000028_intermediate_ca_hierarchy.down.sql create mode 100644 migrations/000028_intermediate_ca_hierarchy.up.sql diff --git a/internal/domain/connector.go b/internal/domain/connector.go index f2fd232..6b14846 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -16,8 +16,20 @@ type Issuer struct { LastTestedAt *time.Time `json:"last_tested_at,omitempty"` TestStatus string `json:"test_status,omitempty"` Source string `json:"source,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + + // HierarchyMode picks the per-issuer CA-hierarchy posture for the + // local issuer adapter. "single" (default, pre-Rank-8 historical) + // loads a pre-signed cert+key from disk via local.Config.CACertPath + // / local.Config.CAKeyPath. "tree" activates first-class N-level + // hierarchy management via the intermediate_cas table; chain + // assembly walks parent_ca_id from the issuing leaf-CA up to the + // root at issuance time. Empty string ≡ HierarchyModeSingle for + // back-compat byte-identical behavior on unmigrated rows. Backed + // by issuers.hierarchy_mode added in migration 000028. + HierarchyMode string `json:"hierarchy_mode,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // DeploymentTarget represents a target system where certificates are deployed. diff --git a/internal/domain/intermediate_ca.go b/internal/domain/intermediate_ca.go new file mode 100644 index 0000000..feb42c9 --- /dev/null +++ b/internal/domain/intermediate_ca.go @@ -0,0 +1,115 @@ +package domain + +import "time" + +// IntermediateCA represents a non-root CA in a multi-level hierarchy. +// One row per certificate (root, policy, issuing) — the parent_ca_id +// FK to itself encodes the tree shape; the owning_issuer_id FK groups +// every CA under one Issuer config row. +// +// Lifecycle: +// +// 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) +// +// Closes the multi-level CA hierarchy gap for FedRAMP boundary-CA, +// financial-services policy-CA, and OT network-CA deployments where +// regulator-mandated certificate-policy separation requires multiple +// layers (root → policy → issuing). +// +// Defense in depth: NEVER persist the CA private key bytes in this +// row. KeyDriverID is a reference (filesystem path / KMS key ID / +// HSM slot) to the signer.Driver instance that owns the key. A SQL- +// injection or row-leak surface must NEVER expose key bytes; only +// the reference can leak. +type IntermediateCA struct { + ID string `json:"id"` // ica- + OwningIssuerID string `json:"owning_issuer_id"` // FK issuers.id + ParentCAID *string `json:"parent_ca_id,omitempty"` // nil for root, FK to self otherwise + Name string `json:"name"` // operator-supplied label + Subject string `json:"subject"` // distinguished name (CN + O + OU + ...) + State IntermediateCAState `json:"state"` // active / retiring / retired + CertPEM string `json:"cert_pem"` // this CA's cert (PEM) + KeyDriverID string `json:"key_driver_id"` // signer.Driver instance ID + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` + PathLenConstraint *int `json:"path_len_constraint,omitempty"` // RFC 5280 §4.2.1.9; nil = no constraint + NameConstraints []NameConstraint `json:"name_constraints,omitempty"` // RFC 5280 §4.2.1.10 + OCSPResponderURL string `json:"ocsp_responder_url,omitempty"` // AIA stamping for issued leaves + Metadata map[string]string `json:"metadata,omitempty"` // policy_id, compliance_tier, owner_team + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// IntermediateCAState is the closed enum of CA-row lifecycle states. +type IntermediateCAState string + +const ( + // IntermediateCAStateActive is the issuing state — the CA can sign + // new children + new leaves under it. + IntermediateCAStateActive IntermediateCAState = "active" + + // IntermediateCAStateRetiring is the drain state — no new children; + // existing children keep issuing until they themselves retire. + IntermediateCAStateRetiring IntermediateCAState = "retiring" + + // IntermediateCAStateRetired is the terminal state — no issuance + // at all; OCSP responder keeps responding for already-issued leaves + // until natural expiry. + IntermediateCAStateRetired IntermediateCAState = "retired" +) + +// IsValidIntermediateCAState reports whether s is a closed-enum value. +func IsValidIntermediateCAState(s IntermediateCAState) bool { + switch s { + case IntermediateCAStateActive, IntermediateCAStateRetiring, IntermediateCAStateRetired: + return true + } + return false +} + +// IsTerminal reports whether s is the immutable terminal state. +func (s IntermediateCAState) IsTerminal() bool { + return s == IntermediateCAStateRetired +} + +// NameConstraint encodes RFC 5280 §4.2.1.10 — Permitted + Excluded +// subtrees. Critical extension when set on the CA cert; the local +// adapter renders this onto the CA's cert at CreateChild time. The +// service layer enforces subset semantics: a child's permitted set +// MUST be a subset of the parent's permitted set + the child's +// excluded set MUST be a superset of the parent's excluded set. +type NameConstraint struct { + Permitted []string `json:"permitted,omitempty"` // e.g., "example.com" → all DNS subtrees ending in example.com + Excluded []string `json:"excluded,omitempty"` +} + +// HierarchyMode picks the per-issuer CA-hierarchy posture, stored on +// the Issuer row. Three values are possible (the database default is +// "single" — back-compat byte-identical for unmigrated rows): +// +// - HierarchyModeSingle (default, pre-Rank-8 historical) — sub-CA +// mode loads a pre-signed cert+key from disk via local.Config. +// CACertPath / local.Config.CAKeyPath. Existing operators upgrade +// with no behavior change. +// - HierarchyModeTree — the issuer's CAs are managed via the +// intermediate_cas table; chain assembly walks the parent_ca_id +// FK from the issuing leaf-CA up to the root + attaches the +// assembled chain to every IssuanceResult. +// +// The local connector reads this from the Issuer row at issue time; +// empty string is treated as HierarchyModeSingle. +const ( + HierarchyModeSingle = "single" + HierarchyModeTree = "tree" +) diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index 7e926c5..b63ce13 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -770,3 +770,25 @@ type ApprovalFilter struct { // PerPage is the number of results per page. PerPage int } + +// IntermediateCARepository defines operations for managing first-class +// CA hierarchies (Rank 8). Every non-root CA is a row, parent_ca_id +// encodes the tree, WalkAncestry returns the leaf-to-root chain via +// a recursive CTE. +// +// Defense in depth: NEVER persist CA private key bytes. The +// implementation stores key_driver_id (a signer.Driver reference) only. +type IntermediateCARepository interface { + Create(ctx context.Context, ca *domain.IntermediateCA) error + Get(ctx context.Context, id string) (*domain.IntermediateCA, error) + ListByIssuer(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) + ListChildren(ctx context.Context, parentCAID string) ([]*domain.IntermediateCA, error) + UpdateState(ctx context.Context, id string, state domain.IntermediateCAState) error + GetActiveRoot(ctx context.Context, issuerID string) (*domain.IntermediateCA, error) + // WalkAncestry returns the chain from leafID up to (and including) + // the root via a postgres recursive CTE. The slice is ordered + // leaf-first; caller verifies the last element has parent_ca_id + // IS NULL (i.e., it's a root). Returns ErrNotFound if leafID does + // not exist. + WalkAncestry(ctx context.Context, leafID string) ([]*domain.IntermediateCA, error) +} diff --git a/internal/repository/postgres/intermediate_ca.go b/internal/repository/postgres/intermediate_ca.go new file mode 100644 index 0000000..053254e --- /dev/null +++ b/internal/repository/postgres/intermediate_ca.go @@ -0,0 +1,297 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/certctl-io/certctl/internal/domain" + "github.com/certctl-io/certctl/internal/repository" +) + +// IntermediateCARepository is the postgres implementation of +// repository.IntermediateCARepository. Rank 8 first-class CA +// hierarchy. +type IntermediateCARepository struct { + db *sql.DB +} + +// NewIntermediateCARepository constructs an IntermediateCARepository +// against the given *sql.DB. Schema defined by migration +// 000028_intermediate_ca_hierarchy.up.sql. +func NewIntermediateCARepository(db *sql.DB) *IntermediateCARepository { + return &IntermediateCARepository{db: db} +} + +// Create inserts a new IntermediateCA row. +func (r *IntermediateCARepository) Create(ctx context.Context, ca *domain.IntermediateCA) error { + if ca.ID == "" { + ca.ID = "ica-" + uuid.NewString() + } + if ca.State == "" { + ca.State = domain.IntermediateCAStateActive + } + if !domain.IsValidIntermediateCAState(ca.State) { + return fmt.Errorf("invalid intermediate CA state %q", ca.State) + } + now := time.Now().UTC() + if ca.CreatedAt.IsZero() { + ca.CreatedAt = now + } + if ca.UpdatedAt.IsZero() { + ca.UpdatedAt = now + } + + nameConstraintsJSON, err := json.Marshal(ca.NameConstraints) + if err != nil { + return fmt.Errorf("marshal name_constraints: %w", err) + } + if len(nameConstraintsJSON) == 0 || string(nameConstraintsJSON) == "null" { + nameConstraintsJSON = []byte("[]") + } + metadataJSON, err := json.Marshal(ca.Metadata) + if err != nil { + return fmt.Errorf("marshal metadata: %w", err) + } + if len(metadataJSON) == 0 || string(metadataJSON) == "null" { + metadataJSON = []byte("{}") + } + + const q = ` + INSERT INTO intermediate_cas + (id, owning_issuer_id, parent_ca_id, name, subject, state, + cert_pem, key_driver_id, not_before, not_after, + path_len_constraint, name_constraints, ocsp_responder_url, + metadata, created_at, updated_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + ` + _, err = r.db.ExecContext(ctx, q, + ca.ID, ca.OwningIssuerID, ca.ParentCAID, ca.Name, ca.Subject, string(ca.State), + ca.CertPEM, ca.KeyDriverID, ca.NotBefore, ca.NotAfter, + ca.PathLenConstraint, nameConstraintsJSON, nullIfEmpty(ca.OCSPResponderURL), + metadataJSON, ca.CreatedAt, ca.UpdatedAt, + ) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" { // unique_violation + return repository.ErrAlreadyExists + } + return fmt.Errorf("insert intermediate CA: %w", err) + } + return nil +} + +// Get returns the row by ID or repository.ErrNotFound. +func (r *IntermediateCARepository) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) { + const q = ` + SELECT id, owning_issuer_id, parent_ca_id, name, subject, state, + cert_pem, key_driver_id, not_before, not_after, + path_len_constraint, name_constraints, ocsp_responder_url, + metadata, created_at, updated_at + FROM intermediate_cas + WHERE id = $1 + ` + row := r.db.QueryRowContext(ctx, q, id) + return scanIntermediateCARow(row) +} + +// ListByIssuer returns every CA row for an issuer. +func (r *IntermediateCARepository) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) { + const q = ` + SELECT id, owning_issuer_id, parent_ca_id, name, subject, state, + cert_pem, key_driver_id, not_before, not_after, + path_len_constraint, name_constraints, ocsp_responder_url, + metadata, created_at, updated_at + FROM intermediate_cas + WHERE owning_issuer_id = $1 + ORDER BY created_at ASC + ` + rows, err := r.db.QueryContext(ctx, q, issuerID) + if err != nil { + return nil, fmt.Errorf("list intermediate CAs: %w", err) + } + defer rows.Close() + return scanIntermediateCARows(rows) +} + +// ListChildren returns direct children of the given CA. +func (r *IntermediateCARepository) ListChildren(ctx context.Context, parentCAID string) ([]*domain.IntermediateCA, error) { + const q = ` + SELECT id, owning_issuer_id, parent_ca_id, name, subject, state, + cert_pem, key_driver_id, not_before, not_after, + path_len_constraint, name_constraints, ocsp_responder_url, + metadata, created_at, updated_at + FROM intermediate_cas + WHERE parent_ca_id = $1 + ORDER BY created_at ASC + ` + rows, err := r.db.QueryContext(ctx, q, parentCAID) + if err != nil { + return nil, fmt.Errorf("list children: %w", err) + } + defer rows.Close() + return scanIntermediateCARows(rows) +} + +// UpdateState transitions a row's lifecycle state. +func (r *IntermediateCARepository) UpdateState(ctx context.Context, id string, state domain.IntermediateCAState) error { + if !domain.IsValidIntermediateCAState(state) { + return fmt.Errorf("invalid state %q", state) + } + const q = ` + UPDATE intermediate_cas + SET state = $2, updated_at = NOW() + WHERE id = $1 + ` + res, err := r.db.ExecContext(ctx, q, id, string(state)) + if err != nil { + return fmt.Errorf("update state: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if n == 0 { + return repository.ErrNotFound + } + return nil +} + +// GetActiveRoot returns the active root CA for an issuer. +func (r *IntermediateCARepository) GetActiveRoot(ctx context.Context, issuerID string) (*domain.IntermediateCA, error) { + const q = ` + SELECT id, owning_issuer_id, parent_ca_id, name, subject, state, + cert_pem, key_driver_id, not_before, not_after, + path_len_constraint, name_constraints, ocsp_responder_url, + metadata, created_at, updated_at + FROM intermediate_cas + WHERE owning_issuer_id = $1 + AND parent_ca_id IS NULL + AND state = 'active' + LIMIT 1 + ` + row := r.db.QueryRowContext(ctx, q, issuerID) + return scanIntermediateCARow(row) +} + +// WalkAncestry returns leaf-to-root chain via recursive CTE. Single +// SQL round-trip, O(depth) rows. +func (r *IntermediateCARepository) WalkAncestry(ctx context.Context, leafID string) ([]*domain.IntermediateCA, error) { + const q = ` + WITH RECURSIVE ancestry AS ( + SELECT id, owning_issuer_id, parent_ca_id, name, subject, state, + cert_pem, key_driver_id, not_before, not_after, + path_len_constraint, name_constraints, ocsp_responder_url, + metadata, created_at, updated_at, 0 AS depth + FROM intermediate_cas + WHERE id = $1 + + UNION ALL + + SELECT i.id, i.owning_issuer_id, i.parent_ca_id, i.name, i.subject, i.state, + i.cert_pem, i.key_driver_id, i.not_before, i.not_after, + i.path_len_constraint, i.name_constraints, i.ocsp_responder_url, + i.metadata, i.created_at, i.updated_at, a.depth + 1 + FROM intermediate_cas i + JOIN ancestry a ON i.id = a.parent_ca_id + ) + SELECT id, owning_issuer_id, parent_ca_id, name, subject, state, + cert_pem, key_driver_id, not_before, not_after, + path_len_constraint, name_constraints, ocsp_responder_url, + metadata, created_at, updated_at + FROM ancestry + ORDER BY depth ASC + ` + rows, err := r.db.QueryContext(ctx, q, leafID) + if err != nil { + return nil, fmt.Errorf("walk ancestry: %w", err) + } + defer rows.Close() + out, err := scanIntermediateCARows(rows) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, repository.ErrNotFound + } + return out, nil +} + +// scanIntermediateCARow scans a single row. +func scanIntermediateCARow(row rowScanner) (*domain.IntermediateCA, error) { + var ( + ca domain.IntermediateCA + stateStr string + parentCAID sql.NullString + pathLenConstraint sql.NullInt64 + ocspResponderURL sql.NullString + nameConstraintsJSON []byte + metadataJSON []byte + ) + err := row.Scan( + &ca.ID, &ca.OwningIssuerID, &parentCAID, &ca.Name, &ca.Subject, &stateStr, + &ca.CertPEM, &ca.KeyDriverID, &ca.NotBefore, &ca.NotAfter, + &pathLenConstraint, &nameConstraintsJSON, &ocspResponderURL, + &metadataJSON, &ca.CreatedAt, &ca.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, repository.ErrNotFound + } + return nil, fmt.Errorf("scan intermediate CA: %w", err) + } + ca.State = domain.IntermediateCAState(stateStr) + if parentCAID.Valid { + s := parentCAID.String + ca.ParentCAID = &s + } + if pathLenConstraint.Valid { + v := int(pathLenConstraint.Int64) + ca.PathLenConstraint = &v + } + if ocspResponderURL.Valid { + ca.OCSPResponderURL = ocspResponderURL.String + } + if len(nameConstraintsJSON) > 0 { + if err := json.Unmarshal(nameConstraintsJSON, &ca.NameConstraints); err != nil { + return nil, fmt.Errorf("unmarshal name_constraints: %w", err) + } + } + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &ca.Metadata); err != nil { + return nil, fmt.Errorf("unmarshal metadata: %w", err) + } + } + return &ca, nil +} + +func scanIntermediateCARows(rows *sql.Rows) ([]*domain.IntermediateCA, error) { + var out []*domain.IntermediateCA + for rows.Next() { + ca, err := scanIntermediateCARow(rows) + if err != nil { + return nil, err + } + out = append(out, ca) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } + return out, nil +} + +// nullIfEmpty returns sql.NullString — Valid=false when s is empty so +// the column is written as SQL NULL rather than empty string. +func nullIfEmpty(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} diff --git a/migrations/000028_intermediate_ca_hierarchy.down.sql b/migrations/000028_intermediate_ca_hierarchy.down.sql new file mode 100644 index 0000000..77b893c --- /dev/null +++ b/migrations/000028_intermediate_ca_hierarchy.down.sql @@ -0,0 +1,15 @@ +-- 000028_intermediate_ca_hierarchy.down.sql — reverse of the up migration. +-- Drops the intermediate_cas table + its indexes + the hierarchy_mode +-- column on issuers. Idempotent (IF EXISTS everywhere). + +DROP INDEX IF EXISTS idx_intermediate_ca_expiring; +DROP INDEX IF EXISTS idx_intermediate_ca_state; +DROP INDEX IF EXISTS idx_intermediate_ca_parent; +DROP INDEX IF EXISTS idx_intermediate_ca_owning_issuer; +DROP INDEX IF EXISTS idx_intermediate_ca_unique_name_per_issuer; +DROP INDEX IF EXISTS idx_intermediate_ca_active_root_per_issuer; + +DROP TABLE IF EXISTS intermediate_cas; + +ALTER TABLE issuers + DROP COLUMN IF EXISTS hierarchy_mode; diff --git a/migrations/000028_intermediate_ca_hierarchy.up.sql b/migrations/000028_intermediate_ca_hierarchy.up.sql new file mode 100644 index 0000000..c9595b5 --- /dev/null +++ b/migrations/000028_intermediate_ca_hierarchy.up.sql @@ -0,0 +1,68 @@ +-- 000028_intermediate_ca_hierarchy.up.sql +-- Rank 8: first-class N-level CA hierarchy management. Closes the +-- FedRAMP / financial-services / OT-network "policy CA in the middle" +-- deployment shape. intermediate_cas captures every non-root CA in +-- the hierarchy with a self-referential parent_ca_id FK; issuers. +-- hierarchy_mode toggles the new code-path behind a flag. +-- +-- All operations use IF NOT EXISTS / IF EXISTS so the migration is +-- idempotent — safe to re-run on every certctl-server boot per the +-- "Idempotent migrations" architecture decision in CLAUDE.md. +-- +-- Defense in depth: NEVER persist CA private key bytes. The +-- key_driver_id column is a reference (filesystem path / KMS key ID +-- / HSM slot) to the signer.Driver instance that owns the key. + +ALTER TABLE issuers + ADD COLUMN IF NOT EXISTS hierarchy_mode VARCHAR(20) NOT NULL DEFAULT 'single'; + +CREATE TABLE IF NOT EXISTS intermediate_cas ( + id TEXT PRIMARY KEY, + owning_issuer_id TEXT NOT NULL REFERENCES issuers(id) ON DELETE RESTRICT, + parent_ca_id TEXT REFERENCES intermediate_cas(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + subject TEXT NOT NULL, + state VARCHAR(20) NOT NULL DEFAULT 'active', + cert_pem TEXT NOT NULL, + key_driver_id TEXT NOT NULL, + not_before TIMESTAMPTZ NOT NULL, + not_after TIMESTAMPTZ NOT NULL, + path_len_constraint INT, + name_constraints JSONB NOT NULL DEFAULT '[]'::jsonb, + ocsp_responder_url TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT intermediate_ca_state_check CHECK ( + state IN ('active', 'retiring', 'retired') + ), + CONSTRAINT intermediate_ca_validity_check CHECK ( + not_after > not_before + ), + CONSTRAINT intermediate_ca_no_self_parent CHECK ( + parent_ca_id IS NULL OR parent_ca_id <> id + ) +); + +-- Partial-unique: at most one ACTIVE root per issuer. A root is a row +-- with parent_ca_id IS NULL (it has no parent in the hierarchy); +-- multiple retired roots can coexist for audit history. +CREATE UNIQUE INDEX IF NOT EXISTS idx_intermediate_ca_active_root_per_issuer + ON intermediate_cas(owning_issuer_id) + WHERE parent_ca_id IS NULL AND state = 'active'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_intermediate_ca_unique_name_per_issuer + ON intermediate_cas(owning_issuer_id, name); + +CREATE INDEX IF NOT EXISTS idx_intermediate_ca_owning_issuer + ON intermediate_cas(owning_issuer_id); + +CREATE INDEX IF NOT EXISTS idx_intermediate_ca_parent + ON intermediate_cas(parent_ca_id); + +CREATE INDEX IF NOT EXISTS idx_intermediate_ca_state + ON intermediate_cas(state); + +CREATE INDEX IF NOT EXISTS idx_intermediate_ca_expiring + ON intermediate_cas(not_after) WHERE state = 'active';