Files
certctl/internal/api/handler/auth_breakglass.go
T
shankar0123 a89c69b751 feat(gui+auth): break-glass admin GUI surface (CRIT-4 closure)
Closes CRIT-4 of the 2026-05-10 audit. Bundle 2 Phase 7.5 shipped the
break-glass backend (Argon2id + lockout + 4 endpoints) but no GUI
surface. Operators recovering during an SSO outage had to hand-craft
curl commands — operationally hostile and the opposite of what
docs/operator/security.md advertised. This commit closes the gap.

Three GUI surfaces:

1. LoginPage.tsx — inline "Use break-glass account (SSO outage
   recovery)" toggle below the API-key form. Clicking reveals an
   amber-bordered inline form (actor-id + password, autocomplete=off).
   Calls breakglassLogin(actor_id, password); on success navigates
   to "/" where AuthProvider re-validates via the session-cookie path.
   Intentionally low-visibility (text-amber-600 small text) — this is
   the deliberate-bypass path, not the everyday-login path.

2. web/src/pages/auth/BreakglassPage.tsx — admin page at /auth/breakglass
   (permission-gated by auth.breakglass.admin). Three sections:
     - Sticky security banner ("every action audited; use only during
       incidents").
     - Set/rotate-password form (≥12-char + confirm-match).
     - Credentialed-actor table with rotate / unlock (disabled when
       not locked) / remove per row. Remove requires type-the-actor-id
       confirmation.

3. Layout.tsx nav — "Break-glass" entry under the auth section. Visible
   to all callers; the page itself permission-gates (server-side 403 is
   the load-bearing defense). Cosmetic hide-when-no-perm is deferred
   to fix 14's LOW bundle.

Backend support (new endpoint required to enumerate credentialed actors):

- internal/repository/breakglass.go — BreakglassCredentialRepository
  gains List(ctx, tenantID) method.
- internal/repository/postgres/breakglass.go — postgres impl; reuses
  the existing breakglassColumns / scanBreakglass helpers.
- internal/auth/breakglass/service.go — Service.List(ctx) method;
  returns ErrDisabled when CERTCTL_BREAKGLASS_ENABLED=false (handler
  maps to 404 for surface invisibility).
- internal/api/handler/auth_breakglass.go — ListCredentials handler;
  password_hash field NEVER serialized to the wire (response shape
  is intentionally limited to actor_id + timestamps + failure_count +
  locked_until).
- internal/api/router/router.go — registers GET
  /api/v1/auth/breakglass/credentials gated by auth.breakglass.admin.
- internal/api/router/openapi_parity_test.go — SpecParityExceptions
  entry for the new endpoint (full OpenAPI row rides along with the
  next OpenAPI sweep).

GUI api/client.ts gains breakglassListCredentials() + the
BreakglassCredentialRow type matching the wire shape.

Six Vitest cases in BreakglassPage.test.tsx pin the contract:
permission gate (forbidden state when caller lacks the perm; admin
surface when they have it), set-password mismatch rejection, set-
password below-threshold-length rejection, unlock-disabled-when-not-
locked, remove-modal type-confirm.

Verification gate green:
- gofmt -l clean on all touched files
- go vet clean
- go test -short -count=1 on internal/api/router (TestRouter_OpenAPIParity
  + TestRouterRBACGateCoverage + TestRouter_AuthExemptAllowlist),
  internal/api/handler (all BCL tests + ListCredentials),
  internal/auth/breakglass (Service.List + stubRepo.List),
  internal/repository/postgres, internal/domain/auth (auditor pin)
  — all pass.

CRIT-1 + CRIT-2 + CRIT-3 from the same audit are already closed on
this branch (commits 457962f, c07825b, 192351e). CRIT-5 (AllowedEmail-
Domains lying field) remains the last Critical blocker for v2.1.0.
Spec: cowork/auth-bundles-fixes-2026-05-10/04-crit-4-breakglass-gui.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-4
2026-05-10 20:24:52 +00:00

318 lines
11 KiB
Go

// 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"
bgdomain "github.com/certctl-io/certctl/internal/auth/breakglass/domain"
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
List(ctx context.Context) ([]*bgdomain.BreakglassCredential, 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)
}
// breakglassCredentialResponse is the wire shape returned by ListCredentials.
// Intentionally omits PasswordHash — the admin GUI only needs metadata to
// render the credentialed-actor table.
type breakglassCredentialResponse struct {
ActorID string `json:"actor_id"`
CreatedAt string `json:"created_at"`
LastPasswordChangeAt string `json:"last_password_change_at"`
FailureCount int `json:"failure_count"`
LockedUntil *string `json:"locked_until,omitempty"`
LastFailureAt *string `json:"last_failure_at,omitempty"`
}
type listBreakglassCredentialsResponse struct {
Credentials []breakglassCredentialResponse `json:"credentials"`
}
// ListCredentials handles GET /api/v1/auth/breakglass/credentials.
// Permission: auth.breakglass.admin.
//
// Audit 2026-05-10 CRIT-4 closure — backs the admin GUI Break-glass
// page. Returns 404 when CERTCTL_BREAKGLASS_ENABLED=false (surface
// invisibility, consistent with the other break-glass admin endpoints).
// The password hash is NEVER serialized to the wire.
func (h *AuthBreakglassHandler) ListCredentials(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
http.NotFound(w, r)
return
}
creds, err := h.svc.List(r.Context())
if err != nil {
if errors.Is(err, breakglass.ErrDisabled) {
http.NotFound(w, r)
return
}
Error(w, http.StatusInternalServerError, "could not list break-glass credentials")
return
}
resp := listBreakglassCredentialsResponse{Credentials: make([]breakglassCredentialResponse, 0, len(creds))}
for _, c := range creds {
row := breakglassCredentialResponse{
ActorID: c.ActorID,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
LastPasswordChangeAt: c.LastPasswordChangeAt.UTC().Format(time.RFC3339),
FailureCount: c.FailureCount,
}
if c.LockedUntil != nil {
s := c.LockedUntil.UTC().Format(time.RFC3339)
row.LockedUntil = &s
}
if c.LastFailureAt != nil {
s := c.LastFailureAt.UTC().Format(time.RFC3339)
row.LastFailureAt = &s
}
resp.Credentials = append(resp.Credentials, row)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}