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
This commit is contained in:
shankar0123
2026-05-10 20:24:52 +00:00
parent 192351e253
commit a89c69b751
13 changed files with 899 additions and 2 deletions
+61
View File
@@ -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)
}
@@ -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.",
+1
View File
@@ -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))
+19
View File
@@ -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.
// =============================================================================
+10
View File
@@ -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
+6
View File
@@ -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)
}
@@ -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
}
+17
View File
@@ -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.
//
+2
View File
@@ -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' },
];
+3
View File
@@ -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(
<Route path="auth/keys" element={<KeysPage />} />
<Route path="auth/settings" element={<AuthSettingsPage />} />
<Route path="auth/approvals" element={<ApprovalsPage />} />
{/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */}
<Route path="auth/breakglass" element={<BreakglassPage />} />
</Route>
</Routes>
</BrowserRouter>
+120 -2
View File
@@ -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<string | null>(null);
const [providers, setProviders] = useState<AuthInfoOIDCProvider[]>([]);
// Break-glass inline form state.
const [showBreakglass, setShowBreakglass] = useState(false);
const [bgActorID, setBgActorID] = useState('');
const [bgPassword, setBgPassword] = useState('');
const [bgError, setBgError] = useState<string | null>(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 (
<div className="min-h-screen bg-page flex items-center justify-center px-4">
<div className="w-full max-w-sm">
@@ -126,6 +159,91 @@ export default function LoginPage() {
The API key is set via <code className="text-ink-faint bg-page px-1 py-0.5 rounded">CERTCTL_AUTH_SECRET</code> on the server.
</p>
</form>
{/* Break-glass entry — low-visibility on purpose. CRIT-4 closure. */}
<div className="mt-4 text-center" data-testid="login-breakglass-entry">
{!showBreakglass ? (
<button
type="button"
onClick={() => setShowBreakglass(true)}
className="text-xs text-amber-600 hover:text-amber-700 hover:underline"
data-testid="login-breakglass-toggle"
>
Use break-glass account (SSO outage recovery)
</button>
) : (
<form
onSubmit={handleBreakglassSubmit}
className="bg-amber-50 border border-amber-200 rounded p-4 mt-4 space-y-3 text-left"
data-testid="login-breakglass-form"
>
<p className="text-xs font-medium text-amber-900">
Break-glass admin login every action is audited. Use only during SSO incidents.
</p>
<div>
<label htmlFor="bg-actor-id" className="block text-xs font-medium text-amber-900 mb-1">
Actor ID
</label>
<input
id="bg-actor-id"
type="text"
value={bgActorID}
onChange={e => 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"
/>
</div>
<div>
<label htmlFor="bg-password" className="block text-xs font-medium text-amber-900 mb-1">
Password
</label>
<input
id="bg-password"
type="password"
value={bgPassword}
onChange={e => 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"
/>
</div>
{bgError && (
<div
className="bg-red-50 border border-red-200 rounded px-3 py-2 text-xs text-red-700"
data-testid="login-breakglass-error"
>
{bgError}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={bgSubmitting || !bgActorID.trim() || !bgPassword}
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white py-2 text-sm font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="login-breakglass-submit"
>
{bgSubmitting ? 'Signing in…' : 'Sign in (break-glass)'}
</button>
<button
type="button"
onClick={() => {
setShowBreakglass(false);
setBgActorID('');
setBgPassword('');
setBgError(null);
}}
className="px-3 py-2 text-sm font-medium text-amber-900 hover:bg-amber-100 rounded transition-colors"
data-testid="login-breakglass-cancel"
>
Cancel
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
+173
View File
@@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
function mockMe(opts: { hasPerm: boolean }) {
(useAuthMe as ReturnType<typeof vi.fn>).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(<BreakglassPage />);
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<typeof vi.fn>).mockResolvedValue([
{
actor_id: 'admin',
created_at: '2026-05-10T00:00:00Z',
last_password_change_at: '2026-05-10T00:00:00Z',
failure_count: 0,
},
]);
renderWithProviders(<BreakglassPage />);
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<typeof vi.fn>).mockResolvedValue([]);
});
it('rejects mismatched passwords', async () => {
renderWithProviders(<BreakglassPage />);
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(<BreakglassPage />);
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<typeof vi.fn>).mockResolvedValue([
{
actor_id: 'alice',
created_at: '2026-05-10T00:00:00Z',
last_password_change_at: '2026-05-10T00:00:00Z',
failure_count: 0,
},
]);
renderWithProviders(<BreakglassPage />);
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<typeof vi.fn>).mockResolvedValue([
{
actor_id: 'alice',
created_at: '2026-05-10T00:00:00Z',
last_password_change_at: '2026-05-10T00:00:00Z',
failure_count: 0,
},
]);
renderWithProviders(<BreakglassPage />);
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');
});
});
});
+456
View File
@@ -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<string | null>(null);
const [removeModalActorID, setRemoveModalActorID] = useState<string | null>(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<string | null>(null);
if (meLoading) return null;
if (!canAdmin) {
return (
<div className="p-6">
<PageHeader title="Break-glass" subtitle="Admin-only SSO-bypass recovery path" />
<ErrorState
title="Forbidden"
message="You need auth.breakglass.admin to view this page."
/>
</div>
);
}
// 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 (
<div className="p-6 max-w-5xl">
<PageHeader
title="Break-glass admin"
subtitle="SSO-bypass recovery path — every action audited. Use only during SSO incidents."
/>
<div
className="bg-amber-50 border border-amber-200 rounded p-4 mb-6 text-sm text-amber-900"
data-testid="breakglass-banner"
>
<strong>Security note.</strong> Break-glass credentials bypass your IdP entirely. Set
the password under <code className="bg-amber-100 px-1 rounded">CERTCTL_BREAKGLASS_ENABLED=true</code> only when SSO
is broken; remove the credential once SSO recovers. Every action here is recorded in the audit log under the
<code className="bg-amber-100 px-1 rounded">auth</code> category.
</div>
{disabledOnServer && (
<ErrorState
title="Break-glass disabled on server"
message="The server is running with CERTCTL_BREAKGLASS_ENABLED=false. Set it to true on the certctl-server process to enable this surface."
data-testid="breakglass-disabled-banner"
/>
)}
{!disabledOnServer && (
<>
{/* Create-new-credential form */}
<section className="bg-surface border border-surface-border rounded p-6 mb-6">
<h2 className="text-base font-semibold text-ink mb-3">Set or rotate password</h2>
<form
onSubmit={async e => {
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"
>
<div>
<label className="block text-xs font-medium text-ink-muted mb-1">Actor ID</label>
<input
type="text"
value={newActorID}
onChange={e => 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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-ink-muted mb-1">Password</label>
<input
type="password"
value={newPassword}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-ink-muted mb-1">Confirm password</label>
<input
type="password"
value={newPasswordConfirm}
onChange={e => 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"
/>
</div>
</div>
{newFormError && (
<div
className="bg-red-50 border border-red-200 rounded px-3 py-2 text-xs text-red-700"
data-testid="breakglass-new-error"
>
{newFormError}
</div>
)}
<button
type="submit"
disabled={!newActorID.trim() || !newPassword || setPwd.isPending}
className="bg-brand-400 hover:bg-brand-500 text-white px-4 py-2 text-sm font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="breakglass-new-submit"
>
{setPwd.isPending ? 'Setting…' : 'Set password'}
</button>
</form>
</section>
{/* Credential list */}
<section className="bg-surface border border-surface-border rounded p-6">
<h2 className="text-base font-semibold text-ink mb-3">Credentialed actors</h2>
{isLoading ? (
<p className="text-sm text-ink-muted">Loading</p>
) : !rows || rows.length === 0 ? (
<p className="text-sm text-ink-muted">No break-glass credentials configured.</p>
) : (
<table className="w-full text-sm" data-testid="breakglass-credentials-table">
<thead>
<tr className="border-b border-surface-border">
<th className="text-left py-2 font-medium text-ink-muted">Actor</th>
<th className="text-left py-2 font-medium text-ink-muted">Last password change</th>
<th className="text-left py-2 font-medium text-ink-muted">Failures</th>
<th className="text-left py-2 font-medium text-ink-muted">Locked until</th>
<th className="text-right py-2 font-medium text-ink-muted">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row: BreakglassCredentialRow) => {
const isLocked = row.locked_until && new Date(row.locked_until) > new Date();
return (
<tr
key={row.actor_id}
className="border-b border-surface-border last:border-0"
data-testid={`breakglass-row-${row.actor_id}`}
>
<td className="py-3 font-mono text-xs">{row.actor_id}</td>
<td className="py-3 text-xs text-ink-muted">
{new Date(row.last_password_change_at).toLocaleString()}
</td>
<td className="py-3 text-xs">
{row.failure_count > 0 ? (
<span className="text-red-700 font-medium">{row.failure_count}</span>
) : (
<span className="text-ink-muted">0</span>
)}
</td>
<td className="py-3 text-xs text-ink-muted">
{isLocked ? (
<span className="text-red-700">
{new Date(row.locked_until!).toLocaleString()}
</span>
) : (
'—'
)}
</td>
<td className="py-3 text-right space-x-2">
<button
onClick={() => setPwdModalActorID(row.actor_id)}
className="text-xs text-brand-400 hover:underline"
data-testid={`breakglass-rotate-${row.actor_id}`}
>
Rotate
</button>
<button
onClick={() => unlock.mutate(row.actor_id)}
disabled={!isLocked || unlock.isPending}
className="text-xs text-amber-700 hover:underline disabled:opacity-30 disabled:no-underline disabled:cursor-not-allowed"
data-testid={`breakglass-unlock-${row.actor_id}`}
>
Unlock
</button>
<button
onClick={() => setRemoveModalActorID(row.actor_id)}
className="text-xs text-red-700 hover:underline"
data-testid={`breakglass-remove-${row.actor_id}`}
>
Remove
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</>
)}
{/* Rotate-password modal */}
{pwdModalActorID && (
<RotatePasswordModal
actorID={pwdModalActorID}
onClose={() => setPwdModalActorID(null)}
onSubmit={async pwd => {
await setPwd.mutateAsync({ actorID: pwdModalActorID, password: pwd });
setPwdModalActorID(null);
}}
/>
)}
{/* Remove-credential confirmation modal */}
{removeModalActorID && (
<RemoveCredentialModal
actorID={removeModalActorID}
onClose={() => setRemoveModalActorID(null)}
onConfirm={async () => {
await remove.mutateAsync(removeModalActorID);
setRemoveModalActorID(null);
}}
/>
)}
</div>
);
}
function RotatePasswordModal({
actorID,
onClose,
onSubmit,
}: {
actorID: string;
onClose: () => void;
onSubmit: (pwd: string) => Promise<void>;
}) {
const [pwd, setPwd] = useState('');
const [pwdConfirm, setPwdConfirm] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
data-testid="breakglass-rotate-modal"
>
<div className="bg-surface rounded-lg p-6 max-w-md w-full shadow-xl">
<h3 className="text-lg font-semibold mb-2">Rotate password for {actorID}</h3>
<p className="text-xs text-ink-muted mb-4">
This revokes every active session for the target actor after the password is rotated.
</p>
<form
onSubmit={async e => {
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"
>
<input
type="password"
value={pwd}
onChange={e => 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"
/>
<input
type="password"
value={pwdConfirm}
onChange={e => 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 && (
<div className="bg-red-50 border border-red-200 rounded px-3 py-2 text-xs text-red-700">
{error}
</div>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={onClose} className="px-3 py-2 text-sm">
Cancel
</button>
<button
type="submit"
disabled={submitting || !pwd || !pwdConfirm}
className="bg-brand-400 hover:bg-brand-500 text-white px-4 py-2 text-sm font-medium rounded disabled:opacity-50"
data-testid="breakglass-rotate-submit"
>
{submitting ? 'Rotating…' : 'Rotate'}
</button>
</div>
</form>
</div>
</div>
);
}
function RemoveCredentialModal({
actorID,
onClose,
onConfirm,
}: {
actorID: string;
onClose: () => void;
onConfirm: () => Promise<void>;
}) {
const [confirmText, setConfirmText] = useState('');
const [submitting, setSubmitting] = useState(false);
const matched = confirmText === actorID;
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
data-testid="breakglass-remove-modal"
>
<div className="bg-surface rounded-lg p-6 max-w-md w-full shadow-xl">
<h3 className="text-lg font-semibold mb-2 text-red-700">Remove break-glass credential</h3>
<p className="text-sm text-ink-muted mb-4">
This deletes the break-glass credential for{' '}
<code className="bg-page px-1 rounded text-xs">{actorID}</code>. The actor will not be
able to use the break-glass login path until a new password is set.
</p>
<p className="text-xs text-ink-muted mb-2">Type the actor ID to confirm:</p>
<input
type="text"
value={confirmText}
onChange={e => 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"
/>
<div className="flex gap-2 justify-end">
<button type="button" onClick={onClose} className="px-3 py-2 text-sm">
Cancel
</button>
<button
type="button"
disabled={!matched || submitting}
onClick={async () => {
setSubmitting(true);
await onConfirm();
}}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="breakglass-remove-confirm-submit"
>
{submitting ? 'Removing…' : 'Remove credential'}
</button>
</div>
</div>
</div>
);
}