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
@@ -76,6 +76,12 @@ type ownerStore interface {
|
||||
AdminExists(ctx context.Context) (bool, error)
|
||||
// UserByUsername loads a staff login projection.
|
||||
UserByUsername(ctx context.Context, username string) (*api.StaffUser, error)
|
||||
// OwnerUsername names the single active Owner seat, or "" when none exists.
|
||||
// provisionOwner refuses to re-target anything but this username: with the
|
||||
// seat occupied, a fresh name would mint a second owner row (UpsertOwner's
|
||||
// insert arm) while the existing — possibly compromised — seat stays live,
|
||||
// and no supported path can delete an owner row afterwards.
|
||||
OwnerUsername(ctx context.Context) (string, error)
|
||||
UpsertOwner(ctx context.Context, id, username, email string) error
|
||||
// InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is
|
||||
// insert-only: a username already taken is a conflict (api.ErrConflict), never a
|
||||
@@ -278,11 +284,20 @@ func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matc
|
||||
// provisionOwner mints or resets the single Owner account direct-to-Postgres,
|
||||
// passwordless. The account is role=owner with no password — the Owner completes
|
||||
// passwordless login setup via the web setup-token flow after `felis setup`.
|
||||
// With a seat already occupied the reset must name that seat (ownerSeatTakenError
|
||||
// otherwise): the upsert's insert arm would silently mint a SECOND owner, and
|
||||
// every owner row is undeletable through the panel, so the tier could never
|
||||
// converge back to one.
|
||||
func provisionOwner(ctx context.Context, s ownerStore, username, email string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return errors.New("owner username is required")
|
||||
}
|
||||
if seat, err := s.OwnerUsername(ctx); err != nil {
|
||||
return fmt.Errorf("check the owner seat: %w", err)
|
||||
} else if seat != "" && seat != username {
|
||||
return &ownerSeatTakenError{seat: seat}
|
||||
}
|
||||
id := newOwnerID()
|
||||
if id == "" {
|
||||
return errors.New("generate owner id: entropy source failed")
|
||||
@@ -293,6 +308,19 @@ func provisionOwner(ctx context.Context, s ownerStore, username, email string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// ownerSeatTakenError refuses an Owner reset that names anything but the
|
||||
// occupied seat, naming it so the operator can retype. Is reports
|
||||
// api.ErrConflict so the TUI's recoverable-error branch (shared with the
|
||||
// operator path's taken-name clash) routes back to the form instead of ending
|
||||
// the console.
|
||||
type ownerSeatTakenError struct{ seat string }
|
||||
|
||||
func (e *ownerSeatTakenError) Error() string {
|
||||
return fmt.Sprintf("an Owner already exists as %q — enter that username to reset the Owner", e.seat)
|
||||
}
|
||||
|
||||
func (e *ownerSeatTakenError) Is(target error) bool { return target == api.ErrConflict }
|
||||
|
||||
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. It is
|
||||
// role=admin and passwordless — an additional staff admin below the single
|
||||
// role=owner identity (migrations 0003 + 0011). UNLIKE provisionOwner, which
|
||||
|
||||
Reference in new issue
Block a user