Compare commits

..

1 Commits

Author SHA1 Message Date
shankar0123 36e722ba12 WIP: M-1 handler sentinel error mapping (checkpoint before branch cleanup)
Uncommitted migration work at the time of branch cleanup. Tagged as
checkpoint/m1-migration-wip so the commit survives git gc --prune=now.

Session context: Phase 3 Part B+C of the M-1 sentinel error migration
was in progress. 38 modified files, 4 new files (errors.go + errors_test.go
in internal/service/ and internal/api/handler/). Resume from this commit
via 'git checkout checkpoint/m1-migration-wip'.
2026-04-24 00:35:12 +00:00
154 changed files with 1700 additions and 8485 deletions
+4 -25
View File
@@ -13,43 +13,22 @@ POSTGRES_PASSWORD=change-me-in-production
# Certctl Server
# All server vars use the CERTCTL_ prefix (see internal/config/config.go)
# ==============================================================================
# IMPORTANT: keep the password segment of CERTCTL_DATABASE_URL in sync with
# POSTGRES_PASSWORD above. If you deploy via `deploy/docker-compose.yml`,
# this value is *overridden* by the compose file's
# `postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/...`
# interpolation — but if you run the binary directly with this .env loaded
# (e.g. `set -a; source .env; ./certctl-server`), update *both* lines.
# Background: editing POSTGRES_PASSWORD after the postgres data directory
# has been initialized once does NOT rotate the password — initdb only
# seeds pg_authid on first boot of an empty volume. See docs/quickstart.md
# "Warning" callout and `internal/repository/postgres/db.go::wrapPingError`
# for the SQLSTATE 28P01 diagnostic that fires when the two drift.
CERTCTL_DATABASE_URL=postgres://certctl:change-me-in-production@postgres:5432/certctl?sslmode=disable
CERTCTL_DATABASE_URL=postgres://certctl:certctl@postgres:5432/certctl?sslmode=disable
CERTCTL_SERVER_HOST=0.0.0.0
CERTCTL_SERVER_PORT=8443
CERTCTL_LOG_LEVEL=info
CERTCTL_LOG_FORMAT=json
# Auth type: "api-key" (production) or "none" (demo/development).
# For JWT/OIDC, run an authenticating gateway in front of certctl
# (oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium) and
# set CERTCTL_AUTH_TYPE=none on the upstream — see
# docs/architecture.md "Authenticating-gateway pattern". G-1 removed
# the in-process "jwt" option (no JWT middleware shipped — silent auth
# downgrade); see docs/upgrade-to-v2-jwt-removal.md if you previously
# set CERTCTL_AUTH_TYPE=jwt.
# Auth type: "api-key", "jwt", or "none" (for demo/development)
CERTCTL_AUTH_TYPE=none
# Required when CERTCTL_AUTH_TYPE is "api-key".
# Required when CERTCTL_AUTH_TYPE is "api-key" or "jwt"
# Generate with: openssl rand -base64 32
# CERTCTL_AUTH_SECRET=change-me-in-production
# ==============================================================================
# Certctl Agent
# ==============================================================================
# HTTPS-only as of v2.2 (TLS 1.3 pinned). Agents reject http:// URLs at
# startup. Use the docker-compose self-signed bootstrap CA bundle from
# `deploy/test/certs/ca.crt` or supply your own via CERTCTL_SERVER_CA_BUNDLE_PATH.
CERTCTL_SERVER_URL=https://localhost:8443
CERTCTL_SERVER_URL=http://localhost:8443
CERTCTL_API_KEY=change-me-in-production
CERTCTL_AGENT_NAME=local-agent
-680
View File
@@ -44,516 +44,6 @@ jobs:
- name: Run govulncheck
run: govulncheck ./...
- name: Forbidden auth-type literal regression guard (G-1)
# G-1 closed the JWT silent auth downgrade by removing "jwt" from the
# accepted CERTCTL_AUTH_TYPE values. This step grep-fails the build
# if "jwt" reappears in any of the *additive* auth-type surfaces:
# the validAuthTypes / ValidAuthTypes() set, the OpenAPI enum, the
# helm chart's allowed-types list, or the .env.example default.
# Comment lines and the dedicated rejection branch in config.go
# (`c.Auth.Type == "jwt"`) are intentionally exempt — those are the
# G-1 fix itself, not a regression.
#
# Connector packages (internal/connector/) are exempt because the
# Google OAuth2 service-account JWT and step-ca provisioner one-
# time-token JWT are external-protocol uses, unrelated to certctl's
# own auth shape. Test files (_test.go) are exempt so negative
# tests can pass the literal.
#
# See docs/upgrade-to-v2-jwt-removal.md for the closure rationale,
# or internal/config/config.go::ValidAuthTypes for the allowed set.
run: |
set -e
# Scoped patterns that indicate "jwt" being added back to an
# allowed-set surface. Each catches a regression shape we've
# actually seen in pre-G-1 code:
# - Go map/slice literal: "jwt": true or "jwt",
# - Go switch case: case "jwt"
# - YAML enum: enum: [..., jwt, ...] or - jwt
# - .env conditional: AUTH_TYPE.*"jwt"|=jwt$
BAD=$(grep -rnEH \
-e '"jwt"\s*:\s*true' \
-e '"jwt"\s*,' \
-e 'case\s+"jwt"' \
-e 'enum:.*\bjwt\b' \
-e '^\s*-\s*jwt\s*$' \
-e 'AUTH_TYPE\s*=\s*jwt\s*$' \
-e 'AUTH_TYPE\s*=\s*jwt\s*#' \
-e 'auth\.type\s*=\s*jwt\s*$' \
-e 'AuthType\("jwt"\)' \
internal/config/ \
internal/api/ \
cmd/ \
api/openapi.yaml \
.env.example \
deploy/.env.example \
deploy/helm/certctl/values.yaml \
deploy/helm/certctl/templates/ \
2>/dev/null \
| grep -v '_test.go' \
| grep -vE '^\s*[^:]+:[0-9]+:\s*(//|#)' \
| grep -v 'is no longer accepted' \
|| true)
if [ -n "$BAD" ]; then
echo "G-1 regression: \"jwt\" reappeared in an allowed-set surface:"
echo "$BAD"
echo ""
echo "Allowed surface for 'jwt' literals: comment lines, the"
echo "dedicated rejection branch in internal/config/config.go,"
echo "and connector packages (Google OAuth2, step-ca)."
echo "See docs/upgrade-to-v2-jwt-removal.md and"
echo "internal/config/config.go::ValidAuthTypes()."
exit 1
fi
- name: Forbidden api_key_hash JSON-shape regression guard (G-2)
# G-2 closed cat-s5-apikey_leak by tagging Agent.APIKeyHash
# `json:"-"` and adding a defense-in-depth Agent.MarshalJSON that
# zeroes the field on the marshal-time copy. This step grep-fails
# the build if `api_key_hash` reappears in any of the *additive*
# JSON-emitting surfaces: a Go struct json tag in internal/domain/,
# an OpenAPI Agent schema property, a TypeScript field declaration
# in web/src/, or an enum-list / discriminator in handler
# production code.
#
# Repository, migration, seed, service, integration-test, and
# unit-test files are exempt — those are server-internal use
# sites (the DB column stays, the in-memory struct field stays,
# the auth-lookup path stays). Comment lines are exempt so the
# G-2 closure rationale can stay in the source.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-s5-apikey_leak for the closure rationale, or
# internal/domain/connector.go::Agent::MarshalJSON for the
# redaction enforcement.
run: |
set -e
# Scoped patterns that indicate api_key_hash being added back
# to a JSON-emitting surface. Each catches a regression shape
# that pre-G-2 actually shipped or that a future refactor
# could plausibly introduce:
# - Go struct tag: `json:"api_key_hash"`
# - Frontend interface: api_key_hash[?]: string
# - OpenAPI schema property: api_key_hash: (column-aligned)
# - YAML enum / array: - api_key_hash
BAD=$(grep -rnEH \
-e 'json:"api_key_hash[",]' \
-e '^\s*api_key_hash\??\s*:' \
-e '^\s*-\s*api_key_hash\s*$' \
internal/domain/ \
internal/api/ \
cmd/ \
api/openapi.yaml \
web/src/ \
2>/dev/null \
| grep -v '_test.go' \
| grep -vE '^\s*[^:]+:[0-9]+:\s*(//|#)' \
|| true)
if [ -n "$BAD" ]; then
echo "G-2 regression: api_key_hash reappeared in a JSON-emitting surface:"
echo "$BAD"
echo ""
echo "Allowed surface for api_key_hash literals: comment lines,"
echo "the database column (migrations/), the in-memory struct"
echo "field tagged \`json:\"-\"\`, and the repository / service"
echo "use sites. See internal/domain/connector.go::Agent and"
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-s5-apikey_leak for the closure rationale."
exit 1
fi
- name: Forbidden plaintext HEALTHCHECK regression guard (U-2)
# U-2 closed cat-u-healthcheck_protocol_mismatch by switching the
# published image's HEALTHCHECK from `curl -f http://localhost:
# 8443/health` (always failed against the HTTPS-only listener) to
# `curl -fsk https://localhost:8443/health`. This step grep-fails
# the build if any Dockerfile in the repo carries the pre-U-2
# plaintext shape — either explicitly (`http://localhost:8443/
# health` in a HEALTHCHECK) or via the looser pattern of any
# HEALTHCHECK that targets `http://` against the certctl server
# port.
#
# Comment lines and the docs/upgrade-to-tls.md:182 expected-to-
# fail invariant ("plaintext is gone, expect Connection refused")
# are intentionally exempt — we DO want the upgrade-doc string
# `http://localhost:8443/health` to remain there, since it
# documents what operators should test for to confirm plaintext
# is dead. The guardrail is scoped to Dockerfile* only, so docs
# are out of its reach.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-u-healthcheck_protocol_mismatch for the closure rationale,
# or deploy/test/healthcheck_test.go for the binary-image
# contract the runtime test pins.
run: |
set -e
# Patterns that catch the actual regression shapes:
# - HEALTHCHECK directive carrying any http:// (even if the
# port differs, no plaintext probe should ship).
# - The exact pre-U-2 string for grep-friendliness.
BAD=$(grep -rnEH \
-e 'HEALTHCHECK.*http://' \
-e 'curl[^|&;]*-f[^|&;]*http://localhost:8443/health' \
Dockerfile Dockerfile.agent Dockerfile.* 2>/dev/null \
| grep -vE '^\s*[^:]+:[0-9]+:\s*#' \
|| true)
if [ -n "$BAD" ]; then
echo "U-2 regression: plaintext HEALTHCHECK reappeared in a Dockerfile:"
echo "$BAD"
echo ""
echo "Allowed: HTTPS HEALTHCHECK with -k (acceptable for"
echo "localhost-to-localhost), or non-HTTP probe shapes"
echo "(pgrep, /proc check). See Dockerfile / Dockerfile.agent"
echo "for the post-U-2 reference shape and"
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-u-healthcheck_protocol_mismatch for rationale."
exit 1
fi
- name: Forbidden migration mount in compose initdb (U-3)
# U-3 closed cat-u-seed_initdb_schema_drift (GitHub #10) by
# eliminating the dual-source-of-truth between
# `migrations/*.up.sql` mounted into postgres
# `/docker-entrypoint-initdb.d/` and the same files re-applied at
# runtime by `RunMigrations`. Pre-U-3 every new migration that
# the seed depended on (000013 added `policy_rules.severity`,
# 000017 renames `retry_interval_seconds`, etc.) had to be added
# by hand to the compose mount list; missing the update crashed
# initdb on first boot, postgres flagged unhealthy, and the
# whole stack failed to start from a fresh clone. Post-U-3 the
# server is the single source of truth — `RunMigrations` +
# `RunSeed` apply everything at boot.
#
# This step grep-fails the build if any compose file under
# `deploy/` re-introduces a `migrations/.*\.sql` mount into
# `/docker-entrypoint-initdb.d`. Comments are exempt so the
# post-fix rationale block in the compose files (which
# documents WHY the mounts were removed) doesn't trip the guard.
# The demo overlay's `seed_demo.sql` is the explicit exception:
# it is tolerated only when it lives behind the
# CERTCTL_DEMO_SEED env var (post-U-3 demo path) — bare initdb
# mounts are NOT tolerated. The grep matches all compose
# mount-list shapes (`-` indented, `volumes:` indented, both),
# so any future drift surfaces here before the operator hits it
# on a fresh clone.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-u-seed_initdb_schema_drift for the closure rationale, or
# internal/repository/postgres/db.go::RunSeed for the runtime
# contract.
run: |
set -e
BAD=$(grep -rnEH \
-e 'migrations/.*\.sql:.*docker-entrypoint-initdb' \
-e 'seed.*\.sql:.*docker-entrypoint-initdb' \
deploy/docker-compose.yml \
deploy/docker-compose.test.yml \
deploy/docker-compose.demo.yml \
2>/dev/null \
| grep -vE '^\s*[^:]+:[0-9]+:\s*#' \
|| true)
if [ -n "$BAD" ]; then
echo "U-3 regression: migration/seed mount into postgres initdb reappeared:"
echo "$BAD"
echo ""
echo "The post-U-3 contract is: postgres comes up with an empty"
echo "schema and the server applies migrations + seed at boot via"
echo "internal/repository/postgres.RunMigrations + RunSeed. Demo"
echo "data lives behind CERTCTL_DEMO_SEED=true (RunDemoSeed),"
echo "not an initdb mount. See"
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-u-seed_initdb_schema_drift for the closure rationale."
exit 1
fi
- name: Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)
# D-1 master closed cat-d-359e92c20cbf (Agent: 'Stale' dead key,
# 'Degraded' missing), cat-d-9f4c8e4a91f1 (Notification: 'dead'
# missing), cat-d-1447e04732e7 (Cert: 'PendingIssuance' dead
# key), cat-f-cert_detail_page_key_render_fallback (render-site
# uses cert.X directly), and cat-f-ae0d06b6588f (Certificate
# TS phantom fields). This step grep-fails the build if either
# half of the closure is reverted:
#
# 1. The dead StatusBadge keys ('Stale' for Agent, 'PendingIssuance'
# for Cert) reappearing as map literals, OR
# 2. The five phantom Certificate TS fields (serial_number,
# fingerprint_sha256, key_algorithm, key_size, issued_at)
# reappearing on the `Certificate` interface in types.ts
# (CertificateVersion legitimately carries them and is
# explicitly excluded by the awk pre-filter below).
#
# Comments are exempt so the closure prose in StatusBadge.tsx +
# types.ts can stay. Test files are exempt so negative tests
# asserting the dead keys fall through to neutral keep working.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-d-* / cat-f-* for the closure rationale, or
# web/src/components/StatusBadge.test.tsx for the live
# enum-coverage contract.
run: |
set -e
BAD_BADGE=$(grep -nE "^\s*(Stale|PendingIssuance)\s*:\s*'badge-" \
web/src/components/StatusBadge.tsx 2>/dev/null \
| grep -v '\.test\.' \
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|| true)
if [ -n "$BAD_BADGE" ]; then
echo "D-1 regression: dead StatusBadge key reappeared:"
echo "$BAD_BADGE"
echo ""
echo "Allowed surface: comment lines naming the removed key in"
echo "the file's preamble. The Go-side AgentStatus values are"
echo "Online/Offline/Degraded (no Stale); CertificateStatus values"
echo "are Pending/Active/... (no PendingIssuance). See"
echo "web/src/components/StatusBadge.test.tsx for the contract."
exit 1
fi
# Certificate TS phantom-field check. Scoped to the
# `export interface Certificate {` block in web/src/api/types.ts
# — CertificateVersion legitimately declares these fields and
# must NOT trip the guardrail. The awk window opens on the
# exact `Certificate {` header (not `CertificateVersion {`,
# not `CertificateProfile {`) and closes at the first `}`,
# then the grep matches a phantom-field declaration anywhere
# in that window.
BAD_TS=$(awk '
/^export interface Certificate \{/ { flag=1; next }
flag && /^\}/ { flag=0 }
flag { print FILENAME":"NR":"$0 }
' web/src/api/types.ts \
| grep -E '\b(serial_number|fingerprint_sha256|key_algorithm|key_size|issued_at)\??\s*:' \
|| true)
if [ -n "$BAD_TS" ]; then
echo "D-1 regression: Certificate TS interface re-added a phantom field:"
echo "$BAD_TS"
echo ""
echo "These fields live on CertificateVersion, not ManagedCertificate."
echo "The Go-side ManagedCertificate has never carried them; the"
echo "TS optional declarations were silently undefined on every"
echo "list response. Render-site consumers (e.g. CertificateDetailPage)"
echo "use latestVersion?.field as the canonical access path."
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-f-ae0d06b6588f for the closure rationale."
exit 1
fi
# D-2 master closed five diff-05x06-* type-drift findings:
# Agent (5 phantoms), Issuer (1 phantom), Notification (1 phantom)
# — TRIM half. The Target (2 missing fields) and DiscoveredCertificate
# (1 missing field) — ADD half is pinned by the literal-construction
# blocks in web/src/api/types.test.ts, not a CI grep. The phantom-
# trim regression vector is an awk-windowed grep per interface
# mirroring the D-1 Certificate check above.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# diff-05x06-7cdf4e78ae24 (Agent), diff-05x06-97fab8783a5c (Issuer),
# diff-05x06-caba9eb3620e (Notification) for the closure rationale.
# D-2 Agent phantom-field check. The grep matches `last_heartbeat`
# but NOT `last_heartbeat_at` (the legitimate Go-emitted field) —
# the `\b...\b` boundaries plus the `grep -v 'last_heartbeat_at'`
# filter handle that.
BAD_AGENT=$(awk '
/^export interface Agent \{/ { flag=1; next }
flag && /^\}/ { flag=0 }
flag { print FILENAME":"NR":"$0 }
' web/src/api/types.ts \
| grep -E '\b(last_heartbeat|capabilities|tags|created_at|updated_at)\??\s*:' \
| grep -v 'last_heartbeat_at' \
|| true)
if [ -n "$BAD_AGENT" ]; then
echo "D-2 regression: Agent TS interface re-added a phantom field:"
echo "$BAD_AGENT"
echo ""
echo "The Go-side internal/domain/connector.go::Agent emits exactly:"
echo "id, name, hostname, status, last_heartbeat_at?, registered_at,"
echo "os, architecture, ip_address, version, retired_at?, retired_reason?."
echo "The five fields blocked by this guard (last_heartbeat,"
echo "capabilities, tags, created_at, updated_at) were TS phantoms"
echo "the Go struct never emitted. See unified-audit.md"
echo "diff-05x06-7cdf4e78ae24 for closure rationale."
exit 1
fi
# D-2 Issuer phantom-field check.
BAD_ISSUER=$(awk '
/^export interface Issuer \{/ { flag=1; next }
flag && /^\}/ { flag=0 }
flag { print FILENAME":"NR":"$0 }
' web/src/api/types.ts \
| grep -E '\bstatus\??\s*:' \
|| true)
if [ -n "$BAD_ISSUER" ]; then
echo "D-2 regression: Issuer TS interface re-added a phantom 'status' field:"
echo "$BAD_ISSUER"
echo ""
echo "The Go-side internal/domain/connector.go::Issuer has no 'status'"
echo "field — only 'enabled' (bool). Render sites derive the displayed"
echo "status from 'enabled' at the call site (see"
echo "web/src/pages/IssuersPage.tsx::issuerStatus). See unified-audit.md"
echo "diff-05x06-97fab8783a5c for closure rationale."
exit 1
fi
# D-2 Notification phantom-field check.
BAD_NOTIF=$(awk '
/^export interface Notification \{/ { flag=1; next }
flag && /^\}/ { flag=0 }
flag { print FILENAME":"NR":"$0 }
' web/src/api/types.ts \
| grep -E '\bsubject\??\s*:' \
|| true)
if [ -n "$BAD_NOTIF" ]; then
echo "D-2 regression: Notification TS interface re-added a phantom 'subject' field:"
echo "$BAD_NOTIF"
echo ""
echo "The Go-side internal/domain/notification.go::NotificationEvent"
echo "has no 'subject' field — only 'message'. Pre-D-2 the consumer"
echo "at NotificationsPage.tsx had a dead '|| n.subject' fallback"
echo "that always fell through. See unified-audit.md"
echo "diff-05x06-caba9eb3620e for closure rationale."
exit 1
fi
- name: Forbidden client-side bulk-action loop regression guard (L-1)
# L-1 master closed cat-l-fa0c1ac07ab5 (bulk-renew loop) and
# cat-l-8a1fb258a38a (bulk-reassign loop) by adding server-side
# bulk endpoints (POST /api/v1/certificates/bulk-renew and
# POST /api/v1/certificates/bulk-reassign) that the GUI calls
# in a single round-trip. Pre-L-1 the GUI looped per-cert
# HTTP calls — 100 selected certs = 100 round-trips × ~50200ms
# each = a 520-second wedge during which the operator stares
# at a progress bar.
#
# This step grep-fails the build if either loop shape reappears
# in CertificatesPage.tsx. Patterns catch the actual pre-L-1
# shapes:
# - `for (const id of ids) { await triggerRenewal(id) }`
# - `for (const id of ids) { await updateCertificate(id, { owner_id }) }`
# - `for (let i = 0; i < ids.length; i++) { await triggerRenewal(ids[i]) }`
#
# Allowed: comment lines explaining the pre-L-1 pattern in the
# docblock above each handler. Test files (_test.tsx) exempt
# so negative-pattern tests can keep working.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-l-fa0c1ac07ab5 and cat-l-8a1fb258a38a for closure
# rationale, or web/src/api/client.ts::bulkRenewCertificates
# / bulkReassignCertificates for the canonical call path.
run: |
set -e
BAD_LOOP=$(grep -nE 'for[[:space:]]*\(' web/src/pages/CertificatesPage.tsx 2>/dev/null \
| grep -E 'await[[:space:]]+(triggerRenewal|updateCertificate)\(' \
| grep -v '\.test\.' \
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|| true)
if [ -n "$BAD_LOOP" ]; then
echo "L-1 regression: client-side bulk-action loop reappeared in CertificatesPage.tsx:"
echo "$BAD_LOOP"
echo ""
echo "Use bulkRenewCertificates({ certificate_ids: [...] }) or"
echo "bulkReassignCertificates({ certificate_ids: [...], owner_id, team_id? })"
echo "instead of looping per-item HTTP calls. See"
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-l-* for rationale."
exit 1
fi
- name: Forbidden orphan-CRUD client function regression guard (B-1)
# B-1 master closed four audit findings — three orphan-update fns
# (cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9) and one orphan CRUD
# surface (cat-b-4631ca092bee, RenewalPolicy) — by wiring per-page
# Edit modals so every backend write endpoint has at least one
# GUI consumer. The fourth finding (cat-b-9b97ffb35ef7) deleted
# the dead `exportCertificatePEM` duplicate.
#
# Pre-B-1 the failure mode was: backend ships a CRUD handler,
# client.ts ships the matching `update*` / `delete*` / `create*`
# function, but no page imports it. Operators were forced to
# `psql` directly to edit team names, owner emails, agent-group
# match rules, issuer names, profile names, or any renewal-policy
# field — turning a 30-second GUI task into a 30-minute database
# excursion with audit-trail gaps.
#
# This step fails the build if any of the eight previously-orphan
# client functions loses its page consumer (i.e. a future refactor
# accidentally re-orphans them). Each fn must have ≥1 non-test
# consumer under web/src/pages/. Tests (*.test.ts(x)) and the
# client.ts definition file itself are exempt.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9, cat-b-4631ca092bee,
# cat-b-9b97ffb35ef7 for closure rationale.
run: |
set -e
ORPHAN_FNS="updateOwner updateTeam updateAgentGroup updateIssuer updateProfile createRenewalPolicy updateRenewalPolicy deleteRenewalPolicy"
FAIL=0
for fn in $ORPHAN_FNS; do
HITS=$(grep -rE "\b${fn}\b" web/src/pages/ 2>/dev/null \
| grep -vE '\.test\.(ts|tsx):' \
| wc -l)
if [ "$HITS" -eq 0 ]; then
echo "::error::B-1 regression: client function '${fn}' has zero consumers under web/src/pages/."
echo " Every backend CRUD endpoint must have a GUI consumer to avoid forcing operators to psql."
echo " Either restore the page consumer or delete the client function in the same commit."
FAIL=1
fi
done
# cat-b-9b97ffb35ef7: exportCertificatePEM was deleted as a dead
# duplicate of downloadCertificatePEM. Block resurrection.
if grep -nE 'export\s+const\s+exportCertificatePEM' web/src/api/client.ts >/dev/null 2>&1; then
echo "::error::B-1 regression: exportCertificatePEM was removed as a dead duplicate of downloadCertificatePEM."
echo " If a JSON variant is needed, add an explicit page consumer in the same commit."
FAIL=1
fi
if [ "$FAIL" -ne 0 ]; then
exit 1
fi
echo "B-1 orphan-CRUD client function guardrail: all 8 functions have page consumers."
- name: Forbidden strings.Contains(err.Error()) regression guard (S-2)
# S-2 closure (cat-s6-efc7f6f6bd50): replaced 30 brittle
# substring-match error-dispatch sites in internal/api/handler/
# with errors.Is + typed sentinels (repository.ErrNotFound,
# repository.ErrForeignKeyConstraint via the
# repository.IsForeignKeyError helper). This step grep-fails
# the build if any new strings.Contains(err.Error(), "not found")
# or strings.Contains(err.Error(), "violates foreign key")
# site appears under internal/api/handler/.
#
# Allowed: closure-comments documenting the convention (e.g.
# bulk_reassignment.go's "post-M-1 errToStatus convention"
# docblock); domain-specific substring patterns that are
# legitimately one-off ("cannot approve", "cannot reject",
# "cannot be parsed", "challenge password") — flagged as
# deferred follow-ups in the S-2 commit message.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-s6-efc7f6f6bd50 for closure rationale.
run: |
set -e
BAD=$(grep -rnE 'strings\.Contains\(err\.Error\(\),\s*"(not found|violates foreign key|RESTRICT)"' internal/api/handler/ 2>/dev/null \
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|| true)
if [ -n "$BAD" ]; then
echo "S-2 regression: brittle substring-match error-dispatch reappeared:"
echo "$BAD"
echo ""
echo "Use errors.Is(err, repository.ErrNotFound) for not-found dispatch,"
echo "or repository.IsForeignKeyError(err) for FK violations."
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-s6-efc7f6f6bd50 for closure rationale."
exit 1
fi
echo "S-2 typed-sentinel error-dispatch guardrail: clean."
- name: Race Detection
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s
@@ -647,176 +137,6 @@ jobs:
working-directory: web
run: npx vite build
- name: Forbidden hardcoded source-count prose regression guard (S-1)
# S-1 master closed cat-s1-9ce1cbe26876 (README + features.md
# stale numeric counts; explicit CLAUDE.md violation per
# "version-stamped numbers rot") and
# cat-s1-features_md_issuer_count_contradiction (features.md
# self-disagreed on issuer count: 9 vs 12 in the same doc).
# The fix replaced source-derived numbers in prose with
# "rebuild via <command>" patterns documented in CLAUDE.md::
# "Current-state commands". This step grep-fails the build if
# any of the previously-stale sites reintroduces a hardcoded
# count.
#
# Allowed surfaces: demo-fixture prose in README ("32
# certificates" — those are seed_demo.sql facts, not live
# source counts), historical-milestone counts in
# WORKSPACE-CHANGELOG.md, the testing-guide example phrasing
# ("README claims 8 issuer connectors but only 6 exist"),
# and any number that quotes the source command immediately
# adjacent.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-s1-9ce1cbe26876 + cat-s1-features_md_issuer_count_contradiction
# for closure rationale.
run: |
set -e
BAD=$(grep -rnE '\b[0-9]+\s+(issuer connectors?|target connectors?|notifier connectors?|discovery connectors?|MCP tools|OpenAPI operations|migrations|database tables|frontend pages|HTTP routes)\b' \
README.md docs/ 2>/dev/null \
| grep -vE 'WORKSPACE-CHANGELOG|seed_demo|demo override' \
| grep -vE 'DRIFT HAZARD|Source: |Rebuild|rebuild via|grep -|wc -l|ls -d|find ' \
| grep -vE 'README claims [0-9]+ issuer connectors but only [0-9]+ exist' \
|| true)
if [ -n "$BAD" ]; then
echo "S-1 regression: hardcoded source-count prose reappeared:"
echo "$BAD"
echo ""
echo "CLAUDE.md rule: 'Numeric claims about current state rot.'"
echo "Replace the count with the grep command from CLAUDE.md::"
echo "'Current-state commands' (e.g. 'ls -d internal/connector/issuer/*/ | wc -l')"
echo "or rephrase to reference the rebuild command on the same line."
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "cat-s1-9ce1cbe26876 for closure rationale."
exit 1
fi
echo "S-1 stale-counts guardrail: clean."
- name: Documented orphan client fns sync guard (P-1)
# P-1 master closed diff-04x03-d24864996ad4 + cat-b-dc46aadab98e
# by documenting 17 detail-page-candidate orphan client.ts
# functions in a docblock at the top of web/src/api/client.ts.
# This step verifies the docblock list ↔ export list relationship:
# every name listed in the docblock must still be declared as
# an export below it (catches drift where someone deletes the
# export but forgets the docblock, or vice versa).
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
run: |
set -e
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOCSPStatus getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
MISSING=""
for fn in $DOCUMENTED; do
if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then
MISSING="${MISSING}${fn} "
fi
done
if [ -n "$MISSING" ]; then
echo "P-1 regression: documented orphan(s) missing from client.ts exports:"
echo " $MISSING"
echo ""
echo "Either restore the export, or delete the corresponding line"
echo "in the documented-orphans docblock at the top of client.ts."
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
echo "diff-04x03-d24864996ad4 for closure rationale."
exit 1
fi
echo "P-1 documented-orphans sync guard: clean ($(echo $DOCUMENTED | wc -w) fns verified)."
- name: Forbidden env-var docs drift regression guard (G-3)
# G-3 master closed cat-g-163dae19bc59 (docs-only env vars
# phantom in features.md), cat-g-b8f8f8796159 (6 config-only
# env vars never documented), and cat-g-renewal_check_interval_rename_drift
# (features.md still advertised the pre-rename
# CERTCTL_RENEWAL_CHECK_INTERVAL after it was renamed to
# CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL). This step runs
# `comm -23` both ways between the env vars defined in Go
# source (config.go + cmd/agent + deploy/test fixtures + ACME
# DNS-01 script env exports) and the env vars mentioned in
# README + docs/ + deploy/helm/.
#
# Allowlist: env vars that are documented as integration-
# surface contracts (script env exports for ACME DNS-01,
# OpenSSL CA scripts, StepCA per-issuer-config-blob fields,
# Webhook per-notifier-config-blob fields, ACME EAB, audit
# exclusion, demo-stack overrides) but not consumed directly
# by config.go. Each entry below has a one-line justification
# — if you add a new entry, add the justification too.
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-g-* for closure rationale.
run: |
set -e
# Defined: config.go + agent + cli + mcp-server + server cmds + test fixtures + ACME DNS export
{
grep -nE '"CERTCTL_[A-Z_]+"' internal/config/config.go | sed -E 's/.*"(CERTCTL_[A-Z_]+)".*/\1/'
grep -rhoE '"CERTCTL_[A-Z_]+"' cmd/agent/*.go cmd/cli/*.go cmd/mcp-server/*.go cmd/server/*.go 2>/dev/null | sed -E 's/"(CERTCTL_[A-Z_]+)"/\1/'
grep -rhoE 'CERTCTL_[A-Z_]+' deploy/test/qa_test.go internal/connector/issuer/acme/dns.go 2>/dev/null
} | grep -E '^CERTCTL_' | sort -u > /tmp/g3-defined.txt
# Documented: README + docs + helm
grep -rhoE '\bCERTCTL_[A-Z_]+\b' README.md docs/ deploy/helm/ 2>/dev/null | sort -u > /tmp/g3-docs.txt
# Allowlist of env vars documented as external integration contracts.
# Each entry justifies itself in one line; if you add to this list,
# add the justification.
ALLOWED='^(
CERTCTL_OPENSSL_SIGN_SCRIPT|
CERTCTL_OPENSSL_REVOKE_SCRIPT|
CERTCTL_OPENSSL_CRL_SCRIPT|
CERTCTL_OPENSSL_TIMEOUT_SECONDS|
CERTCTL_STEPCA_URL|
CERTCTL_STEPCA_FINGERPRINT|
CERTCTL_STEPCA_PROVISIONER|
CERTCTL_STEPCA_PROVISIONER_NAME|
CERTCTL_STEPCA_PROVISIONER_KEY|
CERTCTL_STEPCA_PROVISIONER_JWK|
CERTCTL_STEPCA_PROVISIONER_PASSWORD|
CERTCTL_STEPCA_PASSWORD|
CERTCTL_STEPCA_KEY_PATH|
CERTCTL_STEPCA_ROOT_CA|
CERTCTL_WEBHOOK_URL|
CERTCTL_WEBHOOK_SECRET|
CERTCTL_ACME_EAB_KID|
CERTCTL_ACME_EAB_HMAC|
CERTCTL_ACME_DNS_PROPAGATION_WAIT|
CERTCTL_AUDIT_EXCLUDE_PATHS|
CERTCTL_TLS_|
CERTCTL_TLS_INSECURE_SKIP_VERIFY|
CERTCTL_SERVER_CA_BUNDLE_PATH|
CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY|
CERTCTL_QA_[A-Z_]+
)$'
# ^ The CERTCTL_OPENSSL_* / CERTCTL_STEPCA_* / CERTCTL_WEBHOOK_* /
# CERTCTL_ACME_EAB_* / CERTCTL_ACME_DNS_PROPAGATION_WAIT /
# CERTCTL_AUDIT_EXCLUDE_PATHS / CERTCTL_TLS_* / CERTCTL_SERVER_* /
# CERTCTL_QA_* sets are documented integration-surface contracts
# (script invocations, per-issuer config-blob field names,
# per-notifier config-blob field names, demo-stack overrides,
# test fixtures) — not server-side env vars in config.go.
# The audit's "37 docs-only" count over-flagged these; the
# closure narrows the gate to the specific drift sites
# (renewal-interval rename + 6 config-only) and allowlists
# the documented external contracts here.
ALLOWED_FLAT=$(echo "$ALLOWED" | tr -d '\n ')
DOCS_ONLY=$(comm -13 /tmp/g3-defined.txt /tmp/g3-docs.txt | grep -vE "$ALLOWED_FLAT" || true)
CONFIG_ONLY=$(comm -23 /tmp/g3-defined.txt /tmp/g3-docs.txt || true)
if [ -n "$DOCS_ONLY" ]; then
echo "G-3 regression: env var(s) mentioned in docs but not defined in Go source AND not in the documented integration-surface allowlist:"
echo "$DOCS_ONLY"
echo ""
echo "Either delete from docs (phantom/typo) or add to config.go,"
echo "or add to the ALLOWED list with a one-line justification."
exit 1
fi
if [ -n "$CONFIG_ONLY" ]; then
echo "G-3 regression: env var(s) defined in Go source but never documented:"
echo "$CONFIG_ONLY"
echo ""
echo "Add an entry to docs/features.md (or another canonical doc) so operators can find it."
exit 1
fi
echo "G-3 env-var docs drift guardrail: clean."
helm-lint:
name: Helm Chart Validation
runs-on: ubuntu-latest
-261
View File
@@ -2,267 +2,6 @@
All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/).
## [unreleased] — 2026-04-25
### H-1: Security hardening trio — closed end-to-end
> Three 2026-04-24 audit findings (all P2) that together complete the HTTPS-Everywhere security baseline. The audit flagged: (1) the unauth surface (EST RFC 7030, SCEP, PKI CRL/OCSP, /health, /ready) accepted arbitrary-size request bodies because the `noAuthHandler` middleware chain was missing the `bodyLimitMiddleware` that the authed `apiHandler` chain has; (2) zero security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) were emitted on any response — enabling clickjacking, MIME-sniffing, and untrusted-origin resource loads against the dashboard and API; (3) `CERTCTL_CONFIG_ENCRYPTION_KEY` was accepted with any non-empty value, including a single character — PBKDF2-SHA256 with 100k rounds does not compensate for low-entropy passphrases at scale (CWE-916 / CWE-329).
### Breaking Changes
**Operators with low-entropy `CERTCTL_CONFIG_ENCRYPTION_KEY` will fail to start after upgrade.** Pre-H-1 the field accepted any non-empty string. Post-H-1 it requires ≥32 bytes (e.g. `openssl rand -base64 32`). The startup error names the offending env var, the actual length, the required minimum, and the canonical generation command. Empty (`""`) remains accepted — the existing fail-closed sentinel `crypto.ErrEncryptionKeyRequired` triggers downstream when an empty key tries to encrypt or decrypt. Operators using a short passphrase must rotate before the upgrade.
### Added
- **`internal/api/middleware/securityheaders.go`** (new) — `SecurityHeaders` middleware applies HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and a conservative Content-Security-Policy on every response. Defaults via `SecurityHeadersDefaults()` are: `Strict-Transport-Security: max-age=31536000; includeSubDomains`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer-when-downgrade`, and `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'`. Operators behind a customising reverse proxy can override per-header by setting any field of the config struct to the empty string (omits that header).
- **`bodyLimitMiddleware` wired into `noAuthHandler`** in `cmd/server/main.go`. Same default cap (1 MB, configurable via `CERTCTL_MAX_BODY_SIZE`), same 413 response on overflow. Pre-H-1 only the authed surface had this protection.
- **`securityHeadersMiddleware` wired into BOTH chains** (`middlewareStack` for authed routes; `noAuthHandler` for unauth routes). Applied before the audit middleware so headers reach 4xx/5xx responses too — critical for security posture (an attacker probing for misconfiguration sees the same headers on a 401 as on a 200).
- **`CERTCTL_CONFIG_ENCRYPTION_KEY` length validation** in `internal/config/config.go::Validate()` — rejects keys shorter than 32 bytes with a structured error naming the actual length, the required minimum, and the canonical generation command. Empty keys remain accepted (downstream fail-closed sentinel handles it).
- **Tests:** `internal/api/middleware/securityheaders_test.go` (4 cases — defaults present, empty disables single header, override applied, headers on 4xx/5xx). `internal/config/config_test.go` adds 5 cases for the encryption-key length check (empty accepted, 1-byte rejected, 31-byte rejected at boundary, 32-byte accepted, 44-byte realistic operator key accepted).
### Audit findings closed
- `cat-s5-4936a1cf0118` (P2, EST/SCEP/PKI unauth endpoints bypass `http.MaxBytesReader`)
- `cat-s11-missing_security_headers` (P2, no CSP / HSTS / X-Frame-Options on responses)
- `cat-r-encryption_key_no_length_validation` (P2, encryption key accepted with zero entropy validation)
### Known follow-ups (deferred from H-1 scope)
A weak-key dictionary check (reject `password123`, common ASCII patterns) is deferred — adds operational friction with low marginal entropy gain at the 32-byte minimum. CSP `'unsafe-inline'` for styles is required because Tailwind via Vite injects per-component `<style>` blocks at build time; removing it would require an HTML report or component refactor outside H-1 scope. A `Permissions-Policy` (formerly Feature-Policy) header is not in the H-1 baseline because the dashboard uses no advanced browser APIs (camera, microphone, geolocation); deferred until a real consumer needs it.
### D-2: TS ↔ Go type drift cluster — closed end-to-end
> The 2026-04-24 coverage-gap audit flagged five `diff-05x06-*` findings — every one a TypeScript-vs-Go shape mismatch where the on-wire JSON the backend emits and the TS interface in `web/src/api/types.ts` had drifted apart. D-1 master closed the same pattern for `Certificate` (cat-f-ae0d06b6588f, 5 phantom fields trimmed, plus the cat-f-cert_detail_page_key_render_fallback render-site fix). D-2 closes it for the remaining five entities: Agent, Target, DiscoveredCertificate, Issuer, and Notification. The audit's blunt rule "stricter side is the contract" decides the per-entity verdict — for TS phantoms (fields declared on TS, never emitted by Go) the Go side wins and TS gets trimmed; for TS-missing fields (emitted by Go, absent from TS) the Go side still wins and TS gets the addition. Pre-D-2 the failure modes were: phantom fields silently rendered `'—'` at consumer sites (e.g. AgentDetailPage's "Capabilities" + "Tags" sections always rendered empty; IssuersPage rendered `'Unknown'` for every issuer; NotificationsPage's `n.message || n.subject` fallback always fell through), and missing fields forced `(target as any).retired_at` escapes that lost type-checking. Verify-only side task: Certificate / ManagedCertificate confirmed clean since D-1.
### Breaking Changes
None on the wire. The JSON the backend emits is byte-identical pre/post-D-2 — D-2 is purely TS-side reconciliation. The interface shapes change in ways that are TypeScript compile errors at consumer sites that read trimmed phantoms (intentionally — that's the closure mechanism) but no operator-visible behaviour shifts.
### Added
- `Target` interface gains `retired_at?: string | null` and `retired_reason?: string | null` (mirrors the Agent retirement-fields shape and the Go-side `internal/domain/connector.go::DeploymentTarget` I-004 model). An Agent retire cascades to all associated Targets per `service.RetireAgent → repository.RetireTarget`; the GUI can now type-check the retired-state surfacing without `(target as any).retired_at` escapes.
- `DiscoveredCertificate` interface gains `pem_data?: string`. The Go-side struct (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`, `omitempty`) emits this field on the wire — populated by the agent filesystem scanner, the cloud-secret-manager connectors, and the repo SELECT. Optional because Go uses `omitempty`. Consumers can now reach the raw PEM with type-checked code.
- **CI regression guardrail extension** in `.github/workflows/ci.yml` (renamed `Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)`) — adds three new awk-windowed greps over the Agent / Issuer / Notification interfaces in `types.ts` that fail the build if any of the trimmed phantom fields reappear. The Agent regex `\b(last_heartbeat|capabilities|tags|created_at|updated_at)\b` is paired with a `grep -v 'last_heartbeat_at'` filter to avoid false positives on the legitimate Go-emitted heartbeat field.
### Removed
- `Agent` interface — 5 phantom fields trimmed: `last_heartbeat`, `capabilities`, `tags`, `created_at`, `updated_at`. None emitted by `internal/domain/connector.go::Agent`. Two had real consumers in `AgentDetailPage.tsx` (capabilities + tags sections) — both were removed because their guards always evaluated false. The "Updated" InfoRow that read `agent.updated_at` was also dropped (Go has no equivalent timestamp on Agent). `last_heartbeat_at` flipped from required to optional to match Go's `*time.Time omitempty`.
- `Issuer` interface — phantom `status: string` removed. Go has only `Enabled bool`. Both `IssuersPage.tsx::issuerStatus` and `IssuerDetailPage.tsx::issuerStatus` rewritten to compute `i.enabled ? 'Enabled' : 'Disabled'` exclusively (the pre-D-2 fallback `issuer.status || 'Unknown'` always rendered 'Unknown').
- `Notification` interface — phantom `subject?: string` removed. The dead `{n.message || n.subject}` fallback at `NotificationsPage.tsx:241` was simplified to `{n.message}`. Test mocks in `NotificationsPage.test.tsx` no longer set the field.
### Audit findings closed
- diff-05x06-7cdf4e78ae24 (P2, Agent TS↔Go drift)
- diff-05x06-2044a46f4dd0 (P2, Target TS↔DeploymentTarget Go drift)
- diff-05x06-85ab6b98a2f7 (P2, DiscoveredCertificate TS↔Go drift)
- diff-05x06-97fab8783a5c (P2, Issuer TS↔Go drift)
- diff-05x06-caba9eb3620e (P2, Notification TS↔NotificationEvent Go drift)
- diff-05x06-af18a8d7ef41 (P2, Certificate / ManagedCertificate) — verified no residual drift since D-1; no edit required
### Known follow-ups (deferred from D-2 scope)
A richer Issuer status view that derives from `enabled × test_status` (instead of `enabled` alone) is deferred — a UX scope decision, not a contract drift, and the existing `test_status: 'untested' | 'success' | 'failed'` field is already on the TS interface for whoever picks up that work. Real Agent metadata fields (capabilities advertised at heartbeat time, operator-applied tags) are deferred — D-2 removed the false UI affordance; if/when the product wants real fields, re-introduce in `AgentDetailPage` in the same commit that ships the Go-side change. The `DiscoveredCertificate.pem_data` LIST-response performance optimization (gate emission on the per-id detail path, since pem_data is kilobytes per row) is deferred as a separate backend change — D-2 only closed the contract drift.
### B-1: Orphan-CRUD client functions + RenewalPolicy GUI gap — closed end-to-end
> The 2026-04-24 coverage-gap audit flagged a cluster of operator-blocking GUI omissions: six client.ts `update*` functions (`updateOwner`, `updateTeam`, `updateAgentGroup`, `updateIssuer`, `updateProfile`, plus the full `*RenewalPolicy` CRUD trio) had backend handlers, OpenAPI operations, and exported TypeScript fetchers — but zero page consumers. Operators wanting to fix a typo in an owner's email, rename a team, retarget an agent group's match rules, or edit a renewal-policy field were forced to either delete-and-recreate (losing FK history and audit-trail continuity) or open a `psql` session against the production database directly. The audit's blunt summary: "every backend feature ships with its GUI surface" — a load-bearing CLAUDE.md invariant — was being violated for five operator-facing entities. B-1 closes that violation by wiring per-page Edit modals onto five existing pages, adding a brand-new `RenewalPoliciesPage` for the rp-* CRUD surface, and deleting one dead duplicate (`exportCertificatePEM`) so the public client surface area stops growing without consumers.
### Breaking Changes
None. All five existing pages keep their Create + Delete affordances unchanged; Edit is purely additive. `RenewalPoliciesPage` is a new route at `/renewal-policies` and a new sidebar nav item slotted between Policies and Profiles. The `exportCertificatePEM` helper had zero consumers in `web/`, MCP, CLI, and tests at the time of removal — operators using `downloadCertificatePEM` (the actual call site in `CertificateDetailPage`) are unaffected.
### Added
- **`web/src/pages/RenewalPoliciesPage.tsx`** — a new full-CRUD page for the `rp-*` renewal-policy table. Surfaces a 7-column DataTable (Policy / Renewal Window / Auto / Retries / Alert Thresholds / Created / Actions) with Create, Edit, and Delete affordances. A shared `PolicyFormModal` powers both Create and Edit (the form shape is identical) covering the full domain field set: `name`, `renewal_window_days`, `auto_renew`, `max_retries`, `retry_interval_seconds`, `alert_thresholds_days[]`. The thresholds input parses comma-separated integers (`30, 14, 7, 0`) into the array shape the backend expects. Delete surfaces `repository.ErrRenewalPolicyInUse` (409 from the backend when a policy still has `managed_certificates.renewal_policy_id` references) via an explicit alert so the operator can re-target the dependent certs to a different policy before deletion. Wired into `web/src/main.tsx` routing and `web/src/components/Layout.tsx` sidebar nav.
- **EditOwnerModal** in `web/src/pages/OwnersPage.tsx` — pre-populates from the editing owner via `useEffect`, calls `updateOwner(id, {name, email, team_id})`, mirrors the Create modal's TanStack-Query mutation/invalidation pattern.
- **EditTeamModal** in `web/src/pages/TeamsPage.tsx` — same shape, fields `name`/`description`.
- **EditAgentGroupModal** in `web/src/pages/AgentGroupsPage.tsx` — covers the full match-rule set (`name`, `description`, `match_os`, `match_architecture`, `match_ip_cidr`, `match_version`, `enabled`).
- **EditIssuerModal** in `web/src/pages/IssuersPage.tsx` — deliberately rename-only. The `type` field is shown but disabled, the existing `config` blob (which includes credentials for ACME, ADCS, ZeroSSL, etc.) is forwarded untouched, and only `name` is editable. Footer note: "To change issuer type or rotate credentials, delete and recreate." This trades scope for safety — the audit's destructive-rename complaint is closed without surfacing a credential-edit attack surface that has not been threat-modeled.
- **EditProfileModal** in `web/src/pages/ProfilesPage.tsx` — same rename-only shape. Forwards full `Partial<CertificateProfile>` with policy fields (`allowed_key_algorithms`, `max_ttl_seconds`, `allowed_ekus`, etc.) preserved untouched. Footer note about deferred policy-field editing.
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden orphan-CRUD client function regression guard (B-1)`) — grep-fails the build if any of the eight previously-orphan client functions (`updateOwner`, `updateTeam`, `updateAgentGroup`, `updateIssuer`, `updateProfile`, `createRenewalPolicy`, `updateRenewalPolicy`, `deleteRenewalPolicy`) loses its non-test consumer under `web/src/pages/`. Also blocks resurrection of the deleted `exportCertificatePEM` function. Verified locally on the post-fix tree (passes — all 8 fns have ≥2 consumers); fires against synthetic regressions (delete the Edit modal → guardrail fires the next CI run).
### Removed
- `web/src/api/client.ts::exportCertificatePEM` — closes `cat-b-9b97ffb35ef7`. The function returned `{cert_pem, chain_pem, full_pem}` JSON but had zero consumers across `web/`, MCP, CLI, and tests; `downloadCertificatePEM` (the blob-download path consumed by `CertificateDetailPage`) covers all real call sites. Test references in `web/src/api/client.test.ts` and `client.error.test.ts` were also removed. The CI guardrail blocks resurrection without an accompanying page consumer.
### Audit findings closed
- `cat-b-31ceb6aaa9f1` (P1, `updateOwner`/`updateTeam`/`updateAgentGroup` orphan)
- `cat-b-7a34f893a8f9` (P1, `updateIssuer`/`updateProfile` orphan, rename-only closure)
- `cat-b-4631ca092bee` (P1, RenewalPolicy CRUD orphan — new RenewalPoliciesPage)
- `cat-b-9b97ffb35ef7` (P3, `exportCertificatePEM` dead duplicate)
### Known follow-ups (deferred from B-1 scope)
A fuller `EditIssuerModal` with explicit credential-rotation flow is deferred — that needs an explicit threat model (rotation reuse window, audit-trail granularity, in-flight CSR cancellation), and the audit's destructive-rename complaint is closed by rename-only Edit alone. Likewise an `EditProfileModal` with policy-field editing (max-TTL, allowed EKUs, allowed key algorithms) is deferred because policy edits affect the `enforce_certificate_policy` evaluator's semantics for already-issued certs and warrant their own scope. Per-page Vitest coverage for the new Edit modals is deferred — the CI grep guardrail catches the same regression vector ("page lost its `update*` fn consumer") at lower cost than five new test files.
### L-1: Client-side bulk-action loops — closed end-to-end
> The certctl dashboard's busiest screen (`CertificatesPage.tsx`) had two bulk-action workflows that looped per-cert HTTP calls. Selecting 100 certs and clicking "Renew" issued 100 sequential `POST /api/v1/certificates/{id}/renew` requests; "Reassign owner" issued 100 sequential `PUT /api/v1/certificates/{id}` requests. Each round-trip carried ~50200 ms of Auth → audit-log → handler → service → repo → DB → audit-write → response, so a 100-cert bulk action was a 520-second wedge during which the operator stared at a progress bar. The bulk-revoke endpoint (`POST /api/v1/certificates/bulk-revoke`) already shipped in v2.0.x as the canonical pattern for this; L-1 ports that exact shape to bulk-renew (P1) and bulk-reassign (P2). One backend round-trip; one audit event for the entire operation; per-cert success/skip/error counts in a single response envelope. Bundled with two new MCP tools and an OpenAPI spec update so non-GUI callers (CLI / MCP / blackbox probes) can use the same endpoints.
### Breaking Changes
None. Both endpoints are additive; the per-cert `POST /certificates/{id}/renew` and `PUT /certificates/{id}` paths remain available and unchanged. The frontend implementation switches from looping to single-call, but operators with custom GUIs hitting the per-cert endpoints continue to work.
### Added
- **`POST /api/v1/certificates/bulk-renew`** — enqueues a renewal job for every matching managed certificate. Supports criteria-mode (`{profile_id, owner_id, agent_id, issuer_id, team_id}`) and explicit-IDs mode (`{certificate_ids}`). Mirrors `BulkRevokeCriteria` field-for-field (sans the RFC-5280 reason code). Returns `{total_matched, total_enqueued, total_skipped, total_failed, enqueued_jobs[], errors[]}`. NOT admin-gated — bulk renewal is non-destructive (worst case it kicks off some redundant ACME orders). Status filter: certs in `Archived/Revoked/Expired/RenewalInProgress` are silent-skipped (TotalSkipped++) rather than returned as errors. Implementation: `internal/domain/bulk_renewal.go`, `internal/service/bulk_renewal.go`, `internal/api/handler/bulk_renewal.go`.
- **`POST /api/v1/certificates/bulk-reassign`** — updates `owner_id` (required) and `team_id` (optional) on every cert in `certificate_ids`. Skips certs already owned by the target (silent no-op surfaced as `total_skipped`). Validates the target `owner_id` upfront — a non-existent owner returns 400 (via the typed `service.ErrBulkReassignOwnerNotFound` sentinel) before any cert is touched. NOT admin-gated. Implementation: `internal/domain/bulk_reassignment.go`, `internal/service/bulk_reassignment.go`, `internal/api/handler/bulk_reassignment.go`.
- **MCP tools `certctl_bulk_renew_certificates` and `certctl_bulk_reassign_certificates`** in `internal/mcp/tools.go` + `internal/mcp/types.go`. Mirror the existing `certctl_bulk_revoke_certificates` shape so MCP consumers have a uniform bulk-action surface.
- **OpenAPI schemas** `BulkRenewRequest`, `BulkRenewResult`, `BulkEnqueuedJob`, `BulkReassignRequest`, `BulkReassignResult` plus the two new operations with shared envelope semantics.
- **Frontend client functions** `bulkRenewCertificates(criteria)` and `bulkReassignCertificates(request)` in `web/src/api/client.ts` with full TS types for both request and response envelopes.
- **Service-layer regression tests** for both new services (`internal/service/bulk_renewal_test.go` + `internal/service/bulk_reassignment_test.go`): happy path, criteria-mode, status-skip semantics (RenewalInProgress / Revoked / Archived for renew; already-owned for reassign), empty-criteria rejection, partial-failure tolerance, single-bulk-audit-event contract.
- **Handler-layer regression tests** (`internal/api/handler/bulk_renewal_handler_test.go` + `internal/api/handler/bulk_reassignment_handler_test.go`): happy path, empty-body 400, wrong-method 405, actor attribution from `middleware.GetUser`, owner-not-found-sentinel-→-400 mapping for reassign, generic-service-error-→-500.
- **Domain-layer JSON-shape tests** pinning the wire contract for `BulkRenewalResult` / `BulkReassignmentResult` / `BulkOperationError`.
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden client-side bulk-action loop regression guard (L-1)`) — grep-fails the build if `for(...) await triggerRenewal(...)` or `for(...) await updateCertificate(...)` reappears in `web/src/pages/CertificatesPage.tsx`. Verified: passes against the post-fix tree, fires against synthetic regressions.
### Changed
- **`web/src/pages/CertificatesPage.tsx::handleBulkRenewal`** — rewritten from N-call loop to a single `bulkRenewCertificates({ certificate_ids })` call. Result envelope drives the progress UI (matched / enqueued / skipped / failed counts).
- **`web/src/pages/CertificatesPage.tsx::handleReassign`** (in the reassign modal) — same shape: single `bulkReassignCertificates({ certificate_ids, owner_id })` call. First-error message surfaced when `total_failed > 0`.
- **`internal/api/router/router.go`** — three bulk-* routes (revoke / renew / reassign) registered together as a block before the per-cert `{id}` routes; `HandlerRegistry` gains `BulkRenewal` and `BulkReassignment` fields.
- **`cmd/server/main.go`** — constructs `BulkRenewalService` (threads `cfg.Keygen.Mode` so bulk-renew jobs land in the same initial status as single-cert `TriggerRenewal`) and `BulkReassignmentService` alongside the existing `BulkRevocationService`.
### Performance impact
100-cert bulk-renew workflow goes from ~10 s of sequential per-cert HTTP (worst case) to a single ~100 ms call — roughly 99% latency reduction on the canonical operator workflow. Server-side resource use also drops: one Auth pass, one audit event, one criteria-resolution query, instead of N of each.
### Closed audit findings
- `cat-l-fa0c1ac07ab5` (P1, primary) — bulk renew client-side sequential loop
- `cat-l-8a1fb258a38a` (P2) — bulk owner-reassign client-side sequential loop
### Known follow-ups (deferred from L-1 scope)
- `cat-b-31ceb6aaa9f1` (P1, `updateOwner`/`updateTeam`/`updateAgentGroup` orphan) — different shape; the fix is "wire up the existing PUT endpoints to the GUI", not "add a bulk endpoint".
- `cat-k-e85d1099b2d7` (P2, CertificatesPage no pagination UI) — same page; criteria-mode bulk-renew (`{owner_id: 'o-alice'}`) means an operator can already "renew all of Alice's certs" without paginating, but pagination is still wanted for the table view.
- `cat-i-b0924b6675f8` (P1, MCP missing `claim`/`dismiss`/`acknowledge`) — L-1 added two new MCP tools but does NOT close that finding.
### D-1: StatusBadge enum drift + Certificate phantom fields — closed end-to-end
> The dashboard silently lied in five places. Agents in the `Degraded` state (the only Go-side AgentStatus that means "needs operator attention") rendered as default neutral grey because StatusBadge mapped `Stale` (a key Go has never emitted) to yellow and let the real `Degraded` value fall through to the dictionary default. Dead-letter notifications (`status: 'dead'`, retries exhausted) rendered as default neutral, visually equated with `read` (operator-acknowledged). The Certificate badge map carried a `PendingIssuance` key that no Go enum value ever emits — dead key, latent confusion vector. CertificateDetailPage's Key Algorithm and Key Size rows always rendered `—` even when the data was a single fetch away, because the lookup went through `cert.key_algorithm` directly — and the underlying `Certificate` TypeScript interface declared five optional fields (`serial_number`, `fingerprint_sha256`, `key_algorithm`, `key_size`, `issued_at`) that Go's `ManagedCertificate` has never carried (those values live on `CertificateVersion`). Five findings, two files, one frontend rebuild. Pre-D-1 the only reason this didn't trip a regression suite was that the regression suite never asserted "every Go-emitted enum value gets a non-default StatusBadge class" — D-1 fixes the visual lies and adds a 38-case Vitest property test that walks every Go enum and pins the contract.
### Breaking Changes
- **`Certificate` TypeScript interface no longer declares `serial_number?`, `fingerprint_sha256?`, `key_algorithm?`, `key_size?`, or `issued_at?`.** The Go `ManagedCertificate` (`internal/domain/certificate.go`) has never emitted these fields on list responses; they live on `CertificateVersion` and are reachable via `getCertificateVersions(id)`. Pre-D-5 (the cat-f phantom-fields finding) the optional declarations made `cert.X` always-undefined on lists, and downstream consumers silently rendered `—` for every cert. Post-D-5 a `cert.X` access for any of the five fields is a TypeScript compile error, forcing every consumer to acknowledge the version-fallback pattern. The OpenAPI `ManagedCertificate` schema was already correct — only the TS type was drifted.
- **StatusBadge no longer maps `Stale` (Agent) or `PendingIssuance` (Certificate).** Both were dead keys — no Go enum value emits them. Operators with custom CSS hooked off `.badge-warning` for `Stale` will see the same color come back via the new `Degraded` mapping (same class), but JS/TS code that switches on the literal `'Stale'` will need to switch on `'Degraded'` instead. The `PendingIssuance` deletion has no documented downstream consumer.
### Added
- **`web/src/components/StatusBadge.tsx`: `Degraded` (Agent) → `badge-warning` and `dead` (Notification) → `badge-danger`.** First mappings restore the color contract for the two real Go-side values that previously fell through to the dictionary default. The `Degraded` mapping cross-references `internal/domain/connector.go::AgentStatusDegraded`; the `dead` mapping cross-references `internal/domain/notification.go::NotificationStatusDead`.
- **`web/src/components/StatusBadge.test.tsx`: 38-case Vitest property test.** Iterates every Go-side enum value (`AgentStatus`, `CertificateStatus`, `JobStatus`, `NotificationStatus`, `DiscoveryStatus`, `HealthStatus`) plus the two frontend-synthesized `Enabled`/`Disabled` labels, asserts every value gets a non-default class (or, for the five intentionally-neutral terminal values like `Archived`/`Cancelled`/`read`, an explicit `badge badge-neutral`). Includes negative assertions on the deleted `Stale` and `PendingIssuance` keys (must fall through to neutral) and specific UX-correctness assertions on the operator-attention semantics (`dead` → danger, `Degraded` → warning).
- **`web/src/api/types.test.ts`: D-5 Certificate phantom-fields trim regression.** A `Certificate` literal construction pinned post-trim, plus a sibling `CertificateVersion` literal pinning that the trimmed fields still live on the version envelope. The `tsc --noEmit` gate in CI is the primary enforcement; the test is the documentation of intent.
- **CI regression guardrail in `.github/workflows/ci.yml` (`Forbidden StatusBadge dead-key + Certificate phantom-field regression guard (D-1)`).** Two grep blocks: (1) catches `Stale: 'badge-...'` or `PendingIssuance: 'badge-...'` in `web/src/components/StatusBadge.tsx`; (2) uses an awk-scoped window over the `export interface Certificate {` block in `web/src/api/types.ts` to catch any of the five phantom fields reappearing — explicitly excludes the `CertificateVersion` block which legitimately carries them. Verified locally on the post-fix tree (passes) and against synthetic regressions (each fires the guardrail).
### Changed
- **`web/src/pages/CertificateDetailPage.tsx`: Key Algorithm and Key Size rows now read from `latestVersion?.key_algorithm` / `latestVersion?.key_size`.** Mirrors the existing `latestVersion` fallback used for `serial_number` and `fingerprint_sha256` earlier in the same file. Pre-D-4 these rows accessed `cert.key_algorithm` and `cert.key_size` directly — both phantom fields per D-5 — so the rows always rendered `—`. The same file's `serial_number` / `fingerprint_sha256` / `issued_at` derivations were also simplified to drop the now-impossible `cert.X || latestVersion?.X` cert-side leg.
- **`web/src/components/StatusBadge.tsx` adds a leading docblock** naming the Go-side source-of-truth file for every status family it maps (`AgentStatus`, `CertificateStatus`, `JobStatus`, `NotificationStatus`, `DiscoveryStatus`, `HealthStatus`) and pointing at the property test as the regression vector for future enum changes.
- **`api/openapi.yaml::ManagedCertificate`** gets a leading comment cross-referencing the D-5 closure and explaining why per-issuance fields legitimately don't appear here (they live on `CertificateVersion`). Schema property list unchanged — the OpenAPI spec was already correct.
### Closed audit findings
- `cat-d-359e92c20cbf` (P1 primary) — Agent: `Stale` dead key + `Degraded` neutral fallthrough
- `cat-d-9f4c8e4a91f1` (P2) — Notification: `dead` missing
- `cat-d-1447e04732e7` (P3) — Certificate: `PendingIssuance` dead key
- `cat-f-cert_detail_page_key_render_fallback` (P2) — render-site uses `cert.key_algorithm` directly
- `cat-f-ae0d06b6588f` (P2) — Certificate TS phantom fields (root cause)
### Known follow-ups (deferred from D-1 scope)
The audit's broader type-drift cluster (`diff-05x06-7cdf4e78ae24` Agent TS, `diff-05x06-2044a46f4dd0` DeploymentTarget TS, `diff-05x06-caba9eb3620e` Notification TS, `diff-05x06-85ab6b98a2f7` DiscoveredCertificate TS, `diff-05x06-97fab8783a5c` Issuer TS) is out of D-1 scope. Recon for those is per-type field-by-field diff Go ↔ TS — codegen-shaped, not edit-shaped — and warrants its own D-2 master prompt.
### U-3: GitHub #10 reopened — fresh-clone first-up postgres init failure (P1) — closed end-to-end
> Operator `mikeakasully` cloned v2.0.50 fresh, ran the canonical quickstart `docker compose -f deploy/docker-compose.yml up -d --build`, and postgres reported `unhealthy` indefinitely; dependent containers (certctl-server, certctl-agent) never started. Root cause: the deploy compose stack mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql` into postgres `/docker-entrypoint-initdb.d/`. Postgres applied them at initdb time. Once `seed.sql` referenced columns added by migrations *after* the mounted cutoff (e.g., `policy_rules.severity` from migration 000013, which the mount list never included), initdb crashed mid-seed and the container loop wedged. Two sources of truth — the mount list and the in-tree migration ladder — diverged the moment a seed-touching migration shipped, and the only thing that fixed it was hand-editing the compose file every release. The U-3 closure removes the dual source: postgres now boots empty and the server applies the entire migration ladder + seed at startup via `RunMigrations` + `RunSeed`. Same pattern Helm has used since day one. Bundled with four ride-along audit findings whose fixes are in adjacent code (column rename, missing column, dropped orphan columns, new build-identity endpoint) so operators take the schema-change pain only once.
### Breaking Changes
- **`deploy/docker-compose.yml` postgres no longer initdb-mounts the migration files or `seed.sql`.** Operators running on a populated `postgres_data` volume from a pre-U-3 release see no behavioral change (the schema is already in place; `RunMigrations` is `IF NOT EXISTS` and `RunSeed` is `ON CONFLICT DO NOTHING`). Operators running on a *fresh* clone now rely on the server to apply both — which is the bug fix. There is no rollback path other than re-introducing the dual-source-of-truth hazard. See `internal/repository/postgres/db.go::RunSeed` for the runtime contract.
- **`migrations/000017_db_coupling_cleanup.up.sql` renames `renewal_policies.retry_interval_minutes``retry_interval_seconds`.** The column always held seconds; the column name lied (`cat-o-retry_interval_unit_mismatch`). Operators running raw SQL against the old name need to update their queries. The Go layer (`internal/repository/postgres/renewal_policy.go`) is updated in lockstep so the in-tree code path is unaffected.
- **`migrations/000017_db_coupling_cleanup.up.sql` drops `network_scan_targets.health_check_enabled` and `network_scan_targets.health_check_interval_seconds`.** These columns were declared by a long-ago migration but never wired into Go code (`cat-o-health_check_column_orphans`) — schema noise that confused operators reading raw SQL. Anyone with custom dashboards selecting those columns will break.
- **The compose demo overlay (`deploy/docker-compose.demo.yml`) no longer initdb-mounts `seed_demo.sql`.** It now sets `CERTCTL_DEMO_SEED=true` and the server applies the demo seed at boot via `RunDemoSeed` after baseline migrations + seed.sql are in place. Same single-source-of-truth pattern as the production path.
### Added
- **Migration `000017_db_coupling_cleanup`** (up + down). Bundles three schema changes in idempotent SQL: (1) rename `renewal_policies.retry_interval_minutes``retry_interval_seconds` (DO $$ guard so re-application is safe), (2) add `notification_events.created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, (3) drop the orphan `network_scan_targets.health_check_*` columns. Reduces operator-visible "schema-change releases" from four to one.
- **`internal/repository/postgres.RunSeed`** — runtime equivalent of the deleted initdb mount for `seed.sql`. Called from `cmd/server/main.go` immediately after `RunMigrations`. Idempotent (every INSERT in the shipped seed uses `ON CONFLICT (id) DO NOTHING`); missing-file is a no-op so operators with custom packaging that strips the seed don't break.
- **`internal/repository/postgres.RunDemoSeed`** + **`config.DatabaseConfig.DemoSeed`** + **`CERTCTL_DEMO_SEED` env var.** Replaces the deleted `seed_demo.sql` initdb mount. The compose demo overlay sets `CERTCTL_DEMO_SEED=true` and the server applies the demo seed after baseline. Same idempotency contract as the baseline path. Default-off so a vanilla deploy never lands fake-history rows.
- **`GET /api/v1/version` endpoint** + **`internal/api/handler.VersionHandler`**. Returns `{version, commit, modified, build_time, go_version}` from `runtime/debug.ReadBuildInfo()` with ldflags-supplied `Version` taking priority. Wired through the no-auth dispatch in `cmd/server/main.go` so probes and rollout systems can read build identity without Bearer credentials. Audit middleware excludes the path so rollout polls don't dominate the audit trail. Closes `cat-u-no_version_endpoint`.
- **`notification_events.created_at` column** is now populated by `NotificationRepository.Create` (with a `time.Now()` fallback when the caller leaves it zero) and read back by `scanNotification`. Pre-U-3 the JSON API serialised `0001-01-01T00:00:00Z` — closes `cat-o-notification_created_at_dead_field`.
- **Five regression tests** for the U-3 contract: `TestRunSeed_AppliesIdempotently`, `TestRunSeed_MissingFileIsNoOp`, `TestRunDemoSeed_AppliesIdempotently`, `TestMigration000017_RetryIntervalRename`, `TestMigration000017_NotificationCreatedAt`, `TestMigration000017_HealthCheckOrphansDropped`, plus `TestNotificationRepository_CreatedAt_IsPersisted` / `TestNotificationRepository_CreatedAt_DefaultsToNow` for the round-trip. All testcontainers-gated (skipped under `-short`). Three handler-layer unit tests pin `/api/v1/version` (`TestVersion_ReturnsBuildInfo`, `TestVersion_RejectsNonGet`, `TestVersion_LdflagsOverride`).
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden migration mount in compose initdb (U-3)`) — grep-fails the build if any `migrations/.*\.sql` or `seed.*\.sql` file is re-mounted into `/docker-entrypoint-initdb.d` in any compose file. Catches future drift before a fresh-clone operator hits it.
### Changed
- **`deploy/docker-compose.yml`** + **`deploy/docker-compose.test.yml`** — postgres `volumes:` no longer mount migrations or seed files; postgres healthcheck gains `start_period: 30s`; certctl-server healthcheck gains `start_period: 30s` to absorb the runtime migration + seed application window on first boot.
- **`deploy/docker-compose.demo.yml`** — replaces the `seed_demo.sql` initdb mount with the `CERTCTL_DEMO_SEED=true` env var on `certctl-server`.
- **`migrations/seed.sql`** — `INSERT INTO renewal_policies` updated to use the new `retry_interval_seconds` column name (lockstep with migration 000017).
- **`internal/repository/postgres/renewal_policy.go`** — column references updated to `retry_interval_seconds` across SELECT, INSERT, and UPDATE sites (lockstep with migration 000017).
### Closed audit findings
- `cat-u-seed_initdb_schema_drift` (P1, primary U-3 finding)
- `cat-o-retry_interval_unit_mismatch` (P1)
- `cat-o-notification_created_at_dead_field` (P2)
- `cat-o-health_check_column_orphans` (P1)
- `cat-u-no_version_endpoint` (P2)
### G-1: JWT silent auth downgrade — closed end-to-end
> Pre-G-1 the config validator accepted `CERTCTL_AUTH_TYPE=jwt` and the startup log faithfully echoed `"authentication enabled" "type"="jwt"`. Reasonable people read that and concluded JWT was on. It wasn't. The auth-middleware wiring at `cmd/server/main.go` unconditionally routed every request through the api-key bearer middleware regardless of `cfg.Auth.Type`. So `CERTCTL_AUTH_TYPE=jwt` quietly compared incoming `Authorization: Bearer <something>` against whatever string the operator put in `CERTCTL_AUTH_SECRET` — real JWT clients got 401, and operators who treated `CERTCTL_AUTH_SECRET` as a *signing* secret (because they thought they were configuring JWT) had effectively handed an attacker an api-key. A security finding masquerading as a config option. We chose to remove the option rather than ship JWT middleware — the audit-recommended structural fix that closes the hazard. Operators who actually need JWT/OIDC front certctl with an authenticating gateway (oauth2-proxy / Envoy `ext_authz` / Traefik `ForwardAuth` / Pomerium / Authelia) and run the upstream certctl with `CERTCTL_AUTH_TYPE=none`. The same pattern works on docker-compose and Helm.
### Breaking Changes
- **`CERTCTL_AUTH_TYPE=jwt` is no longer accepted.** Pre-G-1 the value was silently downgraded to api-key middleware. Post-G-1 the server fails at startup with a dedicated diagnostic naming the authenticating-gateway pattern. Operators with this in their env block must either switch to `api-key` (if they were de facto using api-key auth all along — same Bearer token continues to work) or switch to `none` and front certctl with an oauth2-proxy / Envoy / Traefik / Pomerium gateway. See [`docs/upgrade-to-v2-jwt-removal.md`](docs/upgrade-to-v2-jwt-removal.md).
- **Helm chart `server.auth.type=jwt` now fails at `helm install` / `helm upgrade` template time.** New `certctl.validateAuthType` template helper runs on every template that depends on `.Values.server.auth.type` (`server-deployment.yaml`, `server-configmap.yaml`, `server-secret.yaml`) and fails the render with a pointer at the gateway-fronting pattern.
- **OpenAPI spec `auth_type` enum no longer includes `jwt`.** API consumers checking `/api/v1/auth/info` against the spec will see a smaller enum.
### Removed
- Documented references to JWT in the certctl auth surface (config docblocks, middleware/health-handler comments, `.env.example`, `docs/architecture.md` middleware-stack bullet). Connector-level JWT references (Google OAuth2 service-account JWT in `internal/connector/discovery/gcpsm/`, `internal/connector/issuer/googlecas/`; step-ca's provisioner one-time-token JWT in `internal/connector/issuer/stepca/`) are unrelated and untouched — those are external-protocol uses, not certctl's own auth shape.
### Added
- **`config.AuthType` typed alias** with `AuthTypeAPIKey` / `AuthTypeNone` exported constants. Single source of truth for the allowed set across the validator, the runtime defense-in-depth switch in `main.go`, and the helm chart's `validateAuthType` helper.
- **`config.ValidAuthTypes()`** helper returning the complete allowed set; pinned by a property test (`TestValidAuthTypesDoesNotContainJWT`) that fails the build if `"jwt"` is ever re-added to the slice.
- **Defense-in-depth runtime guard** in `cmd/server/main.go` immediately after `config.Load()` — a `switch config.AuthType(cfg.Auth.Type)` that exits 1 if the validator was bypassed (test harness, alt config loader, env-var rebinding).
- **`certctl.validateAuthType` Helm template helper** mirroring the existing `certctl.tls.required` pattern. Fails template render on any `server.auth.type` outside `{api-key, none}`.
- **`docs/architecture.md` "Authenticating-gateway pattern (JWT, OIDC, mTLS)"** section explaining the design rationale for the narrow in-process auth surface and listing oauth2-proxy / Envoy `ext_authz` / Traefik `ForwardAuth` / Pomerium / Authelia / Caddy `forward_auth` / Apache `mod_auth_openidc` / nginx `auth_request` as the standard fronting options.
- **`docs/upgrade-to-v2-jwt-removal.md`** migration guide. Same shape as `docs/upgrade-to-tls.md`. Walks through the dedicated startup error, both recovery paths (`api-key` vs gateway-fronting), a complete docker-compose oauth2-proxy walkthrough, Traefik ForwardAuth and Envoy `ext_authz` patterns, and rollback posture.
- **`deploy/helm/certctl/README.md`** "JWT / OIDC via authenticating gateway" section with a Kubernetes-flavored oauth2-proxy + certctl walkthrough.
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden auth-type literal regression guard (G-1)`) — grep-fails the build if `"jwt"` appears as an auth-type literal in production code or spec. Connector packages exempt (legitimate external-protocol uses).
- **Negative test coverage** in `internal/config/config_test.go`: `TestValidate_JWTAuth_RejectedDedicated` (two table rows pinning that the dedicated G-1 error fires regardless of whether `Secret` is set), `TestValidAuthTypesDoesNotContainJWT` (property-level guard), `TestValidAuthTypesIsExactly_APIKey_None` (allowed-set contract), `TestValidate_GenericInvalidAuthType` (pins that other invalid values still surface the generic invalid-auth-type error, so the dedicated G-1 path doesn't accidentally swallow non-jwt typos).
### Changed
- `internal/api/middleware/middleware.go::AuthConfig.Type` field comment now references the typed `config.AuthType` constants instead of an inline string enumeration.
- `internal/api/handler/health.go::HealthHandler.AuthType` field comment same treatment.
- `internal/api/handler/health_test.go` — the prior `TestAuthInfo_ReturnsAuthType_JWT` (which asserted the handler echoed `"jwt"`, baking the silent-downgrade lie into the regression suite) is removed; the pre-existing `TestAuthInfo_ReturnsAuthType_APIKey` continues to cover the api-key happy path.
- Auth-disabled startup log in `main.go` now points operators at the authenticating-gateway pattern explicitly.
### U-2: Dockerfile HEALTHCHECK protocol mismatch — closed end-to-end
> Pre-U-2 the published `ghcr.io/shankar0123/certctl-server` image shipped with `HEALTHCHECK CMD curl -f http://localhost:8443/health`. The server has been HTTPS-only since the v2.2 HTTPS-Everywhere milestone (`cmd/server/main.go::ListenAndServeTLS`, no plaintext fallback, TLS 1.3 pinned), so the probe failed every interval and Docker marked the container `unhealthy` indefinitely. Operators inside docker-compose / Helm / the example stacks were unaffected — compose overrides the HEALTHCHECK with `--cacert + https://`, Helm uses explicit `httpGet` probes that ignore Docker's HEALTHCHECK, and every example compose file overrides with `curl -sfk https://localhost:8443/health`. But anyone running bare `docker run` / Docker Swarm / Nomad / ECS — exactly the "I just pulled the published image" path — saw permanent `unhealthy` status and (depending on orchestrator policy) a restart-loop. Recon for U-2 also surfaced two adjacent bugs from the same v2.2 milestone gap: the Helm chart's `readinessProbe.httpGet.path` pointed at `/readyz`, a route the server doesn't register (only `/health` and `/ready` are wired and bypass the auth middleware), so K8s readiness probes were getting 404/auth-rejection and pods stayed `NotReady`; and the agent image had no HEALTHCHECK at all (the compose override called `pgrep -f certctl-agent` against an image that didn't ship `procps` — latent always-fail). All three are closed in this commit.
### Fixed
- **`Dockerfile` HEALTHCHECK now speaks HTTPS.** Bare `docker run` / Swarm / Nomad / ECS users no longer see `unhealthy` forever. The probe uses `curl -fsk https://localhost:8443/health``-k` (insecure) is acceptable because the probe is localhost-to-localhost: the same process serving the cert is being probed; the probe never traverses a network. Compose / Helm / examples already perform full cert-chain validation and are unaffected.
- **Helm `server.readinessProbe.httpGet.path` corrected from `/readyz` to `/ready`.** The `/readyz` path was never registered as a no-auth route (see `internal/api/router/router.go:81` and `cmd/server/main.go:920`), so K8s readiness probes received 401 (api-key auth rejection) or 404 (when auth was disabled). Pods previously failed to report Ready under most realistic Helm deployments. Liveness probe path (`/health`) was already correct and is unchanged.
- **`docs/connectors.md` curl examples** (15 sites) updated from `http://localhost:8443/...` to `https://localhost:8443/...` with a one-time `--cacert "$CA"` extraction note matching the existing pattern in `docs/quickstart.md`. Pre-U-2 these examples silently failed against the HTTPS listener.
### Added
- **`Dockerfile.agent` HEALTHCHECK** — `pgrep -f certctl-agent` process-presence check (the agent has no HTTP listener; presence is the right primitive). Bare-`docker run` agents now report health-status the same way compose-managed ones do. Also adds `procps` to the runtime image so `pgrep` is actually available — pre-U-2 the docker-compose override at `deploy/docker-compose.yml:173` called `pgrep -f certctl-agent` against an image that lacked it (latent always-fail; container was reported unhealthy in compose too, just rarely noticed because nothing acted on the signal).
- **`deploy/test/healthcheck_test.go`** (`//go:build integration`) — image-level integration tests. `TestPublishedServerImage_HealthcheckSpecUsesHTTPS` builds the server image, inspects `Config.Healthcheck.Test` via `docker inspect`, and asserts the array contains `https://localhost:8443/health` and `-k`, and does NOT contain `http://localhost:8443/health` (negative regression contract). `TestPublishedAgentImage_HealthcheckSpecExists` builds the agent image and asserts the HEALTHCHECK uses `pgrep` against `certctl-agent`. Both tests `t.Skip` cleanly when docker isn't available (sandbox / CI without docker-in-docker). A third runtime test (`TestPublishedServerImage_HealthcheckTransitionsToHealthy`) is a `t.Skip` placeholder until the harness wires a sidecar postgres for image-level smoke — documented honestly so the next refactor adopts it instead of rediscovering the gap.
- **CI regression guardrail** in `.github/workflows/ci.yml` (`Forbidden plaintext HEALTHCHECK regression guard (U-2)`) — grep-fails the build if any `Dockerfile*` carries `HEALTHCHECK.*http://` or `curl -f http://localhost:8443/health`. Comments exempt; the `docs/upgrade-to-tls.md:182` post-cutover invariant string (which deliberately documents the expected-failure shape) is out of the guardrail's scope because the guardrail only scans Dockerfiles.
### Changed
- `Dockerfile` final-stage HEALTHCHECK lines now carry a long-form docblock explaining the `-k` design choice, the published-image vs compose vs Helm vs examples coverage matrix, and cross-references to the audit closure + the integration test.
- `Dockerfile.agent` runtime stage adds `procps` to the apk install so the new HEALTHCHECK and the existing compose override both have a working `pgrep`.
- `deploy/helm/certctl/values.yaml` server probes block now carries an explanatory comment naming the registered probe routes (`/health`, `/ready`) and the U-2 closure rationale for the `/readyz``/ready` correction.
## [2.2.0] — 2026-04-19
### HTTPS Everywhere — The Irony
+1 -28
View File
@@ -76,34 +76,7 @@ USER certctl
EXPOSE 8443
# Image-level HEALTHCHECK for bare `docker run` / Docker Swarm / Nomad / ECS.
#
# U-2 (P1, cat-u-healthcheck_protocol_mismatch): pre-U-2 this probe used
# `curl -f http://localhost:8443/health`, which always failed against the
# HTTPS-only listener (HTTPS-Everywhere milestone, v2.2 / tag v2.0.47 —
# `cmd/server/main.go::ListenAndServeTLS`, no plaintext fallback, TLS 1.3
# pinned). Operators outside docker-compose / Helm saw permanent
# `unhealthy` status and a restart-loop the first time they pulled the
# image. The compose stack overrides this HEALTHCHECK with `--cacert` to
# the bootstrap CA bundle (deploy/docker-compose.yml:126); the Helm chart
# uses explicit `httpGet` probes with `scheme: HTTPS` and ignores Docker's
# HEALTHCHECK; every example compose file in `examples/*/docker-compose.yml`
# overrides with `curl -sfk https://localhost:8443/health`. This image-
# level probe is for the bare-`docker run` consumer ONLY.
#
# `-k` (insecure) is acceptable here because the probe is localhost-to-
# localhost: the same process serving the cert is being probed; the probe
# never traverses a network. Pinning a `--cacert` is not viable for the
# published image because the bootstrap cert is per-deploy (generated into
# the `certs` named volume on first up; operator-supplied via Helm's
# `existingSecret` or cert-manager). Compose / Helm / examples already
# perform full cert-chain validation and are unaffected.
#
# CI grep guardrail at .github/workflows/ci.yml ("Forbidden plaintext
# HEALTHCHECK regression guard (U-2)") blocks reintroduction of the
# `http://` shape. Image-level integration test in
# deploy/test/healthcheck_test.go pins the contract end-to-end.
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=5 \
CMD curl -fsk https://localhost:8443/health || exit 1
CMD curl -f http://localhost:8443/health || exit 1
ENTRYPOINT ["/app/server"]
+1 -23
View File
@@ -36,14 +36,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
# Stage 2: Runtime
FROM alpine:3.19
# U-2: `procps` ships pgrep, which the HEALTHCHECK below uses to verify the
# agent process is alive. Pre-U-2 the deploy/docker-compose.yml agent
# HEALTHCHECK called `pgrep -f certctl-agent` against this image but
# pgrep wasn't installed — the compose probe was a latent always-fail.
# Adding procps here fixes both the new image-level HEALTHCHECK and the
# pre-existing compose override. Adds ~250KB to the image; acceptable for
# observability parity with the server image.
RUN apk add --no-cache ca-certificates curl procps
RUN apk add --no-cache ca-certificates curl
RUN addgroup -g 1000 certctl && \
adduser -D -u 1000 -G certctl certctl
@@ -58,19 +51,4 @@ RUN mkdir -p /var/lib/certctl/keys && \
USER certctl
# Image-level HEALTHCHECK for bare `docker run` / Docker Swarm / Nomad / ECS.
#
# U-2 (P1, cat-u-healthcheck_protocol_mismatch — adjacent fix): the agent
# has no HTTP listener (it polls the server via outbound HTTPS), so a
# process-presence check is the correct primitive. Pre-U-2 the agent image
# shipped with no HEALTHCHECK at all, so bare-`docker run` operators got
# zero health signal and orchestrators that key off Docker's HEALTHCHECK
# (Swarm, Nomad, ECS) saw the container reported as `none`. The compose
# override at deploy/docker-compose.yml:173 used the same `pgrep -f
# certctl-agent` shape; we mirror it here so the published image has
# parity with the compose stack and the override on docker-compose.yml
# becomes redundant-but-correct rather than load-bearing.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD pgrep -f certctl-agent > /dev/null || exit 1
ENTRYPOINT ["/app/agent"]
+3 -246
View File
@@ -132,14 +132,7 @@ paths:
properties:
auth_type:
type: string
# G-1 (P1): "jwt" removed from this enum after the silent
# auth downgrade was identified — no JWT middleware ships
# with certctl. Operators who need JWT/OIDC front certctl
# with an authenticating gateway (oauth2-proxy / Envoy /
# Traefik / Pomerium) and set CERTCTL_AUTH_TYPE=none
# upstream. See docs/architecture.md "Authenticating-
# gateway pattern".
enum: [api-key, none]
enum: [api-key, jwt, none]
required:
type: boolean
@@ -163,50 +156,6 @@ paths:
"401":
description: Unauthorized
/api/v1/version:
get:
tags: [Health]
summary: Build identity (version, commit, Go runtime)
description: |
Returns the running server's build identity. Served without
auth so rollout systems and blackbox probes can read it without
Bearer credentials. U-3 ride-along (cat-u-no_version_endpoint).
Excluded from audit logging because rollout polling would
otherwise dominate the audit trail.
The Version field follows a fallback ladder: ldflags-supplied
value > VCS commit SHA > "dev". Commit / Modified / BuildTime
come from runtime/debug.BuildInfo (Go 1.18+ stamps these on
every module-tracked build). GoVersion is runtime.Version().
security: []
operationId: getVersion
responses:
"200":
description: Build identity
content:
application/json:
schema:
type: object
required: [version, commit, modified, build_time, go_version]
properties:
version:
type: string
description: Release tag (ldflags-supplied) or VCS SHA fallback or "dev"
example: v2.0.51
commit:
type: string
description: Git SHA from runtime/debug.BuildInfo (vcs.revision); empty when not VCS-tracked
modified:
type: boolean
description: True when build had uncommitted changes (vcs.modified)
build_time:
type: string
description: RFC 3339 build timestamp (vcs.time); empty when not VCS-tracked
go_version:
type: string
description: Go toolchain version that compiled the binary (runtime.Version())
example: go1.25.9
# ─── Certificates ────────────────────────────────────────────────────
/api/v1/certificates:
get:
@@ -470,69 +419,6 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/api/v1/certificates/bulk-renew:
post:
tags: [Certificates]
summary: Bulk renew certificates by criteria or explicit IDs
description: |
Enqueues a renewal job for every matching managed certificate. Mirrors POST
/api/v1/certificates/bulk-revoke shape exactly so operators who already know
that contract have zero new surface to learn. L-1 closure
(cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped per-cert HTTP calls;
post-L-1 it's a single POST. Status filter: certs in
Archived/Revoked/Expired/RenewalInProgress are silent-skipped (TotalSkipped++)
rather than returned as errors. Asynchronous: the action ENQUEUES jobs the
scheduler picks up; per-cert {certificate_id, job_id} pairs are returned in
enqueued_jobs. NOT admin-gated — bulk renewal is non-destructive.
operationId: bulkRenewCertificates
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BulkRenewRequest"
responses:
"200":
description: Bulk renewal result
content:
application/json:
schema:
$ref: "#/components/schemas/BulkRenewResult"
"400":
$ref: "#/components/responses/BadRequest"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/certificates/bulk-reassign:
post:
tags: [Certificates]
summary: Bulk reassign owner (and optionally team) for a set of certificates
description: |
Updates owner_id (required) and team_id (optional) on every certificate in
certificate_ids. Skips certs already owned by the target (silent no-op,
TotalSkipped++). L-2 closure (cat-l-8a1fb258a38a). Narrower than bulk-renew:
explicit IDs only, no criteria-mode. The OwnerID is validated upfront — a
non-existent owner returns 400 before any cert is touched. Verb chosen as
POST (not PATCH) for codebase consistency with bulk-revoke and bulk-renew.
operationId: bulkReassignCertificates
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BulkReassignRequest"
responses:
"200":
description: Bulk reassignment result
content:
application/json:
schema:
$ref: "#/components/schemas/BulkReassignResult"
"400":
$ref: "#/components/responses/BadRequest"
"500":
$ref: "#/components/responses/InternalError"
# ─── Certificate Export ──────────────────────────────────────────────
/api/v1/certificates/{id}/export/pem:
get:
@@ -3555,15 +3441,6 @@ components:
- Archived
ManagedCertificate:
# D-5 (cat-f-ae0d06b6588f, master): per-issuance fields
# (serial_number, fingerprint_sha256, key_algorithm, key_size,
# issued_at) are intentionally NOT declared here. They live on
# CertificateVersion (per-issuance evidence) and are fetched via
# /api/v1/certificates/{id}/versions. ManagedCertificate is the
# management envelope; CertificateVersion is the issuance record.
# Pre-D-5 the TS Certificate interface had them as optional and
# the dashboard's Key Algorithm / Key Size rows always rendered
# '—' as a result. The TS trim restores parity with this schema.
type: object
properties:
id:
@@ -3720,116 +3597,6 @@ components:
type: string
description: Per-certificate error details for failed revocations
# L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
# bulk-renew + bulk-reassign request/result schemas. Mirror
# BulkRevokeRequest/Result envelope shape so frontend bulk-result
# rendering is one helper. See internal/domain/bulk_renewal.go +
# internal/domain/bulk_reassignment.go for the Go-side source of
# truth.
BulkRenewRequest:
type: object
description: Criteria for bulk renewal. At least one selector required.
properties:
profile_id:
type: string
description: Renew all certificates matching this profile
owner_id:
type: string
description: Renew all certificates owned by this owner
agent_id:
type: string
description: Renew all certificates deployed via this agent
issuer_id:
type: string
description: Renew all certificates issued by this issuer
team_id:
type: string
description: Renew all certificates owned by members of this team
certificate_ids:
type: array
items:
type: string
description: Explicit list of certificate IDs to renew
BulkEnqueuedJob:
type: object
properties:
certificate_id:
type: string
job_id:
type: string
description: ID of the renewal job created for this certificate
BulkRenewResult:
type: object
properties:
total_matched:
type: integer
description: Number of certificates matching the criteria
total_enqueued:
type: integer
description: Number of renewal jobs successfully created
total_skipped:
type: integer
description: Certs already RenewalInProgress / Revoked / Archived / Expired (silent no-op)
total_failed:
type: integer
description: Number of certificates whose enqueue path returned an error
enqueued_jobs:
type: array
items:
$ref: "#/components/schemas/BulkEnqueuedJob"
description: Per-certificate {certificate_id, job_id} pairs for the successful enqueue path
errors:
type: array
items:
type: object
properties:
certificate_id:
type: string
error:
type: string
description: Per-certificate error details for the failure path
BulkReassignRequest:
type: object
required: [certificate_ids, owner_id]
properties:
certificate_ids:
type: array
items:
type: string
description: Explicit list of certificate IDs to reassign
owner_id:
type: string
description: Required. New owner_id for every cert in certificate_ids.
team_id:
type: string
description: Optional. When non-empty, also updates team_id on every cert.
BulkReassignResult:
type: object
properties:
total_matched:
type: integer
total_reassigned:
type: integer
description: Number of certs whose owner_id (and optionally team_id) was actually mutated
total_skipped:
type: integer
description: Certs already owned by the target (silent no-op)
total_failed:
type: integer
errors:
type: array
items:
type: object
properties:
certificate_id:
type: string
error:
type: string
# ─── Issuers ─────────────────────────────────────────────────────
IssuerType:
type: string
@@ -3913,18 +3680,8 @@ components:
registered_at:
type: string
format: date-time
# G-2 (P1): the `api_key_hash` field was REMOVED from this
# schema after cat-s5-apikey_leak audit closure. The DB column
# still exists (migrations/000001_initial_schema.up.sql) and
# the server still populates the in-memory struct for the
# auth-lookup path (repository.AgentRepository::GetByAPIKey),
# but the JSON wire shape no longer carries it — see
# internal/domain/connector.go::Agent::APIKeyHash + MarshalJSON
# for the redaction enforcement and docs/architecture.md ER
# diagram for the database-vs-API distinction. Do NOT re-add
# the field here without first removing the JSON-shape redaction
# in the domain package; the CI guardrail at
# .github/workflows/ci.yml will block re-introduction either way.
api_key_hash:
type: string
os:
type: string
architecture:
+11 -135
View File
@@ -39,26 +39,6 @@ func main() {
os.Exit(1)
}
// Defense-in-depth runtime guard for the auth-type discriminator.
//
// G-1 (P1): config.Load() already runs Validate() which rejects "jwt"
// and any value outside config.ValidAuthTypes() with a dedicated
// diagnostic. This switch is belt-and-braces — if a future refactor
// bypasses the validator (test harness, alt config loader, env-var
// rebinding after Load) the server must not silently boot with an
// unsupported auth shape. The error path uses fmt.Fprintf because
// the slog logger is constructed from cfg below this point; we want
// the failure to be visible regardless of log-level configuration.
switch config.AuthType(cfg.Auth.Type) {
case config.AuthTypeAPIKey, config.AuthTypeNone:
// ok — fall through
default:
fmt.Fprintf(os.Stderr,
"unsupported auth type at runtime: %q (valid: %v) — config validation should have caught this; refusing to start\n",
cfg.Auth.Type, config.ValidAuthTypes())
os.Exit(1)
}
// Set up structured logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: cfg.GetLogLevel(),
@@ -86,41 +66,6 @@ func main() {
}
logger.Info("migrations completed")
// Apply baseline seed data.
//
// U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 seed.sql was mounted
// into postgres `/docker-entrypoint-initdb.d/` alongside a hand-curated
// subset of migrations. Adding a migration that introduced a new column
// referenced by seed.sql (cat-o-retry_interval_unit_mismatch /
// policy_rules.severity / etc.) without also updating the compose volume
// mounts caused initdb to crash on first up. Post-U-3 the compose stack
// drops all initdb mounts; postgres comes up with empty schema, the
// server runs RunMigrations above, then this RunSeed call lands the
// baseline data — all from a single source of truth (this binary).
// See internal/repository/postgres/db.go::RunSeed for the contract.
logger.Info("applying baseline seed", "path", cfg.Database.MigrationsPath)
if err := postgres.RunSeed(db, cfg.Database.MigrationsPath); err != nil {
logger.Error("failed to apply seed data", "error", err)
os.Exit(1)
}
logger.Info("seed completed")
// Apply demo overlay seed when CERTCTL_DEMO_SEED=true. Pre-U-3 the demo
// overlay (deploy/docker-compose.demo.yml) mounted seed_demo.sql into
// postgres `/docker-entrypoint-initdb.d/`; that broke once U-3 dropped
// the initdb migration mounts (the demo seed references tables that
// wouldn't exist at initdb time). The runtime path here is the
// post-U-3 replacement. Default-off so a vanilla deploy never lands
// fake-history rows. See postgres.RunDemoSeed for the contract.
if cfg.Database.DemoSeed {
logger.Info("applying demo seed (CERTCTL_DEMO_SEED=true)", "path", cfg.Database.MigrationsPath)
if err := postgres.RunDemoSeed(db, cfg.Database.MigrationsPath); err != nil {
logger.Error("failed to apply demo seed data", "error", err)
os.Exit(1)
}
logger.Info("demo seed completed")
}
// Initialize repositories with real PostgreSQL connection
auditRepo := postgres.NewAuditRepository(db)
certificateRepo := postgres.NewCertificateRepository(db)
@@ -411,14 +356,6 @@ func main() {
// Initialize bulk revocation service
bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger)
// L-1 master (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a): bulk-renew
// and bulk-reassign services. Mirror BulkRevocationService wiring so
// the construction site is co-located with the existing bulk endpoint.
// keygenMode is threaded so bulk-renew jobs land in the same initial
// status (AwaitingCSR vs Pending) as single-cert TriggerRenewal.
bulkRenewalService := service.NewBulkRenewalService(certificateRepo, jobRepo, auditService, logger, cfg.Keygen.Mode)
bulkReassignmentService := service.NewBulkReassignmentService(certificateRepo, ownerRepo, auditService, logger)
// Initialize stats and metrics services
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
// I-005: wire the notification repository so DashboardSummary.NotificationsDead
@@ -449,13 +386,6 @@ func main() {
statsHandler := handler.NewStatsHandler(statsService)
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
// U-3 ride-along (cat-u-no_version_endpoint, P2): the version handler
// answers GET /api/v1/version with build identity (ldflags Version,
// VCS commit/dirty/timestamp, Go runtime version). Wired through the
// no-auth dispatch + audit ExcludePaths below so probes and rollout
// systems can read it without Bearer credentials and without flooding
// the audit trail.
versionHandler := handler.NewVersionHandler()
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
@@ -464,11 +394,6 @@ func main() {
exportHandler := handler.NewExportHandler(exportService)
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
// L-1 master closure: handlers for the new bulk-renew + bulk-reassign
// endpoints. Both registered via HandlerRegistry below; dispatched
// through the standard authed middleware chain (no admin gate).
bulkRenewalHandler := handler.NewBulkRenewalHandler(bulkRenewalService)
bulkReassignmentHandler := handler.NewBulkReassignmentHandler(bulkReassignmentService)
// Initialize digest service (requires email notifier)
var digestService *service.DigestService
@@ -545,16 +470,6 @@ func main() {
// because they share the NotificationServicer dependency (same placement
// pattern as I-001's SetJobRetryInterval above).
sched.SetNotificationRetryInterval(cfg.Scheduler.NotificationRetryInterval)
// C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388): pre-C-1
// the SetShortLivedExpiryCheckInterval setter was defined + tested but
// never called from main.go, so the 30-second hardcoded default in
// scheduler.NewScheduler was effectively the only value. Operators
// running short-lived cert workloads with high churn (or low-churn
// workloads wanting to relax the cadence) had no working knob despite
// CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL being documented. Wire it
// here alongside the other scheduler-interval setters so the
// documented env var actually takes effect.
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
if cfg.NetworkScan.Enabled {
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
@@ -618,10 +533,7 @@ func main() {
Export: exportHandler,
Digest: *digestHandler,
HealthChecks: healthCheckHandler,
BulkRevocation: bulkRevocationHandler,
BulkRenewal: bulkRenewalHandler,
BulkReassignment: bulkReassignmentHandler,
Version: versionHandler,
BulkRevocation: bulkRevocationHandler,
})
// Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled {
@@ -703,7 +615,7 @@ func main() {
// compatibility CERTCTL_AUTH_SECRET is synthesized into legacy-key-N
// entries with Admin=false.
var namedKeys []middleware.NamedAPIKey
if config.AuthType(cfg.Auth.Type) != config.AuthTypeNone {
if cfg.Auth.Type != "none" {
// Translate typed config.NamedAPIKey -> middleware.NamedAPIKey. The
// two structs are field-compatible but live in different packages to
// preserve the config→middleware dependency direction.
@@ -751,17 +663,6 @@ func main() {
})
logger.Info("request body size limit enabled", "max_bytes", cfg.Server.MaxBodySize)
// Security headers middleware — applies HSTS, X-Frame-Options,
// X-Content-Type-Options, Referrer-Policy, and a conservative CSP
// on every response. H-1 closure (cat-s11-missing_security_headers):
// pre-H-1 the server emitted zero security headers; an attacker
// could clickjack the dashboard, sniff MIME types on JSON/PEM
// responses, or load resources from arbitrary origins via inline
// scripts. Defaults are conservative — see internal/api/middleware/
// securityheaders.go::SecurityHeadersDefaults() for the rationale
// per header.
securityHeadersMiddleware := middleware.SecurityHeaders(middleware.SecurityHeadersDefaults())
// API audit log middleware — records every API call to the audit trail
auditAdapter := middleware.NewAuditServiceAdapter(
func(ctx context.Context, actor string, actorType string, action string, resourceType string, resourceID string, details map[string]interface{}) error {
@@ -769,22 +670,16 @@ func main() {
},
)
auditMiddleware := middleware.NewAuditLog(auditAdapter, middleware.AuditConfig{
// /api/v1/version is excluded for the same reason /health and /ready
// are: rollout systems and blackbox probes hammer it on a tight
// interval, and the audit trail's value comes from rare,
// operator-authored mutations — not from sub-second readonly polls.
// U-3 ride-along (cat-u-no_version_endpoint, P2).
ExcludePaths: []string{"/health", "/ready", "/api/v1/version"},
ExcludePaths: []string{"/health", "/ready"},
Logger: logger,
})
logger.Info("API audit logging enabled (excluding /health, /ready, /api/v1/version)")
logger.Info("API audit logging enabled (excluding /health, /ready)")
middlewareStack := []func(http.Handler) http.Handler{
middleware.RequestID,
structuredLogger,
middleware.Recovery,
bodyLimitMiddleware,
securityHeadersMiddleware,
corsMiddleware,
authMiddleware,
auditMiddleware.Middleware,
@@ -809,8 +704,8 @@ func main() {
logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize)
}
if config.AuthType(cfg.Auth.Type) == config.AuthTypeNone {
logger.Warn("authentication disabled (CERTCTL_AUTH_TYPE=none) — not suitable for production except behind an authenticating gateway (oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium)")
if cfg.Auth.Type == "none" {
logger.Warn("authentication disabled (CERTCTL_AUTH_TYPE=none) — not suitable for production")
} else {
logger.Info("authentication enabled", "type", cfg.Auth.Type)
}
@@ -831,29 +726,13 @@ func main() {
if _, err := os.Stat(webDir + "/index.html"); err != nil {
webDir = "./web"
}
// Health/ready routes + EST/SCEP/PKI unauth surface bypass the full
// middleware stack (no auth required). These are registered on the
// inner router without auth, but the outer middleware chain wraps
// everything. Route them directly to the inner router.
//
// H-1 closure (cat-s5-4936a1cf0118): pre-H-1 the noAuthHandler chain
// was RequestID → structuredLogger → Recovery only — missing
// bodyLimitMiddleware that the authed apiHandler chain has. The
// unauth surface includes EST simpleenroll/simplereenroll (RFC 7030),
// SCEP, PKI CRL/OCSP (/.well-known/pki/*), and /health|/ready —
// every one of which accepts a request body. Without a body-size
// cap, an unauthenticated client can send arbitrary-size payloads
// (CSRs, CRL/OCSP requests) and trigger memory pressure on the
// server before the handler ever rejects the input. Post-H-1 the
// same bodyLimitMiddleware that wraps the authed surface also wraps
// the unauth surface — same default cap (CERTCTL_MAX_BODY_SIZE,
// default 1MB), same 413 response on overflow.
// Health/ready routes bypass the full middleware stack (no auth required).
// These are registered on the inner router without auth, but the outer
// middleware chain wraps everything. Route them directly to the inner router.
noAuthHandler := middleware.Chain(apiRouter,
middleware.RequestID,
structuredLogger,
middleware.Recovery,
bodyLimitMiddleware,
securityHeadersMiddleware,
)
dashboardEnabled := false
@@ -990,7 +869,6 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
// Dispatch rules (M-001, audit 2026-04-19, option D):
//
// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection)
// - /api/v1/version → no-auth (U-3 ride-along: build identity for rollout/probes)
// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP)
// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3)
// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword)
@@ -1016,12 +894,10 @@ func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, da
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Health/ready, auth/info, and version bypass auth middleware.
// Health/ready and auth/info bypass auth middleware.
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
// auth/info: React app calls this before login to detect auth mode.
// version: U-3 ride-along (cat-u-no_version_endpoint) — rollout
// systems and blackbox probes need build identity without a key.
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" || path == "/api/v1/version" {
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" {
noAuthHandler.ServeHTTP(w, r)
return
}
-2
View File
@@ -122,8 +122,6 @@ The `volumes` section mounts 10 migration files into PostgreSQL's init directory
**Expert note:** The numbered prefix pattern (`001_`, `002_`, ..., `020_`) ensures deterministic execution order. All migrations use `IF NOT EXISTS` and `ON CONFLICT DO NOTHING` for idempotency, so re-running them against an existing database is safe.
**Stateful volume — first-boot password binding (U-1).** The same "first boot only" semantics that govern migration scripts also govern `POSTGRES_PASSWORD`. The official `postgres` image runs `initdb` exactly once — when `/var/lib/postgresql/data` is empty — and that pass is the only time `POSTGRES_PASSWORD` is written into `pg_authid`. On every subsequent boot, the postgres container ignores the env var and authenticates against whatever password was baked into the data directory on the original `up`. Editing `POSTGRES_PASSWORD` in `.env` after a successful first boot therefore only updates the **certctl-server** container's `CERTCTL_DATABASE_URL` — postgres still expects the previous password, and the server fails to ping with `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01). The certctl-server container surfaces this case explicitly: when SQLSTATE 28P01 fires at startup, the wrap text in `internal/repository/postgres/db.go::wrapPingError` points operators at the two remediation paths — destructive volume teardown via `docker compose -f deploy/docker-compose.yml down -v && up -d --build`, or non-destructive in-place rotation via `docker compose -f deploy/docker-compose.yml exec postgres psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"` followed by a server restart with the matching `POSTGRES_PASSWORD`. Use the destructive path on the demo / first-time setup; use the non-destructive path on any environment that holds data you want to keep.
#### certctl Server
```yaml
+4 -16
View File
@@ -7,20 +7,8 @@
# To start fresh (wipe previous data):
# docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
#
# U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 this overlay mounted
# `seed_demo.sql` into postgres `/docker-entrypoint-initdb.d/`. That worked
# only because the production stack also mounted the migrations there, so
# the schema existed at initdb time. Once U-3 dropped the production
# initdb mounts (single source of truth: server runs RunMigrations + RunSeed
# at boot), the demo seed could no longer be applied at initdb time — the
# tables it references wouldn't exist yet.
#
# Post-U-3 the demo overlay just sets CERTCTL_DEMO_SEED=true; the server
# applies seed_demo.sql at boot via postgres.RunDemoSeed AFTER baseline
# migrations + seed.sql are in place. Same single source of truth, no
# initdb mounts, no schema-vs-seed drift.
services:
certctl-server:
environment:
CERTCTL_DEMO_SEED: "true"
postgres:
volumes:
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/030_seed_demo.sql
+13 -12
View File
@@ -93,17 +93,6 @@ services:
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
#
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10): the test stack used
# to mount a hand-curated subset of migrations + seed.sql + a never-checked-in
# seed_test.sql into postgres `/docker-entrypoint-initdb.d/`. Same hazard as
# the production compose — initdb crashed any time a new migration shipped
# that the seed depended on without the mount list being updated. Post-U-3
# the schema is built EXCLUSIVELY by the server at startup via
# internal/repository/postgres.RunMigrations + RunSeed. Postgres comes up
# empty and the server lands the full ladder + baseline seed in one shot.
# `start_period: 30s` matches the production compose and shields slow CI
# runners from healthcheck flap during initdb.
postgres:
image: postgres:16-alpine
container_name: certctl-test-postgres
@@ -113,6 +102,19 @@ services:
POSTGRES_PASSWORD: testpass
volumes:
- test_postgres_data:/var/lib/postgresql/data
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/025_seed_test.sql
# No seed_demo.sql — start with a clean database for real testing
networks:
certctl-test:
ipv4_address: 10.30.50.2
@@ -123,7 +125,6 @@ services:
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# ---------------------------------------------------------------------------
+11 -29
View File
@@ -53,29 +53,6 @@ services:
- certctl-network
# PostgreSQL database
#
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10):
# Pre-U-3 this stack mounted a hand-curated subset of `migrations/*.up.sql`
# plus `seed.sql` into `/docker-entrypoint-initdb.d/`, and postgres
# initdb-applied them on first boot. The mount list rotted every time a
# new migration shipped that the seed depended on (000013 added
# policy_rules.severity, 000017 renames retry_interval_minutes, etc.) —
# initdb crashed, the container reported `unhealthy` indefinitely, and
# `docker compose -f deploy/docker-compose.yml up -d --build` from a
# fresh clone of v2.0.50 hit it on the first try.
#
# Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
# internal/repository/postgres.RunMigrations + RunSeed. Single source of
# truth, no list to keep in sync. Postgres comes up empty; the server
# waits for it healthy, then applies the full migration ladder + seed in
# one shot. Helm + the dev examples were already runtime-only (Path B)
# and worked through the same window.
#
# `start_period: 30s` gives postgres room to bootstrap on slow runners
# (CI macOS, low-spec laptops) before the healthcheck failure counter
# starts ticking. Pre-U-3 a slow first-init combined with the
# `unhealthy` flap to cascade into certctl-server's `service_healthy`
# depends_on, blocking the whole stack.
postgres:
image: postgres:16-alpine
container_name: certctl-postgres
@@ -87,6 +64,17 @@ services:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
networks:
- certctl-network
healthcheck:
@@ -94,7 +82,6 @@ services:
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Certctl Server (API + scheduler)
@@ -140,11 +127,6 @@ services:
interval: 10s
timeout: 5s
retries: 5
# U-3: server boot now does RunMigrations + RunSeed before listening on
# 8443. On a fresh clone the full migration ladder + seed application
# can take ~10s on a small VM; start_period prevents the first few
# healthcheck attempts from counting as failures while that work runs.
start_period: 30s
restart: unless-stopped
logging:
driver: "json-file"
+2 -2
View File
@@ -246,8 +246,8 @@ helm install certctl certctl/ \
|--------|---------|-------------|
| `server.replicas` | 1 | Number of server replicas |
| `server.port` | 8443 | Server port |
| `server.auth.type` | api-key | Authentication type`api-key` or `none` (G-1: `jwt` removed; for JWT/OIDC use a fronting authenticating gateway, see `docs/architecture.md` and `docs/upgrade-to-v2-jwt-removal.md`) |
| `server.auth.apiKey` | "" | API key (REQUIRED when `auth.type=api-key`) |
| `server.auth.type` | api-key | Authentication type |
| `server.auth.apiKey` | "" | API key (REQUIRED) |
| `server.logging.level` | info | Log level |
| `server.logging.format` | json | Log format |
-148
View File
@@ -1,148 +0,0 @@
# certctl Helm Chart
Production-ready Helm chart for deploying [certctl](https://github.com/shankar0123/certctl) on Kubernetes. Wires up the certctl server (Deployment), PostgreSQL (StatefulSet with PVC), and the agent (DaemonSet — one per node) on a private cluster, with health probes, security contexts, and optional Ingress.
## Quick install
```bash
helm install certctl deploy/helm/certctl/ \
--create-namespace --namespace certctl \
--set server.auth.apiKey="$(openssl rand -base64 32)" \
--set postgresql.auth.password="$(openssl rand -base64 24)"
```
This brings up:
- `<release>-server` Deployment (HTTPS-only on port 8443; TLS 1.3)
- `<release>-postgres` StatefulSet (PostgreSQL 16-alpine, 1 replica, 10Gi PVC by default)
- `<release>-agent` DaemonSet (polls server, generates ECDSA P-256 keys locally)
- Service objects, optional Ingress, and ServiceAccount with RBAC
See [`values.yaml`](values.yaml) for the full configuration surface — issuer settings, target connectors, scheduler intervals, notifier credentials, and resource requests/limits all live there.
## Operational notes
### Postgres password rotation — read this before changing `postgresql.auth.password`
**The trap.** `postgresql.auth.password` is bound to `pg_authid` exactly once — when the StatefulSet's PVC is provisioned and `initdb` runs. The official `postgres:16-alpine` image only runs `initdb` when `/var/lib/postgresql/data` is empty, so on every subsequent rollout the `POSTGRES_PASSWORD` env var is read into the container but **ignored** by postgres itself. The certctl-server container also picks up the new value (via the database URL helper template), so the two halves diverge: server presents the new password, postgres still expects the old one.
**Symptom.** The certctl-server pod's startup log shows:
```
failed to ping database: postgres rejected the configured credentials
(SQLSTATE 28P01 — invalid_password). If you recently rotated POSTGRES_PASSWORD ...
```
That diagnostic is emitted by `internal/repository/postgres/db.go::wrapPingError` — it points operators at the two remediation paths below.
**Remediation, non-destructive (preferred for any environment with real data):**
```bash
# 1. Rotate the password in postgres directly
kubectl -n certctl exec -it <release>-postgres-0 -- \
psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new-password>';"
# 2. Update the secret / Helm values to the same value
helm upgrade <release> deploy/helm/certctl/ \
--reuse-values \
--set postgresql.auth.password='<new-password>'
# 3. Bounce the certctl-server pod so it re-reads the secret
kubectl -n certctl rollout restart deployment/<release>-server
```
**Remediation, destructive (DESTROYS ALL CERTCTL DATA — only acceptable on dev/demo clusters):**
```bash
helm uninstall <release> -n certctl
kubectl -n certctl delete pvc -l \
app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres
helm install <release> deploy/helm/certctl/ \
--namespace certctl \
--set postgresql.auth.password='<new-password>'
```
The PVC re-creates empty, `initdb` runs on first boot of the new postgres pod, and `pg_authid` is seeded with the new password.
**Why we don't fix this in the chart.** The env-vs-`pg_authid` divergence is intrinsic to how the upstream `postgres` image bootstraps — `initdb` is run-once-per-empty-data-dir, and there is no upstream-supported way to make subsequent boots re-seed `pg_authid` from `POSTGRES_PASSWORD`. The ergonomic answer is the runtime diagnostic plus this operational note.
**Cross-references.** Same root cause is documented for the docker-compose path in [`docs/quickstart.md`](../../../docs/quickstart.md) (Warning callout after the `cp .env.example .env` block) and in [`deploy/ENVIRONMENTS.md`](../../ENVIRONMENTS.md) (Stateful volume — first-boot password binding section). The runtime diagnostic itself lives in `internal/repository/postgres/db.go::wrapPingError` with regression coverage in `internal/repository/postgres/db_test.go`.
### Server API key rotation
Unlike the postgres password, `server.auth.apiKey` accepts a comma-separated list, so zero-downtime rotation is straightforward:
```bash
# 1. Add the new key alongside the old
helm upgrade <release> deploy/helm/certctl/ \
--reuse-values \
--set server.auth.apiKey='new-key,old-key'
# 2. Roll your agents / clients over to the new key
# 3. Remove the old key
helm upgrade <release> deploy/helm/certctl/ \
--reuse-values \
--set server.auth.apiKey='new-key'
```
### JWT / OIDC via authenticating gateway
certctl's in-process auth surface is intentionally narrow: `server.auth.type=api-key` for production deployments and `server.auth.type=none` for development. There is no in-process JWT, OIDC, mTLS, or SAML middleware. (`server.auth.type=jwt` was accepted pre-G-1 but silently routed every request through the api-key bearer middleware — silent auth downgrade. The chart now fails at `helm install`/`helm upgrade` template time via the `certctl.validateAuthType` helper if you set it. See [`../../../docs/upgrade-to-v2-jwt-removal.md`](../../../docs/upgrade-to-v2-jwt-removal.md) if you previously had this in your values.)
For deployments that need JWT/OIDC, the canonical Kubernetes-flavored shape is to put oauth2-proxy in front of the certctl Service, attach an authenticating Ingress middleware, and run certctl with `server.auth.type=none`:
```bash
# 1. Install oauth2-proxy (or any OIDC-terminating sidecar) in the same namespace
helm install oauth2-proxy oauth2-proxy/oauth2-proxy \
--namespace certctl \
--set config.clientID="$OIDC_CLIENT_ID" \
--set config.clientSecret="$OIDC_CLIENT_SECRET" \
--set config.cookieSecret="$(openssl rand -base64 32)" \
--set config.configFile='|
provider = "oidc"
oidc_issuer_url = "https://your-issuer/"
upstreams = ["http://<release>-server.certctl.svc.cluster.local:8443"]
pass_authorization_header = true
set_authorization_header = true
email_domains = ["*"]
'
# 2. Install certctl with type=none (gateway terminates auth)
helm install certctl deploy/helm/certctl/ \
--namespace certctl \
--set server.auth.type=none \
--set postgresql.auth.password="$(openssl rand -base64 24)"
# 3. Attach an Ingress that routes through oauth2-proxy
# (Traefik ForwardAuth, nginx auth_request, Envoy ext_authz, etc.)
```
Same root pattern works with Pomerium, Authelia, Caddy `forward_auth`, Apache `mod_auth_openidc`, or any service-mesh `ext_authz`. See [`../../../docs/architecture.md`](../../../docs/architecture.md) "Authenticating-gateway pattern" for the full design rationale and [`../../../docs/upgrade-to-v2-jwt-removal.md`](../../../docs/upgrade-to-v2-jwt-removal.md) for the migration walkthrough.
### TLS certificate sourcing
By default the chart provisions a self-signed cert via the same init-container pattern as the docker-compose deploy. For production, supply an operator-managed Secret (cert-manager, internal CA, etc.) — see [`docs/tls.md`](../../../docs/tls.md) for the full provisioning matrix and [`docs/upgrade-to-tls.md`](../../../docs/upgrade-to-tls.md) for upgrade-from-HTTP procedures.
## Disabling embedded postgres
If you have an existing PostgreSQL cluster, disable the embedded one and point at it directly:
```bash
helm install certctl deploy/helm/certctl/ \
--set postgresql.enabled=false \
--set server.databaseUrl='postgres://certctl:<pw>@my-pg-host:5432/certctl?sslmode=require'
```
The volume-trap section above does **not** apply to this configuration — your postgres operator (or cloud DB) handles password rotation, and you control `pg_authid` directly.
## Uninstall
```bash
helm uninstall <release> -n certctl
# Optional — also delete the postgres PVC (DESTROYS DATA):
kubectl -n certctl delete pvc -l \
app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres
```
By default `helm uninstall` retains the StatefulSet's PVCs, so reinstalling with the same release name preserves the database. If you've changed `postgresql.auth.password` in your values between uninstall and reinstall, you'll hit the trap on the reinstall — apply the non-destructive remediation above, or also delete the PVC.
@@ -169,26 +169,3 @@ per affected resource. No-op when configured correctly.
{{- fail "\n\nserver.tls.certManager.enabled=true but server.tls.certManager.issuerRef.name is empty.\n\nSet:\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md.\n" -}}
{{- end -}}
{{- end }}
{{/*
Auth-type validation gate.
G-1 (P1): pre-G-1 the chart accepted server.auth.type=jwt and the
certctl-server container silently routed every request through the
api-key bearer middleware (no JWT impl ships with certctl). Post-G-1
the chart fails at template-time with a pointer at the authenticating-
gateway pattern. The valid set must stay in sync with
internal/config.ValidAuthTypes() in the Go binary; if you add a value
there you must add it here too (and update the property test in
internal/config/config_test.go that pins both surfaces).
Any template that consumes .Values.server.auth.type should call
`{{ include "certctl.validateAuthType" . }}` at the top so this guard
runs once per affected resource. No-op when configured correctly.
*/}}
{{- define "certctl.validateAuthType" -}}
{{- $valid := list "api-key" "none" -}}
{{- if not (has .Values.server.auth.type $valid) -}}
{{- fail (printf "\n\nserver.auth.type=%q is not supported (valid: %v).\n\nFor JWT/OIDC, run an authenticating gateway in front of certctl\n(oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium) and\nset server.auth.type=none here so the gateway terminates federated\nidentity. See docs/architecture.md \"Authenticating-gateway pattern\"\nand docs/upgrade-to-v2-jwt-removal.md for the migration walkthrough.\n\nG-1 audit closure: pre-G-1 the chart accepted type=jwt and the binary\nsilently downgraded to api-key middleware. The chart now fails at\ntemplate time so misconfigured deployments cannot ship.\n" .Values.server.auth.type $valid) -}}
{{- end -}}
{{- end }}
@@ -1,4 +1,3 @@
{{- include "certctl.validateAuthType" . }}
apiVersion: v1
kind: ConfigMap
metadata:
@@ -1,5 +1,4 @@
{{- include "certctl.tls.required" . }}
{{- include "certctl.validateAuthType" . }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -1,4 +1,3 @@
{{- include "certctl.validateAuthType" . }}
apiVersion: v1
kind: Secret
metadata:
+6 -58
View File
@@ -48,14 +48,7 @@ server:
drop:
- ALL
# Liveness and readiness probes (HTTPS-only as of v2.2).
#
# The two paths exposed for probes are `/health` and `/ready` —
# registered in internal/api/router/router.go:76-85 and bypassing the
# auth middleware via the no-auth list at cmd/server/main.go:920.
# Both serve the same JSON shape today (`{"status":"healthy"}` /
# `{"status":"ready"}`) but exist as separate routes so liveness and
# readiness can diverge in the future without renaming.
# Liveness and readiness probes (HTTPS-only as of v2.2)
livenessProbe:
httpGet:
path: /health
@@ -66,18 +59,9 @@ server:
timeoutSeconds: 5
failureThreshold: 3
# U-2 (P1, cat-u-healthcheck_protocol_mismatch — adjacent fix): pre-U-2
# the readiness probe pointed at `/readyz`, the conventional kube-flavor
# name. The certctl server doesn't register `/readyz` (only `/health`
# and `/ready`) — see cmd/server/main.go:920 and
# internal/api/router/router.go:81. K8s readiness probes therefore
# received a 404 (or, with auth enabled, a 401 from the api-key middleware
# because `/readyz` was NOT in the no-auth bypass set), pods stayed
# `NotReady` indefinitely, and Helm rollouts stalled. Post-U-2 the path
# matches a registered route.
readinessProbe:
httpGet:
path: /ready
path: /readyz
port: https
scheme: HTTPS
initialDelaySeconds: 5
@@ -128,23 +112,10 @@ server:
port: 8443
annotations: {}
# Authentication configuration.
# Valid types: "api-key" (production) or "none" (demo only — disables
# authentication on the API and logs a loud Warn at server startup).
# For JWT/OIDC, run an authenticating gateway in front of certctl
# (oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium)
# and set type=none here so the gateway terminates federated identity.
# See docs/architecture.md "Authenticating-gateway pattern".
#
# G-1 (P1): pre-G-1 the chart accepted server.auth.type=jwt and the
# certctl-server container silently routed every request through the
# api-key bearer middleware — silent auth downgrade. Post-G-1 the
# chart's `certctl.validateAuthType` template helper rejects any value
# outside {api-key, none} at template time. See
# docs/upgrade-to-v2-jwt-removal.md if you previously set type=jwt.
# Authentication configuration
auth:
type: api-key
apiKey: "" # REQUIRED when type=api-key (set via --set or values override).
type: api-key # Options: api-key, none (for demo only)
apiKey: "" # REQUIRED in production - set via --set or values override
# Logging configuration
logging:
@@ -289,30 +260,7 @@ postgresql:
auth:
database: certctl
username: certctl
# REQUIRED set via `--set postgresql.auth.password=<value>` or values override.
#
# WARNING (U-1): rotating this value after first deploy does NOT change the
# database password. The `postgres:16-alpine` image runs `initdb` only when
# /var/lib/postgresql/data is empty, so POSTGRES_PASSWORD is written into
# pg_authid exactly once — on the first boot of the StatefulSet's PVC.
# Subsequent rollouts pick up the new env value in the postgres container
# but the certctl-server container's CERTCTL_DATABASE_URL also picks up
# the new value, while pg_authid still expects the old one — leading to
# `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01).
#
# The certctl-server emits guidance via internal/repository/postgres/db.go::
# wrapPingError when it sees SQLSTATE 28P01 at startup. To resolve in a
# Helm deployment:
# - Non-destructive (preferred for environments with data):
# kubectl exec -it <release>-postgres-0 -- \
# psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"
# then update the secret/values to match and let the certctl-server
# pod restart against the matching credential.
# - Destructive (DESTROYS DATA — only acceptable on dev/demo PVCs):
# helm uninstall <release> && \
# kubectl delete pvc -l app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres && \
# helm install <release> ... # PVC re-creates empty, initdb seeds new password
password: ""
password: "" # REQUIRED - set via --set or values override
# Storage configuration
storage:
-216
View File
@@ -1,216 +0,0 @@
//go:build integration
// Package integration_test — image-level HEALTHCHECK contract.
//
// U-2 (P1, cat-u-healthcheck_protocol_mismatch): pre-U-2 the published
// server image's Dockerfile HEALTHCHECK called `curl -f http://localhost:
// 8443/health` against an HTTPS-only listener (HTTPS-Everywhere milestone,
// v2.2 / tag v2.0.47). Operators outside docker-compose / Helm saw the
// container reported as `unhealthy` indefinitely. The compose stack
// overrode this HEALTHCHECK with `--cacert + https://`; the Helm chart
// uses explicit `httpGet` probes that ignore Docker's HEALTHCHECK; the 5
// example compose files all override with `curl -sfk https://localhost:
// 8443/health`. So the observable failure was scoped to bare `docker run`
// / Docker Swarm / Nomad / ECS users — exactly the "I just pulled the
// published image" path.
//
// This file's tests pin the contract at the binary-image level. The
// matching CI grep guardrail in .github/workflows/ci.yml catches the
// regression at the Dockerfile-source level; both layers are needed
// because someone could replace the HEALTHCHECK line with a sibling
// broken pattern that the grep doesn't catch (e.g., a TCP-only check
// against the HTTPS port).
//
// Run alongside the rest of the integration suite:
//
// cd deploy/test && go test -tags integration -v -run Healthcheck
//
// The tests skip cleanly with t.Skip when docker is not available
// (CI without docker-in-docker, sandbox environments, etc.) so they
// don't block local development on machines without docker.
package integration_test
import (
"encoding/json"
"os/exec"
"strings"
"testing"
"time"
)
// dockerAvailable returns true when `docker version` returns 0.
// We cache it across tests in this file so the skip message prints once.
func dockerAvailable(t *testing.T) bool {
t.Helper()
cmd := exec.Command("docker", "version", "--format", "{{.Server.Version}}")
out, err := cmd.CombinedOutput()
if err != nil {
t.Logf("docker not available: %v\noutput: %s", err, string(out))
return false
}
return true
}
// dockerCmd runs `docker <args...>` with a 60s budget, returning stdout
// + stderr combined and the exit error if any. Used for short-lived
// probes (inspect, build, run -d).
func dockerCmd(t *testing.T, timeout time.Duration, args ...string) (string, error) {
t.Helper()
cmd := exec.Command("docker", args...)
done := make(chan struct{})
var out []byte
var err error
go func() {
out, err = cmd.CombinedOutput()
close(done)
}()
select {
case <-done:
return string(out), err
case <-time.After(timeout):
_ = cmd.Process.Kill()
t.Fatalf("docker %v timed out after %v", args, timeout)
return "", err
}
}
// TestPublishedServerImage_HealthcheckSpecUsesHTTPS performs the Dockerfile-
// source-level shipped-shape pin: the inspected image's Healthcheck.Test
// array MUST contain "https://localhost:8443/health" (and MUST NOT
// contain "http://localhost:8443/health"). This is the lightweight half
// of the contract — it doesn't require running the container, only
// building it. It catches the audit-flagged bug directly.
func TestPublishedServerImage_HealthcheckSpecUsesHTTPS(t *testing.T) {
if !dockerAvailable(t) {
t.Skip("docker not available — skipping image-level HEALTHCHECK test")
}
const imgTag = "certctl-u2-healthcheck-spec-test"
t.Cleanup(func() {
_, _ = dockerCmd(t, 30*time.Second, "rmi", "-f", imgTag)
})
// Build the server image. Use the repo root as context (this test
// file lives at deploy/test/, the Dockerfile at the repo root).
buildOut, err := dockerCmd(t, 5*time.Minute,
"build", "-f", "../../Dockerfile", "-t", imgTag, "../..")
if err != nil {
t.Fatalf("docker build failed: %v\noutput:\n%s", err, buildOut)
}
// Inspect the shipped HEALTHCHECK metadata.
inspectOut, err := dockerCmd(t, 30*time.Second,
"inspect", "--format", "{{json .Config.Healthcheck}}", imgTag)
if err != nil {
t.Fatalf("docker inspect failed: %v\noutput:\n%s", err, inspectOut)
}
var hc struct {
Test []string
Interval int64
Timeout int64
}
if err := json.Unmarshal([]byte(strings.TrimSpace(inspectOut)), &hc); err != nil {
t.Fatalf("could not parse Healthcheck JSON %q: %v", inspectOut, err)
}
joined := strings.Join(hc.Test, " ")
// Positive contract.
if !strings.Contains(joined, "https://localhost:8443/health") {
t.Errorf("Healthcheck.Test does not target https://localhost:8443/health\nfull: %v", hc.Test)
}
// Negative contract — pre-U-2 regression shape MUST be absent.
if strings.Contains(joined, "http://localhost:8443/health") {
t.Errorf("Healthcheck.Test still contains the pre-U-2 plaintext shape: %v", hc.Test)
}
// `-k` (or `--insecure`) must be present because the bootstrap cert
// is per-deploy and the published image can't pin a CA bundle —
// see the U-2 closure docblock on Dockerfile and the audit doc.
if !strings.Contains(joined, "-k") && !strings.Contains(joined, "--insecure") {
t.Errorf("Healthcheck.Test omits -k / --insecure flag (required for self-signed bootstrap probe): %v", hc.Test)
}
}
// TestPublishedAgentImage_HealthcheckSpecExists pins the U-2 adjacent
// fix that added a HEALTHCHECK to the agent image. Pre-U-2 the agent
// image had no HEALTHCHECK declaration, so bare-`docker run` agents got
// `none` health status from Docker. Post-U-2 the agent uses pgrep to
// verify the process is alive (mirroring the docker-compose pattern at
// deploy/docker-compose.yml:173, which also became reliable post-U-2
// because procps is now installed in the runtime image).
func TestPublishedAgentImage_HealthcheckSpecExists(t *testing.T) {
if !dockerAvailable(t) {
t.Skip("docker not available — skipping image-level HEALTHCHECK test")
}
const imgTag = "certctl-u2-agent-healthcheck-spec-test"
t.Cleanup(func() {
_, _ = dockerCmd(t, 30*time.Second, "rmi", "-f", imgTag)
})
buildOut, err := dockerCmd(t, 5*time.Minute,
"build", "-f", "../../Dockerfile.agent", "-t", imgTag, "../..")
if err != nil {
t.Fatalf("docker build failed: %v\noutput:\n%s", err, buildOut)
}
inspectOut, err := dockerCmd(t, 30*time.Second,
"inspect", "--format", "{{json .Config.Healthcheck}}", imgTag)
if err != nil {
t.Fatalf("docker inspect failed: %v\noutput:\n%s", err, inspectOut)
}
trimmed := strings.TrimSpace(inspectOut)
if trimmed == "null" || trimmed == "" {
t.Fatalf("agent image has no HEALTHCHECK (got %q) — U-2 adjacent fix regressed", inspectOut)
}
var hc struct {
Test []string
}
if err := json.Unmarshal([]byte(trimmed), &hc); err != nil {
t.Fatalf("could not parse Healthcheck JSON %q: %v", inspectOut, err)
}
joined := strings.Join(hc.Test, " ")
if !strings.Contains(joined, "pgrep") {
t.Errorf("agent Healthcheck.Test does not use pgrep (lost the process-presence shape): %v", hc.Test)
}
if !strings.Contains(joined, "certctl-agent") {
t.Errorf("agent Healthcheck.Test does not target the certctl-agent process name: %v", hc.Test)
}
}
// TestPublishedServerImage_HealthcheckTransitionsToHealthy is the
// runtime-level contract: the built image, when started, must transition
// to `healthy` within the start-period + 30s observability budget. This
// is the heavy test — it requires the server to actually start, which
// in turn requires either a reachable database OR a startup that fails
// gracefully enough to keep the HEALTHCHECK probe target alive.
//
// The container is started with CERTCTL_DATABASE_URL pointing at an
// unreachable host so the server fails its postgres bring-up — but
// importantly, fails AFTER the TLS listener has come up, because the
// HEALTHCHECK probe target is the TLS listener. We don't actually need
// the database to validate the HEALTHCHECK shape.
//
// IMPORTANT: this test is the runtime contract. If you're working on the
// server's startup ordering and the listener now comes up AFTER the
// database, this test must adapt — start a sidecar postgres via
// testcontainers-go (see internal/integration/lifecycle_test.go for the
// pattern) and connect the certctl-server container to it.
func TestPublishedServerImage_HealthcheckTransitionsToHealthy(t *testing.T) {
if !dockerAvailable(t) {
t.Skip("docker not available — skipping runtime HEALTHCHECK test")
}
if testing.Short() {
t.Skip("runtime HEALTHCHECK test takes ~45s; skipping under -short")
}
t.Skip("runtime probe contract not yet wired to a sidecar postgres; " +
"image-spec contract above (TestPublishedServerImage_HealthcheckSpecUsesHTTPS) " +
"covers the audit-flagged regression. Re-enable once the integration " +
"harness provisions postgres for image-level smoke.")
}
+2 -19
View File
@@ -149,8 +149,6 @@ The agent runs two background loops: a heartbeat (every 60 seconds) to signal it
Retired agents receive `410 Gone` on subsequent heartbeats (`service.ErrAgentRetired`). `cmd/agent` treats 410 as a terminal signal and exits cleanly so retired agents stop phoning home. Migration `000015` flipped `deployment_targets.agent_id` from `ON DELETE CASCADE` to `ON DELETE RESTRICT`, making the old hard-delete path a schema error and forcing all retirement through this contract.
**Registration is by-design pull-only (C-1 closure, cat-b-6177f36636fb).** Agents register themselves at first heartbeat via `install-agent.sh` + `cmd/agent/main.go` — never via the GUI. The `web/src/api/client.ts::registerAgent` client function is intentionally orphan in the dashboard for this reason. It's preserved in `client.ts` (rather than deleted) so future features that want to drive registration from the GUI — for example, a one-click "register proxy agent" panel for network-appliance topologies where the agent runs in a different network zone from the device it manages — can reach the endpoint without a `client.ts` edit. Operators looking to scale agent enrollment use `install-agent.sh` against a config-management system (Ansible, Salt, Puppet) or a baked-in cloud-init script, not the dashboard.
### Web Dashboard
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
@@ -165,10 +163,6 @@ The dashboard includes an **ErrorBoundary component** for graceful error recover
- Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography
- SSE/WebSocket planned for real-time job status updates
**Backend ↔ frontend round-trip rule (B-1 closure):** every backend CRUD operation must have at least one GUI consumer in `web/src/pages/`. Shipping a handler + repository method + OpenAPI operation + `client.ts` fetcher with no page that calls it leaves operators forced to `psql` directly — defeats the "every backend feature ships with its GUI surface" invariant and creates a destructive workflow when the missing path is `update*` (operators delete-and-recreate, losing FK history and audit-trail continuity). The CI guardrail in `.github/workflows/ci.yml` (`Forbidden orphan-CRUD client function regression guard (B-1)`) enforces this for the eight previously-orphan functions (`updateOwner`/`updateTeam`/`updateAgentGroup`/`updateIssuer`/`updateProfile` + `createRenewalPolicy`/`updateRenewalPolicy`/`deleteRenewalPolicy`); apply the same rule when adding any new write endpoint. If a fetcher is needed in `client.ts` before its consumer page exists, leave a TODO referencing this rule and ship them in the same commit.
**TS ↔ Go type contract rule (D-1 + D-2 closure):** every TypeScript interface in `web/src/api/types.ts` must field-match the Go-side `internal/domain/*.go` struct's JSON-emitted shape exactly. Phantom fields (declared on TS, never emitted by Go) silently render `'—'` and lull consumers into thinking a value will arrive that never does; missing fields (emitted by Go, absent from TS) force `(x as any).X` escapes that lose type-checking. Both failure modes are blocked by the CI guardrail in `.github/workflows/ci.yml` (`Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)`) which awk-windows each interface and grep-fails the build on phantom-field reintroduction — currently covers Certificate (D-1), Agent / Issuer / Notification (D-2). Apply the same rule when adding any new on-wire type: the Go-side json tag is the contract, the TS interface adapts to it, and a literal-construction Vitest in `web/src/api/types.test.ts` pins the post-add shape. Stricter side wins: when in doubt, the side that actually emits the field is the contract; never propose adding a phantom on Go to match a TS over-declaration.
### PostgreSQL Database
All state is stored in PostgreSQL 16. The schema uses TEXT primary keys (not UUIDs) with human-readable prefixed IDs like `mc-api-prod`, `t-platform`, `o-alice`.
@@ -354,12 +348,7 @@ erDiagram
}
```
The ER diagram above documents **database shape**, not REST-API wire shape. Several columns are intentionally server-internal and never serialized to clients:
- `agents.api_key_hash` — SHA-256 of the agent's plaintext API key, populated by `service.RegisterAgent` (`hashAPIKey(apiKey)` at `internal/service/agent.go`) and consumed by `repository.AgentRepository::GetByAPIKey` for the auth-lookup. **Not** exposed via the REST API, **not** echoed via CLI / MCP / agent registration response, **never** logged. Enforced by `internal/domain/connector.go::Agent.MarshalJSON` (G-2 audit closure, `cat-s5-apikey_leak`); the OpenAPI Agent schema explicitly excludes the field, the frontend `Agent` interface omits it, and a CI grep guardrail at `.github/workflows/ci.yml` blocks reintroduction.
- `issuers.config` / `deployment_targets.config` — plaintext jsonb shadow of the AES-GCM-encrypted on-disk blob; the encrypted form lives on `EncryptedConfig []byte` (Go-only field tagged `json:"-"`).
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times. Pre-U-3 (`cat-u-seed_initdb_schema_drift`, GitHub #10) the deploy compose stack mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql` into postgres `/docker-entrypoint-initdb.d/` so initdb applied them on first boot, *and* the server re-applied the same files via `RunMigrations` on every start. The dual source of truth was the bug: every time a migration shipped that the seed depended on (e.g., 000013 added `policy_rules.severity`), the mount list had to be updated by hand, and missing the update crashed initdb on first boot. Post-U-3 the server is the single source of truth: postgres comes up with an empty schema, `RunMigrations` applies the entire ladder, then `RunSeed` lands the baseline seed (and `RunDemoSeed` lands the demo overlay when `CERTCTL_DEMO_SEED=true`). Helm has used this pattern since day one (postgres-init `emptyDir`); the docker-compose deploy now matches.
Migrations are idempotent (`IF NOT EXISTS` on all CREATE statements, `ON CONFLICT (id) DO NOTHING` on all seed data) so they're safe to run multiple times — important for Docker Compose where both initdb and the server may run the same SQL.
## Data Flow: Certificate Lifecycle
@@ -902,15 +891,9 @@ The HTTP middleware stack processes requests in the following order (see `cmd/se
4. **BodyLimit** - request body size cap via `http.MaxBytesReader`
5. **RateLimiter** - token bucket rate limiting (optional, when enabled)
6. **CORS** - cross-origin request handling (deny-by-default)
7. **Auth** - API key validation (or none in development; JWT/OIDC via authenticating gateway, see below — not in-process)
7. **Auth** - API key or JWT validation
8. **AuditLog** - records every API call to the audit trail (requires auth context for actor)
### Authenticating-gateway pattern (JWT, OIDC, mTLS)
certctl's in-process authentication surface is intentionally narrow: `api-key` for production deployments and `none` for development. There is no in-process JWT, OIDC, mTLS, or SAML middleware. (`CERTCTL_AUTH_TYPE=jwt` was accepted pre-G-1 but silently routed through the api-key bearer middleware — a security finding masquerading as a config option, removed at the v2.x boundary; see [`upgrade-to-v2-jwt-removal.md`](upgrade-to-v2-jwt-removal.md) if you previously set it.)
For deployments that need JWT/OIDC/mTLS, the standard pattern is to put an authenticating gateway in front of certctl and configure `CERTCTL_AUTH_TYPE=none` on the upstream certctl process. The gateway terminates the federated identity protocol, validates tokens / certificates / SAML assertions, and proxies the authenticated request to certctl as a same-origin call on a private network. This separation gives operators the full breadth of the modern identity ecosystem (oauth2-proxy, Envoy `ext_authz`, Traefik `ForwardAuth`, Pomerium, Authelia, Caddy `forward_auth`, Apache `mod_auth_openidc`, nginx `auth_request`) without certctl itself having to track signing-key rotation, claim mapping, audience validation, and the rest of the JWT/OIDC surface area. Operators wanting per-request actor attribution past the gateway boundary forward the gateway-resolved identity (e.g., `X-Auth-Request-User` from oauth2-proxy) and run a small authorization layer at the gateway that enforces the bearer-key contract certctl actually uses.
### Concurrency Safety
The background scheduler uses `sync/atomic.Bool` idempotency guards on every loop (8 always-on plus up to 4 optional) — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
+15 -32
View File
@@ -1141,30 +1141,13 @@ API Endpoints:
- **`GET /api/v1/digest/preview`** — Render digest HTML for preview (no email sent)
- **`POST /api/v1/digest/send`** — Trigger digest send immediately (outside of schedule)
> **Note (HTTPS-only as of v2.2):** The `curl` examples in this section
> and below all target the HTTPS-only control plane. Extract the
> docker-compose self-signed bootstrap CA bundle once and reuse it on
> every call:
>
> ```bash
> export CA=/tmp/certctl-ca.crt
> docker compose -f deploy/docker-compose.yml exec -T certctl-server \
> cat /etc/certctl/tls/ca.crt > "$CA"
> ```
>
> Then pass `--cacert "$CA"` (or `-k` for one-off smoke tests, never in
> production). The same pattern is documented in
> [`quickstart.md`](quickstart.md). Pre-U-2 these examples used `http://`
> and silently failed against the HTTPS listener; post-U-2 they speak
> HTTPS with the operator-managed CA bundle.
Example:
```bash
# Preview digest
curl --cacert "$CA" https://localhost:8443/api/v1/digest/preview | jq '.html'
curl http://localhost:8443/api/v1/digest/preview | jq '.html'
# Send digest immediately
curl --cacert "$CA" -X POST https://localhost:8443/api/v1/digest/send
curl -X POST http://localhost:8443/api/v1/digest/send
```
Each notifier is enabled by its configuration env var:
@@ -1311,24 +1294,24 @@ The agent scans these directories on startup and every 6 hours, looking for cert
```bash
# List discovered certificates (filter by agent, status)
curl --cacert "$CA" -s "https://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-01&status=new" | jq .
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-01&status=new" | jq .
# Get discovery detail
curl --cacert "$CA" -s https://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID | jq .
curl -s http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID | jq .
# Claim a discovered cert (link to managed certificate)
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim \
curl -s -X POST http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim \
-H "Content-Type: application/json" \
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
# Dismiss a discovery
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/dismiss | jq .
curl -s -X POST http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/dismiss | jq .
# View discovery scan history
curl --cacert "$CA" -s https://localhost:8443/api/v1/discovery-scans | jq .
curl -s http://localhost:8443/api/v1/discovery-scans | jq .
# Summary counts (new, claimed, dismissed)
curl --cacert "$CA" -s https://localhost:8443/api/v1/discovery-summary | jq .
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
```
### Use Cases
@@ -1357,7 +1340,7 @@ Network scan targets can be managed from the **Network Scans** dashboard page (c
```bash
# Create a scan target for your internal network (or use the dashboard's "+ New Target" button)
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets \
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
-H "Content-Type: application/json" \
-d '{
"name": "Production Web Servers",
@@ -1382,26 +1365,26 @@ curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-target
```bash
# List all scan targets
curl --cacert "$CA" -s https://localhost:8443/api/v1/network-scan-targets | jq .
curl -s http://localhost:8443/api/v1/network-scan-targets | jq .
# Create a scan target
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets \
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
-H "Content-Type: application/json" \
-d '{"name": "DMZ", "cidrs": ["172.16.0.0/24"], "ports": [443]}' | jq .
# Get a specific target (includes last_scan_at, last_scan_certs_found)
curl --cacert "$CA" -s https://localhost:8443/api/v1/network-scan-targets/nst-dmz | jq .
curl -s http://localhost:8443/api/v1/network-scan-targets/nst-dmz | jq .
# Trigger an immediate scan (doesn't wait for scheduler)
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets/nst-dmz/scan | jq .
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets/nst-dmz/scan | jq .
# Update scan configuration
curl --cacert "$CA" -s -X PUT https://localhost:8443/api/v1/network-scan-targets/nst-dmz \
curl -s -X PUT http://localhost:8443/api/v1/network-scan-targets/nst-dmz \
-H "Content-Type: application/json" \
-d '{"ports": [443, 8443, 9443], "timeout_ms": 3000}' | jq .
# Delete a scan target
curl --cacert "$CA" -s -X DELETE https://localhost:8443/api/v1/network-scan-targets/nst-dmz
curl -s -X DELETE http://localhost:8443/api/v1/network-scan-targets/nst-dmz
```
### Scheduler Integration
+1 -1
View File
@@ -111,7 +111,7 @@ The full walkthrough — including profile-based issuer assignment, testing with
## Beyond These Examples
These 5 scenarios cover the most common deployment patterns, but certctl supports a broader set of issuer and target backends — see `docs/features.md`'s Issuer Connectors and Target Connectors sections for the live catalogs (rebuild via `ls -d internal/connector/issuer/*/ | wc -l` and `ls -d internal/connector/target/*/ | wc -l`). Once you have the basics running, you can mix and match:
These 5 scenarios cover the most common deployment patterns, but certctl supports 7 issuer backends and 10 target connectors. Once you have the basics running, you can mix and match:
**Issuers:** ACME (Let's Encrypt, ZeroSSL, Buypass, Google Trust Services), Local CA (self-signed or sub-CA), step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA script, Sectigo (coming soon).
+27 -46
View File
@@ -8,30 +8,17 @@ Complete reference of every feature shipped in certctl through v2.1.0 (April 202
| Metric | Count |
|---|---|
<!--
S-1 master closure (cat-s1-9ce1cbe26876, cat-s1-features_md_issuer_count_contradiction):
every numeric count below is captured at the time of the last edit AND
paired with the source-of-truth grep command from CLAUDE.md. CLAUDE.md
rule: "Numeric claims about current state rot the instant the next
release lands." Re-derive before each release; the CI guardrail at
.github/workflows/ci.yml::"Forbidden hardcoded source-count prose
regression guard (S-1)" fails the build on any new prose-only counts
without an adjacent rebuild command.
-->
| Surface | Count (rebuild command) |
|---|---|
| HTTP routes | rebuild via `grep -cE 'r\.Register\("[A-Z]' internal/api/router/router.go` |
| OpenAPI 3.1 operations | rebuild via `grep -cE '^\s+operationId:' api/openapi.yaml` |
| MCP tools | rebuild via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go` |
| CLI commands | rebuild via `grep -cE 'AddCommand|RootCmd\.Add' cmd/cli/*.go internal/cli/*.go` (intentionally narrow — see CLI Scope §) |
| Issuer connectors | rebuild via `ls -d internal/connector/issuer/*/ \| wc -l` (+ EST server) |
| Target connectors | rebuild via `ls -d internal/connector/target/*/ \| wc -l` (includes shared `certutil/`) |
| Notifier connectors | rebuild via `ls -d internal/connector/notifier/*/ \| wc -l` |
| Discovery connectors | rebuild via `ls -d internal/connector/discovery/*/ \| wc -l` |
| Database tables | rebuild via `grep -hE '^CREATE TABLE' migrations/*.up.sql \| sed -E 's/CREATE TABLE (IF NOT EXISTS )?([a-zA-Z_]+).*/\2/' \| sort -u \| wc -l` (across `ls migrations/*.up.sql \| wc -l` migrations) |
| Background scheduler loops | rebuild via `grep -cE '^func \(s \*Scheduler\) [a-zA-Z]+Loop' internal/scheduler/scheduler.go` |
| Web dashboard pages | rebuild via `ls web/src/pages/*.tsx \| grep -v '\.test\.' \| wc -l` |
| Test functions (Go backend) | rebuild via the `find` + `grep '^func Test'` recipe in CLAUDE.md::Current-state commands |
| HTTP routes | 107 (103 under `/api/v1/` + 4 EST) |
| OpenAPI 3.1 operations | 97 |
| MCP tools | 80 |
| CLI commands | 12 |
| Issuer connectors | 9 (+ EST server) |
| Target connectors | 14 |
| Notifier connectors | 6 channels |
| Database tables | 21 (across 10 migrations) |
| Background scheduler loops | 12 (8 always-on + 4 opt-in) |
| Web dashboard pages | 24 |
| Test functions | 1850+ |
| Supported platforms | linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 |
---
@@ -149,7 +136,7 @@ Every API call is recorded to the immutable audit trail. Best-effort (non-blocki
<!-- Source: internal/scheduler/scheduler.go (renewalCheckLoop, 1-hour default interval) -->
The renewal scheduler runs every hour (configurable via `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL`). For each certificate approaching expiration:
The renewal scheduler runs every hour (configurable via `CERTCTL_RENEWAL_CHECK_INTERVAL`). For each certificate approaching expiration:
1. Checks ACME ARI (RFC 9773) if available — CA-directed renewal timing takes priority
2. Falls back to threshold-based logic using per-policy `alert_thresholds_days` (default `[30, 14, 7, 0]`)
@@ -338,9 +325,9 @@ Policies can be scoped to agent groups via `agent_group_id` foreign key. Violati
## Issuer Connectors
<!-- Source: internal/domain/connector.go (IssuerType constants), internal/connector/issuer/. Rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`. -->
<!-- Source: internal/domain/connector.go (12 IssuerType constants), internal/connector/issuer/ -->
The issuer connector catalog (rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`) implements the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
12 issuer connectors implementing the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
### Local CA
@@ -629,9 +616,9 @@ For Let's Encrypt 6-day `shortlived` certificates, ARI is the expected renewal p
## Target Connectors
<!-- Source: internal/domain/connector.go (TargetType constants), internal/connector/target/. Rebuild count via `ls -d internal/connector/target/*/ | wc -l` (includes shared `certutil/`). -->
<!-- Source: internal/domain/connector.go (14 TargetType constants), internal/connector/target/ -->
The target connector catalog (rebuild count via `ls -d internal/connector/target/*/ | wc -l`) implements the `target.Connector` interface. All support `ValidateConfig`, `DeployCertificate`, `ValidateDeployment`.
14 target connector types implementing the `target.Connector` interface. All support `ValidateConfig`, `DeployCertificate`, `ValidateDeployment`.
### Deployment Model
@@ -1114,14 +1101,14 @@ Single SQL `UNION` query replaces the previous "fetch all, filter in Go" approac
| Loop | Default Interval | Always-on | Env Var | Description |
|---|---|---|---|---|
| Renewal check | 1 hour | Yes | `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | Check expiring certs, query ARI, create renewal jobs |
| Job processor | 30 seconds | Yes | `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | Process pending jobs |
| Renewal check | 1 hour | Yes | | Check expiring certs, query ARI, create renewal jobs |
| Job processor | 30 seconds | Yes | | Process pending jobs |
| Job retry | 5 minutes | Yes | `CERTCTL_SCHEDULER_RETRY_INTERVAL` | Retry Failed jobs (I-001) |
| Job timeout reaper | 10 minutes | Yes | `CERTCTL_JOB_TIMEOUT_INTERVAL` (per-state thresholds: `CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT`, `CERTCTL_JOB_AWAITING_CSR_TIMEOUT`) | Fail AwaitingCSR/AwaitingApproval jobs past timeout (I-003) |
| Agent health check | 2 minutes | Yes | `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | Check agent heartbeat staleness |
| Notification processor | 1 minute | Yes | `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | Send queued notifications |
| Job timeout reaper | 10 minutes | Yes | `CERTCTL_JOB_TIMEOUT_INTERVAL` | Fail AwaitingCSR/AwaitingApproval jobs past timeout (I-003) |
| Agent health check | 2 minutes | Yes | | Check agent heartbeat staleness |
| Notification processor | 1 minute | Yes | | Send queued notifications |
| Notification retry | 2 minutes | Yes | `CERTCTL_NOTIFICATION_RETRY_INTERVAL` | Exponential backoff retry for failed notifications; promote to dead-letter after 5 attempts (I-005) |
| Short-lived expiry check | 30 seconds | Yes | `CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL` | Mark short-lived certs expired (C-1: pre-C-1 the setter was unwired and this env var had no effect; post-C-1 it's read by `cmd/server/main.go::sched.SetShortLivedExpiryCheckInterval`) |
| Short-lived expiry check | 30 seconds | Yes | — | Mark short-lived certs expired |
| Network scan | 6 hours | Opt-in | `CERTCTL_NETWORK_SCAN_ENABLED` | Run network discovery scans |
| Digest | 24 hours | Opt-in | `CERTCTL_DIGEST_INTERVAL` | Send certificate digest email (does not run on startup) |
| Endpoint health | 60 seconds | Opt-in | `CERTCTL_HEALTH_CHECK_INTERVAL` | Continuous TLS health probes (M48) |
@@ -1137,7 +1124,7 @@ Single SQL `UNION` query replaces the previous "fetch all, filter in Go" approac
GUI-driven issuer CRUD with AES-256-GCM encrypted config storage in PostgreSQL.
- Per-type config schema validation for all issuer types (rebuild count via `ls -d internal/connector/issuer/*/ | wc -l`)
- Per-type config schema validation for all 9 issuer types
- Test connection flow (instantiates throwaway connector, calls `ValidateConfig`)
- Dynamic `sync.RWMutex`-guarded `IssuerRegistry` — rebuilds without server restart
- Env var backward compatibility: seeds DB on first boot if no DB config exists
@@ -1166,9 +1153,9 @@ Same pattern as issuer configuration:
## Web Dashboard
<!-- Source: web/src/main.tsx (Route elements + page imports), Vite + React 18 + TypeScript + TanStack Query + Recharts. Rebuild page count via `ls web/src/pages/*.tsx | grep -v '\.test\.' | wc -l`. -->
<!-- Source: web/src/main.tsx (25 Route elements, 24 pages), Vite + React 18 + TypeScript + TanStack Query + Recharts -->
The dashboard surface (rebuild count via `ls web/src/pages/*.tsx | grep -v '\.test\.' | wc -l`) wires every page to real API endpoints.
24 pages wired to real API endpoints.
### Pages
@@ -1220,10 +1207,6 @@ Latching state prevents refetch-driven dismissal. `localStorage` dismissal key:
`certctl-cli` — stdlib-only (`flag` + `text/tabwriter`), no Cobra dependency.
### Scope (intentionally narrow)
The CLI focuses on **read-heavy operator triage** (list, get, status, version) and **bulk-action surface** (`certs bulk-revoke`, `import`). It deliberately omits admin CRUD for issuers, targets, owners, teams, agent groups, certificate profiles, renewal policies, policy rules, and notifications — those live in the GUI and the MCP server (rebuild count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go` for the full operator surface). This split is intentional: CLI is the SSH-into-the-prod-host emergency console; GUI is the day-to-day operator console; MCP is the AI/automation surface. Closes audit finding `cat-i-7c8b28936e3d` — pre-this-doc the narrow scope was correct in code but confused readers who scanned `docs/features.md`'s "CLI commands" count and assumed the CLI was incomplete.
### Commands
| Command | Description |
@@ -1291,7 +1274,7 @@ certctl-cli certs bulk-revoke --issuer-id iss-letsencrypt --reason caCompromise
Separate standalone binary (`cmd/mcp-server/`) using the official MCP Go SDK (`modelcontextprotocol/go-sdk`). Stdio transport for Claude, Cursor, and similar AI tool integrations.
- MCP tools covering all API endpoints (rebuild count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go`)
- 80 MCP tools covering all API endpoints
- Stateless HTTP proxy — translates MCP tool calls to REST API calls
- Typed input structs with `jsonschema` struct tags for automatic schema generation
- Binary response support (DER CRL, OCSP)
@@ -1373,9 +1356,7 @@ Config via `values.yaml`. Secrets for API key, database password, SMTP password.
<!-- Source: migrations/ -->
PostgreSQL 16, `database/sql` + `lib/pq` (no ORM). TEXT primary keys with human-readable prefixed IDs. The catalog of tables and migrations rebuilds via the commands in the "At a Glance" table at the top of this doc — re-derive at release time rather than reading hardcoded numbers from prose.
The migration runner reads SQL files from `./migrations/` by default; the path is configurable via `CERTCTL_DATABASE_MIGRATIONS_PATH` for operators running certctl out of a non-standard layout (e.g. a Helm chart that bind-mounts migrations into `/etc/certctl/migrations/`).
21 tables across 10 numbered migrations. PostgreSQL 16. `database/sql` + `lib/pq` (no ORM). TEXT primary keys with human-readable prefixed IDs.
### Migrations
-2
View File
@@ -60,8 +60,6 @@ cp deploy/.env.example deploy/.env
docker compose -f deploy/docker-compose.yml up -d --build
```
> **Warning:** Edit `POSTGRES_PASSWORD` *before* the very first `docker compose up`. Postgres seeds the password into its data directory only on first boot of an empty volume — after that, the password is baked into `pg_authid` and the env var is ignored. If you boot once with the default and later change `POSTGRES_PASSWORD` in `.env`, the certctl-server container picks up the new value but postgres still authenticates against the old one, and the server logs `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01). Two ways out: tear down the volume with `docker compose -f deploy/docker-compose.yml down -v` (this **deletes all data**) and bring up fresh, or rotate non-destructively with `docker compose -f deploy/docker-compose.yml exec postgres psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"` and then restart certctl-server with the matching `POSTGRES_PASSWORD`.
### Docker Compose Environments
The `deploy/` directory contains four compose files for different use cases:
-155
View File
@@ -1,155 +0,0 @@
# Upgrading past G-1 — `CERTCTL_AUTH_TYPE=jwt` removal
If your certctl deployment currently sets `CERTCTL_AUTH_TYPE=jwt` (or `server.auth.type=jwt` in Helm), the next certctl upgrade will fail-fast at startup with a dedicated diagnostic. This guide explains why, what to switch to, and how to keep JWT/OIDC at your edge.
For everyone else — operators running `api-key` or `none` — this upgrade is a no-op. Skip to [`upgrade-to-tls.md`](upgrade-to-tls.md) for the v2.2 HTTPS-everywhere migration if you haven't done that one yet.
## Why we removed it
Pre-G-1, the config validator at `internal/config/config.go` accepted three values for `CERTCTL_AUTH_TYPE`: `api-key`, `jwt`, and `none`. The startup log line at `cmd/server/main.go` faithfully echoed `"authentication enabled" "type"="jwt"` when an operator picked `jwt`. Reasonable people read that and concluded JWT auth was on.
It wasn't. Grep `internal/ cmd/` for `NewJWT`, `JWTMiddleware`, or `jwt.Parse` — pre-G-1, there were zero matches in production code. The auth-middleware wiring at `cmd/server/main.go:653` unconditionally called `middleware.NewAuthWithNamedKeys(namedKeys)` regardless of `cfg.Auth.Type`. So `CERTCTL_AUTH_TYPE=jwt` just routed every request through the api-key bearer middleware, comparing the incoming `Authorization: Bearer <something>` against whatever string the operator put in `CERTCTL_AUTH_SECRET`. Real JWT clients got 401 (the api-key middleware saw the JWT string as a literal token and compared bytes). Operators who treated `CERTCTL_AUTH_SECRET` as a JWT signing secret (and therefore handled it less carefully than an api-key) handed an attacker an api-key. Silent auth downgrade — a security finding masquerading as a config option.
We chose to remove the option rather than implement JWT middleware. Implementing real JWT/OIDC requires jwks vs static-secret rotation, claim mapping (which claim is the actor / the admin flag?), expiry enforcement, audience and issuer validation, key rollover semantics, and regression coverage at the same depth as the existing api-key path. That's a feature, not a fix. The audit-recommended structural fix — and the one that actually closes the hazard — is to fail loudly instead of silently downgrading.
## What changes at startup
Post-G-1, a binary started with `CERTCTL_AUTH_TYPE=jwt` exits non-zero before opening the listener:
```
Failed to load configuration: CERTCTL_AUTH_TYPE=jwt is no longer accepted
(G-1 silent auth downgrade): no JWT middleware ships with certctl. To use
JWT/OIDC, run an authenticating gateway (oauth2-proxy / Envoy ext_authz /
Traefik ForwardAuth / Pomerium) in front of certctl and set
CERTCTL_AUTH_TYPE=none on the upstream. See docs/architecture.md
"Authenticating-gateway pattern" and docs/upgrade-to-v2-jwt-removal.md
for the migration walkthrough
```
Helm operators get the same shape at `helm install` / `helm upgrade` template time: `server.auth.type=jwt` is rejected by the chart's `certctl.validateAuthType` template helper before any Kubernetes object is rendered.
The CI-side regression guard at `.github/workflows/ci.yml` blocks any future PR that re-introduces `"jwt"` as an auth-type literal in production code or spec.
## Recovery — pick one
### Option A — switch to `api-key` (you weren't actually using JWT)
If your `CERTCTL_AUTH_SECRET` was a single high-entropy token and your clients sent it as `Authorization: Bearer <token>`, you were already using api-key auth — you just had `CERTCTL_AUTH_TYPE` set to the wrong string. Flip it:
```
# .env (docker-compose)
CERTCTL_AUTH_TYPE=api-key
CERTCTL_AUTH_SECRET=<your-existing-token>
```
```
# Helm
helm upgrade <release> deploy/helm/certctl/ \
--reuse-values \
--set server.auth.type=api-key \
--set server.auth.apiKey=<your-existing-token>
```
No client changes needed — the same Bearer token continues to work. The startup log will now read `"authentication enabled" "type"="api-key"`, which matches what was actually happening pre-G-1.
### Option B — front certctl with an authenticating gateway
If you genuinely need JWT, OIDC, mTLS, or SAML, run an authenticating gateway in front of certctl and let the gateway terminate the federated identity protocol. Configure certctl for `CERTCTL_AUTH_TYPE=none`:
```
CERTCTL_AUTH_TYPE=none
```
Then put an oauth2-proxy / Envoy `ext_authz` / Traefik `ForwardAuth` / Pomerium / Authelia (etc.) in the network path between operators and certctl. The gateway validates the identity and proxies the authenticated request to certctl as a same-origin call on a private network.
### Concrete walkthrough — oauth2-proxy + certctl on docker-compose
This is the simplest production-grade JWT/OIDC shape. It assumes you have an OIDC provider (Okta, Auth0, Google Workspace, Keycloak, Dex) and a registered client_id / client_secret.
```yaml
# deploy/docker-compose.gateway.yml — overlay on the base compose file
services:
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:latest
command:
- --provider=oidc
- --oidc-issuer-url=https://<your-issuer>/
- --client-id=${OIDC_CLIENT_ID}
- --client-secret=${OIDC_CLIENT_SECRET}
- --cookie-secret=${OAUTH2_PROXY_COOKIE_SECRET} # openssl rand -base64 32
- --upstream=http://certctl-server:8443 # internal-network only; certctl listens on 8443
- --http-address=0.0.0.0:4180
- --email-domain=*
- --pass-access-token=true
- --pass-authorization-header=true
- --set-authorization-header=true # forwards a bearer token upstream
- --skip-provider-button=true
- --reverse-proxy=true
ports:
- "443:4180"
depends_on:
- certctl-server
networks:
- certctl-network
certctl-server:
environment:
CERTCTL_AUTH_TYPE: none # gateway terminates auth — see docs/upgrade-to-v2-jwt-removal.md
# ... rest of the certctl env block unchanged
```
Operators hit `https://<your-host>/`, get redirected through the OIDC provider, land back at oauth2-proxy with a session cookie, and oauth2-proxy proxies their request to certctl on the internal Docker network. certctl itself is HTTPS-only on `:8443` (TLS 1.3, see [`tls.md`](tls.md)) but operator browsers never see that hop directly. Bind certctl-server's `:8443` to the internal Docker network only — do NOT publish it to the host. The audit trail will record the actor as the gateway-forwarded identity if you also configure a small bearer-token-mapping shim at the gateway (most production deployments do this with a per-user api-key issued by the gateway after OIDC validation).
### Traefik ForwardAuth pattern (Kubernetes)
Same shape, kubernetes-flavored:
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-forward-auth
spec:
forwardAuth:
address: http://oauth2-proxy.auth.svc.cluster.local:4180
trustForwardHeader: true
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
- Authorization
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: certctl
spec:
routes:
- match: Host(`certctl.example.com`)
kind: Rule
middlewares:
- name: oidc-forward-auth
services:
- name: certctl-server
port: 8443
```
The certctl Helm release runs with `server.auth.type=none`. The Traefik IngressRoute attaches `oidc-forward-auth` as a middleware so every request is OIDC-validated by oauth2-proxy before reaching certctl.
### Envoy `ext_authz` pattern
For service-mesh deployments (Istio, Consul, plain Envoy), the `ext_authz` filter calls out to an external authorization service per-request. Same outcome: certctl runs `CERTCTL_AUTH_TYPE=none` and Envoy + your authz service handle JWT/OIDC/mTLS at the mesh edge. See the [Envoy ext_authz docs](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) for the configuration surface.
## Rollback
Pre-G-1 binaries silently accepted `CERTCTL_AUTH_TYPE=jwt` and routed through the api-key middleware. Downgrading the binary is the only mechanical rollback path, and it puts you back into the silent-downgrade state — which is exactly what the G-1 audit finding is about. We don't recommend it. If something is forcing your hand, capture the operational issue you're hitting and open a GitHub issue against the certctl repo with the SHAs involved; the Authenticating-gateway pattern was specifically designed to cover the use cases that historically led operators to set `CERTCTL_AUTH_TYPE=jwt`.
There is no on-disk state that changes with this upgrade — no migrations to roll back, no encrypted config to re-encode, no certificates to re-issue. The change is entirely in the config-validation surface and the helm-chart template guard.
## Cross-references
- [`architecture.md`](architecture.md) — "Authenticating-gateway pattern (JWT, OIDC, mTLS)" section.
- [`tls.md`](tls.md) — TLS provisioning patterns. The gateway proxying to certctl-server still needs to trust certctl's TLS cert; same patterns apply.
- [`../deploy/helm/certctl/README.md`](../deploy/helm/certctl/README.md) — Helm-chart-flavored guidance.
- `internal/config/config.go::ValidAuthTypes` — the single source of truth for what's accepted post-G-1.
- `internal/repository/postgres/db.go::wrapPingError` — unrelated; pattern for runtime diagnostic of operator misconfiguration.
- `coverage-gap-audit-2026-04-24-v5/unified-audit.md` — the audit finding (`cat-g-jwt_silent_auth_downgrade`).
-55
View File
@@ -1,55 +0,0 @@
# Deployment Examples
Five turnkey docker-compose scenarios that show certctl deployed against real CA backends and target shapes. Each subdirectory is self-contained — pick the one closest to your stack and have it running in minutes.
| Example | Stack | What it shows |
|---------|-------|---------------|
| [`acme-nginx/`](acme-nginx/acme-nginx.md) | Let's Encrypt + NGINX (HTTP-01) | The default public-CA path: ACME-issued certs deployed to NGINX. |
| [`acme-wildcard-dns01/`](acme-wildcard-dns01/acme-wildcard-dns01.md) | Let's Encrypt wildcard (DNS-01) | Wildcard certificates via DNS-01 with pluggable DNS hooks. |
| [`private-ca-traefik/`](private-ca-traefik/private-ca-traefik.md) | Local CA + Traefik | Internal-only certs from a private CA, deployed to Traefik. |
| [`step-ca-haproxy/`](step-ca-haproxy/step-ca-haproxy.md) | Smallstep step-ca + HAProxy | Self-hosted CA with HAProxy as the deployment target. |
| [`multi-issuer/`](multi-issuer/multi-issuer.md) | Let's Encrypt + Local CA | Public + private certs side-by-side from a single dashboard. |
## Common operational notes
These notes apply to **every** example. They're called out here so the per-example walkthroughs stay focused on the issuer/target wiring instead of repeating ops boilerplate.
### Postgres password rotation — first-boot binding trap (U-1)
Every example file uses `${DB_PASSWORD:-certctl-dev-password}` as the postgres password env var, with the data directory persisted via a named volume. The `postgres:16-alpine` image runs `initdb` exactly once — when `/var/lib/postgresql/data` is empty — and that's the only time `POSTGRES_PASSWORD` is written into `pg_authid`. If you boot once with the default and then change `DB_PASSWORD` (in your shell, in a `.env` file, or in a wrapper script), the certctl-server container picks up the new value but the postgres container continues to authenticate against the old one. The server fails its startup `db.Ping()` with `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01).
The certctl-server emits guidance pointing at the fix when this fires (see `internal/repository/postgres/db.go::wrapPingError`). The two remediation paths:
- **Destructive — wipes all certctl data, only acceptable on demo/test setups:**
```bash
docker compose -f examples/<example>/docker-compose.yml down -v
docker compose -f examples/<example>/docker-compose.yml up -d --build
```
- **Non-destructive — preserves data, rotates `pg_authid` in place:**
```bash
docker compose -f examples/<example>/docker-compose.yml exec postgres \
psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"
# Then redeploy with DB_PASSWORD set to <new> in your shell or .env
```
The cleanest practice for a fresh demo: set `DB_PASSWORD` once in your shell **before** the very first `docker compose up`, and don't change it during the demo's lifetime. If you must rotate, use the non-destructive path.
Same root cause and remediation pattern is documented for the canonical quickstart in [`../docs/quickstart.md`](../docs/quickstart.md), the production compose surface in [`../deploy/ENVIRONMENTS.md`](../deploy/ENVIRONMENTS.md), and the Helm chart in [`../deploy/helm/certctl/README.md`](../deploy/helm/certctl/README.md).
### TLS for the certctl control plane
Every example boots certctl with HTTPS-only on port 8443 (TLS 1.3 pinned, no plaintext listener as of v2.2). The shipped `certctl-tls-init` init container generates a self-signed ECDSA-P256 cert on first boot — fine for the example demos, **never** acceptable for a public deployment. For production, swap the init container for cert-manager, an operator-supplied Secret, or your internal CA — see [`../docs/tls.md`](../docs/tls.md) for the full pattern matrix.
### Tearing down
To stop services but **keep** the postgres volume (so you can pick up where you left off):
```bash
docker compose -f examples/<example>/docker-compose.yml down
```
To stop services **and** wipe all data (clean slate for the next run):
```bash
docker compose -f examples/<example>/docker-compose.yml down -v
```
Note that `down -v` is the only canonical way to recover from the postgres-password trap when the non-destructive `ALTER ROLE` route is unavailable (e.g., you've forgotten the original password).
-2
View File
@@ -2,8 +2,6 @@
This example demonstrates certctl's core use case: **automatically manage TLS certificates for NGINX using Let's Encrypt (ACME HTTP-01 challenges).**
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
## What This Does
- Deploys certctl server (control plane) with PostgreSQL
@@ -2,8 +2,6 @@
**What this does:** Issues wildcard certificates (e.g., `*.example.com`) from Let's Encrypt using DNS-01 challenge validation.
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
This example is ideal for:
- Issuing wildcard certificates (`*.example.com`)
- Services behind NAT, firewalls, or non-public networks
-2
View File
@@ -2,8 +2,6 @@
This example demonstrates certctl managing **both public and internal certificates from a single dashboard**. Public-facing services use Let's Encrypt (ACME), while internal services use a private Local CA — all visible and managed in one place.
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
## The Use Case
You have:
@@ -1,7 +1,5 @@
# Private CA + Traefik Example
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
This example demonstrates certctl managing certificates for **internal services without public CA dependency**. Ideal for enterprise environments where:
- All services are internal (VPN, private networks)
@@ -2,8 +2,6 @@
This example demonstrates certctl managing certificates issued by **Smallstep step-ca** and deploying them to **HAProxy**.
> **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only.
## Scenario
You're a Smallstep user running step-ca as your internal PKI. You have HAProxy load balancers that need certificates. This setup:
@@ -522,7 +522,7 @@ func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
func TestRevokeCertificate_NotFound(t *testing.T) {
handler, mock := newCertHandlerWithMock()
mock.RevokeCertificateFn = func(_ context.Context, id string, reason string, _ string) error {
return fmt.Errorf("certificate not found: %w", ErrMockNotFound)
return fmt.Errorf("certificate not found")
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-missing/revoke", strings.NewReader(`{"reason":"keyCompromise"}`))
@@ -33,7 +33,7 @@ func (m *MockAgentGroupService) GetAgentGroup(_ context.Context, id string) (*do
if m.GetAgentGroupFn != nil {
return m.GetAgentGroupFn(id)
}
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("not found")
}
func (m *MockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
+58 -11
View File
@@ -1,16 +1,16 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// AgentGroupService defines the service interface for agent group operations.
@@ -24,6 +24,19 @@ type AgentGroupService interface {
}
// AgentGroupHandler handles HTTP requests for agent group operations.
//
// Error dispatch (post-M-1): every service error routes through the [errToStatus]
// choke point via `errors.Is` walking the wrap chain, with one explicit
// [service.ErrValidation] arm on the write paths (Create, Update) so the
// composed "validation: <field-specific reason>" message the service layer
// attaches via `fmt.Errorf("%w: ...", ErrValidation)` can be passed through to
// the 400 response body. Before M-1, the Create handler branched on
// `strings.Contains(err.Error(), "invalid"|"required")` — fragile because a
// single reword in [service.validateAgentGroup] would demote the 400 to 500
// with no compile-time signal — and the Update/Delete handlers branched on
// `strings.Contains(err.Error(), "not found")`, coupling HTTP classification
// to repository human-readable strings. Both now ride the typed
// [repository.ErrNotFound] wrap through errToStatus. Mirrors ProfileHandler.
type AgentGroupHandler struct {
svc AgentGroupService
}
@@ -91,7 +104,18 @@ func (h AgentGroupHandler) GetAgentGroup(w http.ResponseWriter, r *http.Request)
group, err := h.svc.GetAgentGroup(r.Context(), id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
// M-1: route through errToStatus so a repo-level `sql.ErrNoRows`
// (wrapped as repository.ErrNotFound) becomes 404, but a transient DB
// failure no longer masquerades as 404 — it correctly surfaces 500. The
// pre-M-1 "any error → 404" shortcut was plausible when Get's only
// expected failure was "not found", but the choke point now gives us
// correct dispatch for free. Mirrors GetProfile.
status := errToStatus(err)
msg := "Failed to get agent group"
if status == http.StatusNotFound {
msg = "Agent group not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -125,7 +149,15 @@ func (h AgentGroupHandler) CreateAgentGroup(w http.ResponseWriter, r *http.Reque
created, err := h.svc.CreateAgentGroup(r.Context(), group)
if err != nil {
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") {
// M-1: replace the 2-term substring net (`"invalid"|"required"`) with a
// single `errors.Is(err, service.ErrValidation)` arm. validateAgentGroup
// wraps every field-specific failure via `fmt.Errorf("%w: <reason>",
// ErrValidation)`, so `err.Error()` still contains the human-readable
// reason (e.g., "agent group name is required") and can be safely passed
// to the 400 body — but the status decision no longer depends on the
// exact wording. Other errors redact to a generic 500. Mirrors
// CreateProfile.
if errors.Is(err, service.ErrValidation) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
@@ -162,11 +194,22 @@ func (h AgentGroupHandler) UpdateAgentGroup(w http.ResponseWriter, r *http.Reque
updated, err := h.svc.UpdateAgentGroup(r.Context(), id, group)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
// M-1: explicit ErrValidation arm preserves the user-facing reason in
// the 400 body (validateAgentGroup wraps every failure via
// `fmt.Errorf("%w: ...", ErrValidation)`); every other error — including
// repo-layer ErrNotFound on a missing row — routes through errToStatus
// so the 404/500 decision no longer depends on substring matching.
// Mirrors UpdateProfile.
if errors.Is(err, service.ErrValidation) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update agent group", requestID)
status := errToStatus(err)
msg := "Failed to update agent group"
if status == http.StatusNotFound {
msg = "Agent group not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -190,11 +233,15 @@ func (h AgentGroupHandler) DeleteAgentGroup(w http.ResponseWriter, r *http.Reque
}
if err := h.svc.DeleteAgentGroup(r.Context(), id); err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID)
return
// M-1: sentinel dispatch replaces the substring 404 check — see the
// parallel comment block in UpdateAgentGroup for the rationale. Mirrors
// DeleteProfile.
status := errToStatus(err)
msg := "Failed to delete agent group"
if status == http.StatusNotFound {
msg = "Agent group not found"
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete agent group", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
-158
View File
@@ -893,161 +893,3 @@ func TestAgentReportJobStatus_ServiceError(t *testing.T) {
func stringPtr(s string) *string {
return &s
}
// G-2 (P1): cat-s5-apikey_leak audit closure tests. Pre-G-2,
// Agent.APIKeyHash was tagged `json:"api_key_hash"` and shipped on
// every wire surface that returned domain.Agent. Post-G-2 the tag is
// "-" and Agent.MarshalJSON enforces redaction via a marshal-time copy
// (see internal/domain/connector_test.go for the type-level pin). These
// four tests are the wire-shape contract — they capture the actual HTTP
// response body via httptest and assert the credential-derivative hash
// is absent.
//
// One sentinel value (g2HandlerLeakSentinel) flows through every fixture
// so a single grep over a failing test's output identifies the leak
// surface immediately.
const g2HandlerLeakSentinel = "sha256:LEAKED-CREDENTIAL-DERIVATIVE-HANDLER-SENTINEL"
func TestListAgents_DoesNotLeakAPIKeyHash(t *testing.T) {
now := time.Now()
mock := &MockAgentService{
ListAgentsFn: func(page, perPage int) ([]domain.Agent, int64, error) {
return []domain.Agent{
{ID: "a-1", Name: "agent-one", Hostname: "host-1",
Status: domain.AgentStatusOnline, RegisteredAt: now,
APIKeyHash: g2HandlerLeakSentinel + "-1"},
{ID: "a-2", Name: "agent-two", Hostname: "host-2",
Status: domain.AgentStatusOnline, RegisteredAt: now,
APIKeyHash: g2HandlerLeakSentinel + "-2"},
}, 2, nil
},
}
h := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?page=1&per_page=50", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.ListAgents(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListAgents status = %d, want 200", w.Code)
}
body := w.Body.String()
if bytes.Contains([]byte(body), []byte("api_key_hash")) {
t.Errorf("ListAgents response leaked \"api_key_hash\" key (G-2 regressed):\n%s", body)
}
if bytes.Contains([]byte(body), []byte(g2HandlerLeakSentinel)) {
t.Errorf("ListAgents response leaked sentinel %q:\n%s", g2HandlerLeakSentinel, body)
}
// Sanity: the non-leaked fields ARE present (handler did serve real data).
for _, want := range []string{"a-1", "a-2", "agent-one", "agent-two"} {
if !bytes.Contains([]byte(body), []byte(want)) {
t.Errorf("ListAgents response missing expected field %q (handler may not be serving data):\n%s", want, body)
}
}
}
func TestGetAgent_DoesNotLeakAPIKeyHash(t *testing.T) {
now := time.Now()
mock := &MockAgentService{
GetAgentFn: func(id string) (*domain.Agent, error) {
return &domain.Agent{
ID: id, Name: "single-agent", Hostname: "single.host",
Status: domain.AgentStatusOnline, RegisteredAt: now,
APIKeyHash: g2HandlerLeakSentinel,
}, nil
},
}
h := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.GetAgent(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetAgent status = %d, want 200, body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if bytes.Contains([]byte(body), []byte("api_key_hash")) {
t.Errorf("GetAgent response leaked \"api_key_hash\" key:\n%s", body)
}
if bytes.Contains([]byte(body), []byte(g2HandlerLeakSentinel)) {
t.Errorf("GetAgent response leaked sentinel:\n%s", body)
}
if !bytes.Contains([]byte(body), []byte("single-agent")) {
t.Errorf("GetAgent response missing the agent name (handler may not be serving data):\n%s", body)
}
}
func TestRegisterAgent_DoesNotLeakAPIKeyHash(t *testing.T) {
// Registration is the most likely path for a freshly-hashed key to
// leak: the service mints a new APIKeyHash inside RegisterAgent
// (service/agent.go:405) and the handler returns the agent struct
// verbatim. Pin that the redaction holds even on a "freshly created"
// agent payload.
now := time.Now()
mock := &MockAgentService{
RegisterAgentFn: func(in domain.Agent) (*domain.Agent, error) {
return &domain.Agent{
ID: "agent-new", Name: in.Name, Hostname: in.Hostname,
Status: domain.AgentStatusOnline, RegisteredAt: now,
APIKeyHash: g2HandlerLeakSentinel,
}, nil
},
}
h := NewAgentHandler(mock)
body := bytes.NewBufferString(`{"name":"freshly-registered","hostname":"new.host"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", body)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.RegisterAgent(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("RegisterAgent status = %d, want 201, body=%s", w.Code, w.Body.String())
}
respBody := w.Body.String()
if bytes.Contains([]byte(respBody), []byte("api_key_hash")) {
t.Errorf("RegisterAgent response leaked \"api_key_hash\" key:\n%s", respBody)
}
if bytes.Contains([]byte(respBody), []byte(g2HandlerLeakSentinel)) {
t.Errorf("RegisterAgent response leaked sentinel:\n%s", respBody)
}
if !bytes.Contains([]byte(respBody), []byte("agent-new")) {
t.Errorf("RegisterAgent response missing the new agent ID (handler may not be serving data):\n%s", respBody)
}
}
func TestListRetiredAgents_DoesNotLeakAPIKeyHash(t *testing.T) {
// I-004 surface — separate handler from ListAgents; same leak risk.
now := time.Now()
retiredAt := now.Add(-1 * time.Hour)
reason := "test cascade"
mock := &MockAgentService{
ListRetiredAgentsFn: func(page, perPage int) ([]domain.Agent, int64, error) {
return []domain.Agent{
{ID: "ret-1", Name: "retired-one", Hostname: "host-r1",
Status: domain.AgentStatusOffline, RegisteredAt: now,
RetiredAt: &retiredAt, RetiredReason: &reason,
APIKeyHash: g2HandlerLeakSentinel},
}, 1, nil
},
}
h := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/retired?page=1&per_page=50", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.ListRetiredAgents(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListRetiredAgents status = %d, want 200, body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if bytes.Contains([]byte(body), []byte("api_key_hash")) {
t.Errorf("ListRetiredAgents response leaked \"api_key_hash\" key:\n%s", body)
}
if bytes.Contains([]byte(body), []byte(g2HandlerLeakSentinel)) {
t.Errorf("ListRetiredAgents response leaked sentinel:\n%s", body)
}
if !bytes.Contains([]byte(body), []byte("ret-1")) {
t.Errorf("ListRetiredAgents response missing the retired agent ID:\n%s", body)
}
}
@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -141,9 +142,7 @@ func TestRetireAgentHandler_Sentinel_403(t *testing.T) {
func TestRetireAgentHandler_NotFound_404(t *testing.T) {
mock, handler := agentRetireTestSetup()
mock.RetireAgentFn = func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
// S-2 closure (cat-s6-efc7f6f6bd50): wrap repository.ErrNotFound
// so the handler's errors.Is dispatch resolves to 404.
return nil, ErrMockNotFound
return nil, errors.New("agent not found")
}
req := httptest.NewRequest(http.MethodDelete, "/api/v1/agents/unknown-id", nil)
+53 -7
View File
@@ -1,7 +1,6 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"encoding/json"
"errors"
@@ -109,7 +108,19 @@ func (h AgentHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
agent, err := h.svc.GetAgent(r.Context(), id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
// M-1 (P2): route through errToStatus so a repo-level
// sql.ErrNoRows (wrapped as repository.ErrNotFound) becomes 404,
// but a transient DB failure no longer masquerades as 404 — it
// correctly surfaces 500. The pre-M-1 "any error → 404" shortcut
// was plausible when Get's only expected failure was "not found",
// but the choke point now gives us correct dispatch for free.
// Mirrors GetAgentGroup / GetProfile.
status := errToStatus(err)
msg := "Failed to get agent"
if status == http.StatusNotFound {
msg = "Agent not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -148,11 +159,20 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
created, err := h.svc.RegisterAgent(r.Context(), agent)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") || strings.Contains(errMsg, "already exists") {
// M-1 (P2): replace the 3-term substring net
// (`"unique"|"duplicate"|"already exists"`) with a typed
// errors.Is(err, service.ErrConflict) arm. The service layer now
// wraps pg SQLSTATE 23505 duplicate-name violations via
// fmt.Errorf("%w: agent name already exists", ErrConflict), so
// classification no longer depends on the exact driver wording.
// Other errors redact to a generic 500 with slog.Error server-
// side diagnostic capture (F-002). Mirrors CreateProfile's
// ErrValidation arm pattern, adapted for the conflict case.
if errors.Is(err, service.ErrConflict) {
ErrorWithRequestID(w, http.StatusConflict, "Agent with this name already exists", requestID)
return
}
slog.Error("RegisterAgent failed", "name", agent.Name, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
return
}
@@ -212,7 +232,15 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusGone, "Agent has been retired", requestID)
return
}
if errors.Is(err, repository.ErrNotFound) {
// M-1 (P2): the pre-M-1 `strings.Contains(err.Error(), "not
// found")` branch now rides the errToStatus choke point, which
// recognizes repository.ErrNotFound via errors.Is. The retired-
// agent sentinel is still checked FIRST above so the 410 Gone
// short-circuit is never masked by the 404 arm. Any other error
// redacts to a generic 500 with slog.Error server-side diagnostic
// capture (F-002). Mirrors GetAgentGroup.
status := errToStatus(err)
if status == http.StatusNotFound {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
return
}
@@ -309,7 +337,16 @@ func (h AgentHandler) AgentCertificatePickup(w http.ResponseWriter, r *http.Requ
certPEM, err := h.svc.CertificatePickup(r.Context(), agentID, certID)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found or not ready", requestID)
// M-1 (P2): route through errToStatus so a repo-level
// sql.ErrNoRows (wrapped as repository.ErrNotFound) becomes 404,
// but a transient DB failure no longer masquerades as 404 — it
// correctly surfaces 500. Mirrors GetAgent.
status := errToStatus(err)
msg := "Failed to retrieve certificate"
if status == http.StatusNotFound {
msg = "Certificate not found or not ready"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -492,7 +529,16 @@ func (h AgentHandler) RetireAgent(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusConflict, body)
return
}
if errors.Is(err, repository.ErrNotFound) {
// M-1 (P2): the pre-M-1 `strings.Contains(err.Error(), "not
// found")` branch now rides the errToStatus choke point, which
// recognizes repository.ErrNotFound via errors.Is. The sentinel
// (ErrAgentIsSentinel, ErrForceReasonRequired) and typed
// (*BlockedByDependenciesError) checks above still run FIRST so
// the 403/400/409 structural refusals are never masked by the
// 404 arm. Any other error redacts to a generic 500 with
// slog.Error server-side diagnostic capture (F-002).
status := errToStatus(err)
if status == http.StatusNotFound {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
return
}
+19 -1
View File
@@ -2,6 +2,7 @@ package handler
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -86,7 +87,24 @@ func (h AuditHandler) GetAuditEvent(w http.ResponseWriter, r *http.Request) {
event, err := h.svc.GetAuditEvent(r.Context(), id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Audit event not found", requestID)
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch was
// a blanket `any error → 404 Audit event not found` shortcut — which
// silently demoted transient DB failures from the service's auditRepo.List
// wrap to 404 Not Found with no observable external signal. Post-M-1:
// service/audit.go GetAuditEvent only wraps the genuine zero-events path
// with service.ErrNotFound via %w, and the repo.List wrap surfaces without
// that sentinel — so errors.Is(err, service.ErrNotFound) picks up the real
// 404s and everything else correctly surfaces as 500 with server-side
// slog.Error capture (F-002 redacted-500 pattern preserved).
status := errToStatus(err)
if status == http.StatusInternalServerError {
slog.Error("GetAuditEvent failed", "audit_event_id", id, "error", err.Error())
}
msg := "Failed to get audit event"
if status == http.StatusNotFound {
msg = "Audit event not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
-104
View File
@@ -1,104 +0,0 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// BulkReassignmentService defines the service interface for bulk
// owner-reassignment operations.
type BulkReassignmentService interface {
BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
}
// BulkReassignmentHandler handles HTTP requests for bulk reassignment
// operations.
type BulkReassignmentHandler struct {
svc BulkReassignmentService
}
// NewBulkReassignmentHandler creates a new BulkReassignmentHandler.
func NewBulkReassignmentHandler(svc BulkReassignmentService) BulkReassignmentHandler {
return BulkReassignmentHandler{svc: svc}
}
// bulkReassignRequest is the JSON shape decoded from the request body.
type bulkReassignRequest struct {
CertificateIDs []string `json:"certificate_ids"`
OwnerID string `json:"owner_id"`
TeamID string `json:"team_id,omitempty"`
}
// BulkReassign handles POST /api/v1/certificates/bulk-reassign
//
// L-2 closure (cat-l-8a1fb258a38a): pre-L-2 the GUI looped
// `await updateCertificate(id, { owner_id })`. Post-L-2 the GUI POSTs
// once and the server mutates owner_id (and optionally team_id) on N
// certs, returning per-cert success/skip/error counts.
//
// Narrower contract than bulk-renew: explicit IDs only, no criteria-mode.
// OwnerID is required; TeamID is optional and updates the team only when
// non-empty (matches the existing per-cert PUT contract).
//
// Auth: any authenticated caller can reassign certs they own/have
// access to. NOT admin-gated — operators reassign ownership during
// team transitions all the time and gating that on admin would block
// the common-case workflow.
//
// Validation order: empty body → 400; empty IDs → 400; missing
// owner_id → 400; non-existent owner_id → 400 via the
// ErrBulkReassignOwnerNotFound sentinel mapped here.
func (h BulkReassignmentHandler) BulkReassign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var req bulkReassignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
request := domain.BulkReassignmentRequest{
CertificateIDs: req.CertificateIDs,
OwnerID: req.OwnerID,
TeamID: req.TeamID,
}
if request.IsEmpty() {
ErrorWithRequestID(w, http.StatusBadRequest,
"At least one certificate_id is required",
requestID)
return
}
if request.OwnerID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "owner_id is required", requestID)
return
}
actor := resolveActor(r.Context())
result, err := h.svc.BulkReassign(r.Context(), request, actor)
if err != nil {
// Sentinel-error → 400 mapping. ErrBulkReassignOwnerNotFound
// means the operator picked an owner that doesn't exist; this
// is bad input (400), not a server error (500). Mirrors the
// post-M-1 errToStatus convention rather than substring-matching
// err.Error().
if errors.Is(err, service.ErrBulkReassignOwnerNotFound) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk reassignment failed: "+err.Error(), requestID)
return
}
JSON(w, http.StatusOK, result)
}
@@ -1,149 +0,0 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
type mockBulkReassignmentService struct {
BulkReassignFn func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
}
func (m *mockBulkReassignmentService) BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
if m.BulkReassignFn != nil {
return m.BulkReassignFn(ctx, request, actor)
}
return &domain.BulkReassignmentResult{}, nil
}
func TestBulkReassign_Handler_HappyPath(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
if request.OwnerID != "o-bob" {
t.Errorf("owner_id = %q, want 'o-bob'", request.OwnerID)
}
return &domain.BulkReassignmentResult{
TotalMatched: 2, TotalReassigned: 2,
}, nil
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1","mc-2"],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var result domain.BulkReassignmentResult
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decode failed: %v", err)
}
if result.TotalReassigned != 2 {
t.Errorf("envelope drift: TotalReassigned=%d, want 2", result.TotalReassigned)
}
}
func TestBulkReassign_Handler_EmptyIDs_400(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":[],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestBulkReassign_Handler_MissingOwnerID_400(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
if !strings.Contains(w.Body.String(), "owner_id") {
t.Errorf("body should name owner_id; got: %s", w.Body.String())
}
}
// TestBulkReassign_Handler_OwnerNotFound_400 — sentinel-error → 400
// mapping. Operator picked an owner that doesn't exist; that's bad
// input, not a server error.
func TestBulkReassign_Handler_OwnerNotFound_400(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
return nil, fmt.Errorf("%w: %s", service.ErrBulkReassignOwnerNotFound, request.OwnerID)
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"],"owner_id":"o-ghost"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (ErrBulkReassignOwnerNotFound → 400)", w.Code)
}
if !strings.Contains(w.Body.String(), "owner not found") {
t.Errorf("body should mention 'owner not found'; got: %s", w.Body.String())
}
}
func TestBulkReassign_Handler_WrongMethod_405(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/v1/certificates/bulk-reassign", nil)
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("%s → %d, want 405", method, w.Code)
}
}
}
func TestBulkReassign_Handler_GenericError_500(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
return nil, errors.New("simulated outage")
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
}
-96
View File
@@ -1,96 +0,0 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// BulkRenewalService defines the service interface for bulk certificate
// renewal. Mirrors BulkRevocationService — handler doesn't import the
// concrete service struct so tests can inject a mock without pulling in
// the full service-layer dependency graph.
type BulkRenewalService interface {
BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error)
}
// BulkRenewalHandler handles HTTP requests for bulk renewal operations.
type BulkRenewalHandler struct {
svc BulkRenewalService
}
// NewBulkRenewalHandler creates a new BulkRenewalHandler.
func NewBulkRenewalHandler(svc BulkRenewalService) BulkRenewalHandler {
return BulkRenewalHandler{svc: svc}
}
// bulkRenewRequest mirrors the BulkRenewalCriteria JSON shape (the
// handler decodes into this struct then hands a domain.BulkRenewalCriteria
// to the service — same indirection as bulkRevokeRequest in
// bulk_revocation.go).
type bulkRenewRequest struct {
ProfileID string `json:"profile_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,omitempty"`
}
// BulkRenew handles POST /api/v1/certificates/bulk-renew
//
// L-1 closure (cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped
// `await triggerRenewal(id)` over the selection. Post-L-1 it POSTs once
// and the server enqueues N renewal jobs server-side, returning a
// per-cert {certificate_id, job_id} envelope.
//
// Request shape mirrors BulkRevokeRequest (criteria-mode + IDs-mode);
// the "renew all certs of profile X before its CA changes" use case is
// why criteria-mode is supported in addition to explicit IDs.
//
// Auth: any authenticated caller can renew certs they have read-access
// to (matches POST /api/v1/certificates/{id}/renew). NOT admin-gated
// like bulk-revoke — bulk-renew is non-destructive (worst case it
// kicks off some redundant ACME orders) so we don't need the
// fleet-scale-destruction gate.
func (h BulkRenewalHandler) BulkRenew(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var req bulkRenewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
criteria := domain.BulkRenewalCriteria{
ProfileID: req.ProfileID,
OwnerID: req.OwnerID,
AgentID: req.AgentID,
IssuerID: req.IssuerID,
TeamID: req.TeamID,
CertificateIDs: req.CertificateIDs,
}
if criteria.IsEmpty() {
ErrorWithRequestID(w, http.StatusBadRequest,
"At least one filter criterion is required (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids)",
requestID)
return
}
actor := resolveActor(r.Context())
result, err := h.svc.BulkRenew(r.Context(), criteria, actor)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk renewal failed: "+err.Error(), requestID)
return
}
JSON(w, http.StatusOK, result)
}
@@ -1,148 +0,0 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// mockBulkRenewalService is a test implementation of BulkRenewalService.
type mockBulkRenewalService struct {
BulkRenewFn func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error)
}
func (m *mockBulkRenewalService) BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
if m.BulkRenewFn != nil {
return m.BulkRenewFn(ctx, criteria, actor)
}
return &domain.BulkRenewalResult{}, nil
}
// authedContext mirrors adminContext but without the admin flag —
// bulk-renew is NOT admin-gated, any authenticated caller can use it.
func authedContext() context.Context {
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-renew")
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
return ctx
}
func TestBulkRenew_Handler_HappyPath(t *testing.T) {
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
if len(criteria.CertificateIDs) != 3 {
t.Errorf("expected 3 IDs, got %d", len(criteria.CertificateIDs))
}
if actor != "alice" {
t.Errorf("actor = %q, want 'alice' (resolved from middleware UserKey)", actor)
}
return &domain.BulkRenewalResult{
TotalMatched: 3,
TotalEnqueued: 3,
EnqueuedJobs: []domain.BulkEnqueuedJob{
{CertificateID: "mc-1", JobID: "job-a"},
{CertificateID: "mc-2", JobID: "job-b"},
{CertificateID: "mc-3", JobID: "job-c"},
},
}, nil
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1","mc-2","mc-3"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var result domain.BulkRenewalResult
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decode failed: %v", err)
}
if result.TotalEnqueued != 3 || len(result.EnqueuedJobs) != 3 {
t.Errorf("envelope drift: enqueued=%d jobs=%d, want 3/3",
result.TotalEnqueued, len(result.EnqueuedJobs))
}
}
func TestBulkRenew_Handler_EmptyBody_400(t *testing.T) {
svc := &mockBulkRenewalService{}
h := NewBulkRenewalHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (empty criteria must reject)", w.Code)
}
if !strings.Contains(w.Body.String(), "filter criterion") {
t.Errorf("body should name the criteria-required contract; got: %s", w.Body.String())
}
}
func TestBulkRenew_Handler_WrongMethod_405(t *testing.T) {
svc := &mockBulkRenewalService{}
h := NewBulkRenewalHandler(svc)
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/v1/certificates/bulk-renew", nil)
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("%s → status %d, want 405", method, w.Code)
}
}
}
func TestBulkRenew_Handler_ActorAttribution(t *testing.T) {
var capturedActor string
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
capturedActor = actor
return &domain.BulkRenewalResult{}, nil
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if capturedActor != "alice" {
t.Errorf("actor not threaded from middleware.UserKey: got %q, want 'alice'", capturedActor)
}
}
func TestBulkRenew_Handler_ServiceError_500(t *testing.T) {
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
return nil, errors.New("simulated DB failure")
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
}
@@ -900,7 +900,7 @@ func TestRevokeCertificate_Handler_AlreadyRevoked(t *testing.T) {
func TestRevokeCertificate_Handler_NotFound(t *testing.T) {
mock := &MockCertificateService{
RevokeCertificateFn: func(_ context.Context, certID string, reason string, _ string) error {
return fmt.Errorf("failed to fetch certificate: not found: %w", ErrMockNotFound)
return fmt.Errorf("failed to fetch certificate: not found")
},
}
@@ -1033,7 +1033,7 @@ func TestGetDERCRL_Success(t *testing.T) {
if issuerID == "iss-local" {
return derCRLData, nil
}
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("issuer not found")
},
}
@@ -1061,7 +1061,7 @@ func TestGetDERCRL_Success(t *testing.T) {
func TestGetDERCRL_IssuerNotFound(t *testing.T) {
mock := &MockCertificateService{
GenerateDERCRLFn: func(_ context.Context, issuerID string) ([]byte, error) {
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("issuer not found")
},
}
@@ -1118,7 +1118,7 @@ func TestHandleOCSP_Success(t *testing.T) {
if issuerID == "iss-local" && serialHex == "12345" {
return ocspResponseBytes, nil
}
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("certificate not found")
},
}
@@ -1159,7 +1159,7 @@ func TestHandleOCSP_MissingSerial(t *testing.T) {
func TestHandleOCSP_IssuerNotFound(t *testing.T) {
mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
return nil, fmt.Errorf("issuer not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("issuer not found")
},
}
@@ -1178,7 +1178,7 @@ func TestHandleOCSP_IssuerNotFound(t *testing.T) {
func TestHandleOCSP_CertNotFound(t *testing.T) {
mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("certificate not found")
},
}
@@ -1529,7 +1529,7 @@ func TestGetCertificateDeployments_Success(t *testing.T) {
func TestGetCertificateDeployments_NotFound(t *testing.T) {
mock := &MockCertificateService{
GetCertificateDeploymentsFn: func(_ context.Context, certID string) ([]domain.DeploymentTarget, error) {
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("certificate not found")
},
}
+48 -63
View File
@@ -1,7 +1,6 @@
package handler
import (
"errors"
"context"
"encoding/json"
"log/slog"
@@ -299,12 +298,13 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
updated, err := h.svc.UpdateCertificate(r.Context(), id, cert)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("UpdateCertificate failed", "cert_id", id, "error", err)
msg = "internal error"
}
slog.Error("UpdateCertificate failed", "cert_id", id, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -328,11 +328,13 @@ func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Re
}
if err := h.svc.ArchiveCertificate(r.Context(), id); err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("ArchiveCertificate failed", "cert_id", id, "error", err)
msg = "internal error"
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -374,12 +376,13 @@ func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *htt
versions, total, err := h.svc.GetCertificateVersions(r.Context(), certID, page, perPage)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("GetCertificateVersions failed", "cert_id", certID, "error", err)
msg = "internal error"
}
slog.Error("GetCertificateVersions failed", "cert_id", certID, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get certificate versions", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -415,20 +418,13 @@ func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Reques
actor := resolveActor(r.Context())
if err := h.svc.TriggerRenewal(r.Context(), certID, actor); err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("TriggerRenewal failed", "cert_id", certID, "error", err)
msg = "internal error"
}
if strings.Contains(errMsg, "cannot renew") {
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
return
}
if strings.Contains(errMsg, "already in progress") {
ErrorWithRequestID(w, http.StatusConflict, errMsg, requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -517,19 +513,13 @@ func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Req
actor := resolveActor(r.Context())
if err := h.svc.RevokeCertificate(r.Context(), certID, req.Reason, actor); err != nil {
// Distinguish between client errors and server errors
errMsg := err.Error()
if strings.Contains(errMsg, "already revoked") ||
strings.Contains(errMsg, "cannot revoke") ||
strings.Contains(errMsg, "invalid revocation reason") {
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("RevokeCertificate failed", "cert_id", certID, "error", err)
msg = "internal error"
}
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") || strings.Contains(errMsg, "failed to get") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to revoke certificate", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -558,16 +548,13 @@ func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) {
derBytes, err := h.svc.GenerateDERCRL(r.Context(), issuerID)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "not found") {
ErrorWithRequestID(w, http.StatusNotFound, errMsg, requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("GenerateDERCRL failed", "issuer_id", issuerID, "error", err)
msg = "internal error"
}
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
ErrorWithRequestID(w, http.StatusNotImplemented, errMsg, requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate CRL", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -603,16 +590,13 @@ func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
derBytes, err := h.svc.GetOCSPResponse(r.Context(), issuerID, serialHex)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "not found") {
ErrorWithRequestID(w, http.StatusNotFound, errMsg, requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("GetOCSPResponse failed", "issuer_id", issuerID, "serial", serialHex, "error", err)
msg = "internal error"
}
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
ErrorWithRequestID(w, http.StatusNotImplemented, errMsg, requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate OCSP response", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -643,12 +627,13 @@ func (h CertificateHandler) GetCertificateDeployments(w http.ResponseWriter, r *
deployments, err := h.svc.GetCertificateDeployments(r.Context(), certID)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "not found") {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("GetCertificateDeployments failed", "cert_id", certID, "error", err)
msg = "internal error"
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get deployments", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -300,7 +300,7 @@ func TestGetDiscovered_Success(t *testing.T) {
if id == "dcert-1" {
return cert, nil
}
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("not found")
},
}
@@ -331,7 +331,7 @@ func TestGetDiscovered_Success(t *testing.T) {
func TestGetDiscovered_NotFound(t *testing.T) {
mock := &MockDiscoveryService{
GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("not found")
},
}
@@ -412,7 +412,7 @@ func TestClaimDiscovered_MissingManagedCertID(t *testing.T) {
func TestClaimDiscovered_NotFound(t *testing.T) {
mock := &MockDiscoveryService{
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string, actor string) error {
return fmt.Errorf("discovered certificate not found: %w", ErrMockNotFound)
return fmt.Errorf("discovered certificate not found")
},
}
@@ -442,7 +442,7 @@ func TestDismissDiscovered_Success(t *testing.T) {
if id == "dcert-1" {
return nil
}
return fmt.Errorf("not found: %w", ErrMockNotFound)
return fmt.Errorf("not found")
},
}
+117
View File
@@ -0,0 +1,117 @@
package handler
import (
"errors"
"net/http"
"github.com/lib/pq"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/service"
)
// errToStatus is the single choke point that maps a service-layer or
// repository-layer error to its HTTP status code. Before M-1 (P2), 42 switch
// branches across 11 handler files classified errors via
// `strings.Contains(err.Error(), ...)` substring matching — a pattern that
// made every HTTP status mapping one sentinel-message reword away from silent
// regression (see M-003 self-approval privilege boundary: a reword of
// ErrSelfApproval.Error() would have demoted 403 Forbidden to 500 Internal
// Server Error with no compile-time error, no test failure, and no observable
// external signal).
//
// All handler branches now route through this function via errors.Is and
// errors.As, which walks the wrap chain built by fmt.Errorf("%w: ...", ...).
// The generic sentinels live in internal/service/errors.go; domain-specific
// sentinels (ErrSelfApproval, ErrAgentIsSentinel, ErrBlockedByDependencies,
// ErrForceReasonRequired, ErrAgentNotFound) wrap those generics via %w so both
// errors.Is(err, ErrSelfApproval) and errors.Is(err, ErrForbidden) succeed on
// the same wrapped error.
//
// # Dispatch order
//
// 1. ErrAgentRetired → 410 Gone. Tested FIRST. It is deliberately NOT wrapped
// under any generic sentinel — 410 Gone is semantically distinct from
// 403/404/409 (permanently-terminated resource identity that drives
// deterministic agent-binary shutdown at cmd/agent/main.go:1291). Must
// short-circuit before any generic check so wrapping can never demote it.
// 2. ErrNotFound → 404 Not Found. Both service.ErrNotFound and
// repository.ErrNotFound route here — repositories wrap sql.ErrNoRows with
// repository.ErrNotFound so a "row not found" escapes the repo layer as a
// typed sentinel rather than an untyped fmt.Errorf string. Tested BEFORE
// ErrForbidden so RFC 7235's preference for hiding resource existence from
// unauthorized callers is preserved (a caller who cannot see a resource
// should get 404, not 403).
// 3. ErrUnauthenticated → 401 Unauthorized. SCEP challenge-password mismatch
// and similar credential failures.
// 4. ErrForbidden → 403 Forbidden. M-003 gate. Tested BEFORE ErrValidation so
// double-wrapping (e.g., a future fmt.Errorf("%w: ctx", ErrSelfApproval)
// in a wrapping call site) cannot demote 403 to 400.
// 5. ErrConflict / repository.ErrRenewalPolicyDuplicateName /
// repository.ErrRenewalPolicyInUse → 409 Conflict. The repo-layer sentinels
// are routed here explicitly so handlers do not need their own dispatch
// tree for G-1's renewal-policy FK + unique-name violations.
// 6. ErrValidation → 400 Bad Request. Generic input validation / malformed
// request bodies / invalid state transitions that the caller could correct
// by changing their request.
// 7. ErrUnprocessable → 422 Unprocessable Entity. Distinct from
// ErrValidation: ErrValidation is "caller sent bad input" (400), while
// ErrUnprocessable is "caller's input was fine but our stored data can't
// satisfy the operation" — e.g., an X.509 PEM in the inventory that fails
// to decode. The pre-M-1 ExportPKCS12 handler pinned 422 on
// strings.Contains(err.Error(), "cannot be parsed"); the sentinel makes
// that dispatch survive message rewording.
// 8. ErrNotImplemented → 501 Not Implemented. Reserved for feature-flag-gated
// code paths.
// 9. *pq.Error fallback on SQLSTATE 23503 (FK violation) / 23505 (unique
// violation) → 409 Conflict. Final branch before the default 500. Anything
// that reaches here is technically a code smell (the repository layer
// should normally wrap driver errors into a typed sentinel) but the status
// mapping is still correct.
//
// # Why a function, not a middleware
//
// Handlers must continue to call [Error] / [ErrorWithRequestID] with a
// caller-chosen human-readable message (sometimes the wrapped err.Error(),
// sometimes a redacted "internal error" for 500s per F-002). This function
// gives handlers the status code; the handler keeps control of the body.
func errToStatus(err error) int {
if err == nil {
return http.StatusOK
}
switch {
case errors.Is(err, service.ErrAgentRetired):
return http.StatusGone // 410 — must short-circuit before generic dispatch
case errors.Is(err, service.ErrNotFound),
errors.Is(err, repository.ErrNotFound):
return http.StatusNotFound // 404 — before ErrForbidden (RFC 7235 existence hiding)
case errors.Is(err, service.ErrUnauthenticated):
return http.StatusUnauthorized // 401
case errors.Is(err, service.ErrForbidden):
return http.StatusForbidden // 403 — before ErrValidation (preserves M-003 gate under double-wrap)
case errors.Is(err, service.ErrConflict),
errors.Is(err, repository.ErrRenewalPolicyDuplicateName),
errors.Is(err, repository.ErrRenewalPolicyInUse):
return http.StatusConflict // 409
case errors.Is(err, service.ErrValidation):
return http.StatusBadRequest // 400
case errors.Is(err, service.ErrUnprocessable):
return http.StatusUnprocessableEntity // 422 — stored-data-unparseable, not caller-input-bad
case errors.Is(err, service.ErrNotImplemented):
return http.StatusNotImplemented // 501
}
// Driver-level fallback. Raw *pq.Error escaping the repository layer is a
// code smell but a real escape hatch today — we still want a correct 409
// instead of a generic 500 for FK/unique violations.
var pgErr *pq.Error
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23503", "23505":
return http.StatusConflict
}
}
return http.StatusInternalServerError
}
+120
View File
@@ -0,0 +1,120 @@
package handler
import (
"errors"
"fmt"
"net/http"
"testing"
"github.com/lib/pq"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/service"
)
// TestErrToStatus_DispatchMatrix pins the handler's single error → HTTP
// status choke point. Each row covers one branch of the dispatch switch and
// the dispatch order invariants documented in errors.go:
//
// - ErrAgentRetired FIRST (410 short-circuits before generic checks)
// - ErrNotFound before ErrForbidden (RFC 7235 existence hiding)
// - ErrForbidden before ErrValidation (preserves M-003 gate under double-wrap)
// - Repo sentinels route to 409 alongside ErrConflict
// - *pq.Error on 23503 / 23505 routes to 409 as the driver-level fallback
// - Default path is 500
func TestErrToStatus_DispatchMatrix(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"nil → 200", nil, http.StatusOK},
// Each generic sentinel resolves to its documented status code.
{"ErrNotFound → 404", service.ErrNotFound, http.StatusNotFound},
{"ErrValidation → 400", service.ErrValidation, http.StatusBadRequest},
{"ErrConflict → 409", service.ErrConflict, http.StatusConflict},
{"ErrForbidden → 403", service.ErrForbidden, http.StatusForbidden},
{"ErrUnauthenticated → 401", service.ErrUnauthenticated, http.StatusUnauthorized},
{"ErrNotImplemented → 501", service.ErrNotImplemented, http.StatusNotImplemented},
// Wrapped domain sentinels route through their generic wrap.
{"ErrSelfApproval → 403 (via ErrForbidden)", service.ErrSelfApproval, http.StatusForbidden},
{"ErrAgentIsSentinel → 403 (via ErrForbidden)", service.ErrAgentIsSentinel, http.StatusForbidden},
{"ErrBlockedByDependencies → 409 (via ErrConflict)", service.ErrBlockedByDependencies, http.StatusConflict},
{"ErrForceReasonRequired → 400 (via ErrValidation)", service.ErrForceReasonRequired, http.StatusBadRequest},
{"ErrAgentNotFound → 400 (via ErrValidation)", service.ErrAgentNotFound, http.StatusBadRequest},
// ErrAgentRetired is standalone — 410 Gone must fire before any
// generic dispatch. This locks in the semantic-distinct short-circuit.
{"ErrAgentRetired → 410", service.ErrAgentRetired, http.StatusGone},
// Repository-layer sentinels (G-1 + M-1).
{"repo.ErrNotFound → 404", repository.ErrNotFound, http.StatusNotFound},
{"wrapped repo.ErrNotFound → 404",
fmt.Errorf("%w: renewal policy rp-foo", repository.ErrNotFound),
http.StatusNotFound},
{"repo.ErrRenewalPolicyDuplicateName → 409", repository.ErrRenewalPolicyDuplicateName, http.StatusConflict},
{"repo.ErrRenewalPolicyInUse → 409", repository.ErrRenewalPolicyInUse, http.StatusConflict},
// Wrapped errors with additional context survive the dispatch.
{"wrapped ErrNotFound with context → 404",
fmt.Errorf("lookup failed: %w", service.ErrNotFound),
http.StatusNotFound},
{"wrapped ErrSelfApproval with context → 403",
fmt.Errorf("approval gate: %w", service.ErrSelfApproval),
http.StatusForbidden},
// Driver-level fallback: raw *pq.Error escaping repo layer.
{"*pq.Error 23503 → 409", &pq.Error{Code: "23503"}, http.StatusConflict},
{"*pq.Error 23505 → 409", &pq.Error{Code: "23505"}, http.StatusConflict},
{"*pq.Error 08006 → 500", &pq.Error{Code: "08006"}, http.StatusInternalServerError},
// Default path.
{"unknown error → 500", errors.New("something arbitrary"), http.StatusInternalServerError},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := errToStatus(c.err)
if got != c.want {
t.Errorf("errToStatus(%v) = %d, want %d", c.err, got, c.want)
}
})
}
}
// TestErrToStatus_AgentRetiredShortCircuit is a dedicated regression guard
// for the most fragile dispatch invariant: ErrAgentRetired's 410 Gone must
// fire FIRST. If a future commit wraps it under ErrForbidden (e.g., to
// include it in a generic "agent operations forbidden" bucket), this test
// goes red and the agent-binary shutdown at cmd/agent/main.go:1291 would
// silently stop triggering.
func TestErrToStatus_AgentRetiredShortCircuit(t *testing.T) {
if got := errToStatus(service.ErrAgentRetired); got != http.StatusGone {
t.Fatalf("ErrAgentRetired → %d, want 410 Gone (short-circuit must fire before any generic dispatch)", got)
}
}
// TestErrToStatus_NotFoundBeforeForbidden locks the RFC 7235 existence-
// hiding dispatch order. If someone were to reorder the switch arms to put
// ErrForbidden first, an authorization failure on a nonexistent resource
// would leak existence via a 403 instead of masking it with a 404.
func TestErrToStatus_NotFoundBeforeForbidden(t *testing.T) {
// A hypothetical wrapping where both would match — contrived but the
// ordering guarantee is what we're testing.
both := fmt.Errorf("%w: layered with %w", service.ErrNotFound, service.ErrForbidden)
if got := errToStatus(both); got != http.StatusNotFound {
t.Errorf("dual-wrapped err → %d, want 404 (ErrNotFound must dispatch before ErrForbidden)", got)
}
}
// TestErrToStatus_ForbiddenBeforeValidation guards the M-003 self-approval
// gate against a future call site that double-wraps ErrSelfApproval under
// ErrValidation (intentionally or accidentally). The dispatch must pick
// 403, not 400.
func TestErrToStatus_ForbiddenBeforeValidation(t *testing.T) {
doubled := fmt.Errorf("%w: %w", service.ErrSelfApproval, service.ErrValidation)
if got := errToStatus(doubled); got != http.StatusForbidden {
t.Errorf("double-wrapped err → %d, want 403 (ErrForbidden must dispatch before ErrValidation — M-003 gate)", got)
}
}
+43 -15
View File
@@ -1,8 +1,6 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"log/slog"
@@ -48,12 +46,26 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
result, err := h.svc.ExportPEM(r.Context(), id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch
// classified 404 via strings.Contains(err.Error(), "not found"), which
// gave false positives on any error whose rendered text happened to
// contain "not found" — notably a transient DB failure when the service
// layer wrapped every certRepo.Get error with "certificate not found".
// Post-M-1: service/export.go now wraps with "failed to get certificate"
// and only the genuine sql.ErrNoRows path surfaces repository.ErrNotFound
// through the wrap chain, so errors.Is(err, repository.ErrNotFound) picks
// up the real 404s and everything else — including transient DB errors —
// correctly surfaces as 500 with server-side slog.Error capture (F-002
// redacted-500 pattern preserved).
status := errToStatus(err)
if status == http.StatusInternalServerError {
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
}
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
msg := "Failed to export certificate"
if status == http.StatusNotFound {
msg = "Certificate not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -96,16 +108,32 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
// M-1 (P2): dispatch routes through errToStatus. The pre-M-1 3-term
// substring net (`"not found"|"cannot be parsed"|"no certificates
// found"`) is replaced with sentinel dispatch:
// - repository.ErrNotFound (from certificate.go Get/GetLatestVersion
// sql.ErrNoRows wrap) → 404
// - service.ErrUnprocessable (from service/export.go ExportPKCS12's
// parsePEMCertificates-failure and empty-chain wraps) → 422 —
// semantically correct because the caller's request is fine; our
// stored PEM chain is what cannot be processed
// - everything else → 500 with slog.Error capture (F-002 redacted-500
// pattern preserved)
// A transient DB failure that pre-M-1 would have been swept into the
// 404 substring branch (because the service wrapped every certRepo.Get
// error with "certificate not found") now correctly surfaces as 500.
status := errToStatus(err)
if status == http.StatusInternalServerError {
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
}
if strings.Contains(err.Error(), "cannot be parsed") || strings.Contains(err.Error(), "no certificates found") {
ErrorWithRequestID(w, http.StatusUnprocessableEntity, "Certificate data cannot be parsed as X.509", requestID)
return
msg := "Failed to export PKCS#12"
switch status {
case http.StatusNotFound:
msg = "Certificate not found"
case http.StatusUnprocessableEntity:
msg = "Certificate data cannot be parsed as X.509"
}
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
+37 -2
View File
@@ -108,9 +108,17 @@ func TestExportPEM_Download(t *testing.T) {
}
func TestExportPEM_NotFound(t *testing.T) {
// M-1 (P2): wrap with service.ErrNotFound via %w so the handler's
// errToStatus choke point dispatches to 404 via errors.Is. Pre-M-1 this
// test used a raw `fmt.Errorf("certificate not found")` string and relied
// on the handler's strings.Contains(err.Error(), "not found") classifier
// — which was the same mechanism that silently misclassified transient DB
// failures whose text happened to include "not found" (see docblock on
// ExportPEM handler). Pinning the sentinel contract makes this test
// regression-proof against wrap-text changes.
mockSvc := &MockExportService{
ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) {
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("%w: certificate", service.ErrNotFound)
},
}
h := NewExportHandler(mockSvc)
@@ -214,9 +222,11 @@ func TestExportPKCS12_EmptyPassword(t *testing.T) {
}
func TestExportPKCS12_NotFound(t *testing.T) {
// M-1 (P2): same sentinel migration as TestExportPEM_NotFound — see
// rationale there.
mockSvc := &MockExportService{
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
return nil, fmt.Errorf("certificate not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("%w: certificate", service.ErrNotFound)
},
}
h := NewExportHandler(mockSvc)
@@ -231,6 +241,31 @@ func TestExportPKCS12_NotFound(t *testing.T) {
}
}
// TestExportPKCS12_Unprocessable pins the M-1 (P2) 422 contract: when the
// service layer wraps a parse failure with service.ErrUnprocessable, the
// handler's errToStatus choke point must dispatch to 422 Unprocessable
// Entity. Pre-M-1 this was classified via a 2-term substring net
// (`"cannot be parsed"|"no certificates found"`) at export.go:101, which
// would have been silently broken by a message reword in service/export.go.
// The new sentinel makes the dispatch survive message rewording.
func TestExportPKCS12_Unprocessable(t *testing.T) {
mockSvc := &MockExportService{
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
return nil, fmt.Errorf("%w: certificate data cannot be parsed as X.509: asn1 decode error", service.ErrUnprocessable)
},
}
h := NewExportHandler(mockSvc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil)
w := httptest.NewRecorder()
h.ExportPKCS12(w, req)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", w.Code)
}
}
func TestExportPKCS12_ServiceError(t *testing.T) {
mockSvc := &MockExportService{
ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) {
+1 -7
View File
@@ -7,14 +7,8 @@ import (
)
// HealthHandler handles health and readiness check endpoints.
//
// G-1 (P1): AuthType is one of "api-key" or "none" — see
// internal/config.AuthType / config.ValidAuthTypes() for the typed
// constants and the rationale for dropping "jwt" (no JWT middleware
// ships with certctl; operators who need JWT/OIDC front certctl with
// an authenticating gateway and set AuthType="none" on the upstream).
type HealthHandler struct {
AuthType string // "api-key" or "none" (see config.AuthType constants)
AuthType string // "api-key", "jwt", "none"
}
// NewHealthHandler creates a new HealthHandler.
+24 -8
View File
@@ -162,14 +162,30 @@ func TestAuthInfo_ReturnsAuthType_None(t *testing.T) {
}
}
// G-1 (P1): the prior `TestAuthInfo_ReturnsAuthType_JWT` asserted the
// handler echoed "jwt" — using the silent-auth-downgrade value as a
// test fixture, which baked the lie into the regression suite. The
// test is removed because "jwt" is now rejected at config-load time
// (see internal/config/config_test.go::TestValidate_JWTAuth_RejectedDedicated)
// and never reaches this handler. The pre-existing
// `TestAuthInfo_ReturnsAuthType_APIKey` above (line ~107) covers the
// api-key happy path; nothing else needs replacing here.
func TestAuthInfo_ReturnsAuthType_JWT(t *testing.T) {
handler := NewHealthHandler("jwt")
req, err := http.NewRequest(http.MethodGet, "/api/v1/auth/info", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
w := httptest.NewRecorder()
handler.AuthInfo(w, req)
var result map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if result["auth_type"] != "jwt" {
t.Errorf("auth_type = %q, want jwt", result["auth_type"])
}
if required, ok := result["required"].(bool); !ok || !required {
t.Errorf("required = %v, want true", result["required"])
}
}
func TestAuthCheck_ReturnsOK(t *testing.T) {
handler := NewHealthHandler("api-key")
+18 -26
View File
@@ -1,8 +1,6 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"log/slog"
@@ -137,16 +135,13 @@ func (h IssuerHandler) CreateIssuer(w http.ResponseWriter, r *http.Request) {
created, err := h.svc.CreateIssuer(r.Context(), issuer)
if err != nil {
h.logger.Error("failed to create issuer", "error", err, "name", issuer.Name, "type", issuer.Type)
errMsg := err.Error()
switch {
case strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate"):
ErrorWithRequestID(w, http.StatusConflict, "An issuer with this name already exists", requestID)
case strings.Contains(errMsg, "unsupported issuer type"):
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
default:
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create issuer", requestID)
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
h.logger.Error("failed to create issuer", "error", err, "name", issuer.Name, "type", issuer.Type)
msg = "internal error"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -179,16 +174,13 @@ func (h IssuerHandler) UpdateIssuer(w http.ResponseWriter, r *http.Request) {
updated, err := h.svc.UpdateIssuer(r.Context(), id, issuer)
if err != nil {
h.logger.Error("failed to update issuer", "error", err, "id", id)
errMsg := err.Error()
switch {
case strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate"):
ErrorWithRequestID(w, http.StatusConflict, "An issuer with this name already exists", requestID)
case strings.Contains(errMsg, "not found"):
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
default:
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update issuer", requestID)
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
h.logger.Error("failed to update issuer", "error", err, "id", id)
msg = "internal error"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -212,13 +204,13 @@ func (h IssuerHandler) DeleteIssuer(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.DeleteIssuer(r.Context(), id); err != nil {
if repository.IsForeignKeyError(err) {
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete issuer: certificates are still using this issuer", requestID)
} else if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
} else {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
h.logger.Error("DeleteIssuer failed", "issuer_id", id, "error", err)
msg = "internal error"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
+2 -2
View File
@@ -383,7 +383,7 @@ func TestApproveJob_Success(t *testing.T) {
func TestApproveJob_NotFound(t *testing.T) {
mock := &MockJobService{
ApproveJobFn: func(id, actor string) error {
return fmt.Errorf("job not found: no rows: %w", ErrMockNotFound)
return fmt.Errorf("job not found: no rows")
},
}
@@ -527,7 +527,7 @@ func TestRejectJob_NoReason(t *testing.T) {
func TestRejectJob_NotFound(t *testing.T) {
mock := &MockJobService{
RejectJobFn: func(id, reason, actor string) error {
return fmt.Errorf("job not found: no rows: %w", ErrMockNotFound)
return fmt.Errorf("job not found: no rows")
},
}
+13 -26
View File
@@ -1,18 +1,16 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// JobService defines the service interface for job operations.
@@ -161,22 +159,13 @@ func (h JobHandler) ApproveJob(w http.ResponseWriter, r *http.Request) {
actor := resolveActor(r.Context())
if err := h.svc.ApproveJob(r.Context(), jobID, actor); err != nil {
// M-003: self-approval by the certificate owner is forbidden.
if errors.Is(err, service.ErrSelfApproval) {
ErrorWithRequestID(w, http.StatusForbidden,
"Self-approval is forbidden: the certificate owner cannot approve their own renewal",
requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("ApproveJob failed", "job_id", jobID, "error", err)
msg = "internal error"
}
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return
}
if strings.Contains(err.Error(), "cannot approve") {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to approve job", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -214,15 +203,13 @@ func (h JobHandler) RejectJob(w http.ResponseWriter, r *http.Request) {
actor := resolveActor(r.Context())
if err := h.svc.RejectJob(r.Context(), jobID, body.Reason, actor); err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("RejectJob failed", "job_id", jobID, "error", err)
msg = "internal error"
}
if strings.Contains(err.Error(), "cannot reject") {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to reject job", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -27,7 +27,7 @@ func (m *mockNetworkScanService) GetTarget(ctx context.Context, id string) (*dom
return t, nil
}
}
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("not found: %s", id)
}
func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
@@ -48,7 +48,7 @@ func (m *mockNetworkScanService) UpdateTarget(ctx context.Context, id string, ta
return t, nil
}
}
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("not found: %s", id)
}
func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error {
@@ -58,7 +58,7 @@ func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) er
return nil
}
}
return fmt.Errorf("not found: %w", ErrMockNotFound)
return fmt.Errorf("not found: %s", id)
}
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
@@ -71,7 +71,7 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
}, nil
}
}
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
return nil, fmt.Errorf("not found: %s", targetID)
}
func TestListNetworkScanTargets(t *testing.T) {
+39 -7
View File
@@ -1,9 +1,8 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -109,7 +108,25 @@ func (h NotificationHandler) GetNotification(w http.ResponseWriter, r *http.Requ
notification, err := h.svc.GetNotification(r.Context(), id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch was
// a blanket `any error → 404 Notification not found` shortcut — which
// silently demoted transient DB failures from the service's List() wrap
// (notification.go:386 pre-M-1) to 404 Not Found with no observable
// external signal. Post-M-1: service/notification.go GetNotification only
// wraps the genuine "not found" path with service.ErrNotFound via %w, and
// every other error (including the List() wrap) surfaces without that
// sentinel — so errors.Is(err, service.ErrNotFound) picks up the real
// 404s and everything else correctly surfaces as 500 with server-side
// slog.Error capture (F-002 redacted-500 pattern preserved).
status := errToStatus(err)
if status == http.StatusInternalServerError {
slog.Error("GetNotification failed", "notification_id", id, "error", err.Error())
}
msg := "Failed to get notification"
if status == http.StatusNotFound {
msg = "Notification not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -172,11 +189,26 @@ func (h NotificationHandler) RequeueNotification(w http.ResponseWriter, r *http.
notificationID := parts[0]
if err := h.svc.RequeueNotification(r.Context(), notificationID); err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
return
// M-1 (P2): dispatch routes through errToStatus. Pre-M-1 this branch
// classified 404 via strings.Contains(err.Error(), "not found"), which
// would have given false positives on any error whose rendered text
// happened to contain "not found" — notably a transient DB failure whose
// driver message mentioned a missing relation or column. Post-M-1: the
// repo-layer Requeue (postgres/notification.go) wraps zero-rows-affected
// with repository.ErrNotFound via %w, and the service layer forwards
// that error with %w — so errors.Is(err, repository.ErrNotFound) walks
// the full wrap chain and picks up the real 404s while everything else
// (including transient DB errors) correctly surfaces as 500 with
// server-side slog.Error capture (F-002 redacted-500 pattern preserved).
status := errToStatus(err)
if status == http.StatusInternalServerError {
slog.Error("RequeueNotification failed", "notification_id", notificationID, "error", err.Error())
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to requeue notification", requestID)
msg := "Failed to requeue notification"
if status == http.StatusNotFound {
msg = "Notification not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
+7 -8
View File
@@ -1,10 +1,9 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -186,13 +185,13 @@ func (h OwnerHandler) DeleteOwner(w http.ResponseWriter, r *http.Request) {
id = parts[0]
if err := h.svc.DeleteOwner(r.Context(), id); err != nil {
if repository.IsForeignKeyError(err) {
ErrorWithRequestID(w, http.StatusConflict, "Cannot delete owner: certificates are still assigned to this owner", requestID)
} else if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
} else {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
status := errToStatus(err)
msg := err.Error()
if status == http.StatusInternalServerError {
slog.Error("DeleteOwner failed", "owner_id", id, "error", err)
msg = "internal error"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
+56 -17
View File
@@ -1,16 +1,16 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"errors"
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// ProfileService defines the service interface for certificate profile operations.
@@ -23,6 +23,20 @@ type ProfileService interface {
}
// ProfileHandler handles HTTP requests for certificate profile operations.
//
// Error dispatch (post-M-1): every service error routes through the [errToStatus]
// choke point via `errors.Is` walking the wrap chain, with one explicit
// [service.ErrValidation] arm on the write paths (Create, Update) so the
// composed "validation: <field-specific reason>" message the service layer
// attaches via `fmt.Errorf("%w: ...", ErrValidation)` can be passed through to
// the 400 response body. Before M-1, the Create and Update handlers branched on
// `strings.Contains(err.Error(), "invalid"|"required"|"must be"|"cannot")` — a
// fragile pattern where a single reword in [service.validateProfile] would
// demote the 400 to 500 with no compile-time signal. The substring-based 404
// branches on Update and Delete likewise depended on the repository's
// human-readable "profile not found" message surviving forever; both now ride
// the same [repository.ErrNotFound] wrap that G-1's renewal-policy and M-1's
// other repositories use.
type ProfileHandler struct {
svc ProfileService
}
@@ -90,7 +104,18 @@ func (h ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
profile, err := h.svc.GetProfile(r.Context(), id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
// M-1: route through errToStatus so a repo-level `sql.ErrNoRows`
// (wrapped as repository.ErrNotFound) becomes 404, but a transient DB
// failure no longer masquerades as 404 — it correctly surfaces 500. The
// pre-M-1 "any error → 404" shortcut was plausible when Get's only
// expected failure was "not found", but the choke point now gives us
// correct dispatch for free. Mirrors GetRenewalPolicy.
status := errToStatus(err)
msg := "Failed to get profile"
if status == http.StatusNotFound {
msg = "Profile not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -125,9 +150,15 @@ func (h ProfileHandler) CreateProfile(w http.ResponseWriter, r *http.Request) {
created, err := h.svc.CreateProfile(r.Context(), profile)
if err != nil {
// Check if it's a validation error from the service
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") ||
strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
// M-1: replace the 4-term substring net
// (`"invalid"|"required"|"must be"|"cannot"`) with a single
// `errors.Is(err, service.ErrValidation)` arm. validateProfile wraps
// every field-specific failure via `fmt.Errorf("%w: <reason>",
// ErrValidation)`, so `err.Error()` still contains the human-readable
// reason (e.g., "RSA minimum key size must be at least 2048") and can be
// safely passed to the 400 body — but the status decision no longer
// depends on the exact wording. Other errors redact to a generic 500.
if errors.Is(err, service.ErrValidation) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
@@ -164,16 +195,21 @@ func (h ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
updated, err := h.svc.UpdateProfile(r.Context(), id, profile)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return
}
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") ||
strings.Contains(err.Error(), "must be") || strings.Contains(err.Error(), "cannot") {
// M-1: explicit ErrValidation arm preserves the user-facing reason in
// the 400 body (validateProfile wraps every failure via
// `fmt.Errorf("%w: ...", ErrValidation)`); every other error — including
// repo-layer ErrNotFound on a missing row — routes through errToStatus
// so the 404/500 decision no longer depends on substring matching.
if errors.Is(err, service.ErrValidation) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update profile", requestID)
status := errToStatus(err)
msg := "Failed to update profile"
if status == http.StatusNotFound {
msg = "Profile not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -197,11 +233,14 @@ func (h ProfileHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.DeleteProfile(r.Context(), id); err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return
// M-1: sentinel dispatch replaces the substring 404 check — see the
// parallel comment block in UpdateProfile for the rationale.
status := errToStatus(err)
msg := "Failed to delete profile"
if status == http.StatusNotFound {
msg = "Profile not found"
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete profile", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
+61 -32
View File
@@ -1,7 +1,6 @@
package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"encoding/json"
"errors"
@@ -27,14 +26,28 @@ type RenewalPolicyService interface {
// RenewalPolicyHandler serves /api/v1/renewal-policies CRUD endpoints.
//
// G-1 + S-2 design note: the service-level `ErrRenewalPolicyDuplicateName` /
// `ErrRenewalPolicyInUse` sentinels alias the repository sentinels (same var
// identity), so `errors.Is` walks transparently across layers. S-2 closure
// (cat-s6-efc7f6f6bd50) extends the same convention to not-found detection:
// repos now wrap `sql.ErrNoRows` via `fmt.Errorf("X not found: %w",
// repository.ErrNotFound)`, handler dispatch uses
// `errors.Is(err, repository.ErrNotFound)`, and `ErrMockNotFound` in
// test_utils.go wraps the same sentinel so the mocks still resolve to 404.
// Error dispatch (post-M-1): every service error routes through the [errToStatus]
// choke point via `errors.Is` walking the wrap chain. Three sentinel identities
// cover the full dispatch surface:
//
// - [service.ErrRenewalPolicyDuplicateName] / [service.ErrRenewalPolicyInUse]
// are `var`-aliased to the repository-layer sentinels of the same name (G-1),
// so handler-side `errors.Is` succeeds against a sentinel raised three layers
// deep in [internal/repository/postgres.RenewalPolicyRepository] without the
// service layer having to translate. [errToStatus] routes both to 409.
// - [repository.ErrNotFound] is wrapped around `sql.ErrNoRows` inside the
// repo's Get/Update/Delete methods via `fmt.Errorf("%w: renewal policy %s",
// repository.ErrNotFound, id)` (M-1). [errToStatus] routes that to 404 in
// the same switch arm as [service.ErrNotFound], preserving the existing
// 404-on-missing behavior that the pre-M-1 substring check provided —
// without the rewording-regression risk that motivated the migration.
//
// The handler layer keeps two explicit `errors.Is` arms for the
// duplicate-name / in-use sentinels so each 409 response can carry a
// constraint-specific human-readable message ("with that name" vs. "still
// referenced by managed certificates"); every other error path — including
// not-found — delegates the status decision to [errToStatus] and provides a
// generic body via the F-002 redacted-500 pattern.
type RenewalPolicyHandler struct {
svc RenewalPolicyService
}
@@ -106,11 +119,18 @@ func (h RenewalPolicyHandler) GetRenewalPolicy(w http.ResponseWriter, r *http.Re
policy, err := h.svc.GetRenewalPolicy(r.Context(), id)
if err != nil {
// Matches the PolicyHandler.GetPolicy convention: any error from the
// service surfaces as 404. The repo wraps sql.ErrNoRows as
// "renewal policy not found: %s" and there's no other expected failure
// mode on Get — the caller gets a clean 404.
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
// M-1: route through errToStatus so a repo-level `sql.ErrNoRows`
// (wrapped as repository.ErrNotFound) becomes 404, but a transient DB
// failure no longer masquerades as 404 — it correctly surfaces 500.
// The pre-M-1 "any error → 404" shortcut was plausible when Get's only
// expected failure was "not found", but the choke point now gives us
// correct dispatch for free.
status := errToStatus(err)
msg := "Failed to get renewal policy"
if status == http.StatusNotFound {
msg = "Renewal policy not found"
}
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -159,11 +179,11 @@ func (h RenewalPolicyHandler) CreateRenewalPolicy(w http.ResponseWriter, r *http
// UpdateRenewalPolicy replaces the fields of an existing renewal policy.
// PUT /api/v1/renewal-policies/{id}
//
// Error mapping:
// - invalid JSON / empty ID → 400
// - ErrRenewalPolicyDuplicateName → 409
// - error text contains "not found" → 404 (see struct doc comment re: substring check)
// - anything else → 500
// Error mapping (post-M-1, sentinel-driven):
// - invalid JSON / empty ID → 400
// - ErrRenewalPolicyDuplicateName (pg 23505) → 409 (explicit arm, custom msg)
// - ErrNotFound (wrapping sql.ErrNoRows) → 404 (via errToStatus)
// - anything else → 500 (via errToStatus, body redacted)
func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
@@ -192,11 +212,17 @@ func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http
ErrorWithRequestID(w, http.StatusConflict, "A renewal policy with that name already exists", requestID)
return
}
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
return
// M-1: drop the `strings.Contains(err.Error(), "not found")` branch.
// [repository.ErrNotFound] now wraps sql.ErrNoRows at the three
// renewal-policy repo methods (Get/Update/Delete), so errToStatus
// routes a missing row to 404 via errors.Is without depending on the
// repo's fmt.Errorf format string surviving a future reword.
status := errToStatus(err)
msg := "Failed to update renewal policy"
if status == http.StatusNotFound {
msg = "Renewal policy not found"
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update renewal policy", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
@@ -206,11 +232,11 @@ func (h RenewalPolicyHandler) UpdateRenewalPolicy(w http.ResponseWriter, r *http
// DeleteRenewalPolicy removes a renewal policy.
// DELETE /api/v1/renewal-policies/{id}
//
// Error mapping:
// - empty ID (trailing slash) → 400
// - ErrRenewalPolicyInUse (pg 23503 FK-RESTRICT against managed_certificates.renewal_policy_id) → 409
// - error text contains "not found" → 404
// - anything else → 500
// Error mapping (post-M-1, sentinel-driven):
// - empty ID (trailing slash) → 400
// - ErrRenewalPolicyInUse (pg 23503 FK-RESTRICT) → 409 (explicit arm, custom msg)
// - ErrNotFound (wrapping sql.ErrNoRows) → 404 (via errToStatus)
// - anything else → 500 (via errToStatus, body redacted)
func (h RenewalPolicyHandler) DeleteRenewalPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
@@ -232,11 +258,14 @@ func (h RenewalPolicyHandler) DeleteRenewalPolicy(w http.ResponseWriter, r *http
ErrorWithRequestID(w, http.StatusConflict, "Renewal policy is still referenced by managed certificates", requestID)
return
}
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Renewal policy not found", requestID)
return
// M-1: sentinel dispatch replaces the substring check — see the
// parallel comment block in UpdateRenewalPolicy for the rationale.
status := errToStatus(err)
msg := "Failed to delete renewal policy"
if status == http.StatusNotFound {
msg = "Renewal policy not found"
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete renewal policy", requestID)
ErrorWithRequestID(w, status, msg, requestID)
return
}
+19 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/asn1"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
@@ -14,6 +15,7 @@ import (
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
"github.com/shankar0123/certctl/internal/service"
)
// SCEPService defines the service interface for SCEP enrollment operations.
@@ -171,11 +173,25 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
result, err := h.svc.PKCSReq(r.Context(), csrPEM, challengePassword, transactionID)
if err != nil {
if strings.Contains(err.Error(), "challenge password") {
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
// M-1 (P2): typed-sentinel dispatch replaces the pre-M-1 substring
// branch `strings.Contains(err.Error(), "challenge password")`. The
// service layer now wraps both challenge-password failure modes (server
// misconfigured / client credential wrong) via `fmt.Errorf("%w: ...",
// ErrUnauthenticated)`, so errors.Is walks the wrap chain without
// depending on the exact wording of the error string. This also
// corrects the HTTP status: pre-M-1 returned 403 Forbidden, but RFC
// 7235 classifies this as 401 Unauthorized (authentication failure, not
// authorization denial). The errToStatus doc block enumerates this as
// the canonical ErrUnauthenticated call site.
if errors.Is(err, service.ErrUnauthenticated) {
ErrorWithRequestID(w, http.StatusUnauthorized, "Invalid challenge password", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
// F-002 redacted-500: every other enrollment failure (CSR parse errors,
// issuer-layer failures, audit-layer failures) returns an opaque body
// so we don't leak internal state through the SCEP response. The
// logger already captured the real error at the service layer.
ErrorWithRequestID(w, http.StatusInternalServerError, "Enrollment failed", requestID)
return
}
+17 -3
View File
@@ -4,12 +4,14 @@ import (
"context"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// mockSCEPService implements SCEPService for testing.
@@ -214,9 +216,21 @@ func TestSCEP_PKIOperation_Success_RawCSR(t *testing.T) {
}
}
// TestSCEP_PKIOperation_ChallengePasswordRejected pins the M-1 (P2) dispatch
// contract: when the service wraps the failure via `fmt.Errorf("%w: ...",
// service.ErrUnauthenticated)` the handler's errToStatus choke point must
// return 401 Unauthorized, NOT 403 Forbidden.
//
// This is a deliberate semantic correction. Pre-M-1 the handler inspected
// err.Error() for the "challenge password" substring and returned 403, which
// misclassified the RFC 7235 condition (the caller presented no valid
// application-layer credential — that is auth failure, not authorization
// denial). The errToStatus doc explicitly cites this code path as the
// canonical ErrUnauthenticated consumer; see handler/errors.go and the
// symmetric M-1 comment block at handler/scep.go in the pkiOperation arm.
func TestSCEP_PKIOperation_ChallengePasswordRejected(t *testing.T) {
svc := &mockSCEPService{
EnrollErr: errors.New("invalid challenge password"),
EnrollErr: fmt.Errorf("%w: invalid challenge password", service.ErrUnauthenticated),
}
h := NewSCEPHandler(svc)
@@ -230,8 +244,8 @@ func TestSCEP_PKIOperation_ChallengePasswordRejected(t *testing.T) {
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 Unauthorized (M-1 dispatch of service.ErrUnauthenticated), got %d: %s", w.Code, w.Body.String())
}
}
+16 -11
View File
@@ -1,22 +1,27 @@
package handler
import (
"errors"
"fmt"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/service"
)
// Mock errors for testing.
//
// S-2 closure (cat-s6-efc7f6f6bd50): ErrMockNotFound now wraps
// repository.ErrNotFound via fmt.Errorf("...: %w", ...) so the
// post-S-2 handler dispatch — which uses errors.Is(err,
// repository.ErrNotFound) instead of strings.Contains — still
// resolves the mock to a 404. The error message text is preserved
// for log inspection; only the wrapping changes.
// M-1: Since the handler layer now classifies errors via the typed-sentinel
// dispatch in [errToStatus] (errors.Is on service + repository sentinels rather
// than substring matching on err.Error()), handler mocks MUST wrap the
// appropriate generic sentinel so `errors.Is(err, service.ErrNotFound)` etc.
// succeed. Using raw errors.New() breaks the dispatch and degrades every
// mock-driven negative-path test to a 500 Internal Server Error — the same
// silent-regression trap the migration was designed to eliminate.
//
// ErrMockServiceFailed deliberately stays untyped so it continues to exercise
// the default 500 path.
var (
ErrMockServiceFailed = fmt.Errorf("mock service error")
ErrMockNotFound = fmt.Errorf("mock not found error: %w", repository.ErrNotFound)
ErrMockUnauthorized = fmt.Errorf("mock unauthorized error")
ErrMockConflict = fmt.Errorf("mock conflict error")
ErrMockServiceFailed = errors.New("mock service error")
ErrMockNotFound = fmt.Errorf("%w: mock not found", service.ErrNotFound)
ErrMockUnauthorized = fmt.Errorf("%w: mock unauthenticated", service.ErrUnauthenticated)
ErrMockConflict = fmt.Errorf("%w: mock conflict", service.ErrConflict)
)
-158
View File
@@ -1,158 +0,0 @@
package handler
import (
"net/http"
"runtime"
"runtime/debug"
)
// VersionHandler exposes the running server's build identity at
// /api/v1/version. U-3 ride-along (cat-u-no_version_endpoint, P2): pre-U-3
// there was no in-band way for an operator (or an automated rollout system)
// to ask "what version of certctl is this binary?" — they had to either read
// the container image tag externally or trust whatever the README said. The
// gap matters for the same operability story U-3 closes: when fresh-clone
// quickstarts fail, the very first question is "what code did I actually
// build", and the only honest answer needs to come from the binary itself.
//
// VersionInfo is populated from three sources, in priority order:
//
// 1. The Version field — typically supplied at build time via
// `-ldflags='-X github.com/shankar0123/certctl/internal/api/handler.Version=v2.0.50'`.
// Production releases set this from the git tag (see release.yml).
//
// 2. runtime/debug.ReadBuildInfo() — populated by Go 1.18+ for any binary
// built from a module. Provides the VCS commit SHA, dirty flag, and
// build timestamp. We read these fields directly so a `go build` from a
// working tree (no -ldflags incantation) still produces a useful
// /api/v1/version payload — the failure mode pre-U-3 was that everything
// looked like "dev" everywhere, which made "is the bug fixed in this
// binary" unanswerable.
//
// 3. Static fallbacks ("dev" / "unknown") — only reached when neither
// ldflags nor build-info are populated, which in practice means
// `go run` from a non-VCS-tracked workspace.
//
// The handler runs through the no-auth bypass dispatch in cmd/server/main.go
// so probes and rollout systems can query it without presenting Bearer
// credentials, mirroring how /health and /ready are reachable. Audit logging
// excludes /api/v1/version for the same reason — the path is hot under
// rollout polling and would otherwise dominate the audit trail.
type VersionHandler struct{}
// Version is overridden at build time via:
//
// -ldflags='-X github.com/shankar0123/certctl/internal/api/handler.Version=<tag>'
//
// release.yml does this for the server container and CLI/agent binaries.
// The empty default (rather than "dev") lets the Handler fall back to the
// runtime/debug VCS revision when ldflags wasn't supplied — preferable to
// returning a literal "dev" that masks the actual git SHA the binary was
// built from.
var Version = ""
// NewVersionHandler returns a value (not a pointer) to match the
// HealthHandler convention — the handler holds no mutable state and is
// safe to copy.
func NewVersionHandler() VersionHandler {
return VersionHandler{}
}
// VersionInfo is the JSON shape returned by GET /api/v1/version.
//
// Field ordering and tag names are part of the contract — operator tooling
// (k8s rollout checks, CI smoke tests, /api/v1/version Prometheus blackbox
// probes) parses this payload and must continue to work across releases.
// Don't rename a field without an OpenAPI bump and a deprecation cycle.
type VersionInfo struct {
// Version is the human-readable release identifier (e.g. "v2.0.50").
// Falls back to the VCS revision when ldflags wasn't set, and to "dev"
// when the build wasn't VCS-tracked at all.
Version string `json:"version"`
// Commit is the git SHA of HEAD at build time, sourced from
// runtime/debug.BuildInfo.Settings["vcs.revision"]. Empty string when
// the binary was built outside a VCS-tracked workspace (rare —
// `go build` from a tarball does this).
Commit string `json:"commit"`
// Modified reports whether the build had uncommitted changes
// (debug.BuildInfo.Settings["vcs.modified"]). True for developer
// builds, false for release builds out of CI.
Modified bool `json:"modified"`
// BuildTime is the RFC 3339 timestamp captured at build time
// (debug.BuildInfo.Settings["vcs.time"]). Empty when not VCS-tracked.
BuildTime string `json:"build_time"`
// GoVersion is the Go toolchain version that compiled the binary
// (runtime.Version, e.g. "go1.25.9"). Useful when triaging stdlib
// behavior differences ("the deploy that broke was on 1.24, this one
// is on 1.25").
GoVersion string `json:"go_version"`
}
// readBuildInfo extracts the VCS settings from debug.BuildInfo and pairs
// them with the ldflags-supplied Version. Split out from ServeHTTP so the
// handler can be unit-tested by injecting synthetic BuildInfo (see
// version_handler_test.go) without depending on the test binary's actual
// debug info.
//
// debug.ReadBuildInfo returns ok=false when the binary was built without
// module info — extremely rare for a Go 1.18+ build, but we guard it so
// the handler degrades to "dev / unknown / runtime.Version()" instead of
// nil-deref panicking.
func readBuildInfo() VersionInfo {
info := VersionInfo{
Version: Version,
GoVersion: runtime.Version(),
}
bi, ok := debug.ReadBuildInfo()
if !ok {
// Pre-Go 1.18 binary or a stripped build with no buildinfo segment.
// Both are pathological in 2026 but worth the two-line guard.
if info.Version == "" {
info.Version = "dev"
}
return info
}
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
info.Commit = s.Value
case "vcs.modified":
// debug.BuildInfo encodes this as the literal string "true" or
// "false"; comparing to "true" is the canonical pattern (mirrors
// how the standard library's own version sub-command parses it).
info.Modified = s.Value == "true"
case "vcs.time":
info.BuildTime = s.Value
}
}
// Fallback ladder for Version: ldflags > VCS commit > "dev". The git
// SHA is more useful than "dev" because it's at least groundable — an
// operator can `git show <sha>` to see what code is actually running.
if info.Version == "" {
if info.Commit != "" {
info.Version = info.Commit
} else {
info.Version = "dev"
}
}
return info
}
// ServeHTTP implements http.Handler. Returns the VersionInfo payload as
// JSON with a 200 status. GET-only — any other method returns 405, matching
// the HealthHandler convention.
func (h VersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
JSON(w, http.StatusOK, readBuildInfo())
}
@@ -1,108 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
)
// TestVersion_ReturnsBuildInfo is the regression for the U-3 ride-along
// cat-u-no_version_endpoint (P2). Three behaviors must hold for the
// endpoint to be useful in operator tooling:
//
// 1. GET /api/v1/version returns 200 with a JSON body that decodes into
// the documented VersionInfo shape — the wire contract that rollout
// systems and Prometheus blackbox probes parse.
// 2. The Go runtime version always populates (runtime.Version() can never
// return empty), so consumers can always answer "which Go did this
// binary compile with" even when ldflags / VCS info are missing.
// 3. The Version field is never empty — the fallback ladder
// (ldflags > VCS commit > "dev") guarantees a non-empty string so
// consumers don't have to special-case absent values.
//
// We don't pin the exact Version value because it depends on whether the
// test binary was built with -ldflags or under `go test`, both of which
// the handler must tolerate. The "no empty string" check is the
// behavioral contract.
func TestVersion_ReturnsBuildInfo(t *testing.T) {
h := NewVersionHandler()
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
contentType := rec.Header().Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json prefix (operator tooling parses JSON)", contentType)
}
var got VersionInfo
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("response body did not decode into VersionInfo: %v\nbody: %s", err, rec.Body.String())
}
// Version must never be empty — the fallback ladder in readBuildInfo
// guarantees this. An empty Version would force every downstream
// consumer (k8s rollouts, Prometheus blackbox, the support tooling)
// to special-case the missing value, which defeats the point of
// /api/v1/version existing.
if got.Version == "" {
t.Error("Version is empty — the fallback ladder (ldflags > VCS commit > 'dev') must guarantee a non-empty value")
}
// GoVersion must equal runtime.Version() — the handler reads it
// directly and cannot be subverted by ldflags or BuildInfo. This is
// the one field that should always be ground-truth.
if got.GoVersion != runtime.Version() {
t.Errorf("GoVersion = %q, want %q (must come straight from runtime.Version())",
got.GoVersion, runtime.Version())
}
}
// TestVersion_RejectsNonGet pins the GET-only contract. /api/v1/version
// is read-only build identity; POST/PUT/DELETE etc. are nonsensical and
// should return 405 like the HealthHandler does. Operator tooling that
// fat-fingers the verb gets a clear error rather than a confusing 200
// from the wrong code path.
func TestVersion_RejectsNonGet(t *testing.T) {
h := NewVersionHandler()
for _, method := range []string{
http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch,
} {
req := httptest.NewRequest(method, "/api/v1/version", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s /api/v1/version → status %d, want 405", method, rec.Code)
}
}
}
// TestVersion_LdflagsOverride locks in the priority order: when the
// build-time Version variable is non-empty (e.g. "v2.0.50" injected by
// release.yml), readBuildInfo MUST surface that value verbatim and not
// silently substitute the VCS commit. The release-pipeline contract
// depends on this — a release tagged v2.0.50 should report "v2.0.50",
// not the underlying SHA.
//
// We achieve test isolation by save/restore on the package-level Version
// variable; t.Cleanup ensures parallel/subsequent tests see the original.
func TestVersion_LdflagsOverride(t *testing.T) {
original := Version
t.Cleanup(func() { Version = original })
Version = "v2.0.50-test"
got := readBuildInfo()
if got.Version != "v2.0.50-test" {
t.Errorf("Version = %q, want %q (ldflags-supplied Version must take priority over VCS fallback)",
got.Version, "v2.0.50-test")
}
}
+1 -7
View File
@@ -117,14 +117,8 @@ func HashAPIKey(key string) string {
}
// AuthConfig holds configuration for the Auth middleware.
//
// G-1 (P1): valid Type values are "api-key" or "none" only. "jwt" was
// removed because no JWT middleware ships with certctl (silent auth
// downgrade pre-G-1). The single source of truth for the allowed set
// lives at internal/config.AuthType / config.ValidAuthTypes() — prefer
// those constants over string literals when comparing.
type AuthConfig struct {
Type string // "api-key" or "none" (see config.AuthType constants)
Type string // "api-key", "jwt", "none"
Secret string // The raw API key or comma-separated list of valid API keys
}
@@ -1,96 +0,0 @@
package middleware
import (
"net/http"
"strings"
)
// SecurityHeadersConfig configures the SecurityHeaders middleware.
//
// Each field is the literal value to send. An empty string means
// "do not send this header" — operators behind a customising reverse
// proxy can disable any header per-deployment without touching code.
// Defaults are applied via SecurityHeadersDefaults() which encodes
// the H-1 closure's recommended baseline for an HTTPS-only API+UI
// host: HSTS, deny-frame, no-MIME-sniff, conservative CSP, and a
// no-referrer-when-downgrade fallback.
//
// H-1 closure (cat-s11-missing_security_headers).
type SecurityHeadersConfig struct {
HSTS string // Strict-Transport-Security
FrameOptions string // X-Frame-Options
ContentTypeOptions string // X-Content-Type-Options
ReferrerPolicy string // Referrer-Policy
ContentSecurityPolicy string // Content-Security-Policy
}
// SecurityHeadersDefaults returns a recommended baseline.
//
// CSP: default-src 'self' confines fetches to the same origin.
// img-src 'self' data: allows inline base64 images (used by the
// dashboard's certctl-logo and a few status icons).
// style-src 'self' 'unsafe-inline' is required because Tailwind
// (via Vite) injects per-component <style> blocks at build time;
// without 'unsafe-inline' the dashboard would render unstyled.
// 'unsafe-inline' is intentionally NOT in script-src — the
// front-end ships as a bundled JS file, no inline scripts.
//
// HSTS: 1-year max-age + includeSubDomains. No `preload` directive
// because preload submission requires explicit operator action and
// the deployment topology may not span all subdomains.
//
// X-Frame-Options: DENY — the dashboard does not need to be embedded
// anywhere, and DENY is more conservative than SAMEORIGIN against
// clickjacking via subdomain takeover.
//
// X-Content-Type-Options: nosniff — prevent MIME sniffing on
// JSON/PEM responses that browsers might otherwise interpret as HTML.
//
// Referrer-Policy: no-referrer-when-downgrade — preserves Referer
// for same-origin navigation (useful for support/diagnostics) but
// strips it on HTTPS→HTTP transitions.
func SecurityHeadersDefaults() SecurityHeadersConfig {
return SecurityHeadersConfig{
HSTS: "max-age=31536000; includeSubDomains",
FrameOptions: "DENY",
ContentTypeOptions: "nosniff",
ReferrerPolicy: "no-referrer-when-downgrade",
ContentSecurityPolicy: "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'",
}
}
// SecurityHeaders returns a middleware that applies the configured
// HTTP response headers on every response. Headers configured to the
// empty string are omitted (operator opted out for that deployment).
//
// Apply BEFORE the audit middleware so headers reach 4xx/5xx responses
// — which is where header omissions matter most for the security
// posture (an attacker probing for misconfiguration sees the same
// headers on a 401 as on a 200).
func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler {
// Pre-trim each value once; the per-request hot path stays a
// straight set of map writes.
type headerEntry struct{ name, value string }
entries := make([]headerEntry, 0, 5)
add := func(name, value string) {
v := strings.TrimSpace(value)
if v != "" {
entries = append(entries, headerEntry{name, v})
}
}
add("Strict-Transport-Security", cfg.HSTS)
add("X-Frame-Options", cfg.FrameOptions)
add("X-Content-Type-Options", cfg.ContentTypeOptions)
add("Referrer-Policy", cfg.ReferrerPolicy)
add("Content-Security-Policy", cfg.ContentSecurityPolicy)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
for _, e := range entries {
h.Set(e.name, e.value)
}
next.ServeHTTP(w, r)
})
}
}
@@ -1,104 +0,0 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestSecurityHeaders_DefaultsAllPresent asserts every default header
// arrives on a 200 response. H-1 closure (cat-s11-missing_security_headers).
func TestSecurityHeaders_DefaultsAllPresent(t *testing.T) {
mw := SecurityHeaders(SecurityHeadersDefaults())
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
handler.ServeHTTP(rec, req)
for _, h := range []string{
"Strict-Transport-Security",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Content-Security-Policy",
} {
if got := rec.Header().Get(h); got == "" {
t.Errorf("expected header %q to be set, got empty", h)
}
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options: got %q, want %q", got, "nosniff")
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("X-Frame-Options: got %q, want %q", got, "DENY")
}
}
// TestSecurityHeaders_EmptyValueDisablesHeader asserts an operator can
// disable a single header by setting its config field to empty without
// affecting the others.
func TestSecurityHeaders_EmptyValueDisablesHeader(t *testing.T) {
cfg := SecurityHeadersDefaults()
cfg.HSTS = "" // simulate operator override
mw := SecurityHeaders(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
t.Errorf("HSTS should be omitted when config value is empty; got %q", got)
}
// Other headers still present
if got := rec.Header().Get("X-Frame-Options"); got == "" {
t.Errorf("X-Frame-Options should still be present (empty HSTS only disables HSTS)")
}
}
// TestSecurityHeaders_OverrideValueApplied asserts a non-default value
// makes it through.
func TestSecurityHeaders_OverrideValueApplied(t *testing.T) {
cfg := SecurityHeadersDefaults()
cfg.FrameOptions = "SAMEORIGIN"
mw := SecurityHeaders(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if got := rec.Header().Get("X-Frame-Options"); got != "SAMEORIGIN" {
t.Errorf("X-Frame-Options: got %q, want %q", got, "SAMEORIGIN")
}
}
// TestSecurityHeaders_AppliedOnErrorResponses asserts headers are
// present on 4xx/5xx as well as 2xx — this is critical for the
// security posture (an attacker probing for misconfiguration sees
// the same headers on a 401 as on a 200).
func TestSecurityHeaders_AppliedOnErrorResponses(t *testing.T) {
mw := SecurityHeaders(SecurityHeadersDefaults())
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusUnauthorized)
}
if got := rec.Header().Get("Strict-Transport-Security"); got == "" {
t.Errorf("HSTS missing on 401 response (must be on every response)")
}
if got := rec.Header().Get("Content-Security-Policy"); got == "" {
t.Errorf("CSP missing on 401 response")
}
}
+2 -33
View File
@@ -67,18 +67,7 @@ type HandlerRegistry struct {
Digest handler.DigestHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
// handler/bulk_reassignment.go.
BulkRenewal handler.BulkRenewalHandler
BulkReassignment handler.BulkReassignmentHandler
RenewalPolicies handler.RenewalPolicyHandler
// Version handles GET /api/v1/version (U-3 ride-along,
// cat-u-no_version_endpoint). Wired through the no-auth dispatch in
// cmd/server/main.go so probes and rollout systems can read build
// identity without Bearer credentials. See handler/version.go.
Version handler.VersionHandler
RenewalPolicies handler.RenewalPolicyHandler
}
// RegisterHandlers sets up all API routes with their handlers.
@@ -100,32 +89,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
middleware.CORS,
middleware.ContentType,
))
// Version endpoint (no auth middleware — used by rollout probes that
// don't carry Bearer tokens; the dispatch layer in cmd/server/main.go
// also routes /api/v1/version through the no-auth chain). U-3 ride-along
// (cat-u-no_version_endpoint, P2). The handler reads
// runtime/debug.BuildInfo for VCS attribution; ldflags-supplied Version
// is preferred when present.
r.mux.Handle("GET /api/v1/version", middleware.Chain(
reg.Version,
middleware.CORS,
middleware.ContentType,
))
// Auth check endpoint (uses full middleware chain via r.Register)
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
// Certificates routes: /api/v1/certificates
// Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
// gives literal segments precedence over pattern-var segments, but
// listing the bulk paths first makes the precedence operator-visible
// and prevents a future refactor from accidentally inverting it. All
// three bulk endpoints share the same envelope shape (criteria/IDs
// in, {total_matched, total_<verb>, total_skipped, total_failed,
// errors[]} out). L-1 master added bulk-renew + bulk-reassign
// alongside the pre-existing bulk-revoke.
// Bulk revoke must be registered before {id} routes to avoid path conflict
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate))
+12 -126
View File
@@ -709,16 +709,6 @@ type DatabaseConfig struct {
URL string
MaxConnections int
MigrationsPath string
// DemoSeed, when true, makes the server apply
// `<MigrationsPath>/seed_demo.sql` after the baseline `seed.sql`. Set
// via CERTCTL_DEMO_SEED. The compose demo overlay
// (deploy/docker-compose.demo.yml) sets this to keep the demo path
// alive after U-3 dropped initdb-mounted seed files. The seed file
// uses ON CONFLICT (id) DO NOTHING so re-running on a populated
// database is safe; missing-file is a no-op (returns nil) so a
// minimal-image deploy that strips seed_demo.sql still boots cleanly.
DemoSeed bool
}
// SchedulerConfig contains scheduler timing configuration.
@@ -784,18 +774,6 @@ type SchedulerConfig struct {
// second.
// Setting: CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT environment variable.
AwaitingApprovalTimeout time.Duration
// ShortLivedExpiryCheckInterval is how often the scheduler scans
// short-lived certificates and marks expired rows as Expired. Default:
// 30 seconds (matches the in-memory default in scheduler.NewScheduler).
// C-1 closure (cat-g-7e38f9708e20 + diff-10xmain-2bf4a0a60388):
// pre-C-1 the setter scheduler.SetShortLivedExpiryCheckInterval was
// defined + tested but never called from cmd/server/main.go, so the
// 30-second default was effectively hardcoded. Operators who needed
// to tune the cadence (e.g. a high-churn short-lived cert tenant)
// had no path. Post-C-1 main.go wires this knob.
// Setting: CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL environment variable.
ShortLivedExpiryCheckInterval time.Duration
}
// LogConfig contains logging configuration.
@@ -824,59 +802,13 @@ type NamedAPIKey struct {
Admin bool
}
// AuthType is the discriminator for the API auth middleware shape. The
// string alias preserves env-var roundtrip (the value flows through getEnv
// as a plain string) while giving us a typed surface for switches and
// validation. Use the named constants below rather than string literals
// so future enum additions/removals are caught at compile time.
//
// G-1 (P1): the pre-G-1 validAuthTypes map literal accepted "jwt" with no
// JWT middleware behind it (silent auth downgrade — the configured type
// was logged as "jwt" but every request routed through the api-key bearer
// middleware regardless). Operators who set CERTCTL_AUTH_TYPE=jwt thought
// they had JWT auth; they didn't. The typed alias + ValidAuthTypes()
// helper make the allowed set the single source of truth across config
// validation, the runtime defense-in-depth switch in main.go, and the
// helm-chart template guard (`certctl.validateAuthType`).
type AuthType string
const (
// AuthTypeAPIKey routes requests through the api-key bearer middleware.
// CERTCTL_AUTH_SECRET (or CERTCTL_API_KEYS_NAMED) is required.
AuthTypeAPIKey AuthType = "api-key"
// AuthTypeNone disables authentication entirely. Development only —
// the server logs a loud Warn at startup. Operators who need
// JWT/OIDC/mTLS run an authenticating gateway (oauth2-proxy / Envoy
// ext_authz / Traefik ForwardAuth / Pomerium) in front of certctl
// and set this value on the upstream certctl process. See
// docs/architecture.md "Authenticating-gateway pattern".
AuthTypeNone AuthType = "none"
)
// ValidAuthTypes returns the allowed CERTCTL_AUTH_TYPE values. The set is
// intentionally narrow — JWT was accepted pre-G-1 with no middleware
// implementation behind it. Single source of truth referenced by the
// validator below, the runtime guard in cmd/server/main.go, the helm
// chart template (`certctl.validateAuthType`), and the property test in
// config_test.go that pins "jwt" out of the slice forever.
func ValidAuthTypes() []AuthType {
return []AuthType{AuthTypeAPIKey, AuthTypeNone}
}
// AuthConfig contains authentication configuration.
type AuthConfig struct {
// Type sets the authentication mechanism for the REST API.
// Valid values: "api-key" (default, production) and "none" (development
// only — disables authentication on the API and logs a loud Warn at
// startup). For JWT/OIDC, run an authenticating gateway (oauth2-proxy /
// Envoy / Traefik ForwardAuth / Pomerium) in front of certctl and set
// CERTCTL_AUTH_TYPE=none on the upstream — see docs/architecture.md
// "Authenticating-gateway pattern" and docs/upgrade-to-v2-jwt-removal.md.
// Valid values: "api-key" (default, production), "jwt", "none" (development only).
// When "api-key", clients must provide Authorization: Bearer <key> header.
// "none" requires explicit opt-in via CERTCTL_AUTH_TYPE env var with warning logged.
// Setting: CERTCTL_AUTH_TYPE environment variable. Default: "api-key".
// Use the AuthType constants (AuthTypeAPIKey / AuthTypeNone) for typed
// comparisons; the field stays `string` to preserve env-var roundtrip
// shape used by getEnv() and downstream Helm/compose interpolation.
Type string
// Secret is the legacy authentication secret (comma-separated API keys).
@@ -943,7 +875,6 @@ func Load() (*Config, error) {
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 25),
MigrationsPath: getEnv("CERTCTL_DATABASE_MIGRATIONS_PATH", "./migrations"),
DemoSeed: getEnvBool("CERTCTL_DEMO_SEED", false),
},
Scheduler: SchedulerConfig{
RenewalCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL", 1*time.Hour),
@@ -960,9 +891,6 @@ func Load() (*Config, error) {
JobTimeoutInterval: getEnvDuration("CERTCTL_JOB_TIMEOUT_INTERVAL", 10*time.Minute),
AwaitingCSRTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_CSR_TIMEOUT", 24*time.Hour),
AwaitingApprovalTimeout: getEnvDuration("CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT", 168*time.Hour),
// C-1 closure: matches the in-memory default at
// internal/scheduler/scheduler.go:145 (30 * time.Second).
ShortLivedExpiryCheckInterval: getEnvDuration("CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL", 30*time.Second),
},
Log: LogConfig{
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
@@ -1191,26 +1119,6 @@ func (c *Config) Validate() error {
return fmt.Errorf("server TLS cert/key pair invalid (cert=%q key=%q): %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, c.Server.TLS.KeyPath, err)
}
// H-1 closure (cat-r-encryption_key_no_length_validation): if
// CERTCTL_CONFIG_ENCRYPTION_KEY is set, enforce a minimum length of
// 32 bytes. Pre-H-1 the field was accepted with any non-empty value
// — including a single character — and PBKDF2-SHA256 (100k rounds)
// alone does not compensate for low-entropy passphrases at scale
// (CWE-916 Use of Password Hash With Insufficient Computational
// Effort + CWE-329 Generation of Predictable IV with CBC Mode).
// 32 bytes ≈ 256 bits when generated via `openssl rand -base64 32`,
// matching the AES-256-GCM key size the passphrase derives. An
// empty key remains accepted — the fail-closed sentinel
// crypto.ErrEncryptionKeyRequired triggers downstream when an
// empty key is asked to encrypt or decrypt sensitive config.
const minEncryptionKeyLength = 32
if c.Encryption.ConfigEncryptionKey != "" && len(c.Encryption.ConfigEncryptionKey) < minEncryptionKeyLength {
return fmt.Errorf(
"CERTCTL_CONFIG_ENCRYPTION_KEY too short (%d bytes; minimum %d). Generate with: openssl rand -base64 32",
len(c.Encryption.ConfigEncryptionKey), minEncryptionKeyLength,
)
}
// Validate database configuration
if c.Database.URL == "" {
return fmt.Errorf("database URL is required")
@@ -1240,40 +1148,18 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid log format: %s", c.Log.Format)
}
// Validate auth type.
//
// G-1 (P1): the pre-G-1 set was {"api-key", "jwt", "none"} with "jwt"
// accepted but no JWT middleware shipped — silent auth downgrade.
// Post-G-1 we route a literal "jwt" value through a dedicated
// rejection that gives operators actionable guidance (the
// authenticating-gateway pattern) instead of the generic
// "invalid auth type". Then we cross-check against ValidAuthTypes()
// so any value outside {api-key, none} surfaces uniformly.
if c.Auth.Type == "jwt" {
return fmt.Errorf(
"CERTCTL_AUTH_TYPE=jwt is no longer accepted (G-1 silent auth " +
"downgrade): no JWT middleware ships with certctl. To use " +
"JWT/OIDC, run an authenticating gateway (oauth2-proxy / " +
"Envoy ext_authz / Traefik ForwardAuth / Pomerium) in " +
"front of certctl and set CERTCTL_AUTH_TYPE=none on the " +
"upstream. See docs/architecture.md \"Authenticating-" +
"gateway pattern\" and docs/upgrade-to-v2-jwt-removal.md " +
"for the migration walkthrough")
// Validate auth type
validAuthTypes := map[string]bool{
"api-key": true,
"jwt": true,
"none": true,
}
authTypeValid := false
for _, t := range ValidAuthTypes() {
if AuthType(c.Auth.Type) == t {
authTypeValid = true
break
}
}
if !authTypeValid {
return fmt.Errorf("invalid auth type: %s (valid: %v)", c.Auth.Type, ValidAuthTypes())
if !validAuthTypes[c.Auth.Type] {
return fmt.Errorf("invalid auth type: %s", c.Auth.Type)
}
// If using API-key, secret is required. (Secret was previously also
// required for "jwt"; removed with the jwt rejection above.)
if c.Auth.Type == string(AuthTypeAPIKey) && c.Auth.Secret == "" {
// If using JWT or API-key, secret is required
if (c.Auth.Type == "jwt" || c.Auth.Type == "api-key") && c.Auth.Secret == "" {
return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type)
}
+15 -230
View File
@@ -458,8 +458,6 @@ func TestValidate_InvalidAuthType(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
if err := cfg.Validate(); err == nil {
@@ -479,8 +477,6 @@ func TestValidate_APIKeyAuth_MissingSecret(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
if err := cfg.Validate(); err == nil {
@@ -488,133 +484,25 @@ func TestValidate_APIKeyAuth_MissingSecret(t *testing.T) {
}
}
// TestValidate_JWTAuth_RejectedDedicated locks down the G-1 fix: pre-G-1
// `CERTCTL_AUTH_TYPE=jwt` was accepted by the validator (the bare error
// path was the empty-secret one previously). Post-G-1 the literal "jwt"
// value is rejected with a dedicated diagnostic regardless of whether
// Secret is set, because there is no JWT middleware in the binary —
// operators who need JWT/OIDC must front certctl with an authenticating
// gateway.
//
// Two table rows pin the contract: missing-secret cannot paper over the
// rejection (the dedicated error fires first, before the secret check),
// and a populated secret also cannot paper over it. Both paths must
// hit the dedicated G-1 diagnostic, not the generic "invalid auth
// type" or "auth secret is required".
func TestValidate_JWTAuth_RejectedDedicated(t *testing.T) {
t.Parallel()
cases := []struct {
name string
secret string
}{
{"jwt rejected (no secret)", ""},
{"jwt rejected (with secret — operator can't paper over)", "anything"},
func TestValidate_JWTAuth_MissingSecret(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "jwt", Secret: ""},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "jwt", Secret: tc.secret},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil; expected dedicated G-1 rejection")
}
const wantSubstr = "CERTCTL_AUTH_TYPE=jwt is no longer accepted"
if !strings.Contains(err.Error(), wantSubstr) {
t.Errorf("Validate() = %v\nwant substring %q (the dedicated G-1 diagnostic)", err, wantSubstr)
}
})
if err := cfg.Validate(); err == nil {
t.Error("Validate() should return error when jwt auth has empty secret")
}
}
// TestValidAuthTypesDoesNotContainJWT is a property-level guard against
// a future PR silently re-introducing "jwt" into the allowed set. If
// someone adds JWT back to ValidAuthTypes(), this test fails immediately
// with a pointer at the audit finding. The matching CI grep guardrail
// in .github/workflows/ci.yml provides a secondary check at build time.
func TestValidAuthTypesDoesNotContainJWT(t *testing.T) {
t.Parallel()
for _, at := range ValidAuthTypes() {
if at == "jwt" {
t.Fatalf("jwt is in ValidAuthTypes — silent auth downgrade regressed (G-1)")
}
}
}
// TestValidAuthTypesIsExactly_APIKey_None pins the current allowed set.
// If a future change adds a new auth type, this test must be updated
// alongside the validator and the helm-chart `validateAuthType` helper —
// keeping all three surfaces in sync.
func TestValidAuthTypesIsExactly_APIKey_None(t *testing.T) {
t.Parallel()
got := ValidAuthTypes()
if len(got) != 2 {
t.Fatalf("ValidAuthTypes() returned %d entries, want 2: %v", len(got), got)
}
want := map[AuthType]bool{AuthTypeAPIKey: true, AuthTypeNone: true}
for _, at := range got {
if !want[at] {
t.Errorf("unexpected auth type in ValidAuthTypes: %q", at)
}
}
}
// TestValidate_GenericInvalidAuthType ensures that values outside the
// allowed set (other than the special-cased "jwt") still surface the
// generic "invalid auth type" error. Pins that the dedicated G-1
// rejection didn't accidentally swallow non-jwt typos.
func TestValidate_GenericInvalidAuthType(t *testing.T) {
t.Parallel()
for _, badType := range []string{"", "garbage", "oidc", "mtls", "API-KEY"} {
t.Run("type="+badType, func(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: badType, Secret: "x"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
err := cfg.Validate()
if err == nil {
t.Fatalf("Validate(type=%q) returned nil; expected invalid-auth-type rejection", badType)
}
if !strings.Contains(err.Error(), "invalid auth type") {
t.Errorf("Validate(type=%q) = %v; want \"invalid auth type\" error", badType, err)
}
if strings.Contains(err.Error(), "G-1 silent auth") {
t.Errorf("Validate(type=%q) = %v; should not hit the dedicated G-1 path for non-jwt values", badType, err)
}
})
}
}
// G-1 (P1): no need to add `TestValidate_NoneAuth_AcceptsEmptySecret` or
// `TestValidate_APIKeyAuth_RequiresSecret` here — the pre-existing tests
// `TestValidate_AuthTypeNone` (above) and `TestValidate_APIKeyAuth_MissingSecret`
// (above) already cover those paths. Documented for the next reader: the
// G-1 fix flipped jwt off but did not disturb either the
// none-bypasses-secret or the api-key-requires-secret behavior.
func TestValidate_InvalidKeygenMode(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
@@ -627,8 +515,6 @@ func TestValidate_InvalidKeygenMode(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
if err := cfg.Validate(); err == nil {
@@ -658,8 +544,6 @@ func TestValidate_InvalidPort(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
if err := cfg.Validate(); err == nil {
@@ -690,8 +574,6 @@ func TestValidate_TLSCertPathEmpty(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
err := cfg.Validate()
@@ -723,8 +605,6 @@ func TestValidate_TLSKeyPathEmpty(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
err := cfg.Validate()
@@ -757,8 +637,6 @@ func TestValidate_TLSCertFileMissing(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
err := cfg.Validate()
@@ -790,8 +668,6 @@ func TestValidate_TLSKeyFileMissing(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
err := cfg.Validate()
@@ -825,8 +701,6 @@ func TestValidate_TLSMismatchedPair(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
err := cfg.Validate()
@@ -850,8 +724,6 @@ func TestValidate_EmptyDatabaseURL(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
if err := cfg.Validate(); err == nil {
@@ -871,8 +743,6 @@ func TestValidate_InvalidLogLevel(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
if err := cfg.Validate(); err == nil {
@@ -892,8 +762,6 @@ func TestValidate_InvalidLogFormat(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
if err := cfg.Validate(); err == nil {
@@ -972,8 +840,6 @@ func TestValidate_DatabaseMaxConnectionsZero(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
},
}
if err := cfg.Validate(); err == nil {
@@ -1209,84 +1075,3 @@ func TestConfig_Scheduler_JobTimeoutValidation(t *testing.T) {
})
}
}
// H-1 closure (cat-r-encryption_key_no_length_validation): validate
// CERTCTL_CONFIG_ENCRYPTION_KEY length. Pre-H-1 the field was accepted
// with any non-empty value (including a single character); post-H-1 a
// minimum 32-byte length is enforced. Empty stays accepted because the
// downstream fail-closed sentinel crypto.ErrEncryptionKeyRequired
// handles the missing-key case for the encrypt/decrypt paths.
func validBaseConfigForEncryption(t *testing.T) *Config {
t.Helper()
return &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
}
}
func TestValidate_EncryptionKey_EmptyAccepted(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
cfg.Encryption.ConfigEncryptionKey = ""
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() returned error for empty key: %v (empty must be accepted; fail-closed sentinel handles it downstream)", err)
}
}
func TestValidate_EncryptionKey_TooShortRejected(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
cfg.Encryption.ConfigEncryptionKey = "x" // 1 byte
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() = nil, want error for 1-byte key")
}
if !strings.Contains(err.Error(), "too short") {
t.Errorf("Validate() error = %q, want to contain %q", err.Error(), "too short")
}
if !strings.Contains(err.Error(), "openssl rand -base64 32") {
t.Errorf("Validate() error = %q, must include the canonical generation command", err.Error())
}
}
func TestValidate_EncryptionKey_BoundaryRejected(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
cfg.Encryption.ConfigEncryptionKey = "12345678901234567890123456789012"[:31] // 31 bytes — one short
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() = nil, want error for 31-byte key (boundary -1)")
}
if !strings.Contains(err.Error(), "too short") {
t.Errorf("Validate() error = %q, want 'too short'", err.Error())
}
}
func TestValidate_EncryptionKey_MinLengthAccepted(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
cfg.Encryption.ConfigEncryptionKey = "12345678901234567890123456789012" // exactly 32 bytes
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() returned error for 32-byte key: %v", err)
}
}
func TestValidate_EncryptionKey_LongAccepted(t *testing.T) {
cfg := validBaseConfigForEncryption(t)
// Realistic operator key from `openssl rand -base64 32` — 44 characters.
cfg.Encryption.ConfigEncryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() returned error for 44-byte key: %v", err)
}
}
-51
View File
@@ -1,51 +0,0 @@
package domain
// BulkReassignmentRequest is the input to POST /api/v1/certificates/bulk-reassign.
//
// L-2 closure (cat-l-8a1fb258a38a): the GUI used to loop
// `await updateCertificate(id, { owner_id: ownerId })` over the selection
// at `web/src/pages/CertificatesPage.tsx::handleReassign`. Post-L-2 it
// POSTs once.
//
// Narrower than BulkRenewalCriteria — the operator workflow is "I have N
// certs selected and I want them all owned by Alice now". Criteria-mode
// reassignment doesn't have a strong use case (operators query first,
// then reassign by ID), so the request is IDs-only. OwnerID is required;
// TeamID is optional and the cert's team_id is updated only when TeamID
// is non-empty (matches the existing per-cert PUT behaviour where empty
// fields leave the existing value unchanged).
type BulkReassignmentRequest struct {
CertificateIDs []string `json:"certificate_ids"`
OwnerID string `json:"owner_id"`
TeamID string `json:"team_id,omitempty"`
}
// IsEmpty returns true if no IDs are provided. The service layer rejects
// empty IDs with a 400 — explicit-IDs is the only selection mode for
// reassignment (no criteria-mode). Naming mirrors BulkRevocationCriteria
// + BulkRenewalCriteria.IsEmpty so the validate-and-reject pattern is
// the same across all three bulk endpoints.
func (r BulkReassignmentRequest) IsEmpty() bool {
return len(r.CertificateIDs) == 0
}
// BulkReassignmentResult mirrors BulkRevocationResult / BulkRenewalResult
// envelope shape so the frontend's bulk-result rendering is one helper.
//
// Counters semantics:
// - TotalMatched: number of certs resolved from CertificateIDs
// - TotalReassigned: number where owner_id (and optionally team_id)
// was actually mutated
// - TotalSkipped: certs already owned by the target OwnerID — no-op
// skip rather than a fake "succeeded" count, so operators see "5 of
// your 10 selections were no-ops" without triaging fake errors
// - TotalFailed: certs where the per-cert update returned an error
// (e.g., the cert no longer exists, the repo update failed)
// - Errors: per-cert error details for the failure path
type BulkReassignmentResult struct {
TotalMatched int `json:"total_matched"`
TotalReassigned int `json:"total_reassigned"`
TotalSkipped int `json:"total_skipped"`
TotalFailed int `json:"total_failed"`
Errors []BulkOperationError `json:"errors,omitempty"`
}
-77
View File
@@ -1,77 +0,0 @@
package domain
import (
"encoding/json"
"testing"
)
func TestBulkReassignmentRequest_IsEmpty(t *testing.T) {
t.Parallel()
tests := []struct {
name string
r BulkReassignmentRequest
want bool
}{
{"all-zero", BulkReassignmentRequest{}, true},
{"empty-ids-slice", BulkReassignmentRequest{CertificateIDs: []string{}}, true},
{"ids-set-but-no-owner", BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}}, false},
// IsEmpty is a pure ID-presence check; OwnerID/TeamID are
// validated separately in the service layer (OwnerID required;
// TeamID optional). This split mirrors how BulkRevocationCriteria
// + reason are validated in two distinct steps.
{"owner-set-but-no-ids", BulkReassignmentRequest{OwnerID: "o-alice"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.r.IsEmpty(); got != tt.want {
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
}
})
}
}
func TestBulkReassignmentResult_JSONShape(t *testing.T) {
t.Parallel()
r := &BulkReassignmentResult{
TotalMatched: 10,
TotalReassigned: 7,
TotalSkipped: 3, // already-owned-by-target — silent no-op
TotalFailed: 0,
Errors: nil,
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var round map[string]interface{}
if err := json.Unmarshal(b, &round); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for _, k := range []string{"total_matched", "total_reassigned", "total_skipped", "total_failed"} {
if _, ok := round[k]; !ok {
t.Errorf("missing JSON field %q in %s", k, string(b))
}
}
if _, ok := round["errors"]; ok {
t.Errorf("nil Errors should be omitempty; got: %s", string(b))
}
}
func TestBulkOperationError_JSONShape(t *testing.T) {
t.Parallel()
e := BulkOperationError{
CertificateID: "mc-1",
Error: "renewal already in progress",
}
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
want := `{"certificate_id":"mc-1","error":"renewal already in progress"}`
if string(b) != want {
t.Errorf("JSON shape drift:\n got: %s\n want: %s", string(b), want)
}
}
-79
View File
@@ -1,79 +0,0 @@
package domain
// BulkRenewalCriteria selects a set of managed certificates to renew. At
// least one selector must be non-empty (IsEmpty() guards this in the
// service layer; same shape and rule as BulkRevocationCriteria so
// operators who already know the bulk-revoke contract have zero new
// surface to learn).
//
// L-1 master closure (cat-l-fa0c1ac07ab5): the GUI used to loop
// `await triggerRenewal(id)` over the selection at
// `web/src/pages/CertificatesPage.tsx::handleBulkRenewal`. 100 certs =
// 100 sequential HTTP round-trips × Auth → audit → handler → service →
// repo → DB → audit. Post-L-1 the GUI POSTs once; the server resolves
// the criteria, applies status filters, and enqueues N renewal jobs.
//
// The "renew all certs of profile X before its CA changes" use case is
// the canonical reason to support criteria-mode in addition to explicit
// IDs. Mirrors `BulkRevocationCriteria` field-for-field.
type BulkRenewalCriteria struct {
ProfileID string `json:"profile_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,omitempty"`
}
// IsEmpty returns true if no filter criteria are set. The service layer
// rejects empty criteria with a 400 (mirrors BulkRevocationCriteria.IsEmpty).
func (c BulkRenewalCriteria) IsEmpty() bool {
return c.ProfileID == "" && c.OwnerID == "" && c.AgentID == "" &&
c.IssuerID == "" && c.TeamID == "" && len(c.CertificateIDs) == 0
}
// BulkRenewalResult is the envelope returned to the caller. Distinct
// from BulkRevocationResult because the action verb differs: renewal
// ENQUEUES a job per matched cert (asynchronous) rather than performing
// the mutation synchronously like revocation. The EnqueuedJobs slice
// gives the caller the job IDs so the GUI can poll
// /api/v1/jobs?status=Running for progress without re-querying the
// certificate list.
//
// Counters semantics (mirror BulkRevocationResult conventions):
// - TotalMatched: number of certs the criteria/IDs resolved to
// - TotalEnqueued: number of renewal jobs successfully created
// - TotalSkipped: certs in a status that disallows renewal (already
// RenewalInProgress, Revoked, or Archived); silent no-op, NOT an error
// - TotalFailed: certs where the enqueue path returned an error
// - EnqueuedJobs: per-cert {certificate_id, job_id} pairs for the
// successful enqueue path (omitempty so an all-skipped batch
// produces a clean response)
// - Errors: per-cert error details for the failure path
type BulkRenewalResult struct {
TotalMatched int `json:"total_matched"`
TotalEnqueued int `json:"total_enqueued"`
TotalSkipped int `json:"total_skipped"`
TotalFailed int `json:"total_failed"`
EnqueuedJobs []BulkEnqueuedJob `json:"enqueued_jobs,omitempty"`
Errors []BulkOperationError `json:"errors,omitempty"`
}
// BulkEnqueuedJob pairs a certificate ID with the renewal job ID that was
// just created for it. Lets the GUI link directly into the job-detail
// page without an extra round-trip to query "what job did this cert
// just get assigned?".
type BulkEnqueuedJob struct {
CertificateID string `json:"certificate_id"`
JobID string `json:"job_id"`
}
// BulkOperationError records a per-certificate failure for any bulk
// operation (renew, reassign — and revoke, which uses the older
// BulkRevocationError shape kept for backwards compatibility on the
// /bulk-revoke wire format). Same shape as BulkRevocationError so the
// frontend's bulk-result rendering is one helper.
type BulkOperationError struct {
CertificateID string `json:"certificate_id"`
Error string `json:"error"`
}
-83
View File
@@ -1,83 +0,0 @@
package domain
import (
"encoding/json"
"testing"
)
// TestBulkRenewalCriteria_IsEmpty pins the validate-and-reject contract:
// empty criteria → service rejects with 400. Mirrors
// TestBulkRevocationCriteria_IsEmpty exactly so the cross-bulk-endpoint
// behaviour is uniform.
func TestBulkRenewalCriteria_IsEmpty(t *testing.T) {
t.Parallel()
tests := []struct {
name string
c BulkRenewalCriteria
want bool
}{
{"all-zero", BulkRenewalCriteria{}, true},
{"profile-id-set", BulkRenewalCriteria{ProfileID: "cp-x"}, false},
{"owner-id-set", BulkRenewalCriteria{OwnerID: "o-alice"}, false},
{"agent-id-set", BulkRenewalCriteria{AgentID: "ag-1"}, false},
{"issuer-id-set", BulkRenewalCriteria{IssuerID: "iss-x"}, false},
{"team-id-set", BulkRenewalCriteria{TeamID: "t-x"}, false},
{"ids-set", BulkRenewalCriteria{CertificateIDs: []string{"mc-1"}}, false},
{"ids-empty-slice", BulkRenewalCriteria{CertificateIDs: []string{}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.c.IsEmpty(); got != tt.want {
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
}
})
}
}
// TestBulkRenewalResult_JSONShape pins the wire contract. Operator
// tooling (k8s rollouts, blackbox probes, the `certctl-cli bulk-renew`
// JSON consumer) parses these field names; renaming any of them is a
// breaking change.
func TestBulkRenewalResult_JSONShape(t *testing.T) {
t.Parallel()
r := &BulkRenewalResult{
TotalMatched: 5,
TotalEnqueued: 4,
TotalSkipped: 1,
TotalFailed: 0,
EnqueuedJobs: []BulkEnqueuedJob{
{CertificateID: "mc-1", JobID: "job-a"},
},
Errors: nil,
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var round map[string]interface{}
if err := json.Unmarshal(b, &round); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
for _, k := range []string{"total_matched", "total_enqueued", "total_skipped", "total_failed", "enqueued_jobs"} {
if _, ok := round[k]; !ok {
t.Errorf("missing JSON field %q in %s", k, string(b))
}
}
// errors omitempty when nil — must NOT appear
if _, ok := round["errors"]; ok {
t.Errorf("nil Errors should be omitempty; got: %s", string(b))
}
// EnqueuedJobs nested shape
jobs := round["enqueued_jobs"].([]interface{})
if len(jobs) != 1 {
t.Fatalf("enqueued_jobs len = %d, want 1", len(jobs))
}
first := jobs[0].(map[string]interface{})
if first["certificate_id"] != "mc-1" || first["job_id"] != "job-a" {
t.Errorf("BulkEnqueuedJob field names drifted: %v", first)
}
}
+1 -45
View File
@@ -46,26 +46,7 @@ type Agent struct {
Status AgentStatus `json:"status"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
// APIKeyHash is the SHA-256 of the agent's plaintext API key,
// populated by service.RegisterAgent (`hashAPIKey(apiKey)`) and
// consumed by repository.AgentRepository::GetByAPIKey at auth time.
// It is server-internal: never serialized to clients, never echoed
// via CLI / MCP / agent registration response, never logged.
//
// G-2 (P1): pre-G-2 the field was tagged `json:"api_key_hash"` and
// shipped on every /api/v1/agents response (cat-s5-apikey_leak). Even
// SHA-256 should not be shipped to clients — it gives an offline
// brute-force target if API-key entropy is low (certctl doesn't enforce
// a minimum on operator-supplied keys), and there is no business reason
// for any client to ever receive it. Post-G-2 the JSON tag is "-" and
// Agent.MarshalJSON below zeroes the field on a copy before delegating
// to the default marshal — defense in depth so a future tag-revert by
// refactor cannot reopen the leak. The DB column, repo SELECT/INSERT/
// UPDATE paths, and service-side hashing are unchanged. See
// docs/architecture.md ER diagram (which documents DB shape, not API
// shape) and coverage-gap-audit-2026-04-24-v5/unified-audit.md
// cat-s5-apikey_leak for the full closure rationale.
APIKeyHash string `json:"-"`
APIKeyHash string `json:"api_key_hash"`
OS string `json:"os"`
Architecture string `json:"architecture"`
IPAddress string `json:"ip_address"`
@@ -79,31 +60,6 @@ type Agent struct {
RetiredReason *string `json:"retired_reason,omitempty"`
}
// MarshalJSON implements json.Marshaler. It explicitly zeros APIKeyHash
// before serialization to defense-in-depth the `json:"-"` tag above.
//
// G-2 (P1): pre-G-2 the field was tagged `json:"api_key_hash"` and
// shipped on every /api/v1/agents response (cat-s5-apikey_leak). Post-G-2
// the tag is "-" and this method enforces redaction even if the tag is
// reverted by a future refactor — the receiver is by-value so the
// APIKeyHash = "" assignment mutates only the marshal-time copy, never
// the caller's original. The type-alias trick (`type alias Agent`)
// breaks the recursive MarshalJSON call that would otherwise stack-
// overflow. Both *Agent and Agent receivers route through here because
// the json package looks the method up via reflect.Value, and a value
// receiver satisfies both kinds of pointer.
//
// Auditor's note for the next reader: do NOT remove this method even if
// the json:"-" tag stays. The CI guardrail at .github/workflows/ci.yml
// also blocks reintroduction at the tag site, but this method is the
// last line of defense for serialization paths that bypass struct tags
// (e.g., a future MarshalJSON on a parent struct that embeds Agent).
func (a Agent) MarshalJSON() ([]byte, error) {
type alias Agent // breaks recursion: alias has no MarshalJSON method
a.APIKeyHash = ""
return json.Marshal(alias(a))
}
// IsRetired returns true when this agent has been soft-retired.
// I-004: callers that iterate active agents (stats dashboard, stale-offline
// sweeper, handler-facing list) must skip retired rows by default.
-181
View File
@@ -1,22 +1,10 @@
package domain
import (
"encoding/json"
"strings"
"testing"
"time"
)
// jsonMarshalDirect / jsonUnmarshalDirect are thin aliases for the
// stdlib encoding/json calls. They exist only to make the G-2 redaction
// tests below grep-friendly: any future test author searching for "how
// is APIKeyHash redaction tested" will land on these and the call sites,
// rather than having to grep through dozens of unrelated json.Marshal
// usages.
func jsonMarshalDirect(v interface{}) ([]byte, error) { return json.Marshal(v) }
func jsonUnmarshalDirect(data []byte, v interface{}) error { return json.Unmarshal(data, v) }
func containsSubstr(haystack, needle string) bool { return strings.Contains(haystack, needle) }
// TestAgent_IsRetired covers the I-004 soft-retirement predicate that gates
// which callers hide an agent row from active listings.
func TestAgent_IsRetired(t *testing.T) {
@@ -65,172 +53,3 @@ func TestAgentDependencyCounts_HasDependencies(t *testing.T) {
})
}
}
// G-2 (P1): the cat-s5-apikey_leak audit closure tests. Pre-G-2,
// Agent.APIKeyHash was tagged `json:"api_key_hash"` and shipped on
// every /api/v1/agents response — credential-derivative leak that gave
// offline brute-force targets to every authenticated client. Post-G-2
// the tag is "-" AND Agent.MarshalJSON zeroes the field on a marshal-
// time copy. These tests pin both layers of the defense:
//
// 1. A populated APIKeyHash is never present in the marshaled JSON.
// 2. The redaction holds on *Agent, on slice elements, and on a
// sentinel literal-value check (so even a future field that
// happens to contain the same hash string would not appear).
// 3. The marshal-time copy does not mutate the caller's original —
// receiver is by-value, but pin it explicitly so a future refactor
// that switches to pointer-receiver gets caught.
// 4. Round-trip preserves every other field (hash dropped on encode,
// cannot reappear on decode because the wire never carries it).
const g2LeakSentinel = "sha256:LEAKED-CREDENTIAL-DERIVATIVE-SENTINEL"
// TestAgent_MarshalJSON_RedactsAPIKeyHash is the marshal-boundary
// contract test: a single Agent value with a populated APIKeyHash must
// not emit the field name nor the sentinel value.
func TestAgent_MarshalJSON_RedactsAPIKeyHash(t *testing.T) {
t.Parallel()
a := Agent{
ID: "agent-test",
Name: "test-agent",
Hostname: "host.example",
Status: AgentStatusOnline,
RegisteredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
APIKeyHash: g2LeakSentinel,
OS: "linux",
Architecture: "amd64",
IPAddress: "10.0.0.1",
Version: "v2.0.49",
}
out, err := jsonMarshalDirect(a)
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
body := string(out)
if containsSubstr(body, "api_key_hash") {
t.Errorf("marshaled body contains \"api_key_hash\" key — G-2 leak regressed:\n%s", body)
}
if containsSubstr(body, "APIKeyHash") {
t.Errorf("marshaled body contains \"APIKeyHash\" — type-alias redaction broke:\n%s", body)
}
if containsSubstr(body, g2LeakSentinel) {
t.Errorf("marshaled body contains the leak sentinel %q — value redaction broke:\n%s", g2LeakSentinel, body)
}
// Sanity: every OTHER non-zero field IS present (this guards against
// the type-alias trick accidentally dropping siblings).
for _, want := range []string{"agent-test", "test-agent", "host.example", "Online", "linux", "amd64", "10.0.0.1", "v2.0.49"} {
if !containsSubstr(body, want) {
t.Errorf("marshaled body missing expected field value %q:\n%s", want, body)
}
}
}
// TestAgent_MarshalJSON_RedactsViaPointer covers the *Agent path that
// handlers hit when calling JSON(w, http.StatusOK, agent) with a *Agent
// from svc.GetAgent. A value-receiver MarshalJSON is reachable from
// pointer values via reflect; this test pins that contract.
func TestAgent_MarshalJSON_RedactsViaPointer(t *testing.T) {
t.Parallel()
a := &Agent{ID: "agent-x", APIKeyHash: g2LeakSentinel}
out, err := jsonMarshalDirect(a)
if err != nil {
t.Fatalf("Marshal *Agent returned error: %v", err)
}
if containsSubstr(string(out), g2LeakSentinel) {
t.Errorf("*Agent marshal leaked sentinel:\n%s", string(out))
}
if containsSubstr(string(out), "api_key_hash") {
t.Errorf("*Agent marshal contains \"api_key_hash\" key:\n%s", string(out))
}
}
// TestAgent_MarshalJSON_RedactsInSlice covers the []domain.Agent path
// the ListAgents handler emits via PagedResponse{Data: agents}. Each
// element must be redacted independently.
func TestAgent_MarshalJSON_RedactsInSlice(t *testing.T) {
t.Parallel()
agents := []Agent{
{ID: "agent-1", APIKeyHash: g2LeakSentinel + "-1"},
{ID: "agent-2", APIKeyHash: g2LeakSentinel + "-2"},
{ID: "agent-3", APIKeyHash: g2LeakSentinel + "-3"},
}
out, err := jsonMarshalDirect(agents)
if err != nil {
t.Fatalf("Marshal []Agent returned error: %v", err)
}
body := string(out)
if containsSubstr(body, "api_key_hash") {
t.Errorf("[]Agent marshal contains \"api_key_hash\" key:\n%s", body)
}
for i := 1; i <= 3; i++ {
sentinel := g2LeakSentinel + "-" + string(rune('0'+i))
if containsSubstr(body, sentinel) {
t.Errorf("[]Agent marshal leaked sentinel %q:\n%s", sentinel, body)
}
}
// Every agent ID is present — the redaction didn't accidentally
// strip the entire element.
for _, id := range []string{"agent-1", "agent-2", "agent-3"} {
if !containsSubstr(body, id) {
t.Errorf("[]Agent marshal missing element ID %q:\n%s", id, body)
}
}
}
// TestAgent_MarshalJSON_DoesNotMutateReceiver pins the by-value-receiver
// contract: marshaling must not zero APIKeyHash on the caller's struct,
// only on the marshal-time copy. This guards against a future refactor
// that switches to pointer receiver and breaks every code path that
// marshals an Agent and then re-uses it (e.g., audit-event payload
// construction immediately after returning the agent in a handler).
func TestAgent_MarshalJSON_DoesNotMutateReceiver(t *testing.T) {
t.Parallel()
a := Agent{ID: "agent-keep", APIKeyHash: g2LeakSentinel}
if _, err := jsonMarshalDirect(a); err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if a.APIKeyHash != g2LeakSentinel {
t.Errorf("MarshalJSON mutated caller's APIKeyHash: got %q want %q", a.APIKeyHash, g2LeakSentinel)
}
}
// TestAgent_MarshalJSON_RoundTrip pins the wire-shape contract: an
// Agent marshaled to JSON and unmarshaled back into a fresh Agent has
// every field preserved EXCEPT APIKeyHash, which the wire never carries.
// This double-confirms the redaction is a one-way guarantee at the
// serialization boundary, not an accidental on-decode behavior.
func TestAgent_MarshalJSON_RoundTrip(t *testing.T) {
t.Parallel()
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
hb := now.Add(-5 * time.Minute)
original := Agent{
ID: "agent-rt",
Name: "rt",
Hostname: "rt.host",
Status: AgentStatusOnline,
LastHeartbeatAt: &hb,
RegisteredAt: now,
APIKeyHash: g2LeakSentinel,
OS: "linux",
Architecture: "arm64",
IPAddress: "10.0.0.99",
Version: "v2.0.49",
}
out, err := jsonMarshalDirect(original)
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
var got Agent
if err := jsonUnmarshalDirect(out, &got); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if got.APIKeyHash != "" {
t.Errorf("APIKeyHash survived round-trip: got %q want empty (the wire must not carry it)", got.APIKeyHash)
}
if got.ID != original.ID || got.Name != original.Name || got.Hostname != original.Hostname {
t.Errorf("identity fields lost in round-trip: got %+v want %+v", got, original)
}
if got.OS != original.OS || got.Architecture != original.Architecture || got.IPAddress != original.IPAddress {
t.Errorf("metadata fields lost in round-trip: got %+v want %+v", got, original)
}
}
-19
View File
@@ -1,19 +0,0 @@
// Package domain — error sentinels.
//
// S-2 closure (cat-s6-efc7f6f6bd50): pre-S-2 every handler-side
// validation-failure dispatch was a `strings.Contains(err.Error(),
// "invalid")` or `"required"` site, brittle to any domain-layer
// message change. Post-S-2 domain validators that surface a
// 400 Bad Request wrap their per-field errors via fmt.Errorf("...: %w",
// domain.ErrValidation) so handlers can dispatch via errors.Is.
package domain
import "errors"
// ErrValidation is the canonical sentinel for input-validation
// failures surfaced by domain-layer Validate() methods. Handlers that
// surface a 400 Bad Request should `errors.Is(err, domain.ErrValidation)`.
// Per-field error messages are still preserved via fmt.Errorf wrapping
// so the response body retains the actionable detail; the sentinel
// only drives the HTTP status code dispatch.
var ErrValidation = errors.New("domain: validation failed")
+7 -6
View File
@@ -626,10 +626,7 @@ func (m *mockJobRepository) List(ctx context.Context) ([]*domain.Job, error) {
func (m *mockJobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
job, ok := m.jobs[id]
if !ok {
// S-2 closure: wrap repository.ErrNotFound so the handler's
// errors.Is dispatch resolves to 404 (matches the Postgres
// repo's post-S-2 wrapping).
return nil, fmt.Errorf("job not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("job not found")
}
return job, nil
}
@@ -1321,7 +1318,10 @@ func (m *mockProfileService) ListProfiles(_ context.Context, page, perPage int)
}
func (m *mockProfileService) GetProfile(_ context.Context, id string) (*domain.CertificateProfile, error) {
return nil, fmt.Errorf("profile not found")
// M-1: wrap service.ErrNotFound so the handler's errToStatus choke point
// routes this to 404 via errors.Is. The Error() message still contains
// "not found" so any pre-migration substring assertions continue to pass.
return nil, fmt.Errorf("%w: profile not found", service.ErrNotFound)
}
func (m *mockProfileService) CreateProfile(_ context.Context, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
@@ -1344,7 +1344,8 @@ func (m *mockAgentGroupService) ListAgentGroups(_ context.Context, page, perPage
}
func (m *mockAgentGroupService) GetAgentGroup(_ context.Context, id string) (*domain.AgentGroup, error) {
return nil, fmt.Errorf("agent group not found")
// M-1: wrap service.ErrNotFound — see GetProfile above for rationale.
return nil, fmt.Errorf("%w: agent group not found", service.ErrNotFound)
}
func (m *mockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
-87
View File
@@ -214,61 +214,6 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
}
return textResult(data)
})
// L-1 master closure (cat-l-fa0c1ac07ab5): bulk-renew MCP tool.
// Mirrors certctl_bulk_revoke_certificates shape sans the Reason
// field. Server returns total_matched / total_enqueued /
// total_skipped / total_failed plus per-cert {certificate_id,
// job_id} pairs in enqueued_jobs.
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_bulk_renew_certificates",
Description: "Bulk renew certificates matching filter criteria (profile_id, owner_id, agent_id, issuer_id, team_id) or an explicit certificate_ids list. At least one selector required. Returns counts of matched, enqueued, skipped, and failed certificates plus per-cert {certificate_id, job_id} pairs.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkRenewCertificatesInput) (*gomcp.CallToolResult, any, error) {
body := map[string]interface{}{}
if input.ProfileID != "" {
body["profile_id"] = input.ProfileID
}
if input.OwnerID != "" {
body["owner_id"] = input.OwnerID
}
if input.AgentID != "" {
body["agent_id"] = input.AgentID
}
if input.IssuerID != "" {
body["issuer_id"] = input.IssuerID
}
if input.TeamID != "" {
body["team_id"] = input.TeamID
}
if len(input.CertificateIDs) > 0 {
body["certificate_ids"] = input.CertificateIDs
}
data, err := c.Post("/api/v1/certificates/bulk-renew", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
// L-2 closure (cat-l-8a1fb258a38a): bulk-reassign MCP tool.
// Narrower than bulk-renew/revoke — IDs-only, no criteria-mode.
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_bulk_reassign_certificates",
Description: "Bulk reassign owner (and optionally team) for a set of certificates. owner_id is required. team_id is optional and updates only when non-empty. Returns counts of matched, reassigned, skipped (already-owned-by-target), and failed certificates.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkReassignCertificatesInput) (*gomcp.CallToolResult, any, error) {
body := map[string]interface{}{
"certificate_ids": input.CertificateIDs,
"owner_id": input.OwnerID,
}
if input.TeamID != "" {
body["team_id"] = input.TeamID
}
data, err := c.Post("/api/v1/certificates/bulk-reassign", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
}
// ── CRL & OCSP ──────────────────────────────────────────────────────
@@ -1238,36 +1183,4 @@ func registerHealthTools(s *gomcp.Server, c *Client) {
}
return textResult(data)
})
// I-2 closure (cat-i-b0924b6675f8): pre-I-2 the README claimed "all
// API endpoints are exposed via MCP" but the discovered-certificate
// lifecycle (claim + dismiss) was never wrapped — operators using
// MCP clients (Claude, Cursor, etc.) had no path to bring an
// out-of-band cert under management or to mark a benign discovery
// as not-of-interest without dropping to the REST API directly.
// These two tools wrap the existing HTTP handlers
// (DiscoveryHandler.ClaimDiscovered + DismissDiscovered).
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_claim_discovered_certificate",
Description: "Link a discovered certificate (dc-*) to an existing managed certificate (mc-*) via POST /api/v1/discovered-certificates/{id}/claim. Use this to bring an out-of-band cert (e.g. one found by an agent filesystem scan or a network scan) under certctl management without re-issuing — the discovered row is marked Managed and its managed_certificate_id is set so subsequent renewals/revocations on the managed cert update both rows.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ClaimDiscoveredCertificateInput) (*gomcp.CallToolResult, any, error) {
body := map[string]string{"managed_certificate_id": input.ManagedCertificateID}
data, err := c.Post("/api/v1/discovered-certificates/"+input.ID+"/claim", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_dismiss_discovered_certificate",
Description: "Dismiss a discovered certificate (POST /api/v1/discovered-certificates/{id}/dismiss). Use this to mark a discovery as not-of-interest (e.g. expired self-signed test certs found by a network scan) — the row stops appearing in the unmanaged-list view but is preserved in the DB for audit history.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input DismissDiscoveredCertificateInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/discovered-certificates/"+input.ID+"/dismiss", nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
}
-43
View File
@@ -72,26 +72,6 @@ type BulkRevokeCertificatesInput struct {
CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to revoke"`
}
// BulkRenewCertificatesInput is the MCP tool input for bulk-renew (L-1
// master closure, cat-l-fa0c1ac07ab5). Mirrors BulkRevokeCertificatesInput
// field-for-field minus Reason.
type BulkRenewCertificatesInput struct {
ProfileID string `json:"profile_id,omitempty" jsonschema:"Renew all certs matching this profile ID"`
OwnerID string `json:"owner_id,omitempty" jsonschema:"Renew all certs owned by this owner"`
AgentID string `json:"agent_id,omitempty" jsonschema:"Renew all certs deployed via this agent"`
IssuerID string `json:"issuer_id,omitempty" jsonschema:"Renew all certs issued by this issuer"`
TeamID string `json:"team_id,omitempty" jsonschema:"Renew all certs owned by members of this team"`
CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to renew"`
}
// BulkReassignCertificatesInput is the MCP tool input for bulk-reassign
// (L-2 closure, cat-l-8a1fb258a38a). IDs-only — no criteria-mode.
type BulkReassignCertificatesInput struct {
CertificateIDs []string `json:"certificate_ids" jsonschema:"Explicit list of certificate IDs to reassign"`
OwnerID string `json:"owner_id" jsonschema:"Required. New owner_id for every cert in certificate_ids"`
TeamID string `json:"team_id,omitempty" jsonschema:"Optional. When non-empty, also updates team_id on every cert"`
}
type ListVersionsInput struct {
ID string `json:"id" jsonschema:"Certificate ID"`
ListParams
@@ -323,29 +303,6 @@ type TimelineInput struct {
Days int `json:"days,omitempty" jsonschema:"Number of days to look back (default 30, max 365)"`
}
// ── Discovered Certificates (I-2 closure) ──────────────────────────
// ClaimDiscoveredCertificateInput is the MCP tool input for claiming a
// discovered certificate (POST /api/v1/discovered-certificates/{id}/claim).
// I-2 closure (cat-i-b0924b6675f8). The HTTP handler at
// internal/api/handler/discovery.go::ClaimDiscovered links the discovered
// row (DC-*) to a managed certificate (mc-*); operators use this to
// bring an out-of-band cert under management without re-issuing.
type ClaimDiscoveredCertificateInput struct {
ID string `json:"id" jsonschema:"Discovered certificate ID (dc-*)"`
ManagedCertificateID string `json:"managed_certificate_id" jsonschema:"Existing managed certificate ID (mc-*) to link to"`
}
// DismissDiscoveredCertificateInput is the MCP tool input for dismissing
// a discovered certificate (POST /api/v1/discovered-certificates/{id}/dismiss).
// I-2 closure (cat-i-b0924b6675f8). Marks the row as not-of-interest
// (e.g. expired self-signed test certs found by a network scan); the row
// stops appearing in the unmanaged-list view but is preserved in the DB
// for audit history.
type DismissDiscoveredCertificateInput struct {
ID string `json:"id" jsonschema:"Discovered certificate ID (dc-*)"`
}
// ── Empty ───────────────────────────────────────────────────────────
type EmptyInput struct{}
-62
View File
@@ -1,62 +0,0 @@
// Package repository defines the repository-layer error sentinels that
// handlers map to HTTP status codes via errors.Is.
//
// S-2 closure (cat-s6-efc7f6f6bd50): pre-S-2 every handler-side
// not-found dispatch was a `strings.Contains(err.Error(), "not found")`
// site (30+ across internal/api/handler/*.go), brittle to any
// repository-layer message change and untyped against the actual
// failure mode. Post-S-2 the dispatch is type-checked: repositories
// wrap sql.ErrNoRows via fmt.Errorf("...: %w", repository.ErrNotFound)
// and FK constraint violations via repository.ErrForeignKeyConstraint;
// handlers consume via errors.Is. The substring matching is preserved
// at the lib/pq boundary inside `errors.go::isFKError` because the
// PostgreSQL driver returns un-typed *pq.Error values whose codes are
// the canonical signal — but it's confined to one helper rather than
// scattered across every handler file. See unified-audit.md
// cat-s6-efc7f6f6bd50 for the closure rationale.
package repository
import (
"errors"
"strings"
)
// ErrNotFound is the canonical sentinel for repository methods that
// return after sql.ErrNoRows (or its wrapped form). Handlers that
// surface a 404 should `errors.Is(err, repository.ErrNotFound)`
// rather than substring-match.
var ErrNotFound = errors.New("repository: row not found")
// ErrForeignKeyConstraint is the canonical sentinel for PostgreSQL
// FK / RESTRICT violations bubbling up from a DELETE or UPDATE.
// Handlers that surface a 409 Conflict should
// `errors.Is(err, repository.ErrForeignKeyConstraint)`.
//
// The B-1 closure introduced ErrRenewalPolicyInUse as the per-entity
// FK sentinel for renewal_policies; future per-entity FK sentinels
// (ErrIssuerInUse, ErrTeamInUse, ErrOwnerInUse) can wrap this generic
// one via fmt.Errorf("...: %w", ErrForeignKeyConstraint) so handlers
// can choose between generic-409 and entity-specific 409 dispatch.
var ErrForeignKeyConstraint = errors.New("repository: foreign key constraint violation")
// IsForeignKeyError detects PostgreSQL FK violation errors from the
// lib/pq driver via the canonical error-text patterns it emits. The
// substring matching is intentionally confined to this helper —
// callers should use this once at the repo layer to wrap into the
// typed ErrForeignKeyConstraint sentinel, then handlers consume via
// errors.Is.
//
// Patterns recognised:
// - "violates foreign key constraint" (the standard PG message)
// - "violates restrict" / "RESTRICT" (DELETE blocked by ON DELETE RESTRICT)
//
// Returns false for nil err so callers can defensively chain it.
func IsForeignKeyError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "violates foreign key") ||
strings.Contains(msg, "RESTRICT") ||
strings.Contains(msg, "violates restrict")
}
+10
View File
@@ -18,7 +18,17 @@ import (
// managed_certificates.renewal_policy_id to renewal_policies.id with ON
// DELETE RESTRICT). Both map onto the same 409 status but with distinct
// messages so operators can tell them apart.
//
// M-1: ErrNotFound is the repo-layer "row not found" sentinel. Repositories
// that historically returned fmt.Errorf("... not found: %s", id) without
// wrapping sql.ErrNoRows now wrap ErrNotFound via fmt.Errorf("%w: ...", so
// the handler layer's single errToStatus choke point can route them to HTTP
// 404 via errors.Is without substring-matching the message text. Existing
// service-level service.ErrNotFound stays a distinct value — both map to 404
// through explicit branches in handler/errors.go (mirrors the G-1 treatment
// of the repo-level 409 sentinels).
var (
ErrNotFound = errors.New("repository: not found")
ErrRenewalPolicyDuplicateName = errors.New("renewal policy name already exists")
ErrRenewalPolicyInUse = errors.New("renewal policy is still referenced by managed certificates")
)
+30 -8
View File
@@ -1,14 +1,15 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// AgentRepository implements repository.AgentRepository
@@ -73,8 +74,13 @@ func (r *AgentRepository) Get(ctx context.Context, id string) (*domain.Agent, er
agent, err := scanAgent(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found: %w", repository.ErrNotFound)
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w so
// the handler's errToStatus choke point dispatches to 404 via
// errors.Is instead of the pre-M-1 strings.Contains(err.Error(),
// "not found") branch at handler/agents.go. Mirrors agent_group and
// profile repositories.
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: agent %s", repository.ErrNotFound, id)
}
return nil, fmt.Errorf("failed to query agent: %w", err)
}
@@ -170,8 +176,12 @@ func (r *AgentRepository) Update(ctx context.Context, agent *domain.Agent) error
return fmt.Errorf("failed to get rows affected: %w", err)
}
// M-1 (P2): wrap the zero-rows-affected condition with
// repository.ErrNotFound so the handler's errToStatus dispatches to 404
// via errors.Is without substring matching. Mirrors agent_group and
// profile repositories.
if rows == 0 {
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
return fmt.Errorf("%w: agent %s", repository.ErrNotFound, agent.ID)
}
return nil
@@ -190,8 +200,10 @@ func (r *AgentRepository) Delete(ctx context.Context, id string) error {
return fmt.Errorf("failed to get rows affected: %w", err)
}
// M-1 (P2): zero-rows-affected → repository.ErrNotFound wrap. Mirrors
// agent_group and profile repositories.
if rows == 0 {
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
return fmt.Errorf("%w: agent %s", repository.ErrNotFound, id)
}
return nil
@@ -237,8 +249,13 @@ func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string, metada
return fmt.Errorf("failed to get rows affected: %w", err)
}
// M-1 (P2): zero-rows-affected → repository.ErrNotFound wrap. Note the
// UPDATE filters on `retired_at IS NULL`, so a retired agent row also
// returns zero-rows-affected here. The service layer short-circuits with
// ErrAgentRetired (410) BEFORE reaching this path via Heartbeat's
// explicit Get check, so the 404 vs 410 distinction is drawn upstream.
if rows == 0 {
return fmt.Errorf("agent not found: %w", repository.ErrNotFound)
return fmt.Errorf("%w: agent %s", repository.ErrNotFound, id)
}
return nil
@@ -259,8 +276,13 @@ func (r *AgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*dom
agent, err := scanAgent(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found: %w", repository.ErrNotFound)
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w.
// The auth middleware calls this on every request; a missing row
// must surface as 404 (well, 401 upstream — the middleware treats
// "no agent matched" as auth failure) via the errToStatus choke
// point, not via substring matching.
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: agent with api key not found", repository.ErrNotFound)
}
return nil, fmt.Errorf("failed to query agent: %w", err)
}
+16 -5
View File
@@ -1,13 +1,14 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// AgentGroupRepository implements agent group CRUD with PostgreSQL.
@@ -50,8 +51,12 @@ func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.Agen
g := &domain.AgentGroup{}
err := row.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture,
&g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w so the
// handler's errToStatus choke point dispatches to 404 via errors.Is
// instead of the pre-M-1 strings.Contains(err.Error(), "not found")
// branch at handler/agent_groups.go. Mirrors profile repository.
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: agent group %s", repository.ErrNotFound, id)
}
if err != nil {
return nil, fmt.Errorf("failed to get agent group: %w", err)
@@ -84,8 +89,11 @@ func (r *AgentGroupRepository) Update(ctx context.Context, group *domain.AgentGr
return fmt.Errorf("failed to update agent group: %w", err)
}
rows, _ := result.RowsAffected()
// M-1 (P2): wrap the zero-rows-affected condition with
// repository.ErrNotFound so the handler's errToStatus dispatches to 404
// via errors.Is without substring matching. Mirrors profile repository.
if rows == 0 {
return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
return fmt.Errorf("%w: agent group %s", repository.ErrNotFound, group.ID)
}
return nil
}
@@ -97,8 +105,11 @@ func (r *AgentGroupRepository) Delete(ctx context.Context, id string) error {
return fmt.Errorf("failed to delete agent group: %w", err)
}
rows, _ := result.RowsAffected()
// M-1 (P2): wrap zero-rows-affected with repository.ErrNotFound so the
// handler's errToStatus dispatches to 404 via errors.Is. Mirrors profile
// repository.
if rows == 0 {
return fmt.Errorf("agent group not found: %w", repository.ErrNotFound)
return fmt.Errorf("%w: agent group %s", repository.ErrNotFound, id)
}
return nil
}
+25 -4
View File
@@ -264,8 +264,14 @@ func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.Man
cert, err := r.scanCertificate(ctx, row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w so
// the handler's errToStatus choke point dispatches to 404 via
// errors.Is instead of the pre-M-1 strings.Contains(err.Error(),
// "not found") branch at handler/export.go (ExportPEM + ExportPKCS12).
// scanCertificate already wraps sql.ErrNoRows via %w, so errors.Is
// walks through. Mirrors profile and agent_group repositories.
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: certificate %s", repository.ErrNotFound, id)
}
return nil, fmt.Errorf("failed to query certificate: %w", err)
}
@@ -396,8 +402,11 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
return fmt.Errorf("failed to get rows affected: %w", err)
}
// M-1 (P2): zero-rows-affected → repository.ErrNotFound wrap so the
// handler's errToStatus dispatches to 404 via errors.Is. Mirrors profile
// and agent_group repositories.
if rows == 0 {
return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
return fmt.Errorf("%w: certificate %s", repository.ErrNotFound, cert.ID)
}
return nil
@@ -418,8 +427,10 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
return fmt.Errorf("failed to get rows affected: %w", err)
}
// M-1 (P2): zero-rows-affected → repository.ErrNotFound wrap. Mirrors
// profile and agent_group repositories.
if rows == 0 {
return fmt.Errorf("certificate not found: %w", repository.ErrNotFound)
return fmt.Errorf("%w: certificate %s", repository.ErrNotFound, id)
}
return nil
@@ -583,6 +594,16 @@ func (r *CertificateRepository) GetLatestVersion(ctx context.Context, certID str
v.KeySize = int(keySize.Int64)
if err != nil {
// M-1 (P2): wrap sql.ErrNoRows with repository.ErrNotFound via %w so
// the handler's errToStatus choke point dispatches to 404 via
// errors.Is. The export service surfaces "no certificate version
// found" on this path — pre-M-1 the handler branched on
// strings.Contains(err.Error(), "not found"), now it walks the wrap
// chain. Non-ErrNoRows errors preserve their "failed to get latest
// certificate version" framing.
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: certificate version for %s", repository.ErrNotFound, certID)
}
return nil, fmt.Errorf("failed to get latest certificate version: %w", err)
}
+2 -175
View File
@@ -2,26 +2,14 @@ package postgres
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/lib/pq"
_ "github.com/lib/pq"
)
// pgErrInvalidPassword is the SQLSTATE for class 28 / code 28P01 —
// invalid_password — emitted by PostgreSQL when the client presents
// credentials that don't match pg_authid. Defined locally because the
// lib/pq package does not export named constants for SQLSTATE codes (it
// only exposes the typed string alias pq.ErrorCode and a name-lookup map
// at runtime). Pinned as a string constant rather than a pq.ErrorCode
// literal so the contract is grep-able from operator-facing log lines.
//
// Reference: https://www.postgresql.org/docs/16/errcodes-appendix.html
const pgErrInvalidPassword = "28P01"
// NewDB opens a PostgreSQL database connection and sets up connection pooling.
func NewDB(connStr string) (*sql.DB, error) {
db, err := sql.Open("postgres", connStr)
@@ -35,65 +23,12 @@ func NewDB(connStr string) (*sql.DB, error) {
// Ping to verify connection
if err := db.Ping(); err != nil {
return nil, wrapPingError(err)
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return db, nil
}
// wrapPingError converts a db.Ping() failure into an operator-friendly
// diagnostic. The default wrap is the original opaque
// `"failed to ping database: <inner>"` shape. The exception is SQLSTATE 28P01
// (invalid_password): when postgres rejects the server's credentials we emit
// extended guidance that names the most common operator misstep — editing
// POSTGRES_PASSWORD in `.env` after the postgres named volume has already
// been initialized — and lists both the destructive (`docker compose down -v`)
// and non-destructive (`ALTER ROLE`) remediations.
//
// U-1 (P1, GitHub #10): closes the audit-flagged
// cat-u-quickstart_postgres_password_volume_trap finding. The postgres
// docker-entrypoint runs initdb only when /var/lib/postgresql/data is empty;
// on subsequent boots the password baked into pg_authid on first boot wins
// over whatever the env var carries, so the env-vs-pg_authid divergence is
// intrinsic to how the postgres image bootstraps and cannot be fixed by us
// upstream of pg_authid. The ergonomic answer is to surface a clear
// diagnostic at the failure site so operators don't waste an hour on
// "is my password right" before discovering the volume needs to be torn
// down (or the role's password rotated in-place).
//
// The wrap chain is preserved via fmt.Errorf("%w", err) so callers using
// errors.As(err, &*pq.Error) on the returned value continue to work; this
// matches the audit's "no substring matching on err.Error()" requirement
// from the M-1 sentinel-error migration.
//
// Returns nil when err is nil so callers can defensively pipe through this
// helper without an extra branch.
func wrapPingError(err error) error {
if err == nil {
return nil
}
var pqErr *pq.Error
if errors.As(err, &pqErr) && string(pqErr.Code) == pgErrInvalidPassword {
return fmt.Errorf(
"failed to ping database: postgres rejected the configured credentials "+
"(SQLSTATE %s — invalid_password). If you recently rotated POSTGRES_PASSWORD "+
"on a docker-compose deploy, the postgres container's data volume still "+
"holds the previous password: initdb seeds POSTGRES_PASSWORD into pg_authid "+
"only on first boot of a fresh data dir, so editing the env var after that "+
"point updates only the certctl-server container. Reset destructively with "+
"`docker compose -f deploy/docker-compose.yml down -v && "+
"docker compose -f deploy/docker-compose.yml up -d --build` (this DESTROYS "+
"all data in the postgres volume), or non-destructively with "+
"`docker compose -f deploy/docker-compose.yml exec postgres "+
"psql -U certctl -c \"ALTER ROLE certctl PASSWORD '<new-password>';\"` "+
"and then redeploy with the matching POSTGRES_PASSWORD. Underlying error: %w",
pgErrInvalidPassword, err)
}
return fmt.Errorf("failed to ping database: %w", err)
}
// RunMigrations reads and executes SQL migration files from a directory.
func RunMigrations(db *sql.DB, migrationsPath string) error {
// Check if migrations directory exists
@@ -131,111 +66,3 @@ func RunMigrations(db *sql.DB, migrationsPath string) error {
return nil
}
// RunSeed reads and executes the baseline seed SQL file from the migrations
// directory. Designed to run AFTER RunMigrations so every column referenced by
// the seed is already in place.
//
// U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 the deploy compose stack
// mounted both a hand-curated subset of `migrations/*.up.sql` and `seed.sql`
// into postgres `/docker-entrypoint-initdb.d/`. Postgres applied them at
// initdb time. When `seed.sql` was updated to reference columns added by
// migrations *after* the mounted cutoff (e.g., `policy_rules.severity` from
// `000013_policy_rule_severity.up.sql`), initdb crashed during the seed step
// and the container was reported `unhealthy` indefinitely — bare
// `docker compose -f deploy/docker-compose.yml up -d --build` from a fresh
// clone of v2.0.50 hit this on the first try (GitHub #10 reopened by
// mikeakasully). Helm and the example compose files were already runtime-
// only (Path B) and worked through the same window.
//
// Post-U-3 the compose stack drops all initdb mounts; postgres comes up with
// an empty schema; the server applies all migrations via RunMigrations and
// then this function applies the seed. Single source of truth, removes the
// drift hazard architecturally.
//
// The seed file is expected at `<migrationsPath>/seed.sql`. Missing-file is
// treated as a no-op (returns nil) so deployments that explicitly remove the
// seed (custom packaging, cert-manager managed schemas) don't break.
//
// Idempotency: every INSERT in the shipped seed.sql uses
// `ON CONFLICT (id) DO NOTHING`, so re-running on a populated DB is safe.
// This function is invoked on every server start, so the contract MUST hold.
//
// Demo seed: `seed_demo.sql` is applied separately by RunDemoSeed below
// when CERTCTL_DEMO_SEED=true (see internal/config/config.go::DemoSeed).
// Splitting demo from baseline keeps a default deploy from accidentally
// landing 90-days-of-fake-history into a real customer database, while
// still giving the demo overlay a single source of truth (no more initdb
// mounts). The demo seed itself uses ON CONFLICT (id) DO NOTHING so it's
// idempotent; missing-file is also tolerated (custom packaging may strip
// seed_demo.sql to shrink the image).
func RunSeed(db *sql.DB, migrationsPath string) error {
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
return fmt.Errorf("migrations directory not found: %s", migrationsPath)
}
seedPath := filepath.Join(migrationsPath, "seed.sql")
content, err := os.ReadFile(seedPath)
if err != nil {
if os.IsNotExist(err) {
// Missing seed.sql is acceptable — operators may have removed it
// for custom-packaging reasons. Return nil rather than fail-loud.
return nil
}
return fmt.Errorf("failed to read seed file %s: %w", seedPath, err)
}
if _, err := db.Exec(string(content)); err != nil {
return fmt.Errorf("failed to execute seed file %s: %w", seedPath, err)
}
return nil
}
// RunDemoSeed applies the demo overlay seed file
// (`<migrationsPath>/seed_demo.sql`) on top of the baseline seed.
//
// U-3 follow-on: pre-U-3 the demo overlay mounted `seed_demo.sql` into
// postgres `/docker-entrypoint-initdb.d/` and relied on initdb to apply it
// alongside the schema. Once U-3 dropped the initdb migration mounts, that
// path stopped working — postgres comes up empty, and the demo seed
// references tables (issuers, certificates, etc.) that wouldn't exist yet
// at initdb time. RunDemoSeed restores the demo capability through the
// same runtime path RunSeed uses, gated by CERTCTL_DEMO_SEED so production
// deploys never accidentally land the fake-history rows.
//
// Order contract: must run AFTER RunSeed so foreign-key references from
// demo rows to baseline rows (e.g., demo certificates referencing
// `rp-default` from baseline) resolve cleanly. The caller in
// cmd/server/main.go enforces this order.
//
// Missing-file is acceptable (returns nil) — operators packaging a
// production-only image often strip seed_demo.sql to shrink the artifact,
// and that should not break boot when CERTCTL_DEMO_SEED happens to be set.
//
// Idempotency: every INSERT in seed_demo.sql uses
// `ON CONFLICT (id) DO NOTHING`, so re-running on a populated DB is safe.
// Server restarts in demo mode therefore re-apply the file harmlessly.
func RunDemoSeed(db *sql.DB, migrationsPath string) error {
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
return fmt.Errorf("migrations directory not found: %s", migrationsPath)
}
seedPath := filepath.Join(migrationsPath, "seed_demo.sql")
content, err := os.ReadFile(seedPath)
if err != nil {
if os.IsNotExist(err) {
// Custom production packaging frequently strips this file.
// Fail-soft to preserve the U-3 contract: a missing seed file
// must not gate server boot.
return nil
}
return fmt.Errorf("failed to read demo seed file %s: %w", seedPath, err)
}
if _, err := db.Exec(string(content)); err != nil {
return fmt.Errorf("failed to execute demo seed file %s: %w", seedPath, err)
}
return nil
}
-168
View File
@@ -1,168 +0,0 @@
// Internal-package tests for db.go — covers the diagnostic dispatch in
// wrapPingError. Lives in `package postgres` (not `postgres_test`) so it can
// call the unexported helper directly without exposing it on the API surface.
//
// Sibling integration tests in this directory live in `package postgres_test`
// (testcontainers-driven, schema-per-test). They exercise the live-DB
// happy path; this file owns the unit-level diagnostic dispatch and runs in
// `-short` mode without spinning up postgres.
//
// U-1 (P1, GitHub #10): closes the audit-flagged
// cat-u-quickstart_postgres_password_volume_trap finding by pinning the
// post-fix wrap-text contract for `db.Ping()` failures. Pre-U-1 every Ping
// error was wrapped with the same opaque `"failed to ping database: %w"`,
// so an operator who edited POSTGRES_PASSWORD after first-boot saw only
// `pq: password authentication failed for user "certctl"` in the server
// log with no pointer to the actual cause (postgres data dir retains the
// initial password from first-boot initdb; subsequent boots ignore the env
// var). Post-U-1 the SQLSTATE-28P01 path emits a multi-line diagnostic
// pointing at the down -v / ALTER ROLE remediation; non-auth failures
// retain the original wrap shape so verbose noise does not bleed into
// transient connection-refused / timeout paths.
package postgres
import (
"errors"
"strings"
"testing"
"github.com/lib/pq"
)
// TestWrapPingError_AuthFailureGuidance asserts the diagnostic wrap fires on
// SQLSTATE 28P01 (invalid_password) and contains all three contract elements:
// the SQLSTATE code (so operators can grep), the down-v destructive
// remediation, and the ALTER ROLE non-destructive remediation. Also asserts
// the wrap chain still satisfies errors.As(err, &*pq.Error) so callers that
// programmatically inspect the underlying postgres error code keep working.
func TestWrapPingError_AuthFailureGuidance(t *testing.T) {
t.Parallel()
original := &pq.Error{
Code: pq.ErrorCode("28P01"),
Message: `password authentication failed for user "certctl"`,
}
wrapped := wrapPingError(original)
if wrapped == nil {
t.Fatal("wrapPingError returned nil for a non-nil input")
}
got := wrapped.Error()
// Contract elements — the operator-facing string is what we ship.
wantSubstrings := []string{
"SQLSTATE 28P01", // operators grep on this
"POSTGRES_PASSWORD", // names the variable that traps
"first boot", // the mechanism in plain language
"down -v", // destructive remediation
"ALTER ROLE", // non-destructive remediation
}
for _, s := range wantSubstrings {
if !strings.Contains(got, s) {
t.Errorf("wrap text missing %q\ngot: %s", s, got)
}
}
// Wrap chain must still expose the underlying *pq.Error for callers
// that want to inspect Code / Detail / Constraint fields. Pre-fix
// callers used errors.As(err, &pqErr) on the unwrapped Ping result;
// the new wrap is fmt.Errorf("...%w", err) so errors.As must walk it.
var pqErr *pq.Error
if !errors.As(wrapped, &pqErr) {
t.Fatalf("errors.As did not extract *pq.Error from wrapped chain: %v", wrapped)
}
if pqErr.Code != "28P01" {
t.Errorf("extracted pq.Error.Code = %q, want %q", pqErr.Code, "28P01")
}
}
// TestWrapPingError_NonAuthErrorPreservesOriginalWrap guards against the
// guidance text bleeding into unrelated failure modes. SQLSTATE 08006
// (connection_failure) is the canonical non-auth case — server unreachable,
// TLS handshake failure, network drop. The wrap should be the original
// shape so transient-error log noise does not include the (now lengthy)
// volume-state remediation paragraph.
func TestWrapPingError_NonAuthErrorPreservesOriginalWrap(t *testing.T) {
t.Parallel()
original := &pq.Error{
Code: pq.ErrorCode("08006"),
Message: "connection refused",
}
wrapped := wrapPingError(original)
if wrapped == nil {
t.Fatal("wrapPingError returned nil for a non-nil input")
}
got := wrapped.Error()
// Original-wrap shape: prefix only, no guidance text.
const wantPrefix = "failed to ping database: "
if !strings.HasPrefix(got, wantPrefix) {
t.Errorf("expected prefix %q, got: %s", wantPrefix, got)
}
// Negative assertions: guidance text MUST NOT appear on non-auth paths.
mustNotContain := []string{
"SQLSTATE 08006", // we only call out 28P01 specifically
"POSTGRES_PASSWORD",
"down -v",
"ALTER ROLE",
}
for _, s := range mustNotContain {
if strings.Contains(got, s) {
t.Errorf("non-auth wrap leaked guidance substring %q\ngot: %s", s, got)
}
}
// Wrap chain still walks for errors.As — same contract as auth path.
var pqErr *pq.Error
if !errors.As(wrapped, &pqErr) {
t.Fatalf("errors.As did not extract *pq.Error from non-auth wrapped chain: %v", wrapped)
}
if pqErr.Code != "08006" {
t.Errorf("extracted pq.Error.Code = %q, want %q", pqErr.Code, "08006")
}
}
// TestWrapPingError_NonPqErrorPreservesOriginalWrap guards the network-level
// case: a pre-handshake failure (TCP refused, DNS, TLS) returns a
// non-*pq.Error from db.Ping(). errors.As must return false, the helper
// must fall through to the generic wrap, and the chain must remain walkable.
func TestWrapPingError_NonPqErrorPreservesOriginalWrap(t *testing.T) {
t.Parallel()
original := errors.New("dial tcp 127.0.0.1:5432: connect: connection refused")
wrapped := wrapPingError(original)
if wrapped == nil {
t.Fatal("wrapPingError returned nil for a non-nil input")
}
got := wrapped.Error()
const wantPrefix = "failed to ping database: "
if !strings.HasPrefix(got, wantPrefix) {
t.Errorf("expected prefix %q, got: %s", wantPrefix, got)
}
if strings.Contains(got, "SQLSTATE") || strings.Contains(got, "POSTGRES_PASSWORD") {
t.Errorf("network-level wrap leaked SQLSTATE/postgres guidance\ngot: %s", got)
}
if !errors.Is(wrapped, original) {
t.Errorf("errors.Is did not walk to original sentinel: %v", wrapped)
}
}
// TestWrapPingError_NilReturnsNil — defensive contract: if Ping returned nil
// (no failure), the helper must not synthesize a fake error. This isn't on
// the documented call path (NewDB only invokes wrapPingError inside the
// `if err != nil` branch), but pinning it prevents a future refactor from
// regressing the contract silently.
func TestWrapPingError_NilReturnsNil(t *testing.T) {
t.Parallel()
if got := wrapPingError(nil); got != nil {
t.Errorf("wrapPingError(nil) = %v, want nil", got)
}
}
+3 -3
View File
@@ -62,7 +62,7 @@ func (r *DiscoveryRepository) GetScan(ctx context.Context, id string) (*domain.D
&scan.ScanDurationMs, &scan.StartedAt, &scan.CompletedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("discovery scan not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("discovery scan not found: %s", id)
}
if err != nil {
return nil, fmt.Errorf("failed to get discovery scan: %w", err)
@@ -190,7 +190,7 @@ func (r *DiscoveryRepository) GetDiscovered(ctx context.Context, id string) (*do
&cert.CreatedAt, &cert.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("discovered certificate not found: %s", id)
}
if err != nil {
return nil, fmt.Errorf("failed to get discovered certificate: %w", err)
@@ -317,7 +317,7 @@ func (r *DiscoveryRepository) UpdateDiscoveredStatus(ctx context.Context, id str
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("discovered certificate not found: %w", repository.ErrNotFound)
return fmt.Errorf("discovered certificate not found: %s", id)
}
return nil
}
+2 -2
View File
@@ -113,7 +113,7 @@ func (r *HealthCheckRepository) Get(ctx context.Context, id string) (*domain.End
&check.CreatedAt, &check.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("health check not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("health check not found: %s", id)
}
if err != nil {
return nil, fmt.Errorf("get health check: %w", err)
@@ -299,7 +299,7 @@ func (r *HealthCheckRepository) GetByEndpoint(ctx context.Context, endpoint stri
&check.CreatedAt, &check.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("health check not found for endpoint: %w", repository.ErrNotFound)
return nil, fmt.Errorf("health check not found for endpoint: %s", endpoint)
}
if err != nil {
return nil, fmt.Errorf("get health check by endpoint: %w", err)
+3 -4
View File
@@ -1,7 +1,6 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -70,7 +69,7 @@ func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer,
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("issuer not found")
}
return nil, fmt.Errorf("failed to query issuer: %w", err)
}
@@ -170,7 +169,7 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er
}
if rows == 0 {
return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
return fmt.Errorf("issuer not found")
}
return nil
@@ -190,7 +189,7 @@ func (r *IssuerRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("issuer not found: %w", repository.ErrNotFound)
return fmt.Errorf("issuer not found")
}
return nil
+4 -5
View File
@@ -1,7 +1,6 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -63,7 +62,7 @@ func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error)
job, err := scanJob(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("job not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("job not found")
}
return nil, fmt.Errorf("failed to query job: %w", err)
}
@@ -124,7 +123,7 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
}
if rows == 0 {
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
return fmt.Errorf("job not found")
}
return nil
@@ -144,7 +143,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
return fmt.Errorf("job not found")
}
return nil
@@ -233,7 +232,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
}
if rows == 0 {
return fmt.Errorf("job not found: %w", repository.ErrNotFound)
return fmt.Errorf("job not found")
}
return nil
+3 -4
View File
@@ -1,7 +1,6 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -69,7 +68,7 @@ func (r *NetworkScanRepository) Get(ctx context.Context, id string) (*domain.Net
&target.CreatedAt, &target.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("network scan target not found: %s", id)
}
if err != nil {
return nil, fmt.Errorf("get network scan target: %w", err)
@@ -118,7 +117,7 @@ func (r *NetworkScanRepository) Update(ctx context.Context, target *domain.Netwo
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
return fmt.Errorf("network scan target not found: %s", target.ID)
}
return nil
}
@@ -131,7 +130,7 @@ func (r *NetworkScanRepository) Delete(ctx context.Context, id string) error {
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("network scan target not found: %w", repository.ErrNotFound)
return fmt.Errorf("network scan target not found: %s", id)
}
return nil
}
+31 -45
View File
@@ -22,37 +22,19 @@ func NewNotificationRepository(db *sql.DB) *NotificationRepository {
return &NotificationRepository{db: db}
}
// Create stores a new notification.
//
// U-3 ride-along (cat-o-notification_created_at_dead_field, P2): the
// `created_at` column is added to notification_events by migration 000017.
// Pre-U-3 the Go domain.NotificationEvent had a CreatedAt field but the
// INSERT path never set it AND no DB column existed — the JSON API
// serialised the field as `0001-01-01T00:00:00Z`, breaking timestamp
// ordering on operator dashboards and any consumer that filtered by age.
// Post-U-3 the column exists with a NOT NULL DEFAULT NOW() backstop, and
// this INSERT explicitly sets it from the domain field. If the caller
// hasn't populated CreatedAt (zero-value time.Time) we substitute
// time.Now() so the row never carries the placeholder zero-time forward
// — the DEFAULT would handle this too, but emitting the value explicitly
// keeps the wire-level JSON consistent with what the row will hold once
// scanNotification reads it back, and prevents a clock-skew gap between
// "Go computed CreatedAt" and "DB applied DEFAULT NOW()" on the read path.
// Create stores a new notification
func (r *NotificationRepository) Create(ctx context.Context, notif *domain.NotificationEvent) error {
if notif.ID == "" {
notif.ID = uuid.New().String()
}
if notif.CreatedAt.IsZero() {
notif.CreatedAt = time.Now()
}
err := r.db.QueryRowContext(ctx, `
INSERT INTO notification_events (
id, type, certificate_id, channel, recipient, message, sent_at, status, error, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
id, type, certificate_id, channel, recipient, message, sent_at, status, error
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`, notif.ID, notif.Type, notif.CertificateID, notif.Channel, notif.Recipient,
notif.Message, notif.SentAt, notif.Status, notif.Error, notif.CreatedAt).Scan(&notif.ID)
notif.Message, notif.SentAt, notif.Status, notif.Error).Scan(&notif.ID)
if err != nil {
return fmt.Errorf("failed to create notification: %w", err)
@@ -120,14 +102,12 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
// Get paginated results. I-005 extends the SELECT with the three retry
// columns (retry_count / next_retry_at / last_error) so scanNotification
// can populate the new fields on domain.NotificationEvent. U-3 extends
// it once more with `created_at` (column added by migration 000017) so
// the field is no longer serialized as 0001-01-01. The column order
// here MUST stay in lockstep with scanNotification below.
// can populate the new fields on domain.NotificationEvent. The column
// order here MUST stay in lockstep with scanNotification below.
offset := (filter.Page - 1) * filter.PerPage
query := fmt.Sprintf(`
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error,
retry_count, next_retry_at, last_error, created_at
retry_count, next_retry_at, last_error
FROM notification_events
%s
ORDER BY sent_at DESC NULLS LAST
@@ -158,7 +138,15 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
return notifs, nil
}
// UpdateStatus updates a notification's delivery status
// UpdateStatus updates a notification's delivery status.
//
// M-1 (P2): zero-rows-affected now wraps repository.ErrNotFound via %w so the
// handler's errToStatus choke point dispatches to 404 via errors.Is. Pre-M-1
// the return was a raw fmt.Errorf("notification not found") string that the
// service layer re-wrapped and the handler classified via strings.Contains —
// one sentinel-message reword away from silently demoting 404 to 500. The
// behavior (error on concurrent-delete / bad-id) is unchanged; only the error
// identity is now type-safe.
func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error {
result, err := r.db.ExecContext(ctx, `
UPDATE notification_events SET status = $1, sent_at = $2 WHERE id = $3
@@ -174,7 +162,7 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
}
if rows == 0 {
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
return fmt.Errorf("%w: notification %s", repository.ErrNotFound, id)
}
return nil
@@ -182,14 +170,8 @@ func (r *NotificationRepository) UpdateStatus(ctx context.Context, id string, st
// scanNotification scans a notification from a row or rows.
//
// I-005 extended the scan list from 9 → 12 columns (adds retry_count,
// next_retry_at, last_error). U-3 extends it once more to 13 columns by
// appending `created_at` (column added by migration 000017,
// cat-o-notification_created_at_dead_field). CreatedAt scans into a
// non-pointer time.Time because the migration declares the column
// NOT NULL with DEFAULT NOW().
//
// Every caller — List, ListRetryEligible, and the four other I-005 retry
// I-005 extends the scan list from 9 → 12 columns (adds retry_count,
// next_retry_at, last_error). Every caller — List and the four new retry
// methods below — funnels rows through this helper, so the SELECT column
// order in every query must match the Scan order here exactly. RetryCount
// scans into an `int` (migration 000016 declares the column NOT NULL with
@@ -202,7 +184,7 @@ func scanNotification(scanner interface {
var notif domain.NotificationEvent
err := scanner.Scan(&notif.ID, &notif.Type, &notif.CertificateID, &notif.Channel,
&notif.Recipient, &notif.Message, &notif.SentAt, &notif.Status, &notif.Error,
&notif.RetryCount, &notif.NextRetryAt, &notif.LastError, &notif.CreatedAt)
&notif.RetryCount, &notif.NextRetryAt, &notif.LastError)
if err != nil {
return nil, fmt.Errorf("failed to scan notification: %w", err)
@@ -274,7 +256,7 @@ func (r *NotificationRepository) ListRetryEligible(ctx context.Context, now time
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, channel, recipient, message, sent_at, status, error,
retry_count, next_retry_at, last_error, created_at
retry_count, next_retry_at, last_error
FROM notification_events
WHERE status = 'failed'
AND next_retry_at IS NOT NULL
@@ -333,10 +315,12 @@ func (r *NotificationRepository) RecordFailedAttempt(ctx context.Context, id str
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
// Same "not found" error shape as UpdateStatus above. The scheduler
// logs-and-continues on this so a concurrently-deleted row doesn't
// break the sweep.
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
// M-1 (P2): wrap repository.ErrNotFound (was raw
// fmt.Errorf("notification not found")). Same "not found" shape as
// UpdateStatus — the scheduler logs-and-continues on a concurrently
// deleted row, but callers that surface the error (the handler) now
// discriminate via errors.Is instead of substring matching.
return fmt.Errorf("%w: notification %s", repository.ErrNotFound, id)
}
return nil
}
@@ -368,7 +352,8 @@ func (r *NotificationRepository) MarkAsDead(ctx context.Context, id string, last
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
// M-1 (P2): wrap repository.ErrNotFound. See UpdateStatus rationale.
return fmt.Errorf("%w: notification %s", repository.ErrNotFound, id)
}
return nil
}
@@ -405,7 +390,8 @@ func (r *NotificationRepository) Requeue(ctx context.Context, id string) error {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("notification not found: %w", repository.ErrNotFound)
// M-1 (P2): wrap repository.ErrNotFound. See UpdateStatus rationale.
return fmt.Errorf("%w: notification %s", repository.ErrNotFound, id)
}
return nil
}
@@ -339,95 +339,6 @@ func TestNotificationRepository_Requeue(t *testing.T) {
}
}
// TestNotificationRepository_CreatedAt_IsPersisted is the U-3 ride-along
// regression for cat-o-notification_created_at_dead_field. Pre-U-3 the
// Go domain.NotificationEvent had a CreatedAt field but the DB had no
// column — JSON serialisation produced 0001-01-01T00:00:00Z, breaking
// timestamp ordering on operator dashboards. Post-U-3 migration 000017
// adds the column NOT NULL DEFAULT NOW(), Create populates it, and
// scanNotification reads it back.
//
// The contract under test is round-trip equivalence: the timestamp the
// caller sets goes into the DB and comes back out unchanged (modulo
// PostgreSQL's microsecond precision). Truncate to microseconds before
// comparing because TIMESTAMPTZ rounds nanoseconds away.
func TestNotificationRepository_CreatedAt_IsPersisted(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewNotificationRepository(db)
ctx := context.Background()
// A specific, recognisable timestamp. Truncated to microseconds so
// the post-roundtrip equality assertion isn't tripped up by Postgres
// dropping the nanosecond tail.
want := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Microsecond)
notif := &domain.NotificationEvent{
Type: domain.NotificationTypeExpirationWarning,
Channel: domain.NotificationChannelWebhook,
Recipient: "https://hooks.example.com/u3",
Message: "U-3 round-trip witness",
Status: string(domain.NotificationStatusPending),
CreatedAt: want,
}
if err := repo.Create(ctx, notif); err != nil {
t.Fatalf("Create failed: %v", err)
}
// Re-read via List (which goes through scanNotification) so we're
// testing both the INSERT and SELECT halves of the U-3 plumbing.
got, err := repo.List(ctx, nil)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(got) != 1 {
t.Fatalf("List returned %d rows, want 1", len(got))
}
if !got[0].CreatedAt.Equal(want) {
t.Errorf("CreatedAt round-trip mismatch:\n set: %v\n got: %v\n"+
"Pre-U-3 this would have come back as 0001-01-01 because the column didn't exist.",
want, got[0].CreatedAt)
}
}
// TestNotificationRepository_CreatedAt_DefaultsToNow verifies the helper
// behavior in Create: when the caller hands over an event with the
// zero-value CreatedAt, Create substitutes time.Now() rather than
// trusting the DB DEFAULT. This keeps wire-level JSON consistent with
// what the row will hold once it's read back, and avoids a clock-skew
// gap between "Go computed the timestamp" and "DB applied DEFAULT NOW()".
func TestNotificationRepository_CreatedAt_DefaultsToNow(t *testing.T) {
tdb := getTestDB(t)
db := tdb.freshSchema(t)
repo := postgres.NewNotificationRepository(db)
ctx := context.Background()
before := time.Now().UTC().Add(-time.Second)
notif := &domain.NotificationEvent{
Type: domain.NotificationTypeExpirationWarning,
Channel: domain.NotificationChannelWebhook,
Recipient: "https://hooks.example.com/zerotime",
Message: "U-3 zero-time fallback",
Status: string(domain.NotificationStatusPending),
// CreatedAt left zero on purpose — the contract is that Create
// fills it in from time.Now() when it's unset.
}
if err := repo.Create(ctx, notif); err != nil {
t.Fatalf("Create failed: %v", err)
}
after := time.Now().UTC().Add(time.Second)
if notif.CreatedAt.IsZero() {
t.Fatalf("CreatedAt is still zero after Create — the fallback in NotificationRepository.Create did not fire")
}
if notif.CreatedAt.Before(before) || notif.CreatedAt.After(after) {
t.Errorf("CreatedAt = %v is outside the [%v, %v] window — the substituted time.Now() should fall inside the test's wall-clock bracket",
notif.CreatedAt, before, after)
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────
// past returns a stable "5 minutes ago" time for fixture seeding. Truncated
+3 -4
View File
@@ -1,7 +1,6 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"fmt"
@@ -62,7 +61,7 @@ func (r *OwnerRepository) Get(ctx context.Context, id string) (*domain.Owner, er
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("owner not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("owner not found")
}
return nil, fmt.Errorf("failed to query owner: %w", err)
}
@@ -111,7 +110,7 @@ func (r *OwnerRepository) Update(ctx context.Context, owner *domain.Owner) error
}
if rows == 0 {
return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
return fmt.Errorf("owner not found")
}
return nil
@@ -131,7 +130,7 @@ func (r *OwnerRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("owner not found: %w", repository.ErrNotFound)
return fmt.Errorf("owner not found")
}
return nil
+3 -3
View File
@@ -63,7 +63,7 @@ func (r *PolicyRepository) GetRule(ctx context.Context, id string) (*domain.Poli
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
return nil, fmt.Errorf("policy rule not found")
}
return nil, fmt.Errorf("failed to query policy rule: %w", err)
}
@@ -114,7 +114,7 @@ func (r *PolicyRepository) UpdateRule(ctx context.Context, rule *domain.PolicyRu
}
if rows == 0 {
return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
return fmt.Errorf("policy rule not found")
}
return nil
@@ -134,7 +134,7 @@ func (r *PolicyRepository) DeleteRule(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("policy rule not found: %w", repository.ErrNotFound)
return fmt.Errorf("policy rule not found")
}
return nil
+11 -5
View File
@@ -1,15 +1,16 @@
package postgres
import (
"github.com/shankar0123/certctl/internal/repository"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// ProfileRepository implements repository.CertificateProfileRepository
@@ -64,8 +65,11 @@ func (r *ProfileRepository) Get(ctx context.Context, id string) (*domain.Certifi
p, err := scanProfile(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("profile not found: %w", repository.ErrNotFound)
if errors.Is(err, sql.ErrNoRows) {
// M-1: wrap repository.ErrNotFound so the handler's errToStatus
// choke point can route this to HTTP 404 via errors.Is without
// substring-matching the "not found" message text.
return nil, fmt.Errorf("%w: profile %s", repository.ErrNotFound, id)
}
return nil, fmt.Errorf("failed to query profile: %w", err)
}
@@ -160,7 +164,8 @@ func (r *ProfileRepository) Update(ctx context.Context, profile *domain.Certific
}
if rows == 0 {
return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
// M-1: wrap repository.ErrNotFound — see Get for rationale.
return fmt.Errorf("%w: profile %s", repository.ErrNotFound, profile.ID)
}
return nil
@@ -179,7 +184,8 @@ func (r *ProfileRepository) Delete(ctx context.Context, id string) error {
}
if rows == 0 {
return fmt.Errorf("profile not found: %w", repository.ErrNotFound)
// M-1: wrap repository.ErrNotFound — see Get for rationale.
return fmt.Errorf("%w: profile %s", repository.ErrNotFound, id)
}
return nil
+11 -6
View File
@@ -36,7 +36,7 @@ func NewRenewalPolicyRepository(db *sql.DB) *RenewalPolicyRepository {
// and require domain-layer churn we're not taking on in this change.
const renewalPolicyColumns = `
id, name, renewal_window_days, auto_renew, max_retries,
retry_interval_seconds, alert_thresholds_days, created_at, updated_at
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
`
// scanRenewalPolicy decodes one renewal_policies row from a Row or Rows
@@ -72,7 +72,10 @@ func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.R
policy, err := scanRenewalPolicy(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
// M-1: wrap repository.ErrNotFound so the handler's errToStatus
// choke point can route this to HTTP 404 via errors.Is without
// substring-matching the "not found" message text.
return nil, fmt.Errorf("%w: renewal policy %s", repository.ErrNotFound, id)
}
return nil, fmt.Errorf("failed to query renewal policy: %w", err)
}
@@ -170,7 +173,7 @@ func (r *RenewalPolicyRepository) Create(ctx context.Context, policy *domain.Ren
insertSQL := `
INSERT INTO renewal_policies (
id, name, renewal_window_days, auto_renew, max_retries,
retry_interval_seconds, alert_thresholds_days, created_at, updated_at
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING ` + renewalPolicyColumns
@@ -240,7 +243,7 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
renewal_window_days = $3,
auto_renew = $4,
max_retries = $5,
retry_interval_seconds = $6,
retry_interval_minutes = $6,
alert_thresholds_days = $7,
updated_at = NOW()
WHERE id = $1
@@ -252,7 +255,8 @@ func (r *RenewalPolicyRepository) Update(ctx context.Context, id string, policy
updated, err := scanRenewalPolicy(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
// M-1: wrap repository.ErrNotFound — see Get for rationale.
return fmt.Errorf("%w: renewal policy %s", repository.ErrNotFound, id)
}
if isUniqueViolation(err) {
return repository.ErrRenewalPolicyDuplicateName
@@ -283,7 +287,8 @@ func (r *RenewalPolicyRepository) Delete(ctx context.Context, id string) error {
return fmt.Errorf("failed to read RowsAffected for delete: %w", err)
}
if rows == 0 {
return fmt.Errorf("renewal policy not found: %w", repository.ErrNotFound)
// M-1: wrap repository.ErrNotFound — see Get for rationale.
return fmt.Errorf("%w: renewal policy %s", repository.ErrNotFound, id)
}
return nil
}
@@ -45,7 +45,7 @@ func TestRenewalPolicyRepository_CRUD(t *testing.T) {
RenewalWindowDays: 30,
AutoRenew: true,
MaxRetries: 5,
RetryInterval: 3600, // stored as seconds in retry_interval_seconds column (renamed in 000017_db_coupling_cleanup, U-3)
RetryInterval: 3600, // stored in retry_interval_minutes column; passthrough
AlertThresholdsDays: []int{30, 14, 7, 0},
}
+1 -1
View File
@@ -78,7 +78,7 @@ func insertCertPrereqsRaw(t *testing.T, db *sql.DB, ctx context.Context, suffix
}
// Create renewal policy
_, err = db.ExecContext(ctx, `INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_seconds, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
_, err = db.ExecContext(ctx, `INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
policyID, "Policy "+suffix, 30, true, 3, 60, now, now)
if err != nil {
t.Fatalf("insertCertPrereqs: create renewal_policy failed: %v", err)

Some files were not shown because too many files have changed in this diff Show More