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
+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.
|
||||
|
||||
Reference in new issue
Block a user