Files
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

137 lines
4.9 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
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()
}