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

+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
}