mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
99a012e3be
Bundle 1 / Phase 0: pure refactor splitting auth surface out of internal/api/middleware so Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles, permissions, scoped grants) have a clean home. Moved to internal/auth/: NamedAPIKey, HashAPIKey, AuthConfig, NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin. Added testfixtures.go (WithActor / WithAdmin / WithActorAdmin) so handler tests don't construct context manually. Stayed in internal/api/middleware/: RequestID, Logging, NewLogging, Recovery, RateLimitConfig, NewRateLimiter (now imports auth.GetUser for per-user keying per audit Category C), CORSConfig, NewCORS, ContentType, CORS, GetRequestID, responseWriter, Chain, audit middleware (now imports auth.GetUser). Updated 22 caller files across cmd/, internal/api/handler/, internal/api/middleware/, internal/mcp/. Existing m008_admin_gate_test.go now scans for auth.IsAdmin( substring; Phase 3 will further evolve to track auth.RequirePermission. Behavior unchanged: all handler / middleware / service / connector / cmd / mcp tests pass with no test-logic edits, only import-path renames. Phase 0 exit criteria: internal/auth/ exists with 6 files; middleware.go went 575 -> 422 lines (auth-related ~150 lines moved out); grep -rE 'middleware\.(GetUser|IsAdmin|UserKey|AdminKey|NamedAPIKey|HashAPIKey|NewAuth)' returns 0 hits; context.WithValue(.*middleware.UserKey/AdminKey) returns 0 hits; go vet ./... clean; go test -short ./... green across all packages tested. Branch: dev/auth-bundle-1. Per cowork/auth-bundle-1-prompt.md, do not merge to master without (1) make verify green, (2) >= 2 external testers confirm, (3) >= 90% coverage on internal/auth/ in .github/coverage-thresholds.yml.
160 lines
5.6 KiB
Go
160 lines
5.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/auth"
|
|
)
|
|
|
|
// HealthHandler handles health and readiness check endpoints.
|
|
//
|
|
// Bundle-5 / Audit H-006 / CWE-754 (Improper Check for Unusual or
|
|
// Exceptional Conditions): pre-Bundle-5, both /health and /ready returned
|
|
// 200 unconditionally with no DB probe. A Kubernetes readinessProbe pointed
|
|
// at /ready would succeed even when the control plane was disconnected from
|
|
// Postgres, masking outages and routing user traffic to a broken instance.
|
|
//
|
|
// Post-Bundle-5 contract:
|
|
//
|
|
// GET /health → 200 always (process alive — liveness signal). No DB probe.
|
|
// k8s liveness probe: do NOT restart pod for DB hiccups.
|
|
// GET /ready → 200 if db.PingContext(2s) succeeds; 503 +
|
|
// {"status":"db_unavailable","error":"..."} if it fails.
|
|
// k8s readiness probe: drain pod when DB unreachable.
|
|
//
|
|
// The handler accepts a nullable DB pool. When nil (test fixtures, or the
|
|
// rare deploy without a DB), Ready degrades to "no probe configured" and
|
|
// returns 200 with {"status":"ready","db":"not_configured"} — preserves
|
|
// backwards compat for callers that haven't wired the dependency yet.
|
|
//
|
|
// 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)
|
|
|
|
// DB is the database pool used by Ready for connectivity probing.
|
|
// May be nil (test fixtures / no-db deploys); Ready degrades gracefully.
|
|
DB *sql.DB
|
|
|
|
// ReadyProbeTimeout is the per-probe ceiling for the DB ping. Defaults
|
|
// to 2s when zero. Exposed so tests can shorten it.
|
|
ReadyProbeTimeout time.Duration
|
|
}
|
|
|
|
// NewHealthHandler creates a new HealthHandler.
|
|
//
|
|
// Bundle-5 / H-006: db may be nil (test fixtures + no-db deploys). When nil,
|
|
// Ready returns 200 with {"db":"not_configured"} — preserves backwards
|
|
// compatibility for the call sites that haven't wired the dependency yet.
|
|
// Production main.go always passes a non-nil pool.
|
|
func NewHealthHandler(authType string, db *sql.DB) HealthHandler {
|
|
return HealthHandler{
|
|
AuthType: authType,
|
|
DB: db,
|
|
ReadyProbeTimeout: 2 * time.Second,
|
|
}
|
|
}
|
|
|
|
// Health responds with a simple health check indicating the service is alive.
|
|
// GET /health
|
|
//
|
|
// Bundle-5 / H-006: shallow on purpose — k8s liveness probe should NOT
|
|
// restart the pod when Postgres is degraded. Use /ready for readiness.
|
|
func (h HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
response := map[string]string{
|
|
"status": "healthy",
|
|
}
|
|
|
|
JSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// Ready responds with readiness status, indicating whether the service is
|
|
// ready to handle requests.
|
|
// GET /ready
|
|
//
|
|
// Bundle-5 / H-006: deep probe via db.PingContext with a 2-second ceiling.
|
|
// Returns 503 + {"status":"db_unavailable","error":"<sanitized>"} when the
|
|
// DB is unreachable so k8s drains the pod. Returns 200 when ping succeeds
|
|
// or when no DB pool is wired (test/no-db deploys).
|
|
func (h HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if h.DB == nil {
|
|
// No DB wired (test fixture or no-db deploy). Don't fail the probe;
|
|
// surface the state for operator visibility.
|
|
JSON(w, http.StatusOK, map[string]string{
|
|
"status": "ready",
|
|
"db": "not_configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
timeout := h.ReadyProbeTimeout
|
|
if timeout <= 0 {
|
|
timeout = 2 * time.Second
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
|
defer cancel()
|
|
|
|
if err := h.DB.PingContext(ctx); err != nil {
|
|
// 503 is the correct readiness-failure status — k8s will drain
|
|
// traffic but won't tear down the pod (that's liveness's job).
|
|
JSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"status": "db_unavailable",
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
JSON(w, http.StatusOK, map[string]string{
|
|
"status": "ready",
|
|
"db": "reachable",
|
|
})
|
|
}
|
|
|
|
// AuthInfo responds with the server's authentication configuration.
|
|
// This lets the GUI know whether to show a login screen.
|
|
// GET /api/v1/auth/info (served without auth middleware)
|
|
func (h HealthHandler) AuthInfo(w http.ResponseWriter, r *http.Request) {
|
|
response := map[string]interface{}{
|
|
"auth_type": h.AuthType,
|
|
"required": h.AuthType != "none",
|
|
}
|
|
JSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// AuthCheck returns 200 if the request has valid auth credentials, along with
|
|
// the resolved named-key identity and admin flag so the GUI can gate
|
|
// admin-only affordances (e.g., the bulk-revoke button).
|
|
//
|
|
// M-003 (Phase B.4): surface the admin flag so the frontend hides affordances
|
|
// that would otherwise 403 at the server. This is a hint for UX only —
|
|
// authorization remains enforced at the handler layer (bulk_revocation.go).
|
|
//
|
|
// The auth middleware runs before this handler, so reaching here means auth
|
|
// passed. `user` falls back to an empty string when auth is disabled
|
|
// (CERTCTL_AUTH_TYPE=none).
|
|
// GET /api/v1/auth/check
|
|
func (h HealthHandler) AuthCheck(w http.ResponseWriter, r *http.Request) {
|
|
response := map[string]interface{}{
|
|
"status": "authenticated",
|
|
"user": auth.GetUser(r.Context()),
|
|
"admin": auth.IsAdmin(r.Context()),
|
|
}
|
|
JSON(w, http.StatusOK, response)
|
|
}
|