package postgres import ( "context" "database/sql" "errors" "fmt" "github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/certctl/internal/repository" ) // OCSPResponseCacheRepository implements repository.OCSPResponseCacheRepository // using PostgreSQL. // // Schema: see migrations/000024_ocsp_response_cache.up.sql. The cache // stores one row per (issuer_id, serial_hex) — the composite primary // key collapses upserts to ON CONFLICT DO UPDATE. The response DER // blob lives in BYTEA — typical sizes are a few hundred bytes for a // single-cert response (one OCSP response wraps one cert; a request // for cert+chain typically issues separate responses). // // Production hardening II Phase 2. type OCSPResponseCacheRepository struct { db *sql.DB } // NewOCSPResponseCacheRepository creates a new repository. func NewOCSPResponseCacheRepository(db *sql.DB) *OCSPResponseCacheRepository { return &OCSPResponseCacheRepository{db: db} } // Compile-time interface check. var _ repository.OCSPResponseCacheRepository = (*OCSPResponseCacheRepository)(nil) // Get returns the cached OCSP response for (issuer, serial). Returns // (nil, nil) on miss so the caller can fall through to live signing // + a write-back via Put (read-through pattern). func (r *OCSPResponseCacheRepository) Get(ctx context.Context, issuerID, serialHex string) (*domain.OCSPResponseCacheEntry, error) { const query = ` SELECT issuer_id, serial_hex, response_der, cert_status, COALESCE(revocation_reason, 0), COALESCE(revoked_at, '0001-01-01 00:00:00 UTC'::timestamptz), this_update, next_update, generated_at FROM ocsp_response_cache WHERE issuer_id = $1 AND serial_hex = $2` var e domain.OCSPResponseCacheEntry err := r.db.QueryRowContext(ctx, query, issuerID, serialHex).Scan( &e.IssuerID, &e.SerialHex, &e.ResponseDER, &e.CertStatus, &e.RevocationReason, &e.RevokedAt, &e.ThisUpdate, &e.NextUpdate, &e.GeneratedAt, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("OCSPResponseCacheRepository.Get: %w", err) } return &e, nil } // Put upserts the cache row for (issuer, serial). The composite PK // collapses repeat-writes to ON CONFLICT DO UPDATE (matches the // crl_cache pattern in 000019). func (r *OCSPResponseCacheRepository) Put(ctx context.Context, e *domain.OCSPResponseCacheEntry) error { const stmt = ` INSERT INTO ocsp_response_cache ( issuer_id, serial_hex, response_der, cert_status, revocation_reason, revoked_at, this_update, next_update, generated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (issuer_id, serial_hex) DO UPDATE SET response_der = EXCLUDED.response_der, cert_status = EXCLUDED.cert_status, revocation_reason = EXCLUDED.revocation_reason, revoked_at = EXCLUDED.revoked_at, this_update = EXCLUDED.this_update, next_update = EXCLUDED.next_update, generated_at = EXCLUDED.generated_at` // Convert the domain's zero-time RevokedAt to nullable for the SQL // row when CertStatus != "revoked" — the cert_status discriminator // is the source of truth, but keeping the nullable columns nullable // in storage is friendlier for ad-hoc queries. var revokedAt interface{} var revocationReason interface{} if e.CertStatus == "revoked" { revokedAt = e.RevokedAt revocationReason = e.RevocationReason } _, err := r.db.ExecContext(ctx, stmt, e.IssuerID, e.SerialHex, e.ResponseDER, e.CertStatus, revocationReason, revokedAt, e.ThisUpdate, e.NextUpdate, e.GeneratedAt) if err != nil { return fmt.Errorf("OCSPResponseCacheRepository.Put: %w", err) } return nil } // Delete removes a single (issuer, serial) entry. Used by // InvalidateOnRevoke when the revocation service wants the cache to // re-sign on the next request rather than carry stale data. func (r *OCSPResponseCacheRepository) Delete(ctx context.Context, issuerID, serialHex string) error { _, err := r.db.ExecContext(ctx, `DELETE FROM ocsp_response_cache WHERE issuer_id = $1 AND serial_hex = $2`, issuerID, serialHex) if err != nil { return fmt.Errorf("OCSPResponseCacheRepository.Delete: %w", err) } return nil } // CountByIssuer returns the count of cached entries per issuer. // Backs the admin observability endpoint at /api/v1/admin/ocsp/cache. func (r *OCSPResponseCacheRepository) CountByIssuer(ctx context.Context) (map[string]int, error) { rows, err := r.db.QueryContext(ctx, `SELECT issuer_id, COUNT(*) FROM ocsp_response_cache GROUP BY issuer_id`) if err != nil { return nil, fmt.Errorf("OCSPResponseCacheRepository.CountByIssuer: %w", err) } defer rows.Close() out := map[string]int{} for rows.Next() { var issuerID string var n int if err := rows.Scan(&issuerID, &n); err != nil { return nil, fmt.Errorf("scan: %w", err) } out[issuerID] = n } return out, rows.Err() }