mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +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:
@@ -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.",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
// =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user