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:
6 files changed
+173
-27
No files matched your search
+24
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user