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:
flyemoji committed 2026-06-27 11:48:41 +09:00
1 parent 885c4a9bd8
commit 2d0bbb0c37
6 files changed
+1079 -210

No files matched your search

+1 -1
View File
@@ -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)
+25 -3
View File
@@ -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.
+6
View File
@@ -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).
+8 -5
View File
@@ -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
}