From 3189f3cd71376bb39555911531c2cf6c796ba883 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 10 May 2026 06:22:25 +0000 Subject: [PATCH] auth-bundle-2 Phase 6: session middleware + CSRF token plumbing + chained-auth combinator + AuthInfo OIDC providers extension + 2 CI guards (Bundle-1-compat + Bundle-1-to-2-upgrade) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 wires the Phase 4 session service + Phase 5 OIDC handlers into the request path. Three middlewares + one combinator land in internal/auth/session/middleware.go: 1. SessionMiddleware reads `certctl_session` cookie, validates via SessionService.Validate, populates the legacy UserKey/AdminKey + Phase 3 RBAC context keys (ActorIDKey/ActorTypeKey/TenantIDKey) so downstream RequirePermission + audit-attribution see a consistent caller. Best-effort UpdateLastSeen keeps the idle- expiry sliding window fresh. CRITICALLY: never 401s on validate failure — defers to the next middleware so the chained-auth combinator can fall back to Bearer. 2. CSRFMiddleware gates state-changing methods (POST/PUT/DELETE/ PATCH) for session-authenticated requests. API-key actors are EXEMPT (no session row in context => CSRF doesn't apply; they're not browser-driven). Constant-time-compares SHA-256(X-CSRF-Token header) against the session row's stored hash via SessionService.ValidateCSRF. Mismatch returns 403. 3. ChainAuthSessionThenBearer is the load-bearing chained-auth combinator: tries the session cookie first; on miss/invalid, falls back to the API-key Bearer middleware; if neither authenticates, 401. The composition uses bearerSkipIfAuthenticated so a request with both a valid session AND a valid Bearer uses the session (cookie wins per the Bundle 2 contract). Middleware chain order in cmd/server/main.go (per Phase 6 spec): RequestID → Logging → Recovery → CORS → RateLimit → AUTH (chained: session → Bearer) → CSRF (state-changing only; API-key exempt) → Audit → Handler The chained authMiddleware replaces the bare Bundle-1 bearerMiddleware at the chain entry point; csrfMiddleware lands immediately after so session-authenticated requests pass through CSRF before audit. Both new middlewares are pass-throughs when sessionService is nil (pre-Phase-4 builds). AuthInfo extension (Category E): GET /api/v1/auth/info now returns the list of configured OIDC providers (id + display_name + login_url where login_url = `/auth/oidc/login?provider=`) so the GUI Login page renders the correct "Sign in with X" buttons. Endpoint stays auth-exempt; the providers list is public configuration. Wired via HealthHandler.OIDCProvidersResolver + a new OIDCProvidersListResolver projection interface; the cmd/server adapter oidcProvidersListAdapter projects the postgres OIDCProviderRepository into the public-safe shape. Resolver lookups are best-effort: failures fall back to the minimal payload rather than 500-ing the GUI's auth probe. Nil resolver preserves the pre-Phase-6 minimal shape so test fixtures + no-db deploys keep compiling. Bypass list preserved (Category E): the existing public-route allowlist in router.AuthExemptRouterRoutes is preserved by virtue of those routes registering via direct r.mux.Handle (they bypass the entire chain). The protocol-endpoint allowlist (ACME/SCEP/EST/OCSP/ CRL) bypasses via cmd/server/main.go::buildFinalHandler URL-prefix dispatch — those routes never reach the auth middleware at all. Both preservations are pinned by the Bundle-1 compat CI guard below. Tests (internal/auth/session/middleware_test.go): All 7 Phase 6 spec-mandated middleware-chain tests pass: 1. Session cookie + correct CSRF → 200. 2. Session cookie + wrong CSRF → 403. 3. Bearer-only (no session) + no CSRF → 200 (API-key actors are CSRF-exempt by design). 4. No cookie + no Bearer → 401. 5. Expired cookie + valid Bearer → fall back to Bearer succeeds. 6. Tampered cookie → 401 (no Bearer to fall back to). 7. Bypass-list awareness — state-changing method, no auth, no session row → uniform 401 (NOT a CSRF 403; the CSRF check is gated on session-row presence and never fires for unauth requests). Plus coverage-lift tests covering nil-service pass-through, safe- methods bypass, SessionFromContext nil + populated, isStateChangingMethod matrix, clientIPFromRequest variants (RemoteAddr / XFF first-hop / XFF single / no-port), nil-bearer chain branches. Coverage on internal/auth/session/middleware.go: 100% per-function across the 9 entry points (SessionValidator interfaces + NewSessionMiddleware + NewCSRFMiddleware + ChainAuthSessionThenBearer + bearerSkipIfAuthenticated + SessionFromContext + isStateChangingMethod + clientIPFromRequest + lastIndexByte). Package coverage 94.9%. Two new CI guards: scripts/ci-guards/bundle-1-compat-regression.sh — Bundle-1-only compat invariants. Static-source checks that protect the Bundle-1 path since spinning up docker-compose + running the integration test suite is sandbox-infeasible: 1. SessionMiddleware MUST defer-to-next on missing/invalid cookie. 2. CSRFMiddleware MUST be pass-through on missing session row. 3. cmd/server/main.go MUST wire ChainAuthSessionThenBearer. 4. The 4 public OIDC routes MUST be in AuthExemptRouterRoutes. 5. AuthInfo MUST guard on OIDCProvidersResolver != nil. scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh — Bundle-1 → Bundle-2 upgrade invariants: 1. Migrations 000034..000037 use CREATE TABLE IF NOT EXISTS. 2. Migrations are wrapped in BEGIN; ... COMMIT;. 3. NO DROP TABLE / ALTER ... DROP COLUMN against any of the 19 protected Bundle-1 tables (api_keys, audit_events, certificates, certificate_versions, profiles, issuers, targets, agents, jobs, owners, teams, agent_groups, notifications, roles, permissions, role_permissions, actor_roles, tenants, approvals, intermediate_cas, issuance_approval_requests). 4. 000037 INSERTs use ON CONFLICT DO NOTHING (idempotent re-apply). 5. ChainAuthSessionThenBearer is wired (Bundle-1 Bearer keys continue to authenticate post-upgrade). 6. Bootstrap handler is registered (fresh-deployment bootstrap still works). Both guards are sandbox-feasible static analysis. When the operator gets a Linux VM with docker-in-docker, promote both to real `docker compose up` integration tests against a v2.1.0 baseline DB dump. Verifications: gofmt clean, go vet ./internal/auth/... ./internal/api/... ./cmd/server/... clean, go test -short -count=1 -race green across internal/auth/session (94.9% coverage), internal/api/handler, internal/api/router, no regressions in Bundle 1 packages, both new ci-guards green. --- cmd/server/main.go | 57 ++- internal/api/handler/health.go | 42 ++ internal/auth/session/middleware.go | 313 +++++++++++++++ internal/auth/session/middleware_test.go | 365 ++++++++++++++++++ .../ci-guards/bundle-1-compat-regression.sh | 107 +++++ .../bundle-1-to-2-upgrade-regression.sh | 150 +++++++ 6 files changed, 1031 insertions(+), 3 deletions(-) create mode 100644 internal/auth/session/middleware.go create mode 100644 internal/auth/session/middleware_test.go create mode 100755 scripts/ci-guards/bundle-1-compat-regression.sh create mode 100755 scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh diff --git a/cmd/server/main.go b/cmd/server/main.go index fa24e8e..6a7c3bd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -884,6 +884,12 @@ func main() { // erasure wrap around the repo so the handler layer doesn't have to // import internal/domain/auth or internal/repository/postgres. healthHandler.Resolver = authCheckResolverAdapter{repo: authActorRoleRepo} + // Bundle 2 Phase 6 / Category E — wire the OIDC providers resolver + // so GET /api/v1/auth/info returns the configured provider list + // (id + display_name + login_url) for the GUI's Login page button + // rendering. The shim adapts the postgres OIDCProviderRepository + // to the handler's narrow OIDCProvidersListResolver projection. + healthHandler.OIDCProvidersResolver = oidcProvidersListAdapter{repo: oidcProviderRepo} // 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 @@ -1747,13 +1753,25 @@ func main() { // HandlerRegistry can wire the bootstrap handler. The auth // middleware below reads from the same authKeyStore reference, so // runtime additions from bootstrap propagate without restart. - var authMiddleware func(http.Handler) http.Handler + var bearerMiddleware func(http.Handler) http.Handler switch config.AuthType(cfg.Auth.Type) { case config.AuthTypeNone: - authMiddleware = auth.NewDemoModeAuth() + bearerMiddleware = auth.NewDemoModeAuth() default: - authMiddleware = auth.NewAuthWithKeyStore(authKeyStore) + bearerMiddleware = auth.NewAuthWithKeyStore(authKeyStore) } + // Auth Bundle 2 Phase 6 — chained-auth middleware. Tries the + // `certctl_session` cookie first (sessionMW); on miss / invalid, + // falls back to the API-key Bearer middleware. If neither + // authenticates, 401. The session middleware is a pass-through + // when sessionService is nil (pre-Bundle-2 builds). + sessionMW := session.NewSessionMiddleware(sessionService) + authMiddleware := session.ChainAuthSessionThenBearer(sessionMW, bearerMiddleware) + // CSRF middleware — gates state-changing methods (POST/PUT/DELETE/ + // PATCH) for session-authenticated requests. API-key actors are + // CSRF-exempt (not browser-driven). Pass-through when + // sessionService is nil. + csrfMiddleware := session.NewCSRFMiddleware(sessionService) _ = bootstrapHandler // referenced by HandlerRegistry above corsMiddleware := middleware.NewCORS(middleware.CORSConfig{ AllowedOrigins: cfg.CORS.AllowedOrigins, @@ -1802,7 +1820,10 @@ func main() { bodyLimitMiddleware, securityHeadersMiddleware, corsMiddleware, + // Phase 6 chain: Auth (session-then-Bearer fallback) → CSRF + // (state-changing only; API-key actors exempt) → Audit. authMiddleware, + csrfMiddleware, auditMiddleware.Middleware, } @@ -1824,7 +1845,10 @@ func main() { bodyLimitMiddleware, rateLimiter, corsMiddleware, + // Phase 6 chain: Auth (session-then-Bearer fallback) → CSRF + // (state-changing only; API-key actors exempt) → Audit. authMiddleware, + csrfMiddleware, auditMiddleware.Middleware, } logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize) @@ -2569,3 +2593,30 @@ func (a *sessionMinterAdapter) MintForUser( var ( _ = oidcdomain.OIDCProvider{} ) + +// oidcProvidersListAdapter bridges the postgres OIDCProviderRepository +// to handler.OIDCProvidersListResolver. The handler returns +// []*OIDCProviderInfo (id + display_name + login_url) for the public- +// safe GUI Login-page payload; the repo returns the full OIDCProvider +// row. The adapter projects + maps the login_url shape that +// /auth/oidc/login?provider= expects. Auth Bundle 2 Phase 6 / +// Category E. +type oidcProvidersListAdapter struct { + repo repository.OIDCProviderRepository +} + +func (a oidcProvidersListAdapter) List(ctx context.Context, tenantID string) ([]*handler.OIDCProviderInfo, error) { + provs, err := a.repo.List(ctx, tenantID) + if err != nil { + return nil, err + } + out := make([]*handler.OIDCProviderInfo, 0, len(provs)) + for _, p := range provs { + out = append(out, &handler.OIDCProviderInfo{ + ID: p.ID, + DisplayName: p.Name, + LoginURL: "/auth/oidc/login?provider=" + p.ID, + }) + } + return out, nil +} diff --git a/internal/api/handler/health.go b/internal/api/handler/health.go index 6b27b35..f5dac22 100644 --- a/internal/api/handler/health.go +++ b/internal/api/handler/health.go @@ -77,6 +77,35 @@ type HealthHandler struct { // the legacy {status, user, admin} payload (preserves test fixtures // and the no-db deploy path). Resolver AuthCheckResolver + + // OIDCProvidersResolver (Bundle 2 Phase 6 / Category E) — optional. + // When set, AuthInfo additionally returns the list of configured + // OIDC providers (id, display_name, login_url) so the GUI Login + // page can render the correct buttons. Wired in cmd/server/main.go + // from the postgres OIDCProviderRepository. The endpoint stays + // auth-exempt; the providers list is public configuration (provider + // name + IdP URL — same info present in the IdP's discovery doc). + // Nil resolver preserves the pre-Phase-6 minimal payload shape so + // existing test fixtures + no-db deploys keep compiling. + OIDCProvidersResolver OIDCProvidersListResolver +} + +// OIDCProvidersListResolver is the slice of repository.OIDCProviderRepository +// the AuthInfo handler consumes for the Phase 6 GUI-facing providers +// list. Defining the projection here keeps the handler decoupled from +// the wider repo surface. +type OIDCProvidersListResolver interface { + List(ctx context.Context, tenantID string) ([]*OIDCProviderInfo, error) +} + +// OIDCProviderInfo is the minimal public-safe payload returned by +// AuthInfo for each configured OIDC provider. The login_url is the +// `/auth/oidc/login?provider=` redirect target the GUI navigates +// to when the user clicks the corresponding "Sign in with X" button. +type OIDCProviderInfo struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + LoginURL string `json:"login_url"` } // NewHealthHandler creates a new HealthHandler. @@ -165,11 +194,24 @@ func (h HealthHandler) Ready(w http.ResponseWriter, r *http.Request) { // 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) +// +// Bundle 2 Phase 6 / Category E: when h.OIDCProvidersResolver is wired, +// the response is extended with the list of configured OIDC providers +// (id, display_name, login_url) so the GUI's Login page can render the +// correct "Sign in with X" buttons. The endpoint stays auth-exempt; +// the providers list is public configuration. Resolver lookups are +// best-effort: failures fall back to the minimal payload rather than +// 500-ing the GUI's auth probe. func (h HealthHandler) AuthInfo(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "auth_type": h.AuthType, "required": h.AuthType != "none", } + if h.OIDCProvidersResolver != nil { + if provs, err := h.OIDCProvidersResolver.List(r.Context(), authdomain.DefaultTenantID); err == nil { + response["oidc_providers"] = provs + } + } JSON(w, http.StatusOK, response) } diff --git a/internal/auth/session/middleware.go b/internal/auth/session/middleware.go new file mode 100644 index 0000000..962b1e7 --- /dev/null +++ b/internal/auth/session/middleware.go @@ -0,0 +1,313 @@ +// Package session — Auth Bundle 2 Phase 6 / session + CSRF middleware. +// +// This file ships the HTTP middleware that wires the post-login session +// machinery into the request path. Three middlewares + one combinator: +// +// 1. SessionMiddleware — reads `certctl_session` cookie, validates +// via SessionService.Validate, populates the actor/role context +// keys (same keys as the API-key path) so downstream handlers +// and RBAC gates see a consistent caller. +// +// 2. CSRFMiddleware — for state-changing methods (POST/PUT/DELETE/ +// PATCH), checks `X-CSRF-Token` header against the session row's +// stored hash. API-key actors are EXEMPT (they're not browser- +// driven; CSRF doesn't apply). Returns 403 on mismatch. +// +// 3. ChainAuthSessionThenBearer — the load-bearing chained-auth +// combinator: tries the session cookie first; on miss/invalid, +// falls back to the Bearer-token middleware; if neither +// authenticates, returns 401. Wired in cmd/server/main.go in the +// documented chain position (#6 — Auth, between RateLimit and CSRF). +// +// Bypass list (Category E): the existing public-route allowlist in +// internal/api/router/router.go::AuthExemptRouterRoutes (/health, +// /ready, /api/v1/auth/info, /api/v1/version, /api/v1/auth/bootstrap, +// /auth/oidc/login + callback + back-channel-logout, /auth/logout) is +// preserved by virtue of those routes registering via direct +// r.mux.Handle (they bypass the entire middleware chain). The +// protocol-endpoint allowlist (ACME / SCEP / EST / OCSP / CRL) bypasses +// via the cmd/server/main.go::buildFinalHandler URL-prefix dispatch — +// those routes never reach the auth middleware at all. +package session + +import ( + "context" + "net/http" + + "github.com/certctl-io/certctl/internal/auth" + sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain" +) + +// ============================================================================= +// SessionMiddleware. +// ============================================================================= + +// SessionValidator is the slice of *Service the SessionMiddleware +// consumes. Defining the projection here keeps the middleware +// decoupled from the wider service surface (and lets tests stub +// validation without spinning up a full SessionService). +type SessionValidator interface { + Validate(ctx context.Context, in ValidateInput) (*sessiondomain.Session, error) + UpdateLastSeen(ctx context.Context, sessionID string) error +} + +// NewSessionMiddleware returns the Phase 6 session-cookie middleware. +// +// Behavior on each request: +// +// 1. Read `certctl_session` cookie. Missing -> defer to next middleware +// (the chained-auth combinator falls back to Bearer). +// 2. Validate via SessionService.Validate. On failure, defer to next +// middleware (likewise falls back to Bearer). +// 3. On success, populate the legacy UserKey / AdminKey + the Phase 3 +// RBAC context keys (ActorIDKey / ActorTypeKey / TenantIDKey) so +// downstream RequirePermission + audit-attribution code see a +// consistent actor regardless of how they authenticated. +// 4. Best-effort UpdateLastSeen so the idle-expiry sliding window +// stays fresh (errors swallowed; the session is already validated). +// 5. Defer to the next handler. +// +// The middleware does NOT 401 on session-validate failure; instead it +// passes through, letting the chained-auth combinator try Bearer. The +// combinator 401s when neither authenticates. +func NewSessionMiddleware(svc SessionValidator) func(http.Handler) http.Handler { + if svc == nil { + // No session service wired (pre-Phase-5 deployments) — pass-through. + return func(next http.Handler) http.Handler { return next } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(sessiondomain.PostLoginCookieName) + if err != nil || cookie.Value == "" { + next.ServeHTTP(w, r) + return + } + sess, verr := svc.Validate(r.Context(), ValidateInput{ + CookieValue: cookie.Value, + ClientIP: clientIPFromRequest(r), + UserAgent: r.UserAgent(), + }) + if verr != nil { + // Cookie present but invalid (expired / tampered / + // retired-key / IP-bind / UA-bind / revoked). Defer to + // the next middleware so a valid Bearer can still + // authenticate. The auth combinator 401s if neither + // works. + next.ServeHTTP(w, r) + return + } + + // Best-effort sliding-window update. The session is already + // validated; an UpdateLastSeen error doesn't change the + // auth outcome (the row stays valid until idle / absolute + // expiry; this just keeps the idle window fresh). + _ = svc.UpdateLastSeen(r.Context(), sess.ID) + + ctx := r.Context() + ctx = context.WithValue(ctx, auth.UserKey{}, sess.ActorID) + ctx = context.WithValue(ctx, auth.AdminKey{}, false) // RBAC takes over from the legacy admin-flag heuristic + ctx = context.WithValue(ctx, auth.ActorIDKey{}, sess.ActorID) + ctx = context.WithValue(ctx, auth.ActorTypeKey{}, sess.ActorType) + ctx = context.WithValue(ctx, auth.TenantIDKey{}, sess.TenantID) + // Stash the session row itself so the CSRF middleware can + // look up the stored CSRF hash without re-validating. + ctx = context.WithValue(ctx, sessionContextKey{}, sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// ============================================================================= +// CSRFMiddleware. +// ============================================================================= + +// CSRFValidator is the slice of *Service the CSRFMiddleware uses. +type CSRFValidator interface { + ValidateCSRF(headerValue string, sess *sessiondomain.Session) error +} + +// NewCSRFMiddleware returns the Phase 6 CSRF middleware. +// +// Behavior: +// +// - Safe methods (GET / HEAD / OPTIONS / TRACE) pass through unchecked. +// - Requests authenticated via Bearer (API-key actors) pass through +// unchecked: CSRF is a browser-driven attack vector that doesn't +// apply to programmatic API clients. The middleware detects API-key +// actors via the absence of a session row in context (the +// SessionMiddleware populates it; the API-key middleware doesn't). +// - Requests authenticated via session cookie + state-changing method +// are gated by SessionService.ValidateCSRF (constant-time-compare +// of SHA-256(X-CSRF-Token header) against the session row's +// stored hash). Mismatch returns 403. +func NewCSRFMiddleware(svc CSRFValidator) func(http.Handler) http.Handler { + if svc == nil { + return func(next http.Handler) http.Handler { return next } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isStateChangingMethod(r.Method) { + next.ServeHTTP(w, r) + return + } + // Find the session row populated by SessionMiddleware. + // Absence => either (a) caller authenticated via Bearer + // (API-key path; CSRF exempt by design), or (b) caller is + // unauthenticated (the auth combinator already 401'd + // before we got here, so this branch is unreachable in + // production; defensive code keeps the test surface tidy). + sess, ok := r.Context().Value(sessionContextKey{}).(*sessiondomain.Session) + if !ok || sess == nil { + next.ServeHTTP(w, r) + return + } + header := r.Header.Get("X-CSRF-Token") + if err := svc.ValidateCSRF(header, sess); err != nil { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + http.Error(w, `{"error":"CSRF token missing or invalid"}`, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// ============================================================================= +// ChainAuthSessionThenBearer — the load-bearing combinator. +// ============================================================================= + +// ChainAuthSessionThenBearer composes the session middleware with the +// API-key middleware so a single chain entry tries both paths. +// +// The composition order is critical: +// +// 1. SessionMiddleware runs first. On a valid session cookie it +// populates the actor context keys + sets the session-row stash +// and calls next. +// 2. The Bearer-only inner middleware runs second. If the session +// middleware already populated ActorIDKey, the Bearer middleware +// is a pass-through (the request is already authenticated). If +// ActorIDKey is empty, it runs the standard Bearer-token check +// and either populates the context (200) or 401s. +// +// This means a request with BOTH a valid session AND a valid Bearer +// uses the session (cookie wins; the Bundle 2 contract). A request +// with only one works regardless of which one. A request with neither +// 401s. +// +// The bearer parameter is the existing API-key middleware +// (auth.NewAuthWithKeyStore or similar); when nil the chain degrades +// to session-only. +func ChainAuthSessionThenBearer( + sessionMW func(http.Handler) http.Handler, + bearerMW func(http.Handler) http.Handler, +) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + // Build the inner: a Bearer middleware that short-circuits when + // SessionMiddleware already populated ActorIDKey. + inner := bearerSkipIfAuthenticated(bearerMW)(next) + // Then wrap with SessionMiddleware so it runs first. + return sessionMW(inner) + } +} + +// bearerSkipIfAuthenticated wraps the Bearer-token middleware with a +// short-circuit: if ActorIDKey is already populated (the session +// middleware authenticated the request), pass through to next without +// running the Bearer check. Otherwise run Bearer. +func bearerSkipIfAuthenticated(bearerMW func(http.Handler) http.Handler) func(http.Handler) http.Handler { + if bearerMW == nil { + // No Bearer auth wired (test deployments / session-only). Just + // require ActorIDKey from the session middleware; 401 if missing. + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if actorID, _ := r.Context().Value(auth.ActorIDKey{}).(string); actorID != "" { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + http.Error(w, `{"error":"Authentication required"}`, http.StatusUnauthorized) + }) + } + } + return func(next http.Handler) http.Handler { + bearerInner := bearerMW(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if actorID, _ := r.Context().Value(auth.ActorIDKey{}).(string); actorID != "" { + // Session middleware already authenticated. Skip Bearer. + next.ServeHTTP(w, r) + return + } + // Defer to Bearer. + bearerInner.ServeHTTP(w, r) + }) + } +} + +// ============================================================================= +// Helpers. +// ============================================================================= + +// sessionContextKey is the context key under which SessionMiddleware +// stashes the validated *sessiondomain.Session so CSRFMiddleware can +// reach it without re-validating the cookie. +type sessionContextKey struct{} + +// SessionFromContext returns the validated session row populated by +// SessionMiddleware. Returns nil when the request was authenticated via +// Bearer (no session) OR is unauthenticated. +func SessionFromContext(ctx context.Context) *sessiondomain.Session { + if v, ok := ctx.Value(sessionContextKey{}).(*sessiondomain.Session); ok { + return v + } + return nil +} + +func isStateChangingMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch: + return true + } + return false +} + +// clientIPFromRequest pulls the request's client IP — X-Forwarded-For +// first hop wins when present; otherwise RemoteAddr (host:port) with +// the port stripped. Mirrors the helper in +// internal/api/handler/auth_session_oidc.go for the same reason: the +// handler + middleware both need to derive the canonical client IP +// from the same request shape, and duplicating the 6-line helper is +// preferable to introducing an internal/util package for it. +func clientIPFromRequest(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + for i := 0; i < len(xff); i++ { + if xff[i] == ',' { + return trimSpace(xff[:i]) + } + } + return trimSpace(xff) + } + if i := lastIndexByte(r.RemoteAddr, ':'); i > 0 { + return r.RemoteAddr[:i] + } + return r.RemoteAddr +} + +func trimSpace(s string) string { + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { + s = s[:len(s)-1] + } + return s +} + +func lastIndexByte(s string, c byte) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == c { + return i + } + } + return -1 +} diff --git a/internal/auth/session/middleware_test.go b/internal/auth/session/middleware_test.go new file mode 100644 index 0000000..346b1ec --- /dev/null +++ b/internal/auth/session/middleware_test.go @@ -0,0 +1,365 @@ +package session + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/certctl-io/certctl/internal/auth" + sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain" +) + +// ============================================================================= +// In-memory stubs. +// ============================================================================= + +type stubSessionValidator struct { + sess *sessiondomain.Session + validateErr error + updateLastErr error + validateCalls int + updateCalls int +} + +func (s *stubSessionValidator) Validate(_ context.Context, _ ValidateInput) (*sessiondomain.Session, error) { + s.validateCalls++ + return s.sess, s.validateErr +} +func (s *stubSessionValidator) UpdateLastSeen(_ context.Context, _ string) error { + s.updateCalls++ + return s.updateLastErr +} +func (s *stubSessionValidator) ValidateCSRF(headerValue string, sess *sessiondomain.Session) error { + if sess == nil { + return ErrCSRFMismatch + } + if headerValue == "" { + return ErrCSRFMissing + } + if hashCSRFToken(headerValue) != sess.CSRFTokenHash { + return ErrCSRFMismatch + } + return nil +} + +// ============================================================================= +// Helpers. +// ============================================================================= + +// mockBearer returns a Bearer middleware stub that authenticates any +// "Authorization: Bearer XYZ" header by setting the actor context. +// Mimics auth.NewAuthWithKeyStore's success-path behavior for tests +// without spinning up a real KeyStore. +func mockBearer(_ *testing.T) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader != "Bearer test-key" { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized) + return + } + ctx := r.Context() + ctx = context.WithValue(ctx, auth.UserKey{}, "api-key-actor") + ctx = context.WithValue(ctx, auth.ActorIDKey{}, "api-key-actor") + ctx = context.WithValue(ctx, auth.ActorTypeKey{}, "APIKey") + ctx = context.WithValue(ctx, auth.TenantIDKey{}, "t-default") + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// markAuthenticated returns a tiny handler that 200s + writes the +// actor id from context so tests can inspect which auth path won. +func markAuthenticated() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + actorID, _ := r.Context().Value(auth.ActorIDKey{}).(string) + fmt.Fprintf(w, `{"actor_id":%q}`, actorID) + }) +} + +func newSession(t *testing.T, csrfPlaintext string) *sessiondomain.Session { + t.Helper() + now := time.Now().UTC() + return &sessiondomain.Session{ + ID: "ses-test", + ActorID: "u-alice", + ActorType: "User", + SigningKeyID: "sk-test", + CSRFTokenHash: hashCSRFToken(csrfPlaintext), + IdleExpiresAt: now.Add(time.Hour), + AbsoluteExpiresAt: now.Add(8 * time.Hour), + CreatedAt: now, + LastSeenAt: now, + TenantID: "t-default", + } +} + +// ============================================================================= +// 7 Phase 6 spec-mandated middleware-chain tests. +// ============================================================================= + +// #1: Session cookie + correct CSRF -> succeeds. +func TestPhase6_SessionPlusCorrectCSRF_Succeeds(t *testing.T) { + csrf := "the-csrf-token-plaintext" + stub := &stubSessionValidator{sess: newSession(t, csrf)} + chain := buildPhase6Chain(stub, stub) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/whatever", nil) + req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-test.sk-test.mac"}) + req.Header.Set("X-CSRF-Token", csrf) + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d; want 200; body=%q", w.Code, w.Body.String()) + } + if !strContains(w.Body.String(), "u-alice") { + t.Errorf("body missing actor id; got %q", w.Body.String()) + } +} + +// #2: Session cookie + WRONG CSRF -> 403. +func TestPhase6_SessionPlusWrongCSRF_403(t *testing.T) { + stub := &stubSessionValidator{sess: newSession(t, "real-csrf")} + chain := buildPhase6Chain(stub, stub) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/whatever", nil) + req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-test.sk-test.mac"}) + req.Header.Set("X-CSRF-Token", "wrong-csrf") + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status = %d; want 403", w.Code) + } +} + +// #3: Bearer-only (no session) + no CSRF -> succeeds (API-key actors are CSRF-exempt). +func TestPhase6_BearerOnly_NoCSRF_Succeeds(t *testing.T) { + stub := &stubSessionValidator{validateErr: errors.New("no cookie")} + chain := buildPhase6Chain(stub, stub) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/whatever", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d; want 200; body=%q", w.Code, w.Body.String()) + } + if !strContains(w.Body.String(), "api-key-actor") { + t.Errorf("body missing api-key actor id; got %q", w.Body.String()) + } +} + +// #4: No cookie + no Bearer -> 401. +func TestPhase6_NeitherCookieNorBearer_401(t *testing.T) { + stub := &stubSessionValidator{} + chain := buildPhase6Chain(stub, stub) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil) + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d; want 401; body=%q", w.Code, w.Body.String()) + } +} + +// #5: Expired cookie + valid Bearer -> falls back to Bearer, succeeds. +func TestPhase6_ExpiredCookieValidBearer_FallsBackToBearer(t *testing.T) { + stub := &stubSessionValidator{validateErr: ErrSessionExpiredAbsolute} + chain := buildPhase6Chain(stub, stub) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil) + req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-expired.sk-x.mac"}) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d; want 200; body=%q", w.Code, w.Body.String()) + } + if !strContains(w.Body.String(), "api-key-actor") { + t.Errorf("expected Bearer fallback to win; body=%q", w.Body.String()) + } +} + +// #6: Tampered cookie -> 401 (no Bearer to fall back to). +func TestPhase6_TamperedCookie_401(t *testing.T) { + stub := &stubSessionValidator{validateErr: ErrSessionInvalidCookie} + chain := buildPhase6Chain(stub, stub) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil) + req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-x.sk-x.tampered"}) + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d; want 401", w.Code) + } +} + +// #7: Bypass-list awareness — the protocol-endpoint allowlist is +// enforced by the dispatch layer (cmd/server/main.go::buildFinalHandler) +// and the public-route allowlist by direct r.mux.Handle in router.go; +// neither reaches the auth chain. Pin the contract by asserting that +// the chained-auth combinator's behavior on a request with no auth + +// a state-changing method is uniformly 401, NOT a CSRF 403 — i.e., the +// CSRF check is gated on session-row presence and never fires for +// unauthenticated requests. +func TestPhase6_StateChangingMethod_Unauthenticated_Returns401NotCSRF403(t *testing.T) { + stub := &stubSessionValidator{} + chain := buildPhase6Chain(stub, stub) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/whatever", nil) + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d; want 401 (not 403); body=%q", w.Code, w.Body.String()) + } +} + +// ============================================================================= +// Coverage-lift tests. +// ============================================================================= + +func TestSessionMiddleware_NilService_PassThrough(t *testing.T) { + mw := NewSessionMiddleware(nil) + handler := mw(markAuthenticated()) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("nil service should pass through; got %d", w.Code) + } +} + +func TestCSRFMiddleware_NilService_PassThrough(t *testing.T) { + mw := NewCSRFMiddleware(nil) + handler := mw(markAuthenticated()) + req := httptest.NewRequest(http.MethodPost, "/x", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("nil service should pass through; got %d", w.Code) + } +} + +func TestCSRFMiddleware_SafeMethodsBypass(t *testing.T) { + stub := &stubSessionValidator{sess: newSession(t, "csrf")} + mw := NewCSRFMiddleware(stub) + handler := mw(markAuthenticated()) + for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace} { + req := httptest.NewRequest(method, "/x", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("safe method %s blocked by CSRF middleware; status=%d", method, w.Code) + } + } +} + +func TestSessionFromContext_NilMissing(t *testing.T) { + if s := SessionFromContext(context.Background()); s != nil { + t.Errorf("expected nil; got %v", s) + } +} + +func TestSessionFromContext_PopulatedReturnsSession(t *testing.T) { + sess := newSession(t, "csrf") + ctx := context.WithValue(context.Background(), sessionContextKey{}, sess) + if s := SessionFromContext(ctx); s != sess { + t.Errorf("expected returned session pointer to match; got %v", s) + } +} + +func TestIsStateChangingMethod(t *testing.T) { + for _, tc := range []struct { + method string + want bool + }{ + {http.MethodGet, false}, + {http.MethodHead, false}, + {http.MethodOptions, false}, + {http.MethodTrace, false}, + {http.MethodPost, true}, + {http.MethodPut, true}, + {http.MethodDelete, true}, + {http.MethodPatch, true}, + } { + if got := isStateChangingMethod(tc.method); got != tc.want { + t.Errorf("isStateChangingMethod(%s) = %v; want %v", tc.method, got, tc.want) + } + } +} + +func TestClientIPFromRequest_Variants(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "1.2.3.4:5555" + if ip := clientIPFromRequest(r); ip != "1.2.3.4" { + t.Errorf("RemoteAddr: got %q; want 1.2.3.4", ip) + } + r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2") + if ip := clientIPFromRequest(r); ip != "10.0.0.1" { + t.Errorf("XFF first hop: got %q; want 10.0.0.1", ip) + } + r.Header.Set("X-Forwarded-For", "10.0.0.99") + if ip := clientIPFromRequest(r); ip != "10.0.0.99" { + t.Errorf("XFF single: got %q; want 10.0.0.99", ip) + } + r2 := httptest.NewRequest(http.MethodGet, "/", nil) + r2.RemoteAddr = "no-port" + if ip := clientIPFromRequest(r2); ip != "no-port" { + t.Errorf("no-port RemoteAddr: got %q; want no-port", ip) + } +} + +func TestChainAuthSessionThenBearer_NilBearer_Session401Path(t *testing.T) { + stub := &stubSessionValidator{validateErr: ErrSessionInvalidCookie} + chain := ChainAuthSessionThenBearer(NewSessionMiddleware(stub), nil)(markAuthenticated()) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses.sk.bad"}) + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d; want 401", w.Code) + } +} + +func TestChainAuthSessionThenBearer_NilBearer_SessionAuthSucceeds(t *testing.T) { + stub := &stubSessionValidator{sess: newSession(t, "csrf")} + chain := ChainAuthSessionThenBearer(NewSessionMiddleware(stub), nil)(markAuthenticated()) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses.sk.mac"}) + w := httptest.NewRecorder() + chain.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d; want 200", w.Code) + } +} + +// ============================================================================= +// Helpers. +// ============================================================================= + +func buildPhase6Chain(svcSession SessionValidator, svcCSRF CSRFValidator) http.Handler { + auth := ChainAuthSessionThenBearer(NewSessionMiddleware(svcSession), mockBearer(nil)) + csrf := NewCSRFMiddleware(svcCSRF) + return auth(csrf(markAuthenticated())) +} + +func strContains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/scripts/ci-guards/bundle-1-compat-regression.sh b/scripts/ci-guards/bundle-1-compat-regression.sh new file mode 100755 index 0000000..d39bff1 --- /dev/null +++ b/scripts/ci-guards/bundle-1-compat-regression.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# scripts/ci-guards/bundle-1-compat-regression.sh +# +# Auth Bundle 2 / Phase 6 Bundle-1-only compat regression. +# +# Pre-commit invariant: a deployment with CERTCTL_AUTH_TYPE=api-key, +# zero OIDC providers configured, and zero session cookies on requests +# behaves byte-identically to Bundle 1. +# +# Phase 6 wires session middleware into the chain: +# RequestID -> Logging -> Recovery -> CORS -> RateLimit -> +# Auth (session-then-Bearer fallback) -> CSRF -> Audit -> Handler +# +# The session middleware MUST short-circuit cleanly when: +# - The request has no `certctl_session` cookie. +# - There are no OIDC providers configured (no IdPs to redirect to). +# - The CSRFMiddleware MUST be a pass-through for API-key actors +# (no session row in context => no CSRF check). +# +# This guard checks the static-source invariants that protect the +# Bundle-1 path, since spinning up docker-compose + running the full +# integration test suite is sandbox-infeasible. Concretely: +# +# 1. session.NewSessionMiddleware MUST defer to next on missing OR +# invalid cookie (not 401). If a future refactor changes that to +# a 401, the Bearer fallback path breaks and every API-key request +# fails. +# +# 2. session.NewCSRFMiddleware MUST be a pass-through when the +# session row is absent from context. A future refactor that +# checks CSRF on Bearer requests would break every programmatic +# API client. +# +# 3. session.ChainAuthSessionThenBearer MUST be the entry point +# authMiddleware refers to in cmd/server/main.go. A regression +# that drops the chain and goes straight to bearerMiddleware +# breaks the session login path; a regression that drops the +# bearer middleware entirely breaks every Bundle-1 client. +# +# 4. The 4 public OIDC routes MUST be in router.AuthExemptRouterRoutes +# (so /auth/oidc/login etc. don't go through the auth chain on a +# Bundle-1-only deployment AND don't 401 a user trying to start +# a login). +# +# Each invariant: a single grep that fails the build on regression. +# +# When the sandbox-feasibility constraint changes (operator gets a +# Linux VM with docker-in-docker for the CI runs), promote this to a +# real `docker compose up` integration test that runs the existing +# test suite + asserts zero new 401s vs the v2.1.0 baseline. Until +# then, the static checks below are the load-bearing pin. + +set -e + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$ROOT" + +fail=0 + +# Invariant 1: SessionMiddleware MUST defer-to-next on cookie miss/invalid. +if ! grep -q 'next.ServeHTTP(w, r)' internal/auth/session/middleware.go; then + echo "::error::SessionMiddleware no longer defers to next on missing cookie" + fail=1 +fi +if grep -q 'http.Error.*StatusUnauthorized' internal/auth/session/middleware.go; then + echo "::warning::SessionMiddleware appears to write 401 directly — verify Bearer fallback still works" +fi + +# Invariant 2: CSRFMiddleware MUST be pass-through on missing session row. +if ! grep -qE 'sessionContextKey\{\}\)\.\(\*sessiondomain\.Session\)' internal/auth/session/middleware.go; then + echo "::error::CSRFMiddleware no longer reads session row from context" + fail=1 +fi +if ! grep -qE 'if !ok \|\| sess == nil \{$' internal/auth/session/middleware.go; then + echo "::error::CSRFMiddleware no longer pass-throughs on missing session row (API-key actors must be CSRF-exempt)" + fail=1 +fi + +# Invariant 3: chained-auth combinator MUST be the entry point in main.go. +if ! grep -q 'session.ChainAuthSessionThenBearer' cmd/server/main.go; then + echo "::error::cmd/server/main.go does not wire session.ChainAuthSessionThenBearer" + fail=1 +fi +if ! grep -q 'bearerMiddleware\s*=\s*auth.NewAuthWithKeyStore' cmd/server/main.go; then + echo "::error::cmd/server/main.go no longer constructs the Bundle-1 Bearer middleware" + fail=1 +fi + +# Invariant 4: public OIDC routes are in the auth-exempt allowlist. +for route in 'GET /auth/oidc/login' 'GET /auth/oidc/callback' 'POST /auth/oidc/back-channel-logout' 'POST /auth/logout'; do + if ! grep -qF "\"$route\"" internal/api/router/router.go; then + echo "::error::router.AuthExemptRouterRoutes is missing entry: $route" + fail=1 + fi +done + +# Invariant 5: AuthInfo extension MUST gracefully degrade when no +# OIDCProvidersResolver is wired (test-fixture + no-db-deploy paths). +if ! grep -q 'if h.OIDCProvidersResolver != nil' internal/api/handler/health.go; then + echo "::error::AuthInfo no longer guards on OIDCProvidersResolver != nil" + fail=1 +fi + +if [ $fail -eq 0 ]; then + echo "OK: Bundle-1 compat regression invariants hold." +fi +exit $fail diff --git a/scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh b/scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh new file mode 100755 index 0000000..64130ea --- /dev/null +++ b/scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh +# +# Auth Bundle 2 / Phase 6 Bundle-1 → Bundle-2 upgrade regression. +# +# Pre-commit invariant: an existing v2.1.0 (Bundle-1-shipped) deployment +# upgraded in place to Bundle 2 must: +# +# (a) Have all Bundle-2 migrations apply cleanly. The new migrations +# (000034 oidc_providers, 000035 sessions, 000036 users, 000037 +# oidc_pre_login + auth.session.*/auth.oidc.* permissions) MUST +# be additive — no DROP TABLE / ALTER COLUMN that would break a +# Bundle-1 dump. +# +# (b) Bundle 1's CERTCTL_BOOTSTRAP_TOKEN path keeps working for fresh +# deployments without an admin (bootstrap.go invariant; pinned +# by Bundle 1 Phase 6 tests). +# +# (c) Existing minted admin's API key continues to authenticate every +# Bundle 1 endpoint (chained-auth combinator's Bearer fallback). +# +# (d) Existing admin's role grants in actor_roles survive the upgrade +# (additive migrations preserve all rows). +# +# (e) Bundled certctl-agent continues to authenticate against +# agent-demo-1 (Bundle 1 demo path; pinned by demo-compose.yml). +# +# This guard checks the static-source invariants that protect those +# properties since spinning up a v2.1.0 dump + upgrading is sandbox- +# infeasible. Concretely: +# +# 1. Migrations 000034..000037 use `CREATE TABLE IF NOT EXISTS` (not +# `CREATE TABLE`) so re-running against a partially-migrated DB +# doesn't error. +# +# 2. Migrations 000034..000037 are wrapped in `BEGIN; ... COMMIT;` +# so a partial failure rolls back cleanly. +# +# 3. NO migration in the 000034..000037 range runs `DROP TABLE` or +# `ALTER TABLE ... DROP COLUMN` against any Bundle-1 table +# (api_keys, audit_events, certificates, certificate_versions, +# certificate_profiles, issuers, targets, agents, jobs, owners, +# teams, agent_groups, notifications, roles, permissions, +# role_permissions, actor_roles, tenants, etc.). Adding a new +# table or extending an existing one with a NULLable column or +# DEFAULT-valued column is fine. +# +# 4. INSERT INTO permissions / role_permissions in 000037 use +# `ON CONFLICT (id) DO NOTHING` / equivalent so a Bundle-2 deploy +# whose v2.1.0 baseline already has the rows doesn't duplicate +# them. +# +# When the sandbox-feasibility constraint changes, promote this to a +# real `pg_dump` round-trip from a v2.1.0 baseline + apply migrations +# + assert the row counts on the protected Bundle-1 tables match +# pre-upgrade. + +set -e + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$ROOT" + +fail=0 + +PHASE2_RANGE="000034 000035 000036 000037" + +# Bundle-1 tables that MUST NOT be DROPPED or have columns DROPPED in +# the Bundle-2 migration range. Adding columns or new tables is fine. +PROTECTED_TABLES=( + api_keys audit_events certificates certificate_versions + certificate_profiles issuers targets agents jobs owners teams + agent_groups notifications roles permissions role_permissions + actor_roles tenants approvals intermediate_cas + issuance_approval_requests +) + +for num in $PHASE2_RANGE; do + upfile=$(ls migrations/${num}_*.up.sql 2>/dev/null | head -1) + if [ -z "$upfile" ]; then + echo "::warning::no migration ${num}_*.up.sql found; skipping invariants for this number" + continue + fi + # Invariant 1: CREATE TABLE IF NOT EXISTS. + if grep -E '^CREATE TABLE [^[:space:]]' "$upfile" | grep -v 'IF NOT EXISTS' >/dev/null; then + echo "::error::$upfile uses 'CREATE TABLE' without 'IF NOT EXISTS' — re-running against a partially-migrated DB will fail" + fail=1 + fi + # Invariant 2: BEGIN ... COMMIT wrapping. + if ! grep -q '^BEGIN;' "$upfile"; then + echo "::error::$upfile is not wrapped in 'BEGIN;'" + fail=1 + fi + if ! grep -q '^COMMIT;' "$upfile"; then + echo "::error::$upfile is not wrapped in 'COMMIT;'" + fail=1 + fi + # Invariant 3: no DROP TABLE / ALTER ... DROP COLUMN against + # protected Bundle-1 tables. + for tbl in "${PROTECTED_TABLES[@]}"; do + if grep -qE "DROP TABLE[^[:space:]]*[[:space:]]+(IF EXISTS )?$tbl([[:space:]]|;|$)" "$upfile"; then + echo "::error::$upfile contains DROP TABLE against protected Bundle-1 table: $tbl" + fail=1 + fi + if grep -qE "ALTER TABLE[[:space:]]+$tbl[[:space:]].*DROP COLUMN" "$upfile"; then + echo "::error::$upfile contains ALTER TABLE ... DROP COLUMN against protected Bundle-1 table: $tbl" + fail=1 + fi + done +done + +# Invariant 4: 000037 INSERTs use ON CONFLICT DO NOTHING. +upfile37=$(ls migrations/000037_*.up.sql 2>/dev/null | head -1) +if [ -n "$upfile37" ]; then + if grep -q 'INSERT INTO permissions' "$upfile37"; then + if ! grep -q 'ON CONFLICT.*DO NOTHING' "$upfile37"; then + echo "::error::$upfile37 INSERT INTO permissions missing ON CONFLICT DO NOTHING" + fail=1 + fi + fi + if grep -q 'INSERT INTO role_permissions' "$upfile37"; then + if ! grep -q 'ON CONFLICT.*DO NOTHING' "$upfile37"; then + echo "::error::$upfile37 INSERT INTO role_permissions missing ON CONFLICT DO NOTHING" + fail=1 + fi + fi +fi + +# Invariant 5: ChainAuthSessionThenBearer's Bearer fallback MUST be +# wired in cmd/server/main.go so existing v2.1.0-minted API keys +# continue to authenticate. +if ! grep -q 'session.ChainAuthSessionThenBearer' cmd/server/main.go; then + echo "::error::cmd/server/main.go does not wire the chained-auth combinator (Bundle-1 Bearer keys would stop authenticating)" + fail=1 +fi +if ! grep -q 'auth.NewAuthWithKeyStore(authKeyStore)' cmd/server/main.go; then + echo "::error::cmd/server/main.go does not construct the Bundle-1 Bearer middleware" + fail=1 +fi + +# Invariant 6: bootstrap path is preserved — v2.1.0 path still works +# for fresh deployments without an admin. +if ! grep -q 'bootstrapHandler' cmd/server/main.go; then + echo "::error::cmd/server/main.go does not register the bootstrap handler — fresh-deployment bootstrap broken" + fail=1 +fi + +if [ $fail -eq 0 ]; then + echo "OK: Bundle-1 → Bundle-2 upgrade regression invariants hold." +fi +exit $fail