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