Files
orca/internal/store/cert_repo.go
T
Jon Chery 181cc769e6 feat(P08): CA, CSR, fingerprint, rotation, redact, TLS config + cert repo
Internal CA with CSR join, mTLS 1.3 config builders, rotation alarm,
PEM redaction, and cert inventory schema (REQ-033/034/035/036).

- internal/security/ca.go: CAInit/LoadCA/SignCSR, file mode enforcement
  (ca.crt 0644, ca.key 0600) per REQ-033
- internal/security/csr.go: GenerateCSR with DNS + IP SANs (REQ-036)
- internal/security/fingerprint.go: SHA-256 hex of cert DER
- internal/security/rotation.go: 30d pre-expiry alarm, history pruning
- internal/security/redact.go: PEM private key block stripping (REQ-035)
- internal/security/tls_config.go: TLS 1.3 with AEAD allowlist
- internal/security/certgen_test.go: round-trip + mode + rotation + redact
- internal/store/migrations/0004_certs.sql: cert inventory table
- internal/store/cert_repo.go: CRUD + PruneOlderThan (REQ-025)

---ci---
project: orca
phase: 8
milestone: v0.2
status: execute
---/ci---
2026-06-03 21:18:50 +00:00

180 lines
5.8 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// CertKind enumerates the kinds of certs orca tracks. 'ca' is the
// cluster's internal CA; 'server' is a per-node server cert.
type CertKind string
const (
CertKindCA CertKind = "ca"
CertKindServer CertKind = "server"
)
// Cert is the in-memory representation of a row in the `certs` table.
type Cert struct {
ID string `json:"id"`
Kind CertKind `json:"kind"`
NodeID string `json:"node_id"`
SerialHex string `json:"serial_hex"`
SubjectCN string `json:"subject_cn"`
IssuerCN string `json:"issuer_cn"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
Fingerprint string `json:"fingerprint"`
SourcePath string `json:"source_path,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CertRepo is a CRUD wrapper around the `certs` table.
type CertRepo struct {
db *sql.DB
}
func NewCertRepo(db *sql.DB) *CertRepo {
return &CertRepo{db: db}
}
// Insert persists a new cert. Fills CreatedAt to now() if zero. The caller
// is responsible for setting ID, SerialHex, Fingerprint, etc.
func (r *CertRepo) Insert(ctx context.Context, c *Cert) error {
if c == nil {
return errors.New("CertRepo.Insert: nil cert")
}
if c.ID == "" {
return errors.New("CertRepo.Insert: ID is required")
}
if c.Kind == "" {
return errors.New("CertRepo.Insert: Kind is required")
}
if c.CreatedAt.IsZero() {
c.CreatedAt = time.Now().UTC()
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO certs (id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, string(c.Kind), c.NodeID, c.SerialHex, c.SubjectCN, c.IssuerCN,
c.NotBefore, c.NotAfter, c.Fingerprint, c.SourcePath, c.CreatedAt)
if err != nil {
return fmt.Errorf("CertRepo.Insert: %w", err)
}
return nil
}
// Get returns a single cert by ID. Returns ErrNotFound if absent.
func (r *CertRepo) Get(ctx context.Context, id string) (*Cert, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE id = ?`, id)
return scanCert(row)
}
// List returns all certs ordered by created_at DESC. Use ListByNode /
// LatestForKind for filtered queries.
func (r *CertRepo) List(ctx context.Context) ([]*Cert, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("CertRepo.List: %w", err)
}
defer rows.Close()
var certs []*Cert
for rows.Next() {
c, err := scanCert(rows)
if err != nil {
return nil, err
}
certs = append(certs, c)
}
return certs, rows.Err()
}
// ListByNode returns certs belonging to a node (or matching node_id for the
// CA — CA rows use node_id = ”).
func (r *CertRepo) ListByNode(ctx context.Context, nodeID string) ([]*Cert, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? ORDER BY created_at DESC`, nodeID)
if err != nil {
return nil, fmt.Errorf("CertRepo.ListByNode: %w", err)
}
defer rows.Close()
var certs []*Cert
for rows.Next() {
c, err := scanCert(rows)
if err != nil {
return nil, err
}
certs = append(certs, c)
}
return certs, rows.Err()
}
// LatestForKind returns the most recent cert of the given kind for the given
// node. Returns ErrNotFound if none exists. nodeID may be empty to query
// the cluster-wide CA.
func (r *CertRepo) LatestForKind(ctx context.Context, nodeID string, kind CertKind) (*Cert, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? AND kind = ? ORDER BY created_at DESC LIMIT 1`,
nodeID, string(kind))
return scanCert(row)
}
// PruneOlderThan deletes certs beyond the most recent `keep` rows for
// (nodeID, kind), ordered by created_at DESC. Returns the number of
// rows deleted. `keep` must be > 0; values <= 0 are treated as 1.
func (r *CertRepo) PruneOlderThan(ctx context.Context, nodeID, kind string, keep int) (int64, error) {
if keep <= 0 {
keep = 1
}
// Two-step delete: first find the cutoff created_at, then delete
// everything older. Done in a single transaction via ExecContext.
// modernc/sqlite supports multiple statements in a single Exec only
// via the "multi-statement" pragma; we use a subquery instead.
res, err := r.db.ExecContext(ctx,
`DELETE FROM certs WHERE node_id = ? AND kind = ? AND id NOT IN (
SELECT id FROM certs WHERE node_id = ? AND kind = ?
ORDER BY created_at DESC LIMIT ?
)`,
nodeID, kind, nodeID, kind, keep)
if err != nil {
return 0, fmt.Errorf("CertRepo.PruneOlderThan: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// Delete removes a cert by ID. Returns ErrNotFound if no rows affected.
func (r *CertRepo) Delete(ctx context.Context, id string) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM certs WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("CertRepo.Delete: %w", err)
}
rows, _ := res.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
}
func scanCert(s scanner) (*Cert, error) {
var (
c Cert
kindStr string
)
err := s.Scan(&c.ID, &kindStr, &c.NodeID, &c.SerialHex, &c.SubjectCN, &c.IssuerCN,
&c.NotBefore, &c.NotAfter, &c.Fingerprint, &c.SourcePath, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("scan cert: %w", err)
}
c.Kind = CertKind(kindStr)
return &c, nil
}