From 2d0bbb0c372748d32dafeb0db47b07799fbc6d25 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sat, 27 Jun 2026 11:48:41 +0900 Subject: [PATCH] feat(cli): attribute break-glass recovery to the SysAdmin who runs it Root is machine authority, not a human identity, so `felis breakGlass` now also records WHICH SysAdmin broke the glass. Even under `sudo felis breakGlass` an account and password are entered in the TUI; the root gate is necessary but no longer sufficient for accountability. The console resolves one of three modes up front and audits the difference: - bootstrap (no staff account exists yet): the typed credential mints the first Owner; the act is attributed to the OS user ($SUDO_USER, else root) and recorded verified:false. - recovery (an admin already exists): the operator authenticates as an existing admin via bcrypt; the verified identity is the accountable actor and the row is recorded verified:true. - root override (the typed credential did not verify): a deliberate OVERRIDE token proceeds under local-root authority, attributed to the OS user and recorded verified:false. Break-glass never refuses - recovering when no admin password can be produced is its whole job. Attribution is best-effort, not proof (whoever runs this is root and can edit Postgres directly); the audit row is honest about which it is. - internal/api: AuditEntry gains an optional jsonb Payload (nil maps to SQL NULL, so existing callers are unaffected); PGRepo.Audit writes it and a new PGRepo.AdminExists drives the bootstrap-vs-recovery switch. - the accountability row is written the instant the credential changes, before local auth is enabled, so a failed toggle write can never leave a reset credential with no "who did it" record. - local_auth_enabled is now one exported api.LocalAuthEnabledKey shared by the break-glass writer and the per-request reader, replacing two drifting copies of the literal. - break-glass password entry reuses the panel's 8-72-byte rule so a credential set here is never later rejected by web change-password. Covered by Go unit tests over a fake owner store: auth match/non-match, the three audit modes and their payloads, that a dead audit sink does not fail the recovery, that the audit precedes the toggle write, and a headless drive of the TUI state machine asserting no credential reaches provisioning without a verified admin or an explicit OVERRIDE. --- cmd/felis/breakglass.go | 707 +++++++++++++++++++++++------ cmd/felis/breakglass_test.go | 533 +++++++++++++++++++--- internal/api/handlers_auth_test.go | 2 +- internal/api/pgrepo.go | 28 +- internal/api/repo.go | 6 + internal/api/session.go | 13 +- 6 files changed, 1079 insertions(+), 210 deletions(-) diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 52cb35d..c0fa0b9 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "flag" "fmt" @@ -30,32 +31,50 @@ import ( // interactive TUI, NOT a CLI: bare `felis` prints CLI usage, while `felis breakGlass` // opens this full-screen console. It refuses to run unless euid is 0 (sudo/root). // +// Root is necessary but NOT sufficient for accountability: root is machine +// authority, not a human identity, so the console additionally captures WHO is +// breaking the glass. When a staff account already exists it asks the operator to +// authenticate as an existing admin (the verified identity is the accountable +// actor); when none exists yet it bootstraps the first Owner from the typed +// credential and attributes the act to the OS user. The audit row records the +// difference. This attribution is best-effort, not tamper-proof — whoever runs +// this is root and can edit Postgres directly — but it produces an honest trail +// for an honest operator, which is the point. +// // The richer break-glass operations (OP create, halt, sync, S3) land in a later // phase; this file is intentionally scoped to the one provisioning operation that // makes the local-auth thin thread reachable. -// localAuthEnabledSettingKey is the platform_settings key the live API reads to -// decide whether local-password sessions are honored. It MUST match the -// (unexported) localAuthEnabledKey the api package consults per-request; the VM -// smoke test (login after break-glass) is what guarantees they stay in sync. -const localAuthEnabledSettingKey = "local_auth_enabled" +// breakGlassOverrideToken is the literal an operator must type to proceed when no +// admin credential could be verified. Requiring an explicit, deliberate word (not a +// bare Enter) keeps the unverified root override from happening by reflex. +const breakGlassOverrideToken = "OVERRIDE" // bootstrapPasswordAlphabet excludes visually ambiguous glyphs (0/O, 1/I/l) so a -// human can transcribe the one-time password off a terminal without error. +// human can transcribe a generated one-time password off a terminal without error. const bootstrapPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" -// ownerStore is the minimal repo surface break-glass provisioning needs. -// *api.PGRepo satisfies it; the unit tests drive a fake, so the core provisioning -// logic is exercised without a database or a terminal. +// ownerStore is the minimal repo surface the break-glass console needs. +// *api.PGRepo satisfies it; the unit tests drive a fake, so the core logic +// (authentication, provisioning, accountability audit) is exercised without a +// database or a terminal. type ownerStore interface { + // AdminExists reports whether any authenticatable staff account already exists. + // It is the bootstrap-vs-recovery switch. + AdminExists(ctx context.Context) (bool, error) + // UserByUsername loads a staff login projection for credential verification. + UserByUsername(ctx context.Context, username string) (*api.StaffUser, error) UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error SetSetting(ctx context.Context, key string, value []byte) error + // Audit records the break-glass accountability row. + Audit(ctx context.Context, e api.AuditEntry) error } // cmdBreakGlass is the `felis breakGlass` entrypoint: the root gate, config load, -// database open, and the interactive TUI. Everything below runBreakGlassTUI is -// I/O at the edge; the provisioning logic itself is plain functions over -// ownerStore so it stays testable off a terminal. +// database open, accountability detection, and the interactive TUI. Everything +// below runBreakGlassTUI is I/O at the edge; the authentication/provisioning/audit +// logic itself is plain functions over ownerStore so it stays testable off a +// terminal. func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("breakGlass", flag.ContinueOnError) fs.SetOutput(stderr) @@ -88,7 +107,17 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { defer drv.Close() repo := api.NewPGRepo(drv.DB()) - res, err := runBreakGlassTUI(ctx, repo, cfg.Server.RootDomain) + + // Decide bootstrap (no admin yet → typed credential mints the first Owner) vs + // recovery (an admin exists → the operator must authenticate as one) BEFORE the + // alt-screen TUI takes over, so a database fault surfaces as a plain error. + adminExists, err := repo.AdminExists(ctx) + if err != nil { + fmt.Fprintf(stderr, "felis breakGlass: detect existing admin: %v\n", err) + return 1 + } + + res, err := runBreakGlassTUI(ctx, repo, cfg.Server.RootDomain, accountableOSUser(), adminExists) if err != nil { fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) return 1 @@ -99,19 +128,43 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { return 0 } - // The TUI runs on the alternate screen, which is torn down on exit and takes the - // in-console password display with it. Re-print a durable summary to the normal - // screen so the one-time bootstrap password survives in scrollback long enough - // for the operator to log in. It is shown, never persisted: only the bcrypt hash - // reached the database. + // The TUI runs on the alternate screen, which is torn down on exit and takes its + // display with it. Re-print a durable summary to the normal screen so the + // outcome — and any generated one-time password — survives in scrollback long + // enough for the operator to log in. fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username) - fmt.Fprintf(stdout, "One-time bootstrap password (you MUST change it on first login):\n\n %s\n\n", res.password) + fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser) + if res.displayPassword != "" { + // A one-time password was generated (recovery / root override). It is shown, + // never persisted: only the bcrypt hash reached the database. + fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword) + } else { + // Bootstrap: the operator typed the password themselves, so we do NOT echo it + // back into scrollback. + fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).") + } + if res.auditWarning != "" { + fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning) + } if res.rootDomain != "" { fmt.Fprintf(stdout, "Log in at https://op.console.%s with that username and password.\n", res.rootDomain) } return 0 } +// accountableOSUser returns the human who escalated to root, best-effort, for the +// audit trail. sudo sets SUDO_USER to the invoking account; a direct root shell +// leaves it empty, in which case we record "root". This is attribution, not proof: +// the environment can be forged, so sudo's own syslog entry — not this value — is +// the tamper-evident record. The root gate is the real authority gate; this only +// answers "which human" for an honest operator. +func accountableOSUser() string { + if u := strings.TrimSpace(os.Getenv("SUDO_USER")); u != "" { + return u + } + return "root" +} + // newOwnerID returns a fresh, unguessable id for the Owner row. It mirrors the // crypto/rand hex idiom used elsewhere (internal/submit, internal/build): 16 bytes // give uuid-equivalent entropy and the value is path/argv-safe lowercase hex. A @@ -150,73 +203,209 @@ func generateBootstrapPassword() (string, error) { return string(out), nil } -// provisionOwner mints or resets the single Owner account direct-to-Postgres and -// returns the one-time bootstrap password to display. The account is created with +// validateOwnerPassword mirrors api.validateNewPassword (handlers_auth.go): a +// break-glass credential must satisfy the SAME 8–72-byte rule the panel's own +// change-password enforces, so an operator can never set a password here that the +// web change-password flow would later reject. 72 is bcrypt's hard input limit. +func validateOwnerPassword(pw string) error { + if len(pw) < 8 { + return errors.New("password must be at least 8 characters") + } + if len(pw) > 72 { + return errors.New("password must be at most 72 bytes") + } + return nil +} + +// authenticateAdmin verifies a typed credential against an existing admin account +// for recovery-mode attribution. matched is the stored username on success. +// +// ok==false with err==nil is NOT a failure to surface — it means the credential did +// not match any admin password. The caller offers an explicit root override instead +// of refusing, because break-glass must still recover when no admin credential can +// be produced (a forgotten password is the canonical reason the web login is +// unreachable in the first place). Only a real datastore fault returns err. +func authenticateAdmin(ctx context.Context, s ownerStore, username, password string) (matched string, ok bool, err error) { + username = strings.TrimSpace(username) + if username == "" || password == "" { + return "", false, nil + } + u, err := s.UserByUsername(ctx, username) + if errors.Is(err, api.ErrNotFound) { + return "", false, nil + } + if err != nil { + return "", false, err + } + // Only an admin row carrying a bcrypt hash is an authenticatable staff identity; + // a player row (role=user, hash NULL → empty PasswordHash) can never attribute a + // break-glass action. + if u.Role != "admin" || u.PasswordHash == "" { + return "", false, nil + } + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil { + return "", false, nil + } + return u.Username, true, nil +} + +// provisionOwner mints or resets the single Owner account direct-to-Postgres with +// the given (already-validated-by-the-caller) password. The account is created with // must_change_password=true, which is load-bearing: it is what arms the API's // lockdown middleware so the Owner can do nothing but change the password on first -// login. The returned plaintext exists ONLY to be shown once on this terminal — it -// is never logged or persisted; only its bcrypt hash reaches the database. -func provisionOwner(ctx context.Context, s ownerStore, username, email string) (string, error) { +// login. Only the bcrypt hash reaches the database; the plaintext never does. +func provisionOwner(ctx context.Context, s ownerStore, username, email, password string) error { username = strings.TrimSpace(username) if username == "" { - return "", errors.New("username is required") + return errors.New("owner username is required") + } + if err := validateOwnerPassword(password); err != nil { + return err } id := newOwnerID() if id == "" { - return "", errors.New("generate owner id: entropy source failed") - } - password, err := generateBootstrapPassword() - if err != nil { - return "", err + return errors.New("generate owner id: entropy source failed") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - return "", fmt.Errorf("hash bootstrap password: %w", err) + return fmt.Errorf("hash owner password: %w", err) } if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil { - return "", fmt.Errorf("write owner: %w", err) + return fmt.Errorf("write owner: %w", err) } - return password, nil + return nil } // enableLocalAuth flips the runtime local_auth_enabled toggle on -// direct-to-Postgres. It is the second load-bearing write of break-glass: without -// it handleLogin returns 403 and the freshly provisioned Owner cannot log in, so a +// direct-to-Postgres. It is a load-bearing write of break-glass: without it +// handleLogin returns 403 and the freshly provisioned Owner cannot log in, so a // successful provisionOwner with local auth off is not a usable thin thread. func enableLocalAuth(ctx context.Context, s ownerStore) error { // The setting is read back with json.Unmarshal into a bool, so the stored jsonb // value must be the literal true. - if err := s.SetSetting(ctx, localAuthEnabledSettingKey, []byte("true")); err != nil { + if err := s.SetSetting(ctx, api.LocalAuthEnabledKey, []byte("true")); err != nil { return fmt.Errorf("enable local auth: %w", err) } return nil } +// breakGlassOp is a fully-resolved operation the TUI hands to the core once the +// operator has been identified (bootstrap) or authenticated (recovery / override). +type breakGlassOp struct { + mode string // "bootstrap" | "recovery" | "root_override" + accountable string // recorded as the audit actor (verified admin, or OS user) + osUser string // $SUDO_USER (or "root"); recorded in the payload + ownerUsername string + ownerEmail string + ownerPassword string // typed (bootstrap); "" => generate a one-time password + attemptedAdmin string // recovery / override: the admin username the operator typed +} + +// breakGlassOutcome is what performBreakGlass reports back to the TUI. +type breakGlassOutcome struct { + displayPassword string // non-empty only when a one-time password was generated + auditErr error // non-nil if the accountability row could not be written +} + +// performBreakGlass executes a resolved break-glass operation: provision (or reset) +// the Owner, enable local-password login, then record a best-effort accountability +// audit row. A typed ownerPassword (bootstrap) is used as-is; an empty one (recovery +// / root override) is replaced with a generated one-time password returned for +// one-time display. The audit write is best-effort: a logging failure is reported +// via auditErr but does NOT fail the recovery — break-glass must still work when the +// audit sink is unhappy. +func performBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) { + password := op.ownerPassword + generated := false + if password == "" { + p, err := generateBootstrapPassword() + if err != nil { + return breakGlassOutcome{}, err + } + password, generated = p, true + } + if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil { + return breakGlassOutcome{}, err + } + // Record accountability the instant the credential changes — BEFORE enabling + // local auth, which can still fail. Auditing only after both writes would let a + // failed enableLocalAuth leave a just-reset credential with no "who did it" row; + // the audit is best-effort, so doing it first never blocks the recovery. + out := breakGlassOutcome{auditErr: auditBreakGlass(ctx, s, op)} + if generated { + out.displayPassword = password + } + if err := enableLocalAuth(ctx, s); err != nil { + return breakGlassOutcome{}, err + } + return out, nil +} + +// auditBreakGlass writes the break-glass accountability row. The actor is the +// resolved human identity (a verified admin in recovery, the OS user otherwise); +// the payload carries the full who/what/how so an after-the-fact reader can tell a +// verified recovery from an unverified root override. It is best-effort — the caller +// does not fail the recovery if this write fails — and intentionally honest: it +// records attribution, it does not prove it (a malicious root can edit the row). +func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error { + payload := map[string]any{ + "mode": op.mode, + "owner": op.ownerUsername, + "os_user": op.osUser, + "verified": op.mode == "recovery", + } + if op.attemptedAdmin != "" { + payload["admin_account"] = op.attemptedAdmin + } + blob, err := json.Marshal(payload) + if err != nil { + return err + } + return s.Audit(ctx, api.AuditEntry{ + Actor: op.accountable, + Source: "break-glass", + Action: "break_glass." + op.mode, + Payload: blob, + }) +} + // ---- interactive TUI (the untested shell over the tested core) ---- // breakGlassResult is what the TUI hands back to cmdBreakGlass for the durable // post-exit summary. provisioned is false on cancel. type breakGlassResult struct { - provisioned bool - username string - password string - rootDomain string + provisioned bool + mode string + accountable string + osUser string + username string + displayPassword string // empty when the operator typed their own bootstrap password + auditWarning string + rootDomain string } -type bgState int +type bgStep int const ( - stateForm bgState = iota - stateWorking - stateDone - stateError + stepAuth bgStep = iota // recovery: authenticate as an existing admin + stepOverride // recovery: admin auth failed → deliberate root override + stepProvision // collect the Owner target (and password, in bootstrap) + stepWorking + stepDone + stepError ) -// provisionedMsg is delivered to the model when the background provisioning -// command finishes. -type provisionedMsg struct { - password string - err error +// authResultMsg carries the outcome of the off-goroutine admin credential check. +type authResultMsg struct { + matched string + ok bool + err error +} + +// performedMsg carries the outcome of the off-goroutine break-glass writes. +type performedMsg struct { + outcome breakGlassOutcome + err error } var ( @@ -224,56 +413,128 @@ var ( bgLabelStyle = lipgloss.NewStyle().Bold(true) bgHintStyle = lipgloss.NewStyle().Faint(true) bgErrStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) + bgWarnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) bgOKStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")) bgPwStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")).Background(lipgloss.Color("11")).Padding(0, 1) bgBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 3) ) -// bgModel is the bubbletea model for the provisioning console. It is a pointer -// model so Update can mutate in place; the only field touched from the background -// command is read after the command returns via provisionedMsg. +// bgModel is the bubbletea model for the break-glass console. It is a pointer model +// so Update can mutate in place; fields touched from a background command are read +// only after that command returns via authResultMsg / performedMsg. type bgModel struct { - ctx context.Context - store ownerStore - rootDomain string + ctx context.Context + store ownerStore + rootDomain string + osUser string + adminExists bool + step bgStep inputs []textinput.Model focus int - state bgState formErr string + working string - username string - password string - err error + // resolved as the flow advances + mode string + accountable string + attemptedAdmin string + + // result + ownerUsername string + displayPassword string + auditWarning string + err error } -func newBGModel(ctx context.Context, s ownerStore, rootDomain string) *bgModel { - user := textinput.New() - user.Placeholder = "owner" - user.SetValue("owner") - user.CharLimit = 64 - user.Width = 40 - user.Prompt = "" - user.Focus() - - email := textinput.New() - email.Placeholder = "(optional)" - email.CharLimit = 254 - email.Width = 40 - email.Prompt = "" - - return &bgModel{ - ctx: ctx, - store: s, - rootDomain: rootDomain, - inputs: []textinput.Model{user, email}, - focus: 0, - state: stateForm, +func newBGModel(ctx context.Context, s ownerStore, rootDomain, osUser string, adminExists bool) *bgModel { + m := &bgModel{ + ctx: ctx, + store: s, + rootDomain: rootDomain, + osUser: osUser, + adminExists: adminExists, } + if adminExists { + // Recovery: an admin already exists, so the operator must identify themselves + // before the glass breaks. + m.step = stepAuth + m.buildAuth() + } else { + // Bootstrap: no staff account exists yet; the typed credential mints the first + // Owner, attributed to the OS user. + m.mode = "bootstrap" + m.accountable = osUser + m.step = stepProvision + m.buildProvision(true) + } + return m } func (m *bgModel) Init() tea.Cmd { return textinput.Blink } +// bgInput builds a styled text input; password fields echo a mask, never the glyphs, +// because this is typed on a shared root console. +func bgInput(placeholder string, charLimit int, password bool) textinput.Model { + ti := textinput.New() + ti.Placeholder = placeholder + ti.CharLimit = charLimit + ti.Width = 44 + ti.Prompt = "" + if password { + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + } + return ti +} + +// setInputs installs a fresh input set, focuses the first, and returns its blink cmd. +func (m *bgModel) setInputs(ins []textinput.Model) tea.Cmd { + m.inputs = ins + m.focus = 0 + var cmd tea.Cmd + for i := range m.inputs { + if i == 0 { + cmd = m.inputs[i].Focus() + } else { + m.inputs[i].Blur() + } + } + return cmd +} + +func (m *bgModel) buildAuth() tea.Cmd { + user := bgInput("admin username", 64, false) + pass := bgInput("admin password", 128, true) + return m.setInputs([]textinput.Model{user, pass}) +} + +func (m *bgModel) buildOverride() tea.Cmd { + confirm := bgInput("type "+breakGlassOverrideToken, 16, false) + return m.setInputs([]textinput.Model{confirm}) +} + +// buildProvision installs the Owner-target inputs. withPassword adds the password + +// confirm fields used only in bootstrap mode; in recovery/override a one-time +// password is generated, so the operator does not type one. +func (m *bgModel) buildProvision(withPassword bool) tea.Cmd { + user := bgInput("owner", 64, false) + user.SetValue("owner") + email := bgInput("(optional)", 254, false) + ins := []textinput.Model{user, email} + if withPassword { + ins = append(ins, bgInput("at least 8 characters", 128, true)) + ins = append(ins, bgInput("re-enter password", 128, true)) + } + return m.setInputs(ins) +} + +func (m *bgModel) enterProvision() tea.Cmd { + m.step = stepProvision + m.formErr = "" + return m.buildProvision(m.mode == "bootstrap") +} + func (m *bgModel) focusInput(i int) tea.Cmd { if i < 0 { i = len(m.inputs) - 1 @@ -303,109 +564,257 @@ func (m *bgModel) updateInputs(msg tea.Msg) tea.Cmd { func (m *bgModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case provisionedMsg: + case authResultMsg: if msg.err != nil { - m.state, m.err = stateError, msg.err - } else { - m.state, m.password = stateDone, msg.password + m.step, m.err = stepError, msg.err + return m, nil + } + if msg.ok { + // Verified: this admin is the accountable identity for the recovery. + m.mode = "recovery" + m.accountable = msg.matched + return m, m.enterProvision() + } + // The credential did not verify. Do NOT refuse — break-glass must still + // recover when no admin password can be produced. Offer a deliberate root + // override, attributed to the OS user and audited as unverified. + m.step = stepOverride + return m, m.buildOverride() + + case performedMsg: + if msg.err != nil { + m.step, m.err = stepError, msg.err + return m, nil + } + m.step = stepDone + m.displayPassword = msg.outcome.displayPassword + if msg.outcome.auditErr != nil { + m.auditWarning = msg.outcome.auditErr.Error() } return m, nil case tea.KeyMsg: - switch m.state { - case stateDone, stateError: + switch m.step { + case stepDone, stepError: // Any key dismisses the terminal screen. return m, tea.Quit - case stateWorking: - // Ignore input while the database write is in flight. + case stepWorking: + // Ignore input while a database write is in flight. return m, nil - case stateForm: - switch msg.String() { - case "ctrl+c", "esc": - return m, tea.Quit - case "tab", "down": - return m, m.focusInput(m.focus + 1) - case "shift+tab", "up": - return m, m.focusInput(m.focus - 1) - case "enter": - if strings.TrimSpace(m.inputs[0].Value()) == "" { - m.formErr = "username is required" - return m, m.focusInput(0) - } - m.username = strings.TrimSpace(m.inputs[0].Value()) - m.state, m.formErr = stateWorking, "" - return m, provisionCmd(m.ctx, m.store, m.username, m.inputs[1].Value()) - } + default: + return m.handleFormKey(msg) } } - cmd := m.updateInputs(msg) - return m, cmd + return m, m.updateInputs(msg) +} + +func (m *bgModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + if m.step == stepOverride { + // Back out of the override to re-enter the admin credential. + m.step, m.formErr = stepAuth, "" + return m, m.buildAuth() + } + return m, tea.Quit + case "tab", "down": + return m, m.focusInput(m.focus + 1) + case "shift+tab", "up": + return m, m.focusInput(m.focus - 1) + case "enter": + return m.submit() + } + return m, m.updateInputs(msg) +} + +func (m *bgModel) submit() (tea.Model, tea.Cmd) { + switch m.step { + case stepAuth: + return m.submitAuth() + case stepOverride: + return m.submitOverride() + case stepProvision: + return m.submitProvision() + } + return m, nil +} + +func (m *bgModel) submitAuth() (tea.Model, tea.Cmd) { + user := strings.TrimSpace(m.inputs[0].Value()) + pass := m.inputs[1].Value() + if user == "" || pass == "" { + m.formErr = "enter the username and password of an existing admin" + return m, nil + } + m.attemptedAdmin = user + m.formErr, m.working = "", "Verifying the admin credential…" + m.step = stepWorking + return m, authCmd(m.ctx, m.store, user, pass) +} + +func (m *bgModel) submitOverride() (tea.Model, tea.Cmd) { + if m.inputs[0].Value() != breakGlassOverrideToken { + m.formErr = "type " + breakGlassOverrideToken + " exactly to proceed, or esc to go back" + return m, nil + } + m.mode = "root_override" + m.accountable = m.osUser + return m, m.enterProvision() +} + +func (m *bgModel) submitProvision() (tea.Model, tea.Cmd) { + owner := strings.TrimSpace(m.inputs[0].Value()) + if owner == "" { + m.formErr = "owner username is required" + return m, m.focusInput(0) + } + email := m.inputs[1].Value() + password := "" // empty => performBreakGlass generates a one-time password + if m.mode == "bootstrap" { + pw := m.inputs[2].Value() + confirm := m.inputs[3].Value() + if err := validateOwnerPassword(pw); err != nil { + m.formErr = err.Error() + return m, m.focusInput(2) + } + if pw != confirm { + m.formErr = "the two passwords do not match" + return m, m.focusInput(3) + } + password = pw + } + m.ownerUsername = owner + op := breakGlassOp{ + mode: m.mode, + accountable: m.accountable, + osUser: m.osUser, + ownerUsername: owner, + ownerEmail: email, + ownerPassword: password, + attemptedAdmin: m.attemptedAdmin, + } + m.formErr, m.working = "", "Provisioning the Owner account…" + m.step = stepWorking + return m, performCmd(m.ctx, m.store, op) +} + +// authCmd runs the admin credential check off the UI goroutine. +func authCmd(ctx context.Context, s ownerStore, user, pass string) tea.Cmd { + return func() tea.Msg { + matched, ok, err := authenticateAdmin(ctx, s, user, pass) + return authResultMsg{matched: matched, ok: ok, err: err} + } +} + +// performCmd runs the break-glass writes off the UI goroutine. +func performCmd(ctx context.Context, s ownerStore, op breakGlassOp) tea.Cmd { + return func() tea.Msg { + out, err := performBreakGlass(ctx, s, op) + return performedMsg{outcome: out, err: err} + } } func (m *bgModel) View() string { var b strings.Builder b.WriteString(bgTitleStyle.Render("⚠ FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE") + "\n\n") - switch m.state { - case stateForm: - b.WriteString("Provision (or reset) the Owner account and enable local-password login.\n") - b.WriteString("This writes directly to Postgres, bypassing the web Zero-Trust path.\n\n") + switch m.step { + case stepAuth: + b.WriteString("A staff account already exists. Identify yourself before breaking the glass.\n") + b.WriteString("Authenticate as an existing admin — this records WHO performed the recovery.\n") + b.WriteString(bgHintStyle.Render("Best-effort attribution, not a second authority gate (root already let you in).") + "\n\n") + b.WriteString(bgLabelStyle.Render("Admin username") + "\n") + b.WriteString(m.inputs[0].View() + "\n\n") + b.WriteString(bgLabelStyle.Render("Admin password") + "\n") + b.WriteString(m.inputs[1].View() + "\n\n") + if m.formErr != "" { + b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") + } + b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter verify · esc cancel") + "\n") + + case stepOverride: + b.WriteString(bgErrStyle.Render("✗ That credential did not match any admin account.") + "\n\n") + b.WriteString("You can still proceed under local-root authority. This is a ROOT OVERRIDE:\n") + b.WriteString("it will be recorded as an UNVERIFIED break-glass attributed to the OS user\n") + b.WriteString(bgLabelStyle.Render("\""+m.osUser+"\"") + ", not to a verified admin.\n\n") + b.WriteString(bgLabelStyle.Render("Type "+breakGlassOverrideToken+" to proceed") + "\n") + b.WriteString(m.inputs[0].View() + "\n\n") + if m.formErr != "" { + b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") + } + b.WriteString(bgHintStyle.Render("enter confirm · esc go back to admin login") + "\n") + + case stepProvision: + if m.mode == "bootstrap" { + b.WriteString("No staff account exists yet — bootstrapping the first Owner.\n") + b.WriteString("You are recorded as OS user " + bgLabelStyle.Render("\""+m.osUser+"\"") + ".\n\n") + } else if m.mode == "root_override" { + b.WriteString(bgWarnStyle.Render("ROOT OVERRIDE") + " by OS user " + bgLabelStyle.Render("\""+m.osUser+"\"") + " — resetting the Owner account.\n") + b.WriteString("A new one-time password will be generated and shown once.\n\n") + } else { + b.WriteString("Authenticated as admin " + bgLabelStyle.Render("\""+m.accountable+"\"") + " — resetting the Owner account.\n") + b.WriteString("A new one-time password will be generated and shown once.\n\n") + } b.WriteString(bgLabelStyle.Render("Owner username") + "\n") b.WriteString(m.inputs[0].View() + "\n\n") b.WriteString(bgLabelStyle.Render("Owner email (optional)") + "\n") b.WriteString(m.inputs[1].View() + "\n\n") + if m.mode == "bootstrap" { + b.WriteString(bgLabelStyle.Render("Owner password") + bgHintStyle.Render(" (you will change it on first login)") + "\n") + b.WriteString(m.inputs[2].View() + "\n\n") + b.WriteString(bgLabelStyle.Render("Confirm password") + "\n") + b.WriteString(m.inputs[3].View() + "\n\n") + } if m.formErr != "" { b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") } b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n") - case stateWorking: - b.WriteString("Provisioning the Owner account…\n") + case stepWorking: + msg := m.working + if msg == "" { + msg = "Working…" + } + b.WriteString(msg + "\n") - case stateDone: + case stepDone: b.WriteString(bgOKStyle.Render("✓ Owner provisioned · local-password login ENABLED") + "\n\n") - box := bgLabelStyle.Render("username ") + m.username + "\n" + - bgLabelStyle.Render("password ") + bgPwStyle.Render(m.password) + box := bgLabelStyle.Render("username ") + m.ownerUsername + if m.displayPassword != "" { + box += "\n" + bgLabelStyle.Render("password ") + bgPwStyle.Render(m.displayPassword) + } b.WriteString(bgBoxStyle.Render(box) + "\n\n") - b.WriteString(bgErrStyle.Render("Record this password now — it is shown only once.") + "\n") + b.WriteString(bgHintStyle.Render("recorded as "+m.accountable+" · mode "+m.mode+" · os user "+m.osUser) + "\n\n") + if m.displayPassword != "" { + b.WriteString(bgErrStyle.Render("Record this password now — it is shown only once.") + "\n") + } else { + b.WriteString("Log in with the password you just entered.\n") + } b.WriteString("You will be required to change it on first login.\n\n") + if m.auditWarning != "" { + b.WriteString(bgWarnStyle.Render("⚠ accountability record was NOT written: "+m.auditWarning) + "\n\n") + } if m.rootDomain != "" { b.WriteString("Log in at " + bgLabelStyle.Render("https://op.console."+m.rootDomain) + "\n\n") } b.WriteString(bgHintStyle.Render("press any key to exit") + "\n") - case stateError: - b.WriteString(bgErrStyle.Render("✗ Provisioning failed") + "\n\n") + case stepError: + b.WriteString(bgErrStyle.Render("✗ Break-glass failed") + "\n\n") b.WriteString(m.err.Error() + "\n\n") b.WriteString(bgHintStyle.Render("press any key to exit") + "\n") } return b.String() } -// provisionCmd runs the two load-bearing writes off the UI goroutine and reports -// the outcome back as a provisionedMsg. The Owner row is written first, then local -// auth is enabled. If enabling local auth fails, the message carries the error and -// Update routes to stateError: the generated plaintext in msg.password is dropped — -// never displayed and never stored anywhere — so a partial run leaks nothing. This -// is safe because the Owner row is unreachable for login while local_auth_enabled is -// unset, and re-running break-glass is idempotent (UpsertOwner is ON CONFLICT, so it -// overwrites the hash and re-issues a fresh password) and converges the toggle. -func provisionCmd(ctx context.Context, s ownerStore, username, email string) tea.Cmd { - return func() tea.Msg { - pw, err := provisionOwner(ctx, s, username, email) - if err == nil { - err = enableLocalAuth(ctx, s) - } - return provisionedMsg{password: pw, err: err} - } -} - // runBreakGlassTUI drives the bubbletea program and projects the final model onto a // breakGlassResult. It is the thin, untested shell; the logic it invokes -// (provisionOwner / enableLocalAuth) is unit-tested directly. -func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain string) (breakGlassResult, error) { - final, err := tea.NewProgram(newBGModel(ctx, s, rootDomain), tea.WithAltScreen()).Run() +// (authenticateAdmin / performBreakGlass) is unit-tested directly. +func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, osUser string, adminExists bool) (breakGlassResult, error) { + final, err := tea.NewProgram(newBGModel(ctx, s, rootDomain, osUser, adminExists), tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err } @@ -413,13 +822,17 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain string) (bre if !ok { return breakGlassResult{}, errors.New("unexpected final model") } - if m.state == stateError { + if m.step == stepError { return breakGlassResult{}, m.err } return breakGlassResult{ - provisioned: m.state == stateDone, - username: m.username, - password: m.password, - rootDomain: rootDomain, + provisioned: m.step == stepDone, + mode: m.mode, + accountable: m.accountable, + osUser: m.osUser, + username: m.ownerUsername, + displayPassword: m.displayPassword, + auditWarning: m.auditWarning, + rootDomain: rootDomain, }, nil } diff --git a/cmd/felis/breakglass_test.go b/cmd/felis/breakglass_test.go index 5440743..8c52b38 100644 --- a/cmd/felis/breakglass_test.go +++ b/cmd/felis/breakglass_test.go @@ -7,16 +7,27 @@ import ( "strings" "testing" + "felis.lolicon.best/internal/api" + + tea "github.com/charmbracelet/bubbletea" "golang.org/x/crypto/bcrypt" ) -// fakeOwnerStore records what break-glass provisioning writes, so the core logic -// is exercised without a database or a terminal. +// fakeOwnerStore records what break-glass provisioning writes and answers the +// identity lookups, so the core logic (authentication, provisioning, accountability +// audit) is exercised without a database or a terminal. type fakeOwnerStore struct { - upserts []upsertCall - settings map[string][]byte + upserts []upsertCall + settings map[string][]byte + audits []api.AuditEntry + users map[string]*api.StaffUser // keyed by username + admins bool // AdminExists answer + upsertErr error setErr error + auditErr error + userErr error // non-not-found error from UserByUsername + adminErr error } type upsertCall struct { @@ -24,6 +35,23 @@ type upsertCall struct { mustChange bool } +func (f *fakeOwnerStore) AdminExists(_ context.Context) (bool, error) { + if f.adminErr != nil { + return false, f.adminErr + } + return f.admins, nil +} + +func (f *fakeOwnerStore) UserByUsername(_ context.Context, username string) (*api.StaffUser, error) { + if f.userErr != nil { + return nil, f.userErr + } + if u, ok := f.users[username]; ok { + return u, nil + } + return nil, api.ErrNotFound +} + func (f *fakeOwnerStore) UpsertOwner(_ context.Context, id, username, email, passwordHash string, mustChange bool) error { if f.upsertErr != nil { return f.upsertErr @@ -43,13 +71,57 @@ func (f *fakeOwnerStore) SetSetting(_ context.Context, key string, value []byte) return nil } +func (f *fakeOwnerStore) Audit(_ context.Context, e api.AuditEntry) error { + if f.auditErr != nil { + return f.auditErr + } + f.audits = append(f.audits, e) + return nil +} + +// mkAdmin builds an authenticatable admin row (role=admin, real bcrypt hash) for the +// fake. MinCost keeps the hash fast — these tests are about wiring, not bcrypt. +func mkAdmin(t *testing.T, username, password string) *api.StaffUser { + t.Helper() + h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + t.Fatalf("hash: %v", err) + } + return &api.StaffUser{ID: "usr-admin", Username: username, Role: "admin", PasswordHash: string(h)} +} + +func TestValidateOwnerPassword(t *testing.T) { + cases := []struct { + name string + pw string + ok bool + }{ + {"too short", "1234567", false}, + {"minimum", "12345678", true}, + {"comfortable", "Mid-Range-1", true}, + {"at the bcrypt limit", strings.Repeat("a", 72), true}, + {"past the bcrypt limit", strings.Repeat("a", 73), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateOwnerPassword(tc.pw) + if tc.ok && err != nil { + t.Errorf("validateOwnerPassword(%d bytes) = %v, want nil", len(tc.pw), err) + } + if !tc.ok && err == nil { + t.Errorf("validateOwnerPassword(%d bytes) = nil, want error", len(tc.pw)) + } + }) + } +} + func TestProvisionOwner(t *testing.T) { ctx := context.Background() t.Run("happy path mints a must-change admin with a verifiable hash", func(t *testing.T) { f := &fakeOwnerStore{} - pw, err := provisionOwner(ctx, f, "owner", "me@example.com") - if err != nil { + const pw = "valid-test-pw" + if err := provisionOwner(ctx, f, "owner", "me@example.com", pw); err != nil { t.Fatalf("provisionOwner: %v", err) } if len(f.upserts) != 1 { @@ -70,19 +142,18 @@ func TestProvisionOwner(t *testing.T) { if !strings.HasPrefix(got.id, "usr-") { t.Errorf("id = %q, want usr- prefix", got.id) } - // The plaintext is returned only to be shown; only the hash is stored. The - // returned password must verify against the stored hash. + // Only the hash is stored; the typed plaintext must verify against it. if bcrypt.CompareHashAndPassword([]byte(got.passwordHash), []byte(pw)) != nil { - t.Error("returned password does not verify against the stored hash") + t.Error("typed password does not verify against the stored hash") } - if pw == got.passwordHash { + if got.passwordHash == pw { t.Error("stored hash equals plaintext — password was not hashed") } }) t.Run("trims surrounding whitespace", func(t *testing.T) { f := &fakeOwnerStore{} - if _, err := provisionOwner(ctx, f, " owner ", " e@x.io "); err != nil { + if err := provisionOwner(ctx, f, " owner ", " e@x.io ", "valid-test-pw"); err != nil { t.Fatalf("provisionOwner: %v", err) } if f.upserts[0].username != "owner" || f.upserts[0].email != "e@x.io" { @@ -92,7 +163,7 @@ func TestProvisionOwner(t *testing.T) { t.Run("rejects an empty username before any write", func(t *testing.T) { f := &fakeOwnerStore{} - if _, err := provisionOwner(ctx, f, " ", ""); err == nil { + if err := provisionOwner(ctx, f, " ", "", "valid-test-pw"); err == nil { t.Fatal("want error for empty username") } if len(f.upserts) != 0 { @@ -100,25 +171,20 @@ func TestProvisionOwner(t *testing.T) { } }) - t.Run("propagates a store error", func(t *testing.T) { - f := &fakeOwnerStore{upsertErr: errors.New("boom")} - if _, err := provisionOwner(ctx, f, "owner", ""); err == nil { - t.Fatal("want error when the store fails") + t.Run("rejects a weak password before any write", func(t *testing.T) { + f := &fakeOwnerStore{} + if err := provisionOwner(ctx, f, "owner", "", "short"); err == nil { + t.Fatal("want error for a sub-8-byte password") + } + if len(f.upserts) != 0 { + t.Errorf("want no upsert on weak password, got %d", len(f.upserts)) } }) - t.Run("two runs mint distinct passwords", func(t *testing.T) { - f := &fakeOwnerStore{} - a, err := provisionOwner(ctx, f, "owner", "") - if err != nil { - t.Fatal(err) - } - b, err := provisionOwner(ctx, f, "owner", "") - if err != nil { - t.Fatal(err) - } - if a == b { - t.Error("two provisions produced the same bootstrap password") + t.Run("propagates a store error", func(t *testing.T) { + f := &fakeOwnerStore{upsertErr: errors.New("boom")} + if err := provisionOwner(ctx, f, "owner", "", "valid-test-pw"); err == nil { + t.Fatal("want error when the store fails") } }) } @@ -128,9 +194,9 @@ func TestEnableLocalAuth(t *testing.T) { if err := enableLocalAuth(context.Background(), f); err != nil { t.Fatalf("enableLocalAuth: %v", err) } - raw, ok := f.settings[localAuthEnabledSettingKey] + raw, ok := f.settings[api.LocalAuthEnabledKey] if !ok { - t.Fatalf("setting %q not written", localAuthEnabledSettingKey) + t.Fatalf("setting %q not written", api.LocalAuthEnabledKey) } // Mirror how the live API parses the toggle (session.go localAuthEnabled): the // stored jsonb must round-trip to the bool true, or the gate fails closed and the @@ -158,9 +224,9 @@ func TestGenerateBootstrapPassword(t *testing.T) { t.Errorf("password contains out-of-alphabet rune %q", c) } } - // bcrypt's hard limit is 72 bytes; a bootstrap password must stay well under it. - if len(pw) > 72 { - t.Errorf("length %d exceeds bcrypt's 72-byte limit", len(pw)) + // A generated password must satisfy the same rule provisionOwner enforces. + if err := validateOwnerPassword(pw); err != nil { + t.Errorf("generated password fails validateOwnerPassword: %v", err) } other, err := generateBootstrapPassword() if err != nil { @@ -171,37 +237,396 @@ func TestGenerateBootstrapPassword(t *testing.T) { } } -func TestProvisionCmd(t *testing.T) { - t.Run("performs both load-bearing writes", func(t *testing.T) { - f := &fakeOwnerStore{} - msg := provisionCmd(context.Background(), f, "owner", "")() - pm, ok := msg.(provisionedMsg) +func TestAuthenticateAdmin(t *testing.T) { + ctx := context.Background() + + t.Run("verifies a matching admin credential", func(t *testing.T) { + f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin(t, "root", "correct horse")}} + matched, ok, err := authenticateAdmin(ctx, f, "root", "correct horse") + if err != nil { + t.Fatalf("authenticateAdmin: %v", err) + } if !ok { - t.Fatalf("got %T, want provisionedMsg", msg) + t.Fatal("ok = false, want true for the correct password") } - if pm.err != nil { - t.Fatalf("provisionCmd error: %v", pm.err) - } - if pm.password == "" { - t.Error("empty password in result") - } - if len(f.upserts) != 1 { - t.Errorf("want 1 owner upsert, got %d", len(f.upserts)) - } - if _, ok := f.settings[localAuthEnabledSettingKey]; !ok { - t.Error("local auth was not enabled — login would 403") + if matched != "root" { + t.Errorf("matched = %q, want root", matched) } }) - t.Run("does not enable local auth if the owner write fails", func(t *testing.T) { + t.Run("a wrong password is a non-match, not an error", func(t *testing.T) { + f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin(t, "root", "correct horse")}} + _, ok, err := authenticateAdmin(ctx, f, "root", "wrong") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Error("ok = true, want false for a wrong password") + } + }) + + t.Run("a non-admin role can never attribute a break-glass", func(t *testing.T) { + player := mkAdmin(t, "alice", "correct horse") + player.Role = "user" // a player row, even with a hash, is not staff + f := &fakeOwnerStore{users: map[string]*api.StaffUser{"alice": player}} + _, ok, err := authenticateAdmin(ctx, f, "alice", "correct horse") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Error("ok = true, want false for a non-admin role") + } + }) + + t.Run("a hashless admin row is a non-match", func(t *testing.T) { + f := &fakeOwnerStore{users: map[string]*api.StaffUser{ + "ghost": {Username: "ghost", Role: "admin", PasswordHash: ""}, + }} + _, ok, err := authenticateAdmin(ctx, f, "ghost", "anything") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Error("ok = true, want false when no hash is set") + } + }) + + t.Run("an unknown user is a non-match, not an error", func(t *testing.T) { + f := &fakeOwnerStore{} + _, ok, err := authenticateAdmin(ctx, f, "nobody", "pw") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Error("ok = true, want false for an unknown user") + } + }) + + t.Run("empty input is a non-match with no store call", func(t *testing.T) { + f := &fakeOwnerStore{userErr: errors.New("must not be called")} + if _, ok, err := authenticateAdmin(ctx, f, "", "pw"); ok || err != nil { + t.Errorf("empty username: ok=%v err=%v, want false,nil", ok, err) + } + if _, ok, err := authenticateAdmin(ctx, f, "root", ""); ok || err != nil { + t.Errorf("empty password: ok=%v err=%v, want false,nil", ok, err) + } + }) + + t.Run("a datastore fault is surfaced", func(t *testing.T) { + f := &fakeOwnerStore{userErr: errors.New("db down")} + if _, _, err := authenticateAdmin(ctx, f, "root", "pw"); err == nil { + t.Fatal("want error when the store fails") + } + }) +} + +// auditOf decodes the single recorded audit row's payload for assertions. +func auditOf(t *testing.T, f *fakeOwnerStore) (api.AuditEntry, map[string]any) { + t.Helper() + if len(f.audits) != 1 { + t.Fatalf("want exactly 1 audit row, got %d", len(f.audits)) + } + e := f.audits[0] + var payload map[string]any + if err := json.Unmarshal(e.Payload, &payload); err != nil { + t.Fatalf("audit payload is not valid JSON: %v", err) + } + return e, payload +} + +func TestPerformBreakGlass(t *testing.T) { + ctx := context.Background() + + t.Run("bootstrap uses the typed password and never echoes it", func(t *testing.T) { + f := &fakeOwnerStore{} + op := breakGlassOp{ + mode: "bootstrap", + accountable: "deploybot", + osUser: "deploybot", + ownerUsername: "owner", + ownerEmail: "owner@example.com", + ownerPassword: "valid-test-pw", + } + out, err := performBreakGlass(ctx, f, op) + if err != nil { + t.Fatalf("performBreakGlass: %v", err) + } + // The operator typed their own password, so it must NOT be surfaced for display. + if out.displayPassword != "" { + t.Errorf("displayPassword = %q, want empty for a typed bootstrap password", out.displayPassword) + } + if out.auditErr != nil { + t.Errorf("auditErr = %v, want nil", out.auditErr) + } + if len(f.upserts) != 1 || bcrypt.CompareHashAndPassword([]byte(f.upserts[0].passwordHash), []byte("valid-test-pw")) != nil { + t.Error("owner was not provisioned with the typed password") + } + if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok { + t.Error("local auth was not enabled — login would 403") + } + e, payload := auditOf(t, f) + if e.Actor != "deploybot" || e.Source != "break-glass" || e.Action != "break_glass.bootstrap" { + t.Errorf("audit envelope = %+v, want actor=deploybot source=break-glass action=break_glass.bootstrap", e) + } + if payload["verified"] != false { + t.Errorf("payload.verified = %v, want false for bootstrap", payload["verified"]) + } + if _, present := payload["admin_account"]; present { + t.Error("payload.admin_account present, want omitted when no admin was attempted") + } + if payload["os_user"] != "deploybot" || payload["owner"] != "owner" { + t.Errorf("payload = %v, want os_user=deploybot owner=owner", payload) + } + }) + + t.Run("recovery generates a one-time password and records a verified row", func(t *testing.T) { + f := &fakeOwnerStore{} + op := breakGlassOp{ + mode: "recovery", + accountable: "root", + osUser: "alice", + ownerUsername: "owner", + attemptedAdmin: "root", + } + out, err := performBreakGlass(ctx, f, op) + if err != nil { + t.Fatalf("performBreakGlass: %v", err) + } + if out.displayPassword == "" { + t.Fatal("displayPassword empty, want a generated one-time password") + } + // The shown password must be the one actually stored (as a hash). + if bcrypt.CompareHashAndPassword([]byte(f.upserts[0].passwordHash), []byte(out.displayPassword)) != nil { + t.Error("displayed password does not match the stored hash") + } + e, payload := auditOf(t, f) + if e.Actor != "root" || e.Action != "break_glass.recovery" { + t.Errorf("audit envelope = %+v, want actor=root action=break_glass.recovery", e) + } + if payload["verified"] != true { + t.Errorf("payload.verified = %v, want true for recovery", payload["verified"]) + } + if payload["admin_account"] != "root" { + t.Errorf("payload.admin_account = %v, want root", payload["admin_account"]) + } + }) + + t.Run("root override records an unverified row attributed to the OS user", func(t *testing.T) { + f := &fakeOwnerStore{} + op := breakGlassOp{ + mode: "root_override", + accountable: "alice", + osUser: "alice", + ownerUsername: "owner", + attemptedAdmin: "typo-admin", + } + out, err := performBreakGlass(ctx, f, op) + if err != nil { + t.Fatalf("performBreakGlass: %v", err) + } + if out.displayPassword == "" { + t.Error("displayPassword empty, want a generated one-time password") + } + e, payload := auditOf(t, f) + if e.Actor != "alice" || e.Action != "break_glass.root_override" { + t.Errorf("audit envelope = %+v, want actor=alice action=break_glass.root_override", e) + } + if payload["verified"] != false { + t.Errorf("payload.verified = %v, want false for root override", payload["verified"]) + } + // The attempted (failed) admin is preserved so the trail shows what was tried. + if payload["admin_account"] != "typo-admin" { + t.Errorf("payload.admin_account = %v, want typo-admin", payload["admin_account"]) + } + }) + + t.Run("an audit failure does not fail the recovery", func(t *testing.T) { + f := &fakeOwnerStore{auditErr: errors.New("audit sink down")} + op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"} + out, err := performBreakGlass(ctx, f, op) + if err != nil { + t.Fatalf("performBreakGlass returned %v, want nil — break-glass must survive a dead audit sink", err) + } + if out.auditErr == nil { + t.Error("auditErr = nil, want the surfaced audit failure") + } + // The load-bearing writes must still have happened. + if len(f.upserts) != 1 { + t.Error("owner was not provisioned despite a recoverable audit failure") + } + if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok { + t.Error("local auth was not enabled despite a recoverable audit failure") + } + }) + + t.Run("does not enable local auth or audit if the owner write fails", func(t *testing.T) { f := &fakeOwnerStore{upsertErr: errors.New("boom")} - msg := provisionCmd(context.Background(), f, "owner", "")() - pm := msg.(provisionedMsg) - if pm.err == nil { + op := breakGlassOp{mode: "bootstrap", accountable: "root", osUser: "root", ownerUsername: "owner", ownerPassword: "valid-test-pw"} + if _, err := performBreakGlass(ctx, f, op); err == nil { t.Fatal("want error when the owner write fails") } if len(f.settings) != 0 { t.Error("local auth should not be enabled when provisioning failed") } + if len(f.audits) != 0 { + t.Error("no audit row should be written when provisioning failed") + } + }) + + t.Run("records accountability before enabling local auth, surviving an enableLocalAuth failure", func(t *testing.T) { + // The credential is reset by provisionOwner; if the audit were written only + // after enableLocalAuth, a failed toggle write would leave that reset with no + // "who did it" row. Order guarantees the accountability row lands first. + f := &fakeOwnerStore{setErr: errors.New("settings write down")} + op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"} + if _, err := performBreakGlass(ctx, f, op); err == nil { + t.Fatal("want error when enableLocalAuth fails") + } + if len(f.upserts) != 1 { + t.Error("owner should have been provisioned before the toggle write failed") + } + if len(f.audits) != 1 { + t.Fatalf("accountability row count = %d, want 1 — the audit must precede enableLocalAuth", len(f.audits)) + } + if f.audits[0].Actor != "root" || f.audits[0].Action != "break_glass.recovery" { + t.Errorf("audit = %+v, want actor=root action=break_glass.recovery", f.audits[0]) + } + }) +} + +func TestAccountableOSUser(t *testing.T) { + t.Run("prefers SUDO_USER", func(t *testing.T) { + t.Setenv("SUDO_USER", "alice") + if got := accountableOSUser(); got != "alice" { + t.Errorf("accountableOSUser() = %q, want alice", got) + } + }) + t.Run("falls back to root when SUDO_USER is unset", func(t *testing.T) { + t.Setenv("SUDO_USER", "") + if got := accountableOSUser(); got != "root" { + t.Errorf("accountableOSUser() = %q, want root", got) + } + }) +} + +// testRoot is the sanctioned placeholder root domain for tests (never a real host). +const testRoot = "mc.example.net" + +// advance feeds one message to the model and returns it re-typed as *bgModel, so the +// state-machine assertions can read the resolved fields. The returned cmd is dropped: +// these tests drive the gating transitions directly (authResultMsg / key presses) +// rather than running the off-goroutine store commands. +func advance(t *testing.T, m *bgModel, msg tea.Msg) *bgModel { + t.Helper() + next, _ := m.Update(msg) + bm, ok := next.(*bgModel) + if !ok { + t.Fatalf("Update returned %T, want *bgModel", next) + } + return bm +} + +// TestBGModelGating drives the break-glass state machine headlessly to lock in the +// accountability gate: a credential never advances to provisioning without either a +// verified admin (recovery) or a deliberate, explicit OVERRIDE (root override), and +// the resolved actor matches the path taken. This is the "which SysAdmin" guarantee. +func TestBGModelGating(t *testing.T) { + ctx := context.Background() + + t.Run("recovery starts at auth; a non-matching credential offers override, never provision", func(t *testing.T) { + m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true) + if m.step != stepAuth || m.mode != "" { + t.Fatalf("initial step/mode = %v/%q, want stepAuth and an unresolved mode", m.step, m.mode) + } + m = advance(t, m, authResultMsg{ok: false}) + if m.step != stepOverride { + t.Errorf("after a non-matching credential step = %v, want stepOverride", m.step) + } + if m.mode == "recovery" { + t.Error("mode must NOT become recovery on a failed credential — that would forge attribution") + } + }) + + t.Run("recovery with a verified admin enters provision attributed to that admin", func(t *testing.T) { + m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true) + m = advance(t, m, authResultMsg{matched: "bob", ok: true}) + if m.step != stepProvision { + t.Fatalf("step = %v, want stepProvision", m.step) + } + if m.mode != "recovery" || m.accountable != "bob" { + t.Errorf("mode/accountable = %q/%q, want recovery/bob (the verified admin, not the OS user)", m.mode, m.accountable) + } + // Recovery generates the one-time password, so no password fields are shown. + if len(m.inputs) != 2 { + t.Errorf("recovery provision inputs = %d, want 2 (owner, email — no typed password)", len(m.inputs)) + } + }) + + t.Run("an auth lookup error surfaces an error screen, not a silent override", func(t *testing.T) { + m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true) + m = advance(t, m, authResultMsg{err: errors.New("db unreachable")}) + if m.step != stepError || m.err == nil { + t.Errorf("step/err = %v/%v, want stepError with a non-nil err", m.step, m.err) + } + }) + + t.Run("the root override requires the exact OVERRIDE token", func(t *testing.T) { + m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true) + m = advance(t, m, authResultMsg{ok: false}) // → stepOverride + m.inputs[0].SetValue("override") // wrong case must not pass + m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.step != stepOverride || m.formErr == "" { + t.Errorf("wrong token: step/formErr = %v/%q, want stay on stepOverride with an error", m.step, m.formErr) + } + if m.mode == "root_override" { + t.Error("mode must not flip to root_override without the exact token") + } + m.inputs[0].SetValue(breakGlassOverrideToken) + m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.step != stepProvision || m.mode != "root_override" || m.accountable != "alice" { + t.Errorf("after OVERRIDE: step/mode/accountable = %v/%q/%q, want stepProvision/root_override/alice (the OS user)", m.step, m.mode, m.accountable) + } + }) + + t.Run("empty admin credentials do not start a verification", func(t *testing.T) { + m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "alice", true) + m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) // both inputs blank + if m.step != stepAuth || m.formErr == "" { + t.Errorf("blank submit: step/formErr = %v/%q, want stay on stepAuth with an error", m.step, m.formErr) + } + }) + + t.Run("bootstrap starts at provision as the OS user and requires a valid, matching password", func(t *testing.T) { + f := &fakeOwnerStore{} + m := newBGModel(ctx, f, testRoot, "deploybot", false) + if m.step != stepProvision || m.mode != "bootstrap" || m.accountable != "deploybot" { + t.Fatalf("initial step/mode/accountable = %v/%q/%q, want stepProvision/bootstrap/deploybot", m.step, m.mode, m.accountable) + } + if len(m.inputs) != 4 { + t.Fatalf("bootstrap inputs = %d, want 4 (owner, email, password, confirm)", len(m.inputs)) + } + // Too-short password is blocked, with no writes. + m.inputs[2].SetValue("short") + m.inputs[3].SetValue("short") + m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.step != stepProvision || m.formErr == "" { + t.Errorf("weak password: step/formErr = %v/%q, want stay on stepProvision with an error", m.step, m.formErr) + } + // A mismatched confirmation is blocked. + m.inputs[2].SetValue("valid-test-pw") + m.inputs[3].SetValue("valid-test-XX") + m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.step != stepProvision || m.formErr == "" { + t.Errorf("mismatch: step/formErr = %v/%q, want stay on stepProvision with an error", m.step, m.formErr) + } + if len(f.upserts) != 0 { + t.Error("no owner should be provisioned while the form is invalid") + } + // Valid + matching advances to the working state (the write is dispatched). + m.inputs[3].SetValue("valid-test-pw") + m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.step != stepWorking || m.ownerUsername != "owner" { + t.Errorf("valid submit: step/owner = %v/%q, want stepWorking/owner", m.step, m.ownerUsername) + } }) } diff --git a/internal/api/handlers_auth_test.go b/internal/api/handlers_auth_test.go index 88392ac..9bd27ce 100644 --- a/internal/api/handlers_auth_test.go +++ b/internal/api/handlers_auth_test.go @@ -23,7 +23,7 @@ import ( func seedAuthAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) { t.Helper() repo := newFakeRepo() - repo.settings[localAuthEnabledKey] = []byte("true") + repo.settings[LocalAuthEnabledKey] = []byte("true") hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { t.Fatalf("hash seed password: %v", err) diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index ba122a0..85bffdd 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -351,10 +351,16 @@ func (p *PGRepo) LatestBackup(ctx context.Context, serverName string) (*BackupRe } func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error { + // A nil Payload must land as SQL NULL, not the text "null"; a non-nil Payload is + // passed as a JSON text the jsonb column parses (same idiom as reaper.PGStore). + var payload any + if len(e.Payload) > 0 { + payload = string(e.Payload) + } _, err := p.db.ExecContext(ctx, - `INSERT INTO audit_logs (actor, source, action, server_name, request_id) - VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''))`, - e.Actor, e.Source, e.Action, e.ServerName, e.RequestID) + `INSERT INTO audit_logs (actor, source, action, server_name, request_id, payload) + VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6)`, + e.Actor, e.Source, e.Action, e.ServerName, e.RequestID, payload) return err } @@ -379,6 +385,22 @@ func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUse return &u, nil } +// AdminExists reports whether any authenticatable staff account already exists — +// an admin row WITH a bcrypt password hash. It is the break-glass console's +// bootstrap-vs-recovery switch: false means the typed credential mints the first +// Owner (no prior identity to verify against), true means the operator must +// identify against an existing admin for accountability. It is not on the Repo +// interface because only the break-glass CLI consults it. +func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) { + const q = `SELECT EXISTS ( + SELECT 1 FROM users WHERE role = 'admin' AND password_hash IS NOT NULL)` + var exists bool + if err := p.db.QueryRowContext(ctx, q).Scan(&exists); err != nil { + return false, err + } + return exists, nil +} + // UserByID loads the same staff projection by id, or ErrNotFound. The // change-password flow re-verifies the caller's current password with it: the // session yields a user id, not a username. diff --git a/internal/api/repo.go b/internal/api/repo.go index bb54adc..c5032b1 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -35,6 +35,12 @@ type AuditEntry struct { Action string ServerName string RequestID string + // Payload is an optional structured detail blob stored in the audit_logs.payload + // jsonb column. It MUST be valid JSON or nil; nil (the zero value) is stored as + // SQL NULL, so existing callers that leave it unset are unaffected. The + // break-glass console uses it to record the accountability detail (mode, target + // owner, OS user, admin account) that does not fit the flat columns. + Payload []byte } // BackupView is one row of GET /api/v1/backups (spec §7, world_backups in §22). diff --git a/internal/api/session.go b/internal/api/session.go index 243bed1..5229b08 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -28,12 +28,15 @@ const ( sessionCookieName = "felis_session" // sessionTTL bounds a local-password session. Staff re-authenticate after it. sessionTTL = 12 * time.Hour - // localAuthEnabledKey gates whether local-password sessions are honored. It is - // flipped on by `felis breakGlass` direct-to-Postgres at first-run and read - // live per-request, so enabling local auth needs no pod roll. - localAuthEnabledKey = "local_auth_enabled" ) +// LocalAuthEnabledKey is the platform_settings key that gates whether +// local-password sessions are honored. It is flipped on by `felis breakGlass` +// direct-to-Postgres at first-run and read live per-request, so enabling local +// auth needs no pod roll. Exported so the break-glass writer and this +// per-request reader share one source of truth instead of drifting copies. +const LocalAuthEnabledKey = "local_auth_enabled" + // newSessionToken returns a fresh opaque session value (256 bits, URL-safe). It // is the cookie value; only its hash is persisted. func newSessionToken() (string, error) { @@ -161,7 +164,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) { // (minting one) consult it, so the two never disagree about whether local auth is // live. func localAuthEnabled(ctx context.Context, repo Repo) bool { - raw, err := repo.GetSetting(ctx, localAuthEnabledKey) + raw, err := repo.GetSetting(ctx, LocalAuthEnabledKey) if err != nil { return false // ErrNotFound (never enabled) or a transient read error → closed }