fix(provisioning): keep the Owner seat single; clash on the operator name stays retryable

Two defects live-drilled in the break-glass staff provisioning:

- An Owner reset that typed any username other than the occupied seat took
  UpsertOwner's insert arm and silently minted a SECOND owner row, leaving the
  existing seat — possibly the compromised account the reset was meant to
  replace — live; every owner row is undeletable through the panel, so the tier
  could never converge back to one. provisionOwner now refuses with
  ownerSeatTakenError naming the seat (recoverable: the TUI routes back to the
  form); bootstrap still mints, and the seat's own username still resets in
  place. PGRepo gains OwnerUsername for the guard.
- InsertOperator returned the raw driver error on a taken username while the
  console keys its rename prompt off api.ErrConflict — the "choose another
  name" leg died with SQLSTATE 23505 against real Postgres (the fake encoded
  the contract; PGRepo had drifted). Map the unique violation to ErrConflict
  and pin it in pgint.

Live (auditfix37): fresh username refused naming the seat; seat reset kept the
id/email with still exactly one owner; taken operator name returned to the form
with the retry note, and the retyped name succeeded (drill rows cleaned).
This commit is contained in:
Lemon-miaow committed 2026-09-23 07:48:54 +08:00
1 parent f6dbfd3625
commit 55d515d41f
6 files changed
+173 -27

No files matched your search

+24 -2
View File
@@ -1037,16 +1037,38 @@ func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email string) er
return err
}
// OwnerUsername names the single active Owner seat, or "" when no owner exists.
// It backs the console's single-seat guard: once a seat is occupied only that
// username may be re-targeted (see cmd/felis provisionOwner), because a fresh
// name would take the upsert's insert arm and mint a SECOND owner row that no
// supported path can remove (the panel protects every owner row).
func (p *PGRepo) OwnerUsername(ctx context.Context) (string, error) {
var name string
switch err := p.db.QueryRowContext(ctx,
`SELECT username FROM users WHERE role = 'owner' AND deleted_at IS NULL ORDER BY created_at LIMIT 1`).Scan(&name); {
case errors.Is(err, sql.ErrNoRows):
return "", nil
case err != nil:
return "", err
default:
return name, nil
}
}
// InsertOperator mints a NEW Operator (additional staff admin) account
// direct-to-Postgres. role is forced to 'admin'. UNLIKE UpsertOwner this is
// insert-only: a username conflict is left untouched and surfaces as a driver
// error, so adding an Operator can never silently reset the Owner's or another
// insert-only: a username conflict leaves the existing row untouched and
// surfaces as ErrConflict — the console routes a rename off that sentinel — so
// adding an Operator can never silently reset the Owner's or another
// Operator's row. The account is passwordless by design. The empty email is
// stored as NULL.
func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'admin')`,
id, username, email)
if err != nil && isUniqueViolation(err) {
return ErrConflict
}
return err
}
+24 -1
View File
@@ -666,14 +666,37 @@ func TestOwnerProvisioningWritesOwnerRole(t *testing.T) {
if ok, err := repo.AdminExists(ctx); err != nil || !ok {
t.Fatalf("AdminExists = (%v, %v), want true (the owner counts as staff)", ok, err)
}
// OwnerUsername names the seat the console guard protects. The shared test
// database may hold owner rows from earlier tests, so assert the returned name
// IS an active owner rather than one specific row.
seat, err := repo.OwnerUsername(ctx)
if err != nil {
t.Fatalf("OwnerUsername: %v", err)
}
if seat == "" {
t.Fatal("OwnerUsername = empty, want an active owner seat")
}
var active int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM users WHERE username = $1 AND role = 'owner' AND deleted_at IS NULL`, seat).
Scan(&active); err != nil || active != 1 {
t.Fatalf("OwnerUsername returned %q, not an active owner row (count=%d err=%v)", seat, active, err)
}
// Operators stay plain admins: the owner tier stays singular.
opID := "usr-op-" + suffix(t)
if err := repo.InsertOperator(ctx, opID, "op-"+suffix(t), ""); err != nil {
opName := "op-" + suffix(t)
if err := repo.InsertOperator(ctx, opID, opName, ""); err != nil {
t.Fatalf("InsertOperator: %v", err)
}
if role := userRole(t, opID); role != "admin" {
t.Fatalf("InsertOperator role = %q, want admin", role)
}
// A taken username must surface as api.ErrConflict: the console routes its
// rename prompt off that sentinel (the cmd fake encoded the contract; PGRepo
// returned the raw driver error until this arm was mapped).
if err := repo.InsertOperator(ctx, "usr-op2-"+suffix(t), opName, ""); !errors.Is(err, api.ErrConflict) {
t.Fatalf("InsertOperator on a taken username = %v, want ErrConflict", err)
}
}
// The setup wizard's MC-bind path establishes THE Owner, so it writes the same