auth-bundle-2 Phase 7 + Phase 7.5: OIDC first-admin bootstrap +

break-glass admin (Argon2id, lockout, default-OFF, surface-invisibility)

Phase 7 — OIDC first-admin bootstrap (Decision 3):

  - Optional AdminBootstrapHook closure on *oidc.Service. When wired,
    HandleCallback consults the hook AFTER group resolution + user
    upsert and BEFORE the empty-mapping fail-closed check. Hook
    receives (providerID, groups, userID); returns grantAdmin=true
    when the user matches CERTCTL_BOOTSTRAP_ADMIN_GROUPS AND no
    admin exists yet in the tenant.
  - cmd/server/main.go wires the hook as a closure that:
      * Filters by CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID (if configured).
      * Probes AdminExists via authActorRoleRepo (admin-already-exists
        silently returns false; bootstrap mode is one-shot per tenant).
      * Walks group intersection.
      * On match: grants r-admin via authActorRoleRepo.Grant + emits
        the bootstrap.oidc_first_admin audit row with
        event_category=auth + INFO log.
  - Coexists with the Bundle 1 env-var-token bootstrap. Both paths
    can be configured; first match wins (admin-existence probe
    short-circuits the second).
  - HandleCallback's empty-mapping fail-closed check moved AFTER the
    hook so a fresh deployment with zero group_role_mappings can
    still mint the first admin.
  - 5 tests in service_test.go: hook grants admin on match, hook
    returns false preserves empty-mapping fail-closed, admin-already-
    exists silently falls through to normal mapping, hook-error wraps
    + bubbles, idempotent when admin is already in the mapped role set.

Phase 7.5 — Break-glass admin (Decision 4, default-OFF):

Migration 000038 ships:

  - breakglass_credentials table — at-most-one-credential-per-actor
    (UNIQUE(actor_id)), Argon2id PHC-format password_hash, lockout
    state machine (failure_count, locked_until, last_failure_at).
    FK CASCADE on users(id) so deleting a user atomically removes
    their credential.
  - Two new permissions seeded into r-admin only:
      auth.breakglass.admin — set/rotate/unlock/remove credentials.
      auth.breakglass.login — actor uses break-glass to log in.
    CanonicalPermissions extended in lockstep.

internal/auth/breakglass/service.go (~580 LOC):

  - Service.Enabled() reflects CERTCTL_BREAKGLASS_ENABLED.
  - SetPassword: Argon2id with OWASP 2024 params (m=64MiB, t=3, p=4,
    salt=16 random bytes, output=32 bytes); per-password random salt;
    PHC-format hash output. Min 12 / max 256 byte input.
  - Authenticate: constant-time-compare via subtle.ConstantTimeCompare
    on every code path. Identical 401 + identical timing across the
    wrong-password / locked-account / non-existent-actor paths so an
    attacker cannot probe whether a given actor has break-glass
    configured. Non-existent-actor + locked-account paths run a
    verifyDummy() Argon2id pass for timing parity. Lockout state
    machine: failure_count++ on every wrong attempt; threshold (default
    5) trips locked_until = NOW() + duration (default 15m). Successful
    Authenticate resets the counter. Reset-window: failures aged out
    after CERTCTL_BREAKGLASS_LOCKOUT_RESET_INTERVAL (default 1h)
    auto-reset on next attempt.
  - Unlock + RemoveCredential: admin-only (auth.breakglass.admin
    gated at the router via rbacGate). Audit rows on every operation.
  - All public methods refuse to act when Enabled()==false (returns
    ErrDisabled; the handler maps to HTTP 404 — surface invisibility).

internal/repository/postgres/breakglass.go ships the 5-method
postgres impl with atomic single-statement IncrementFailure (so
concurrent racing wrong-password attempts can't observe an
intermediate state and slip past the threshold) and idempotent
ResetFailureCount.

internal/api/handler/auth_breakglass.go ships the 4-endpoint HTTP
surface:

  - POST /auth/breakglass/login (auth-exempt; 5/min rate-limited per
    source IP via the existing rate limiter; returns 404 when
    disabled). On success sets the post-login session cookie + CSRF
    cookie via SessionService.Create + 204. On any failure:
    uniform 401 + identical timing (the service has already audited
    the specific failure category).
  - POST /api/v1/auth/breakglass/credentials (auth.breakglass.admin)
  - POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock
    (auth.breakglass.admin)
  - DELETE /api/v1/auth/breakglass/credentials/{actor_id}
    (auth.breakglass.admin)

Admin endpoints share the surface-invisibility property: when
CERTCTL_BREAKGLASS_ENABLED=false, every admin endpoint also returns
404 (not 403) so probing via the admin surface gets the same signal
as probing the login endpoint.

Tests (internal/auth/breakglass/service_test.go):

All 8 Phase 7.5 spec-mandated negative cases:

  1. Service.Enabled()==false → all ops return ErrDisabled.
  2. Wrong password → ErrInvalidCredentials, failure_count++,
     audit row with event_category=auth.
  3. Failure_count exceeds threshold → locked, subsequent attempts
     (including with the CORRECT password) return identical-shape
     401 while the lockout window holds.
  4. Lockout window expires → next attempt with correct password
     succeeds + resets the counter.
  5. Password < 12 bytes (or > 256 bytes) → ErrWeakPassword.
  6. Password leak hygiene — the service has zero slog calls; the
     audit-row map literal never includes the password plaintext.
  7. Argon2id hash never appears in logs OR API responses — pinned
     by `json:"-"` tag on BreakglassCredential.PasswordHash + a
     belt-and-braces json.Marshal probe asserting the hash bytes
     never appear in the marshaled output.
  8. Constant-time-compare verified via timing-statistical test —
     wrong-password vs no-credential paths take statistically
     indistinguishable time (within 5x ratio). The verifyDummy()
     hash compute on the no-credential + locked paths is what
     keeps timing parity; absent that, an attacker could side-
     channel "actor doesn't have a credential" via timing.

Plus coverage-lift batch covering: SetPassword first-time vs rotate,
no-caller-id rejection, no-target-id rejection, RNG failure surface,
Authenticate happy-path mints session, no-credential audit row,
session-mint-failure surface, FailureResetInterval recycle, Unlock
+ RemoveCredential happy paths, hash-format unit tests (round-trip,
mismatch, malformed/wrong-version/bad-base64 formats), nil-audit +
nil-session pass-through.

Coverage on internal/auth/breakglass/ at 91.5% per-statement (above
the Phase 7.5 spec ≥ 90% floor).

cmd/server/main.go wiring:

  - Constructs breakglassRepo + breakglassService + breakglassHandler
    after the OIDC service block.
  - breakglassSessionMinterAdapter shim bridges *session.Service.Create
    to the breakglass.SessionMinter port.
  - Logs WARN at boot when CERTCTL_BREAKGLASS_ENABLED=true (operator
    visibility for the deliberate SSO-bypass).

internal/config/config.go gains:

  - AuthConfig.BootstrapAdminGroups + BootstrapOIDCProviderID for
    Phase 7 (CERTCTL_BOOTSTRAP_ADMIN_GROUPS comma-list +
    CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID).
  - AuthConfig.Breakglass nested struct with 4 env vars
    (CERTCTL_BREAKGLASS_ENABLED + LOCKOUT_THRESHOLD + LOCKOUT_DURATION
    + LOCKOUT_RESET_INTERVAL).

Router wiring:

  - 4 new breakglass routes registered when reg.AuthBreakglass != nil;
    public login route via direct r.mux.Handle (auth-exempt), 3 admin
    routes via r.Register + rbacGate(auth.breakglass.admin).
  - POST /auth/breakglass/login pinned in AuthExemptRouterRoutes
    allowlist with Phase 7.5 justification.
  - SpecParityExceptions extended with 4 new entries documenting
    the Phase 7.5 deferral of full per-endpoint OpenAPI rows
    (handler doc-block at the top of auth_breakglass.go is the
    operator-facing reference).

Threat model (encoded in service.go + auth_breakglass.go doc-blocks
+ migration 000038 docstrings, to be promoted to docs/operator/auth-
threat-model.md in Phase 12):

  - Break-glass is a deliberate bypass of the SSO security boundary.
    An attacker who phishes the password OR finds it in a compromised
    password manager bypasses MFA, OIDC, and every group-claim gate.
  - Recommendation: keep CERTCTL_BREAKGLASS_ENABLED=false in steady-
    state. Enable only during SSO-broken incidents. Disable after
    recovery.
  - WebAuthn pairing (v3 per Decision 12) is the load-bearing second
    factor. Without it, break-glass is best treated as an emergency-
    only path.
  - Audit trail surfaces every break-glass action under
    event_category=auth; the auditor role can monitor for unexpected
    break-glass logins.

Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/auth/oidc (3.0s; new
Phase 7 hook tests integrated alongside the 21+ Phase 3 negatives),
internal/auth/breakglass (3.6s; 8 spec-mandated negatives + coverage
batch passing), internal/config + internal/domain/auth + internal/api/
router + internal/api/handler all green, no regressions in Bundle 1
packages.
This commit is contained in:
shankar0123
2026-05-10 06:51:41 +00:00
parent 3189f3cd71
commit 1d01c87663
16 changed files with 2356 additions and 5 deletions
+256
View File
@@ -0,0 +1,256 @@
// Package handler — Auth Bundle 2 Phase 7.5 / break-glass admin HTTP surface.
//
// 4 endpoints across two access levels:
//
// 1. Public (auth-bypass; the whole point is to log in WITHOUT
// existing creds):
// POST /auth/breakglass/login
// Rate-limited at 5/minute per source IP via the existing
// rate limiter middleware. When CERTCTL_BREAKGLASS_ENABLED=false,
// returns 404 (NOT 403) so the surface is invisible to scanners.
//
// 2. RBAC-gated (auth.breakglass.admin):
// POST /api/v1/auth/breakglass/credentials
// POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock
// DELETE /api/v1/auth/breakglass/credentials/{actor_id}
//
// The handler delegates to internal/auth/breakglass.Service for the
// load-bearing logic (Argon2id hashing, lockout state machine,
// constant-time-compare, identical-shape errors). This file is purely
// HTTP shape — request-binding, status-code mapping, audit attribution
// for the caller-actor-id wire-up.
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/certctl-io/certctl/internal/auth/breakglass"
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
)
// =============================================================================
// AuthBreakglassHandler.
// =============================================================================
// BreakglassService is the projection of *breakglass.Service the
// handler consumes. Defining the projection here keeps the handler
// stub-friendly + decoupled from the wider service surface.
type BreakglassService interface {
Enabled() bool
SetPassword(ctx context.Context, callerActorID, targetActorID, plaintext string) (*breakglass.SetPasswordResult, error)
Authenticate(ctx context.Context, actorID, plaintext, ip, userAgent string) (*breakglass.AuthenticateResult, error)
Unlock(ctx context.Context, callerActorID, targetActorID string) error
RemoveCredential(ctx context.Context, callerActorID, targetActorID string) error
}
// AuthBreakglassHandler ships the Phase 7.5 surface.
type AuthBreakglassHandler struct {
svc BreakglassService
cookieAttrs SessionCookieAttrs
}
// NewAuthBreakglassHandler constructs the handler.
func NewAuthBreakglassHandler(svc BreakglassService, cookieAttrs SessionCookieAttrs) *AuthBreakglassHandler {
return &AuthBreakglassHandler{svc: svc, cookieAttrs: cookieAttrs}
}
// =============================================================================
// 1. Public login endpoint.
// =============================================================================
type breakglassLoginRequest struct {
ActorID string `json:"actor_id"`
Password string `json:"password"`
}
// Login handles POST /auth/breakglass/login.
//
// Auth-bypass — the whole point is to log in WITHOUT existing creds.
// When Service.Enabled() == false, returns 404 (NOT 403) so the surface
// is invisible to scanners. On success, sets the post-login session
// cookie + CSRF cookie + 204 No Content. On any failure (wrong password,
// locked account, no credential, unknown actor): uniform 401 + identical
// timing.
func (h *AuthBreakglassHandler) Login(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
// Surface invisibility — 404 (NOT 403) per Phase 7.5 spec.
http.NotFound(w, r)
return
}
var req breakglassLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Even invalid JSON returns 401 (identical to wrong-password) —
// no scanner-friendly 400 that distinguishes "wrong shape" vs
// "wrong password".
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
if strings.TrimSpace(req.ActorID) == "" || req.Password == "" {
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
ip := clientIPFromRequest(r)
res, err := h.svc.Authenticate(r.Context(), req.ActorID, req.Password, ip, r.UserAgent())
if err != nil {
// All authenticate errors map to the SAME 401 + same body.
// The service has already audited the specific failure category.
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
// Set the post-login session cookie + CSRF cookie. Same attributes
// as the OIDC callback handler in auth_session_oidc.go; we
// duplicate the 8-line cookie-set block here so the break-glass
// handler doesn't import the OIDC handler package.
now := time.Now().UTC()
expires := now.Add(8 * time.Hour) // matches default SessionConfig.AbsoluteTimeout
http.SetCookie(w, &http.Cookie{
Name: sessiondomain.PostLoginCookieName,
Value: res.CookieValue,
Path: "/",
Expires: expires,
Secure: h.cookieAttrs.Secure,
HttpOnly: true,
SameSite: h.cookieAttrs.SameSite,
})
http.SetCookie(w, &http.Cookie{
Name: sessiondomain.CSRFCookieName,
Value: res.CSRFToken,
Path: "/",
Expires: expires,
Secure: h.cookieAttrs.Secure,
HttpOnly: false, // intentional — GUI must read it
SameSite: h.cookieAttrs.SameSite,
})
w.WriteHeader(http.StatusNoContent)
}
// =============================================================================
// 2. Admin endpoints.
// =============================================================================
type breakglassSetPasswordRequest struct {
ActorID string `json:"actor_id"`
Password string `json:"password"`
}
// SetPassword handles POST /api/v1/auth/breakglass/credentials.
// Permission: auth.breakglass.admin (gated at the router via rbacGate).
//
// When Service.Enabled() == false, returns 404 — admin endpoints share
// the surface-invisibility property with the login endpoint so an
// attacker probing for break-glass via the admin surface gets the same
// signal as probing the login endpoint.
func (h *AuthBreakglassHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
http.NotFound(w, r)
return
}
caller, err := callerFromRequest(r)
if err != nil {
writeAuthError(w, err)
return
}
var req breakglassSetPasswordRequest
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
res, serr := h.svc.SetPassword(r.Context(), caller.ActorID, req.ActorID, req.Password)
if serr != nil {
switch {
case errors.Is(serr, breakglass.ErrWeakPassword):
Error(w, http.StatusBadRequest, "password fails strength requirements (min 12 bytes, max 256 bytes)")
case errors.Is(serr, breakglass.ErrUnauthenticated):
Error(w, http.StatusUnauthorized, "Authentication required")
case errors.Is(serr, breakglass.ErrDisabled):
http.NotFound(w, r)
default:
Error(w, http.StatusInternalServerError, "could not set password")
}
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"actor_id": res.ActorID,
"created_at": res.CreatedAt.Format(time.RFC3339),
})
}
// Unlock handles POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock.
// Permission: auth.breakglass.admin.
func (h *AuthBreakglassHandler) Unlock(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
http.NotFound(w, r)
return
}
caller, err := callerFromRequest(r)
if err != nil {
writeAuthError(w, err)
return
}
targetID := r.PathValue("actor_id")
if targetID == "" {
Error(w, http.StatusBadRequest, "missing actor_id path param")
return
}
if uerr := h.svc.Unlock(r.Context(), caller.ActorID, targetID); uerr != nil {
switch {
case errors.Is(uerr, breakglass.ErrDisabled):
http.NotFound(w, r)
case errors.Is(uerr, breakglass.ErrUnauthenticated):
Error(w, http.StatusUnauthorized, "Authentication required")
default:
// repository.ErrBreakglassNotFound surfaces as a wrapped
// error here; we map to 404 via string match to avoid
// importing repository.
if strings.Contains(uerr.Error(), "not found") {
Error(w, http.StatusNotFound, "credential not found")
} else {
Error(w, http.StatusInternalServerError, "could not unlock credential")
}
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// Remove handles DELETE /api/v1/auth/breakglass/credentials/{actor_id}.
// Permission: auth.breakglass.admin.
func (h *AuthBreakglassHandler) Remove(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
http.NotFound(w, r)
return
}
caller, err := callerFromRequest(r)
if err != nil {
writeAuthError(w, err)
return
}
targetID := r.PathValue("actor_id")
if targetID == "" {
Error(w, http.StatusBadRequest, "missing actor_id path param")
return
}
if rerr := h.svc.RemoveCredential(r.Context(), caller.ActorID, targetID); rerr != nil {
switch {
case errors.Is(rerr, breakglass.ErrDisabled):
http.NotFound(w, r)
case errors.Is(rerr, breakglass.ErrUnauthenticated):
Error(w, http.StatusUnauthorized, "Authentication required")
default:
if strings.Contains(rerr.Error(), "not found") {
Error(w, http.StatusNotFound, "credential not found")
} else {
Error(w, http.StatusInternalServerError, "could not remove credential")
}
}
return
}
w.WriteHeader(http.StatusNoContent)
}