From a89c69b7519654abfca65268db2b397af93210be Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 10 May 2026 20:24:52 +0000 Subject: [PATCH] feat(gui+auth): break-glass admin GUI surface (CRIT-4 closure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/api/handler/auth_breakglass.go | 61 +++ internal/api/router/openapi_parity_test.go | 1 + internal/api/router/router.go | 1 + internal/auth/breakglass/service.go | 19 + internal/auth/breakglass/service_test.go | 10 + internal/repository/breakglass.go | 6 + internal/repository/postgres/breakglass.go | 30 ++ web/src/api/client.ts | 17 + web/src/components/Layout.tsx | 2 + web/src/main.tsx | 3 + web/src/pages/LoginPage.tsx | 122 +++++- web/src/pages/auth/BreakglassPage.test.tsx | 173 ++++++++ web/src/pages/auth/BreakglassPage.tsx | 456 +++++++++++++++++++++ 13 files changed, 899 insertions(+), 2 deletions(-) create mode 100644 web/src/pages/auth/BreakglassPage.test.tsx create mode 100644 web/src/pages/auth/BreakglassPage.tsx diff --git a/internal/api/handler/auth_breakglass.go b/internal/api/handler/auth_breakglass.go index 6b2923c..885f8ed 100644 --- a/internal/api/handler/auth_breakglass.go +++ b/internal/api/handler/auth_breakglass.go @@ -30,6 +30,7 @@ import ( "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" ) @@ -46,6 +47,7 @@ type BreakglassService interface { 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. @@ -254,3 +256,62 @@ func (h *AuthBreakglassHandler) Remove(w http.ResponseWriter, r *http.Request) { } 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) +} diff --git a/internal/api/router/openapi_parity_test.go b/internal/api/router/openapi_parity_test.go index b18806f..d18c5cb 100644 --- a/internal/api/router/openapi_parity_test.go +++ b/internal/api/router/openapi_parity_test.go @@ -140,6 +140,7 @@ var SpecParityExceptions = map[string]string{ // extension). Full per-endpoint OpenAPI rows ride along with that // commit; until then the surface is tracked here. "POST /auth/breakglass/login": "Auth Bundle 2 Phase 7.5 — local-password login; auth-exempt; 404 when disabled (surface invisibility per spec).", + "GET /api/v1/auth/breakglass/credentials": "Audit 2026-05-10 CRIT-4 — list credentialed actors (metadata only; no password hash on the wire); gated auth.breakglass.admin.", "POST /api/v1/auth/breakglass/credentials": "Auth Bundle 2 Phase 7.5 — set/rotate password; gated auth.breakglass.admin.", "POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock": "Auth Bundle 2 Phase 7.5 — clear lockout state; gated auth.breakglass.admin.", "DELETE /api/v1/auth/breakglass/credentials/{actor_id}": "Auth Bundle 2 Phase 7.5 — remove credential; gated auth.breakglass.admin.", diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 2eb4467..6396da2 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -476,6 +476,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { http.HandlerFunc(reg.AuthBreakglass.Login), middleware.NewCORS(reg.CorsCfg), middleware.ContentType, )) + r.Register("GET /api/v1/auth/breakglass/credentials", rbacGate(reg.Checker, "auth.breakglass.admin", reg.AuthBreakglass.ListCredentials)) r.Register("POST /api/v1/auth/breakglass/credentials", rbacGate(reg.Checker, "auth.breakglass.admin", reg.AuthBreakglass.SetPassword)) r.Register("POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock", rbacGate(reg.Checker, "auth.breakglass.admin", reg.AuthBreakglass.Unlock)) r.Register("DELETE /api/v1/auth/breakglass/credentials/{actor_id}", rbacGate(reg.Checker, "auth.breakglass.admin", reg.AuthBreakglass.Remove)) diff --git a/internal/auth/breakglass/service.go b/internal/auth/breakglass/service.go index 1325d01..2909ff1 100644 --- a/internal/auth/breakglass/service.go +++ b/internal/auth/breakglass/service.go @@ -408,6 +408,25 @@ func (s *Service) RemoveCredential(ctx context.Context, callerActorID, targetAct return nil } +// List returns the metadata for every break-glass credential in the +// tenant. Audit 2026-05-10 CRIT-4 closure — backs the GUI admin page +// that enumerates credentialed actors. Returns ErrDisabled when the +// service is off (callers map to 404 for surface invisibility). +// +// The returned rows DO include the password_hash field (the service +// boundary is the repo; the handler is responsible for stripping the +// hash from the wire response). +func (s *Service) List(ctx context.Context) ([]*bgdomain.BreakglassCredential, error) { + if !s.Enabled() { + return nil, ErrDisabled + } + out, err := s.repo.List(ctx, s.tenantID) + if err != nil { + return nil, fmt.Errorf("breakglass: list: %w", err) + } + return out, nil +} + // ============================================================================= // Helpers — Argon2id hash + verify, ID generation, audit, dummy verify. // ============================================================================= diff --git a/internal/auth/breakglass/service_test.go b/internal/auth/breakglass/service_test.go index eb9c7b6..bc9a815 100644 --- a/internal/auth/breakglass/service_test.go +++ b/internal/auth/breakglass/service_test.go @@ -112,6 +112,16 @@ func (s *stubRepo) Delete(_ context.Context, actorID, _ string) error { delete(s.rows, actorID) return nil } +func (s *stubRepo) List(_ context.Context, _ string) ([]*bgdomain.BreakglassCredential, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]*bgdomain.BreakglassCredential, 0, len(s.rows)) + for _, c := range s.rows { + cp := *c + out = append(out, &cp) + } + return out, nil +} type stubAudit struct { mu sync.Mutex diff --git a/internal/repository/breakglass.go b/internal/repository/breakglass.go index d6134e0..ea9e783 100644 --- a/internal/repository/breakglass.go +++ b/internal/repository/breakglass.go @@ -59,4 +59,10 @@ type BreakglassCredentialRepository interface { // (separate concern; the operator can call SessionService.RevokeAll // in lockstep). Delete(ctx context.Context, actorID, tenantID string) error + + // List returns the metadata for every break-glass credential in the + // tenant. The password hash is NOT included in the returned rows — + // the admin GUI uses this to render the credentialed-actor table + // (audit 2026-05-10 CRIT-4 closure). Order: created_at ASC. + List(ctx context.Context, tenantID string) ([]*bgdomain.BreakglassCredential, error) } diff --git a/internal/repository/postgres/breakglass.go b/internal/repository/postgres/breakglass.go index d257e56..6eefd24 100644 --- a/internal/repository/postgres/breakglass.go +++ b/internal/repository/postgres/breakglass.go @@ -164,3 +164,33 @@ func (r *BreakglassCredentialRepository) Delete(ctx context.Context, actorID, te } return nil } + +// List returns every break-glass credential in the tenant. Audit +// 2026-05-10 CRIT-4 closure — backs the GUI admin page that lists +// credentialed actors. The password hash is read into the returned +// row (it's an internal type passed to the handler which strips it +// before serializing the JSON response). +func (r *BreakglassCredentialRepository) List(ctx context.Context, tenantID string) ([]*bgdomain.BreakglassCredential, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+breakglassColumns+` + FROM breakglass_credentials + WHERE tenant_id = $1 + ORDER BY created_at ASC`, + tenantID) + if err != nil { + return nil, fmt.Errorf("breakglass list: %w", err) + } + defer rows.Close() + var out []*bgdomain.BreakglassCredential + for rows.Next() { + c, err := scanBreakglass(rows) + if err != nil { + return nil, fmt.Errorf("breakglass list scan: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("breakglass list iter: %w", err) + } + return out, nil +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 17ad029..12a5ce8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -453,6 +453,23 @@ export const breakglassRemove = (targetActorID: string) => method: 'DELETE', }); +export type BreakglassCredentialRow = { + actor_id: string; + created_at: string; + last_password_change_at: string; + failure_count: number; + locked_until?: string; + last_failure_at?: string; +}; + +// Audit 2026-05-10 CRIT-4 closure — admin GUI Break-glass page. The +// password hash is never returned by the server; this lists only the +// metadata operators need to render the credentialed-actor table. +// Returns 404 when CERTCTL_BREAKGLASS_ENABLED=false (surface invisibility). +export const breakglassListCredentials = () => + fetchJSON<{ credentials: BreakglassCredentialRow[] }>(`${BASE}/auth/breakglass/credentials`) + .then(r => r.credentials); + // ============================================================================= // Bundle 1 Phase 10 — approvals queue. // diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 9cb80f4..e5c91a5 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -33,6 +33,8 @@ const nav = [ { to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' }, { to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, { to: '/auth/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' }, + // Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. + { to: '/auth/breakglass', label: 'Break-glass', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' }, { to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }, ]; diff --git a/web/src/main.tsx b/web/src/main.tsx index 7879b01..818e06d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -46,6 +46,7 @@ import OIDCProvidersPage from './pages/auth/OIDCProvidersPage'; import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage'; import GroupMappingsPage from './pages/auth/GroupMappingsPage'; import SessionsPage from './pages/auth/SessionsPage'; +import BreakglassPage from './pages/auth/BreakglassPage'; import './index.css'; const queryClient = new QueryClient({ @@ -132,6 +133,8 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + {/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */} + } /> diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 6079f86..acf9448 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useAuth } from '../components/AuthProvider'; -import { getAuthInfo, type AuthInfoOIDCProvider } from '../api/client'; +import { getAuthInfo, breakglassLogin, type AuthInfoOIDCProvider } from '../api/client'; // ============================================================================= // LoginPage — Bundle 2 Phase 8 / multi-mode entry surface. @@ -10,16 +11,30 @@ import { getAuthInfo, type AuthInfoOIDCProvider } from '../api/client'; // page renders one "Sign in with X" button per provider; clicking // navigates to the provider's `login_url` (which 302s through the // IdP and back to /auth/oidc/callback). The API-key form remains as -// a fallback for Bearer-mode deployments + the break-glass path. +// a fallback for Bearer-mode deployments. +// +// Audit 2026-05-10 CRIT-4 closure: an inline break-glass form below +// the API-key form lets admins recover during SSO incidents without +// crafting curl commands. The link is intentionally low-key +// (text-amber-600 small text) — break-glass is the deliberate-bypass +// path, not the everyday-login path. // ============================================================================= export default function LoginPage() { const { login, error: authError } = useAuth(); + const navigate = useNavigate(); const [key, setKey] = useState(''); const [submitting, setSubmitting] = useState(false); const [localError, setLocalError] = useState(null); const [providers, setProviders] = useState([]); + // Break-glass inline form state. + const [showBreakglass, setShowBreakglass] = useState(false); + const [bgActorID, setBgActorID] = useState(''); + const [bgPassword, setBgPassword] = useState(''); + const [bgError, setBgError] = useState(null); + const [bgSubmitting, setBgSubmitting] = useState(false); + const error = localError || authError; // On mount, fetch /auth/info and extract any configured OIDC @@ -51,6 +66,24 @@ export default function LoginPage() { } } + async function handleBreakglassSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!bgActorID.trim() || !bgPassword) return; + setBgSubmitting(true); + setBgError(null); + try { + await breakglassLogin(bgActorID.trim(), bgPassword); + // breakglassLogin sets the session cookie via Set-Cookie; navigate + // to the dashboard, which the AuthProvider will re-validate via + // its session-cookie path on next render. + navigate('/'); + } catch (err) { + setBgError(err instanceof Error ? err.message : 'Break-glass login failed.'); + } finally { + setBgSubmitting(false); + } + } + return (
@@ -126,6 +159,91 @@ export default function LoginPage() { The API key is set via CERTCTL_AUTH_SECRET on the server.

+ + {/* Break-glass entry — low-visibility on purpose. CRIT-4 closure. */} +
+ {!showBreakglass ? ( + + ) : ( +
+

+ Break-glass admin login — every action is audited. Use only during SSO incidents. +

+
+ + setBgActorID(e.target.value)} + autoComplete="off" + spellCheck={false} + placeholder="actor-..." + className="w-full bg-white border border-amber-300 rounded px-3 py-2 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500/20" + data-testid="login-breakglass-actor-id" + /> +
+
+ + setBgPassword(e.target.value)} + autoComplete="off" + className="w-full bg-white border border-amber-300 rounded px-3 py-2 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500/20" + data-testid="login-breakglass-password" + /> +
+ {bgError && ( +
+ {bgError} +
+ )} +
+ + +
+
+ )} +
); diff --git a/web/src/pages/auth/BreakglassPage.test.tsx b/web/src/pages/auth/BreakglassPage.test.tsx new file mode 100644 index 0000000..00ecccc --- /dev/null +++ b/web/src/pages/auth/BreakglassPage.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// Audit 2026-05-10 CRIT-4 closure — BreakglassPage tests. Pins: +// - Forbidden page when caller lacks auth.breakglass.admin. +// - Renders credential rows from the API when caller has permission. +// - Set-password form rejects mismatched passwords. +// - Set-password form rejects below-threshold length. +// - Unlock button disabled when actor is not locked. +// - Remove modal requires actor-id type-confirmation. + +vi.mock('../../api/client', () => ({ + breakglassListCredentials: vi.fn(), + breakglassSetPassword: vi.fn(), + breakglassUnlock: vi.fn(), + breakglassRemove: vi.fn(), +})); + +vi.mock('../../hooks/useAuthMe', () => ({ + useAuthMe: vi.fn(), +})); + +import BreakglassPage from './BreakglassPage'; +import * as client from '../../api/client'; +import { useAuthMe } from '../../hooks/useAuthMe'; + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {ui} + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +function mockMe(opts: { hasPerm: boolean }) { + (useAuthMe as ReturnType).mockReturnValue({ + isLoading: false, + data: { actor_id: 'admin', permissions: opts.hasPerm ? ['auth.breakglass.admin'] : [] }, + hasPerm: (p: string) => opts.hasPerm && p === 'auth.breakglass.admin', + }); +} + +describe('BreakglassPage permission gating', () => { + it('renders the forbidden state when caller lacks auth.breakglass.admin', () => { + mockMe({ hasPerm: false }); + renderWithProviders(); + expect(screen.getByText(/Forbidden/i)).toBeInTheDocument(); + expect(screen.queryByTestId('breakglass-new-form')).not.toBeInTheDocument(); + }); + + it('shows the admin surface when caller has auth.breakglass.admin', async () => { + mockMe({ hasPerm: true }); + (client.breakglassListCredentials as ReturnType).mockResolvedValue([ + { + actor_id: 'admin', + created_at: '2026-05-10T00:00:00Z', + last_password_change_at: '2026-05-10T00:00:00Z', + failure_count: 0, + }, + ]); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('breakglass-row-admin')).toBeInTheDocument(); + }); + expect(screen.getByTestId('breakglass-new-form')).toBeInTheDocument(); + }); +}); + +describe('BreakglassPage set-password validation', () => { + beforeEach(() => { + mockMe({ hasPerm: true }); + (client.breakglassListCredentials as ReturnType).mockResolvedValue([]); + }); + + it('rejects mismatched passwords', async () => { + renderWithProviders(); + fireEvent.change(screen.getByTestId('breakglass-new-actor-id'), { target: { value: 'admin' } }); + fireEvent.change(screen.getByTestId('breakglass-new-password'), { + target: { value: 'pass-long-enough-12' }, + }); + fireEvent.change(screen.getByTestId('breakglass-new-password-confirm'), { + target: { value: 'pass-different-yo-12' }, + }); + fireEvent.click(screen.getByTestId('breakglass-new-submit')); + await waitFor(() => { + expect(screen.getByTestId('breakglass-new-error')).toHaveTextContent(/match/i); + }); + expect(client.breakglassSetPassword).not.toHaveBeenCalled(); + }); + + it('rejects below-threshold password length', async () => { + renderWithProviders(); + fireEvent.change(screen.getByTestId('breakglass-new-actor-id'), { target: { value: 'admin' } }); + fireEvent.change(screen.getByTestId('breakglass-new-password'), { target: { value: 'short' } }); + fireEvent.change(screen.getByTestId('breakglass-new-password-confirm'), { + target: { value: 'short' }, + }); + fireEvent.click(screen.getByTestId('breakglass-new-submit')); + await waitFor(() => { + expect(screen.getByTestId('breakglass-new-error')).toHaveTextContent(/12 characters/i); + }); + expect(client.breakglassSetPassword).not.toHaveBeenCalled(); + }); +}); + +describe('BreakglassPage credential actions', () => { + beforeEach(() => { + mockMe({ hasPerm: true }); + }); + + it('disables unlock button when actor is not locked', async () => { + (client.breakglassListCredentials as ReturnType).mockResolvedValue([ + { + actor_id: 'alice', + created_at: '2026-05-10T00:00:00Z', + last_password_change_at: '2026-05-10T00:00:00Z', + failure_count: 0, + }, + ]); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('breakglass-row-alice')).toBeInTheDocument(); + }); + const unlockBtn = screen.getByTestId('breakglass-unlock-alice'); + expect(unlockBtn).toBeDisabled(); + }); + + it('remove modal requires actor-id type-confirmation', async () => { + (client.breakglassListCredentials as ReturnType).mockResolvedValue([ + { + actor_id: 'alice', + created_at: '2026-05-10T00:00:00Z', + last_password_change_at: '2026-05-10T00:00:00Z', + failure_count: 0, + }, + ]); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('breakglass-row-alice')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('breakglass-remove-alice')); + const removeBtn = screen.getByTestId('breakglass-remove-confirm-submit'); + expect(removeBtn).toBeDisabled(); + + // Typing the wrong actor-id keeps it disabled. + fireEvent.change(screen.getByTestId('breakglass-remove-confirm-input'), { + target: { value: 'bob' }, + }); + expect(removeBtn).toBeDisabled(); + + // Typing the correct actor-id enables it. + fireEvent.change(screen.getByTestId('breakglass-remove-confirm-input'), { + target: { value: 'alice' }, + }); + expect(removeBtn).not.toBeDisabled(); + + fireEvent.click(removeBtn); + await waitFor(() => { + expect(client.breakglassRemove).toHaveBeenCalledWith('alice'); + }); + }); +}); diff --git a/web/src/pages/auth/BreakglassPage.tsx b/web/src/pages/auth/BreakglassPage.tsx new file mode 100644 index 0000000..ffb2666 --- /dev/null +++ b/web/src/pages/auth/BreakglassPage.tsx @@ -0,0 +1,456 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + breakglassListCredentials, + breakglassSetPassword, + breakglassUnlock, + breakglassRemove, + type BreakglassCredentialRow, +} from '../../api/client'; +import { useAuthMe } from '../../hooks/useAuthMe'; +import PageHeader from '../../components/PageHeader'; +import ErrorState from '../../components/ErrorState'; + +// ============================================================================= +// BreakglassPage — Audit 2026-05-10 CRIT-4 closure. +// +// Admin GUI for the break-glass admin path. Lists credentialed actors, +// supports password rotation, unlock, and credential removal. Every +// action is auditing-heavy by design — break-glass is the deliberate +// SSO-bypass path, intended for use during SSO incidents only. +// +// Route: /auth/breakglass +// Permission: auth.breakglass.admin +// +// Backend: +// GET /api/v1/auth/breakglass/credentials (list) +// POST /api/v1/auth/breakglass/credentials (set/rotate password) +// POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock (unlock after lockout) +// DELETE /api/v1/auth/breakglass/credentials/{actor_id} (remove credential) +// +// Surface invisibility: every backend endpoint returns 404 when +// CERTCTL_BREAKGLASS_ENABLED=false; the page renders a "disabled" +// banner in that case (the list query 404s and we treat that as the +// disabled-on-server signal). +// ============================================================================= + +export default function BreakglassPage() { + const { isLoading: meLoading, hasPerm } = useAuthMe(); + const qc = useQueryClient(); + + // Permission gate. If meLoading, render nothing (avoid flicker). + const canAdmin = hasPerm('auth.breakglass.admin'); + + const { + data: rows, + isLoading, + error: loadErr, + } = useQuery({ + queryKey: ['breakglass', 'credentials'], + queryFn: () => breakglassListCredentials(), + enabled: canAdmin, + retry: false, + }); + + const setPwd = useMutation({ + mutationFn: ({ actorID, password }: { actorID: string; password: string }) => + breakglassSetPassword(actorID, password), + onSuccess: () => qc.invalidateQueries({ queryKey: ['breakglass'] }), + }); + const unlock = useMutation({ + mutationFn: (actorID: string) => breakglassUnlock(actorID), + onSuccess: () => qc.invalidateQueries({ queryKey: ['breakglass'] }), + }); + const remove = useMutation({ + mutationFn: (actorID: string) => breakglassRemove(actorID), + onSuccess: () => qc.invalidateQueries({ queryKey: ['breakglass'] }), + }); + + // Modal state. + const [pwdModalActorID, setPwdModalActorID] = useState(null); + const [removeModalActorID, setRemoveModalActorID] = useState(null); + // New-credential row form state (separate from rotation modal). + const [newActorID, setNewActorID] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newPasswordConfirm, setNewPasswordConfirm] = useState(''); + const [newFormError, setNewFormError] = useState(null); + + if (meLoading) return null; + + if (!canAdmin) { + return ( +
+ + +
+ ); + } + + // 404 from the list endpoint == server has CERTCTL_BREAKGLASS_ENABLED=false. + const disabledOnServer = + loadErr instanceof Error && /not enabled|404|disabled/i.test(loadErr.message); + + return ( +
+ + +
+ Security note. Break-glass credentials bypass your IdP entirely. Set + the password under CERTCTL_BREAKGLASS_ENABLED=true only when SSO + is broken; remove the credential once SSO recovers. Every action here is recorded in the audit log under the + auth category. +
+ + {disabledOnServer && ( + + )} + + {!disabledOnServer && ( + <> + {/* Create-new-credential form */} +
+

Set or rotate password

+
{ + e.preventDefault(); + setNewFormError(null); + if (newPassword !== newPasswordConfirm) { + setNewFormError('Passwords do not match.'); + return; + } + if (newPassword.length < 12) { + setNewFormError('Password must be at least 12 characters.'); + return; + } + try { + await setPwd.mutateAsync({ actorID: newActorID.trim(), password: newPassword }); + setNewActorID(''); + setNewPassword(''); + setNewPasswordConfirm(''); + } catch (err) { + setNewFormError(err instanceof Error ? err.message : 'Could not set password.'); + } + }} + className="space-y-3" + data-testid="breakglass-new-form" + > +
+ + setNewActorID(e.target.value)} + placeholder="actor-..." + autoComplete="off" + spellCheck={false} + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400" + data-testid="breakglass-new-actor-id" + /> +
+
+
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400" + data-testid="breakglass-new-password" + /> +
+
+ + setNewPasswordConfirm(e.target.value)} + autoComplete="new-password" + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400" + data-testid="breakglass-new-password-confirm" + /> +
+
+ {newFormError && ( +
+ {newFormError} +
+ )} + +
+
+ + {/* Credential list */} +
+

Credentialed actors

+ {isLoading ? ( +

Loading…

+ ) : !rows || rows.length === 0 ? ( +

No break-glass credentials configured.

+ ) : ( + + + + + + + + + + + + {rows.map((row: BreakglassCredentialRow) => { + const isLocked = row.locked_until && new Date(row.locked_until) > new Date(); + return ( + + + + + + + + ); + })} + +
ActorLast password changeFailuresLocked untilActions
{row.actor_id} + {new Date(row.last_password_change_at).toLocaleString()} + + {row.failure_count > 0 ? ( + {row.failure_count} + ) : ( + 0 + )} + + {isLocked ? ( + + {new Date(row.locked_until!).toLocaleString()} + + ) : ( + '—' + )} + + + + +
+ )} +
+ + )} + + {/* Rotate-password modal */} + {pwdModalActorID && ( + setPwdModalActorID(null)} + onSubmit={async pwd => { + await setPwd.mutateAsync({ actorID: pwdModalActorID, password: pwd }); + setPwdModalActorID(null); + }} + /> + )} + + {/* Remove-credential confirmation modal */} + {removeModalActorID && ( + setRemoveModalActorID(null)} + onConfirm={async () => { + await remove.mutateAsync(removeModalActorID); + setRemoveModalActorID(null); + }} + /> + )} +
+ ); +} + +function RotatePasswordModal({ + actorID, + onClose, + onSubmit, +}: { + actorID: string; + onClose: () => void; + onSubmit: (pwd: string) => Promise; +}) { + const [pwd, setPwd] = useState(''); + const [pwdConfirm, setPwdConfirm] = useState(''); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + return ( +
+
+

Rotate password for {actorID}

+

+ This revokes every active session for the target actor after the password is rotated. +

+
{ + e.preventDefault(); + setError(null); + if (pwd !== pwdConfirm) { + setError('Passwords do not match.'); + return; + } + if (pwd.length < 12) { + setError('Password must be at least 12 characters.'); + return; + } + setSubmitting(true); + try { + await onSubmit(pwd); + } catch (err) { + setError(err instanceof Error ? err.message : 'Rotation failed.'); + setSubmitting(false); + } + }} + className="space-y-3" + > + setPwd(e.target.value)} + autoComplete="new-password" + placeholder="New password (≥12 chars)" + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400" + data-testid="breakglass-rotate-password" + /> + setPwdConfirm(e.target.value)} + autoComplete="new-password" + placeholder="Confirm password" + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm focus:outline-none focus:border-brand-400" + data-testid="breakglass-rotate-password-confirm" + /> + {error && ( +
+ {error} +
+ )} +
+ + +
+
+
+
+ ); +} + +function RemoveCredentialModal({ + actorID, + onClose, + onConfirm, +}: { + actorID: string; + onClose: () => void; + onConfirm: () => Promise; +}) { + const [confirmText, setConfirmText] = useState(''); + const [submitting, setSubmitting] = useState(false); + const matched = confirmText === actorID; + + return ( +
+
+

Remove break-glass credential

+

+ This deletes the break-glass credential for{' '} + {actorID}. The actor will not be + able to use the break-glass login path until a new password is set. +

+

Type the actor ID to confirm:

+ setConfirmText(e.target.value)} + placeholder={actorID} + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm mb-4 focus:outline-none focus:border-red-400" + data-testid="breakglass-remove-confirm-input" + /> +
+ + +
+
+
+ ); +}