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.
This commit is contained in:
6 files changed
+1054
-185
No files matched your search
+537
-124
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -30,32 +31,50 @@ import (
|
|||||||
// interactive TUI, NOT a CLI: bare `felis` prints CLI usage, while `felis breakGlass`
|
// 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).
|
// 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
|
// 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
|
// phase; this file is intentionally scoped to the one provisioning operation that
|
||||||
// makes the local-auth thin thread reachable.
|
// makes the local-auth thin thread reachable.
|
||||||
|
|
||||||
// localAuthEnabledSettingKey is the platform_settings key the live API reads to
|
// breakGlassOverrideToken is the literal an operator must type to proceed when no
|
||||||
// decide whether local-password sessions are honored. It MUST match the
|
// admin credential could be verified. Requiring an explicit, deliberate word (not a
|
||||||
// (unexported) localAuthEnabledKey the api package consults per-request; the VM
|
// bare Enter) keeps the unverified root override from happening by reflex.
|
||||||
// smoke test (login after break-glass) is what guarantees they stay in sync.
|
const breakGlassOverrideToken = "OVERRIDE"
|
||||||
const localAuthEnabledSettingKey = "local_auth_enabled"
|
|
||||||
|
|
||||||
// bootstrapPasswordAlphabet excludes visually ambiguous glyphs (0/O, 1/I/l) so a
|
// 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"
|
const bootstrapPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||||
|
|
||||||
// ownerStore is the minimal repo surface break-glass provisioning needs.
|
// ownerStore is the minimal repo surface the break-glass console needs.
|
||||||
// *api.PGRepo satisfies it; the unit tests drive a fake, so the core provisioning
|
// *api.PGRepo satisfies it; the unit tests drive a fake, so the core logic
|
||||||
// logic is exercised without a database or a terminal.
|
// (authentication, provisioning, accountability audit) is exercised without a
|
||||||
|
// database or a terminal.
|
||||||
type ownerStore interface {
|
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
|
UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error
|
||||||
SetSetting(ctx context.Context, key string, value []byte) 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,
|
// cmdBreakGlass is the `felis breakGlass` entrypoint: the root gate, config load,
|
||||||
// database open, and the interactive TUI. Everything below runBreakGlassTUI is
|
// database open, accountability detection, and the interactive TUI. Everything
|
||||||
// I/O at the edge; the provisioning logic itself is plain functions over
|
// below runBreakGlassTUI is I/O at the edge; the authentication/provisioning/audit
|
||||||
// ownerStore so it stays testable off a terminal.
|
// logic itself is plain functions over ownerStore so it stays testable off a
|
||||||
|
// terminal.
|
||||||
func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||||
fs := flag.NewFlagSet("breakGlass", flag.ContinueOnError)
|
fs := flag.NewFlagSet("breakGlass", flag.ContinueOnError)
|
||||||
fs.SetOutput(stderr)
|
fs.SetOutput(stderr)
|
||||||
@@ -88,7 +107,17 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
|||||||
defer drv.Close()
|
defer drv.Close()
|
||||||
|
|
||||||
repo := api.NewPGRepo(drv.DB())
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -99,19 +128,43 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// The TUI runs on the alternate screen, which is torn down on exit and takes the
|
// The TUI runs on the alternate screen, which is torn down on exit and takes its
|
||||||
// in-console password display with it. Re-print a durable summary to the normal
|
// display with it. Re-print a durable summary to the normal screen so the
|
||||||
// screen so the one-time bootstrap password survives in scrollback long enough
|
// outcome — and any generated one-time password — survives in scrollback long
|
||||||
// for the operator to log in. It is shown, never persisted: only the bcrypt hash
|
// enough for the operator to log in.
|
||||||
// reached the database.
|
|
||||||
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
|
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 != "" {
|
if res.rootDomain != "" {
|
||||||
fmt.Fprintf(stdout, "Log in at https://op.console.%s with that username and password.\n", res.rootDomain)
|
fmt.Fprintf(stdout, "Log in at https://op.console.%s with that username and password.\n", res.rootDomain)
|
||||||
}
|
}
|
||||||
return 0
|
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
|
// 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
|
// 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
|
// give uuid-equivalent entropy and the value is path/argv-safe lowercase hex. A
|
||||||
@@ -150,72 +203,208 @@ func generateBootstrapPassword() (string, error) {
|
|||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// provisionOwner mints or resets the single Owner account direct-to-Postgres and
|
// validateOwnerPassword mirrors api.validateNewPassword (handlers_auth.go): a
|
||||||
// returns the one-time bootstrap password to display. The account is created with
|
// 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
|
// 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
|
// 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
|
// login. Only the bcrypt hash reaches the database; the plaintext never does.
|
||||||
// is never logged or persisted; only its bcrypt hash reaches the database.
|
func provisionOwner(ctx context.Context, s ownerStore, username, email, password string) error {
|
||||||
func provisionOwner(ctx context.Context, s ownerStore, username, email string) (string, error) {
|
|
||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
if 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()
|
id := newOwnerID()
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return "", errors.New("generate owner id: entropy source failed")
|
return errors.New("generate owner id: entropy source failed")
|
||||||
}
|
|
||||||
password, err := generateBootstrapPassword()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
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 {
|
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
|
// enableLocalAuth flips the runtime local_auth_enabled toggle on
|
||||||
// direct-to-Postgres. It is the second load-bearing write of break-glass: without
|
// direct-to-Postgres. It is a load-bearing write of break-glass: without it
|
||||||
// it handleLogin returns 403 and the freshly provisioned Owner cannot log in, so a
|
// 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.
|
// successful provisionOwner with local auth off is not a usable thin thread.
|
||||||
func enableLocalAuth(ctx context.Context, s ownerStore) error {
|
func enableLocalAuth(ctx context.Context, s ownerStore) error {
|
||||||
// The setting is read back with json.Unmarshal into a bool, so the stored jsonb
|
// The setting is read back with json.Unmarshal into a bool, so the stored jsonb
|
||||||
// value must be the literal true.
|
// 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 fmt.Errorf("enable local auth: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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) ----
|
// ---- interactive TUI (the untested shell over the tested core) ----
|
||||||
|
|
||||||
// breakGlassResult is what the TUI hands back to cmdBreakGlass for the durable
|
// breakGlassResult is what the TUI hands back to cmdBreakGlass for the durable
|
||||||
// post-exit summary. provisioned is false on cancel.
|
// post-exit summary. provisioned is false on cancel.
|
||||||
type breakGlassResult struct {
|
type breakGlassResult struct {
|
||||||
provisioned bool
|
provisioned bool
|
||||||
|
mode string
|
||||||
|
accountable string
|
||||||
|
osUser string
|
||||||
username string
|
username string
|
||||||
password string
|
displayPassword string // empty when the operator typed their own bootstrap password
|
||||||
|
auditWarning string
|
||||||
rootDomain string
|
rootDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
type bgState int
|
type bgStep int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateForm bgState = iota
|
stepAuth bgStep = iota // recovery: authenticate as an existing admin
|
||||||
stateWorking
|
stepOverride // recovery: admin auth failed → deliberate root override
|
||||||
stateDone
|
stepProvision // collect the Owner target (and password, in bootstrap)
|
||||||
stateError
|
stepWorking
|
||||||
|
stepDone
|
||||||
|
stepError
|
||||||
)
|
)
|
||||||
|
|
||||||
// provisionedMsg is delivered to the model when the background provisioning
|
// authResultMsg carries the outcome of the off-goroutine admin credential check.
|
||||||
// command finishes.
|
type authResultMsg struct {
|
||||||
type provisionedMsg struct {
|
matched string
|
||||||
password string
|
ok bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// performedMsg carries the outcome of the off-goroutine break-glass writes.
|
||||||
|
type performedMsg struct {
|
||||||
|
outcome breakGlassOutcome
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,56 +413,128 @@ var (
|
|||||||
bgLabelStyle = lipgloss.NewStyle().Bold(true)
|
bgLabelStyle = lipgloss.NewStyle().Bold(true)
|
||||||
bgHintStyle = lipgloss.NewStyle().Faint(true)
|
bgHintStyle = lipgloss.NewStyle().Faint(true)
|
||||||
bgErrStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9"))
|
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"))
|
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)
|
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)
|
bgBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
// bgModel is the bubbletea model for the provisioning console. It is a pointer
|
// bgModel is the bubbletea model for the break-glass console. It is a pointer model
|
||||||
// model so Update can mutate in place; the only field touched from the background
|
// so Update can mutate in place; fields touched from a background command are read
|
||||||
// command is read after the command returns via provisionedMsg.
|
// only after that command returns via authResultMsg / performedMsg.
|
||||||
type bgModel struct {
|
type bgModel struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
store ownerStore
|
store ownerStore
|
||||||
rootDomain string
|
rootDomain string
|
||||||
|
osUser string
|
||||||
|
adminExists bool
|
||||||
|
|
||||||
|
step bgStep
|
||||||
inputs []textinput.Model
|
inputs []textinput.Model
|
||||||
focus int
|
focus int
|
||||||
state bgState
|
|
||||||
formErr string
|
formErr string
|
||||||
|
working string
|
||||||
|
|
||||||
username string
|
// resolved as the flow advances
|
||||||
password string
|
mode string
|
||||||
|
accountable string
|
||||||
|
attemptedAdmin string
|
||||||
|
|
||||||
|
// result
|
||||||
|
ownerUsername string
|
||||||
|
displayPassword string
|
||||||
|
auditWarning string
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBGModel(ctx context.Context, s ownerStore, rootDomain string) *bgModel {
|
func newBGModel(ctx context.Context, s ownerStore, rootDomain, osUser string, adminExists bool) *bgModel {
|
||||||
user := textinput.New()
|
m := &bgModel{
|
||||||
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,
|
ctx: ctx,
|
||||||
store: s,
|
store: s,
|
||||||
rootDomain: rootDomain,
|
rootDomain: rootDomain,
|
||||||
inputs: []textinput.Model{user, email},
|
osUser: osUser,
|
||||||
focus: 0,
|
adminExists: adminExists,
|
||||||
state: stateForm,
|
|
||||||
}
|
}
|
||||||
|
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 }
|
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 {
|
func (m *bgModel) focusInput(i int) tea.Cmd {
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
i = len(m.inputs) - 1
|
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) {
|
func (m *bgModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case provisionedMsg:
|
case authResultMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.state, m.err = stateError, msg.err
|
m.step, m.err = stepError, msg.err
|
||||||
} else {
|
return m, nil
|
||||||
m.state, m.password = stateDone, msg.password
|
}
|
||||||
|
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
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch m.state {
|
switch m.step {
|
||||||
case stateDone, stateError:
|
case stepDone, stepError:
|
||||||
// Any key dismisses the terminal screen.
|
// Any key dismisses the terminal screen.
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case stateWorking:
|
case stepWorking:
|
||||||
// Ignore input while the database write is in flight.
|
// Ignore input while a database write is in flight.
|
||||||
return m, nil
|
return m, nil
|
||||||
case stateForm:
|
default:
|
||||||
|
return m.handleFormKey(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, m.updateInputs(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *bgModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
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
|
return m, tea.Quit
|
||||||
case "tab", "down":
|
case "tab", "down":
|
||||||
return m, m.focusInput(m.focus + 1)
|
return m, m.focusInput(m.focus + 1)
|
||||||
case "shift+tab", "up":
|
case "shift+tab", "up":
|
||||||
return m, m.focusInput(m.focus - 1)
|
return m, m.focusInput(m.focus - 1)
|
||||||
case "enter":
|
case "enter":
|
||||||
if strings.TrimSpace(m.inputs[0].Value()) == "" {
|
return m.submit()
|
||||||
m.formErr = "username is required"
|
}
|
||||||
|
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)
|
return m, m.focusInput(0)
|
||||||
}
|
}
|
||||||
m.username = strings.TrimSpace(m.inputs[0].Value())
|
email := m.inputs[1].Value()
|
||||||
m.state, m.formErr = stateWorking, ""
|
password := "" // empty => performBreakGlass generates a one-time password
|
||||||
return m, provisionCmd(m.ctx, m.store, m.username, m.inputs[1].Value())
|
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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := m.updateInputs(msg)
|
// performCmd runs the break-glass writes off the UI goroutine.
|
||||||
return m, cmd
|
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 {
|
func (m *bgModel) View() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(bgTitleStyle.Render("⚠ FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE") + "\n\n")
|
b.WriteString(bgTitleStyle.Render("⚠ FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE") + "\n\n")
|
||||||
|
|
||||||
switch m.state {
|
switch m.step {
|
||||||
case stateForm:
|
case stepAuth:
|
||||||
b.WriteString("Provision (or reset) the Owner account and enable local-password login.\n")
|
b.WriteString("A staff account already exists. Identify yourself before breaking the glass.\n")
|
||||||
b.WriteString("This writes directly to Postgres, bypassing the web Zero-Trust path.\n\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(bgLabelStyle.Render("Owner username") + "\n")
|
||||||
b.WriteString(m.inputs[0].View() + "\n\n")
|
b.WriteString(m.inputs[0].View() + "\n\n")
|
||||||
b.WriteString(bgLabelStyle.Render("Owner email (optional)") + "\n")
|
b.WriteString(bgLabelStyle.Render("Owner email (optional)") + "\n")
|
||||||
b.WriteString(m.inputs[1].View() + "\n\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 != "" {
|
if m.formErr != "" {
|
||||||
b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n")
|
b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n")
|
||||||
}
|
}
|
||||||
b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n")
|
b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n")
|
||||||
|
|
||||||
case stateWorking:
|
case stepWorking:
|
||||||
b.WriteString("Provisioning the Owner account…\n")
|
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")
|
b.WriteString(bgOKStyle.Render("✓ Owner provisioned · local-password login ENABLED") + "\n\n")
|
||||||
box := bgLabelStyle.Render("username ") + m.username + "\n" +
|
box := bgLabelStyle.Render("username ") + m.ownerUsername
|
||||||
bgLabelStyle.Render("password ") + bgPwStyle.Render(m.password)
|
if m.displayPassword != "" {
|
||||||
|
box += "\n" + bgLabelStyle.Render("password ") + bgPwStyle.Render(m.displayPassword)
|
||||||
|
}
|
||||||
b.WriteString(bgBoxStyle.Render(box) + "\n\n")
|
b.WriteString(bgBoxStyle.Render(box) + "\n\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")
|
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")
|
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 != "" {
|
if m.rootDomain != "" {
|
||||||
b.WriteString("Log in at " + bgLabelStyle.Render("https://op.console."+m.rootDomain) + "\n\n")
|
b.WriteString("Log in at " + bgLabelStyle.Render("https://op.console."+m.rootDomain) + "\n\n")
|
||||||
}
|
}
|
||||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||||
|
|
||||||
case stateError:
|
case stepError:
|
||||||
b.WriteString(bgErrStyle.Render("✗ Provisioning failed") + "\n\n")
|
b.WriteString(bgErrStyle.Render("✗ Break-glass failed") + "\n\n")
|
||||||
b.WriteString(m.err.Error() + "\n\n")
|
b.WriteString(m.err.Error() + "\n\n")
|
||||||
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
b.WriteString(bgHintStyle.Render("press any key to exit") + "\n")
|
||||||
}
|
}
|
||||||
return b.String()
|
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
|
// runBreakGlassTUI drives the bubbletea program and projects the final model onto a
|
||||||
// breakGlassResult. It is the thin, untested shell; the logic it invokes
|
// breakGlassResult. It is the thin, untested shell; the logic it invokes
|
||||||
// (provisionOwner / enableLocalAuth) is unit-tested directly.
|
// (authenticateAdmin / performBreakGlass) is unit-tested directly.
|
||||||
func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain string) (breakGlassResult, error) {
|
func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||||
final, err := tea.NewProgram(newBGModel(ctx, s, rootDomain), tea.WithAltScreen()).Run()
|
final, err := tea.NewProgram(newBGModel(ctx, s, rootDomain, osUser, adminExists), tea.WithAltScreen()).Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return breakGlassResult{}, err
|
return breakGlassResult{}, err
|
||||||
}
|
}
|
||||||
@@ -413,13 +822,17 @@ func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain string) (bre
|
|||||||
if !ok {
|
if !ok {
|
||||||
return breakGlassResult{}, errors.New("unexpected final model")
|
return breakGlassResult{}, errors.New("unexpected final model")
|
||||||
}
|
}
|
||||||
if m.state == stateError {
|
if m.step == stepError {
|
||||||
return breakGlassResult{}, m.err
|
return breakGlassResult{}, m.err
|
||||||
}
|
}
|
||||||
return breakGlassResult{
|
return breakGlassResult{
|
||||||
provisioned: m.state == stateDone,
|
provisioned: m.step == stepDone,
|
||||||
username: m.username,
|
mode: m.mode,
|
||||||
password: m.password,
|
accountable: m.accountable,
|
||||||
|
osUser: m.osUser,
|
||||||
|
username: m.ownerUsername,
|
||||||
|
displayPassword: m.displayPassword,
|
||||||
|
auditWarning: m.auditWarning,
|
||||||
rootDomain: rootDomain,
|
rootDomain: rootDomain,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
+477
-52
@@ -7,16 +7,27 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/api"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeOwnerStore records what break-glass provisioning writes, so the core logic
|
// fakeOwnerStore records what break-glass provisioning writes and answers the
|
||||||
// is exercised without a database or a terminal.
|
// identity lookups, so the core logic (authentication, provisioning, accountability
|
||||||
|
// audit) is exercised without a database or a terminal.
|
||||||
type fakeOwnerStore struct {
|
type fakeOwnerStore struct {
|
||||||
upserts []upsertCall
|
upserts []upsertCall
|
||||||
settings map[string][]byte
|
settings map[string][]byte
|
||||||
|
audits []api.AuditEntry
|
||||||
|
users map[string]*api.StaffUser // keyed by username
|
||||||
|
admins bool // AdminExists answer
|
||||||
|
|
||||||
upsertErr error
|
upsertErr error
|
||||||
setErr error
|
setErr error
|
||||||
|
auditErr error
|
||||||
|
userErr error // non-not-found error from UserByUsername
|
||||||
|
adminErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
type upsertCall struct {
|
type upsertCall struct {
|
||||||
@@ -24,6 +35,23 @@ type upsertCall struct {
|
|||||||
mustChange bool
|
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 {
|
func (f *fakeOwnerStore) UpsertOwner(_ context.Context, id, username, email, passwordHash string, mustChange bool) error {
|
||||||
if f.upsertErr != nil {
|
if f.upsertErr != nil {
|
||||||
return f.upsertErr
|
return f.upsertErr
|
||||||
@@ -43,13 +71,57 @@ func (f *fakeOwnerStore) SetSetting(_ context.Context, key string, value []byte)
|
|||||||
return nil
|
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) {
|
func TestProvisionOwner(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
t.Run("happy path mints a must-change admin with a verifiable hash", func(t *testing.T) {
|
t.Run("happy path mints a must-change admin with a verifiable hash", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{}
|
f := &fakeOwnerStore{}
|
||||||
pw, err := provisionOwner(ctx, f, "owner", "[email protected]")
|
const pw = "valid-test-pw"
|
||||||
if err != nil {
|
if err := provisionOwner(ctx, f, "owner", "[email protected]", pw); err != nil {
|
||||||
t.Fatalf("provisionOwner: %v", err)
|
t.Fatalf("provisionOwner: %v", err)
|
||||||
}
|
}
|
||||||
if len(f.upserts) != 1 {
|
if len(f.upserts) != 1 {
|
||||||
@@ -70,19 +142,18 @@ func TestProvisionOwner(t *testing.T) {
|
|||||||
if !strings.HasPrefix(got.id, "usr-") {
|
if !strings.HasPrefix(got.id, "usr-") {
|
||||||
t.Errorf("id = %q, want usr- prefix", got.id)
|
t.Errorf("id = %q, want usr- prefix", got.id)
|
||||||
}
|
}
|
||||||
// The plaintext is returned only to be shown; only the hash is stored. The
|
// Only the hash is stored; the typed plaintext must verify against it.
|
||||||
// returned password must verify against the stored hash.
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(got.passwordHash), []byte(pw)) != nil {
|
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.Error("stored hash equals plaintext — password was not hashed")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("trims surrounding whitespace", func(t *testing.T) {
|
t.Run("trims surrounding whitespace", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{}
|
f := &fakeOwnerStore{}
|
||||||
if _, err := provisionOwner(ctx, f, " owner ", " [email protected] "); err != nil {
|
if err := provisionOwner(ctx, f, " owner ", " [email protected] ", "valid-test-pw"); err != nil {
|
||||||
t.Fatalf("provisionOwner: %v", err)
|
t.Fatalf("provisionOwner: %v", err)
|
||||||
}
|
}
|
||||||
if f.upserts[0].username != "owner" || f.upserts[0].email != "[email protected]" {
|
if f.upserts[0].username != "owner" || f.upserts[0].email != "[email protected]" {
|
||||||
@@ -92,7 +163,7 @@ func TestProvisionOwner(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("rejects an empty username before any write", func(t *testing.T) {
|
t.Run("rejects an empty username before any write", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{}
|
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")
|
t.Fatal("want error for empty username")
|
||||||
}
|
}
|
||||||
if len(f.upserts) != 0 {
|
if len(f.upserts) != 0 {
|
||||||
@@ -100,25 +171,20 @@ func TestProvisionOwner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("propagates a store error", func(t *testing.T) {
|
t.Run("rejects a weak password before any write", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{upsertErr: errors.New("boom")}
|
f := &fakeOwnerStore{}
|
||||||
if _, err := provisionOwner(ctx, f, "owner", ""); err == nil {
|
if err := provisionOwner(ctx, f, "owner", "", "short"); err == nil {
|
||||||
t.Fatal("want error when the store fails")
|
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) {
|
t.Run("propagates a store error", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{}
|
f := &fakeOwnerStore{upsertErr: errors.New("boom")}
|
||||||
a, err := provisionOwner(ctx, f, "owner", "")
|
if err := provisionOwner(ctx, f, "owner", "", "valid-test-pw"); err == nil {
|
||||||
if err != nil {
|
t.Fatal("want error when the store fails")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -128,9 +194,9 @@ func TestEnableLocalAuth(t *testing.T) {
|
|||||||
if err := enableLocalAuth(context.Background(), f); err != nil {
|
if err := enableLocalAuth(context.Background(), f); err != nil {
|
||||||
t.Fatalf("enableLocalAuth: %v", err)
|
t.Fatalf("enableLocalAuth: %v", err)
|
||||||
}
|
}
|
||||||
raw, ok := f.settings[localAuthEnabledSettingKey]
|
raw, ok := f.settings[api.LocalAuthEnabledKey]
|
||||||
if !ok {
|
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
|
// 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
|
// 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)
|
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.
|
// A generated password must satisfy the same rule provisionOwner enforces.
|
||||||
if len(pw) > 72 {
|
if err := validateOwnerPassword(pw); err != nil {
|
||||||
t.Errorf("length %d exceeds bcrypt's 72-byte limit", len(pw))
|
t.Errorf("generated password fails validateOwnerPassword: %v", err)
|
||||||
}
|
}
|
||||||
other, err := generateBootstrapPassword()
|
other, err := generateBootstrapPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -171,37 +237,396 @@ func TestGenerateBootstrapPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvisionCmd(t *testing.T) {
|
func TestAuthenticateAdmin(t *testing.T) {
|
||||||
t.Run("performs both load-bearing writes", func(t *testing.T) {
|
ctx := context.Background()
|
||||||
f := &fakeOwnerStore{}
|
|
||||||
msg := provisionCmd(context.Background(), f, "owner", "")()
|
t.Run("verifies a matching admin credential", func(t *testing.T) {
|
||||||
pm, ok := msg.(provisionedMsg)
|
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 {
|
if !ok {
|
||||||
t.Fatalf("got %T, want provisionedMsg", msg)
|
t.Fatal("ok = false, want true for the correct password")
|
||||||
}
|
}
|
||||||
if pm.err != nil {
|
if matched != "root" {
|
||||||
t.Fatalf("provisionCmd error: %v", pm.err)
|
t.Errorf("matched = %q, want root", matched)
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
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: "[email protected]",
|
||||||
|
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")}
|
f := &fakeOwnerStore{upsertErr: errors.New("boom")}
|
||||||
msg := provisionCmd(context.Background(), f, "owner", "")()
|
op := breakGlassOp{mode: "bootstrap", accountable: "root", osUser: "root", ownerUsername: "owner", ownerPassword: "valid-test-pw"}
|
||||||
pm := msg.(provisionedMsg)
|
if _, err := performBreakGlass(ctx, f, op); err == nil {
|
||||||
if pm.err == nil {
|
|
||||||
t.Fatal("want error when the owner write fails")
|
t.Fatal("want error when the owner write fails")
|
||||||
}
|
}
|
||||||
if len(f.settings) != 0 {
|
if len(f.settings) != 0 {
|
||||||
t.Error("local auth should not be enabled when provisioning failed")
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
func seedAuthAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
|
func seedAuthAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
repo.settings[localAuthEnabledKey] = []byte("true")
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("hash seed password: %v", err)
|
t.Fatalf("hash seed password: %v", err)
|
||||||
|
|||||||
+25
-3
@@ -351,10 +351,16 @@ func (p *PGRepo) LatestBackup(ctx context.Context, serverName string) (*BackupRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error {
|
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,
|
_, err := p.db.ExecContext(ctx,
|
||||||
`INSERT INTO audit_logs (actor, source, action, server_name, request_id)
|
`INSERT INTO audit_logs (actor, source, action, server_name, request_id, payload)
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''))`,
|
VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6)`,
|
||||||
e.Actor, e.Source, e.Action, e.ServerName, e.RequestID)
|
e.Actor, e.Source, e.Action, e.ServerName, e.RequestID, payload)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +385,22 @@ func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUse
|
|||||||
return &u, nil
|
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
|
// UserByID loads the same staff projection by id, or ErrNotFound. The
|
||||||
// change-password flow re-verifies the caller's current password with it: the
|
// change-password flow re-verifies the caller's current password with it: the
|
||||||
// session yields a user id, not a username.
|
// session yields a user id, not a username.
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ type AuditEntry struct {
|
|||||||
Action string
|
Action string
|
||||||
ServerName string
|
ServerName string
|
||||||
RequestID 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).
|
// BackupView is one row of GET /api/v1/backups (spec §7, world_backups in §22).
|
||||||
|
|||||||
@@ -28,12 +28,15 @@ const (
|
|||||||
sessionCookieName = "felis_session"
|
sessionCookieName = "felis_session"
|
||||||
// sessionTTL bounds a local-password session. Staff re-authenticate after it.
|
// sessionTTL bounds a local-password session. Staff re-authenticate after it.
|
||||||
sessionTTL = 12 * time.Hour
|
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
|
// newSessionToken returns a fresh opaque session value (256 bits, URL-safe). It
|
||||||
// is the cookie value; only its hash is persisted.
|
// is the cookie value; only its hash is persisted.
|
||||||
func newSessionToken() (string, error) {
|
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
|
// (minting one) consult it, so the two never disagree about whether local auth is
|
||||||
// live.
|
// live.
|
||||||
func localAuthEnabled(ctx context.Context, repo Repo) bool {
|
func localAuthEnabled(ctx context.Context, repo Repo) bool {
|
||||||
raw, err := repo.GetSetting(ctx, localAuthEnabledKey)
|
raw, err := repo.GetSetting(ctx, LocalAuthEnabledKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false // ErrNotFound (never enabled) or a transient read error → closed
|
return false // ErrNotFound (never enabled) or a transient read error → closed
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user