181cc769e6
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---
180 lines
5.8 KiB
Go
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
|
|
}
|