mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 10:49:22 +00:00
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 68ca42f, ca1e135, 00eace8). 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:
+120
-2
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user