Unverified Commit 2d0bbb0c authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 885c4a9b
Loading
Loading
Loading
Loading
+538 −125

File changed.

Preview size limit exceeded, changes collapsed.

+473 −48

File changed.

Preview size limit exceeded, changes collapsed.

+1 −1
Changes for internal/api/handlers_auth_test.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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

File changed.

Preview size limit exceeded, changes collapsed.

+6 −0
Changes for internal/api/repo.go: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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).
Loading