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
+1079
-210
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user