Refuse an OIDC sign-in from creating the very first user
Closes the race terdut-operator#1 found: /api/bootstrap and OIDC auto-provisioning both key off the same signal (SELECT COUNT(*) FROM users) with no coordination between them, so an otherwise-ordinary OIDC sign-in against a freshly-created, not-yet-bootstrapped install could create user #1 itself and take the one slot /api/bootstrap expects to win uncontested (terdut-operator's own design, DESIGN.md §1/§6, assumes it is the only caller). The operator has no way to recover from losing that race -- it never gets a credential, and nothing it owns can clear the occupying user row. resolveSSOUser now checks the same gate handleBootstrap already does, right where it's about to create a brand-new user (an identity nobody has linked yet, that also matches no existing local account by email) -- not anywhere else, since every other sign-in on an already-bootstrapped install is unaffected. New sso_error code `not_bootstrapped`: the person sees "this install is still setting up, try again in a moment" and a second attempt once something has actually bootstrapped succeeds normally, same as any other first sign-in. Does not fix the other half of that issue (BootstrapStateLost's own "delete and recreate" instructions still don't work once something has occupied the slot some other way) -- this closes the specific race, not every path to that state.
This commit is contained in:
@@ -48,6 +48,17 @@ const (
|
||||
ssoNoEmail ssoError = "no_email" // the provider sent no email address
|
||||
ssoEmailConflict ssoError = "email_conflict" // a local account has this email and cannot be linked
|
||||
ssoDisabled ssoError = "disabled" // the linked account is disabled
|
||||
// ssoNotBootstrapped: this identity has no existing account, and no user
|
||||
// exists on this install yet either -- creating one here would race
|
||||
// POST /api/bootstrap for the one gitops-managed installs expect to win
|
||||
// it (terdut-operator's own DESIGN.md §1, §6), which has no way to
|
||||
// recover if it loses. The person sees this for at most as long as it
|
||||
// takes whatever is bootstrapping this install to finish; signing in
|
||||
// again afterward hits the ordinary first-sign-in path. Found by
|
||||
// terdut-operator#1: nothing stopped an otherwise-ordinary OIDC sign-in
|
||||
// from quietly winning this race against an operator that assumed it
|
||||
// was the only caller.
|
||||
ssoNotBootstrapped ssoError = "not_bootstrapped"
|
||||
)
|
||||
|
||||
// handleAuthConfig says how this server can be signed in to, so the login form
|
||||
@@ -366,6 +377,20 @@ func resolveSSOUser(ctx context.Context, tx *sql.Tx, cfg config.OIDC, id *oidc.I
|
||||
return 0, ssoEmailConflict
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Creating the very first user is /api/bootstrap's own job (same
|
||||
// gate, same table: SELECT COUNT(*) FROM users in handleBootstrap).
|
||||
// An identity nobody has linked yet, on an install with no users at
|
||||
// all, is exactly the race terdut-operator#1 found: whoever gets
|
||||
// here first wins a slot the other side has no way to recover from
|
||||
// losing. Refusing it here costs an otherwise-ordinary sign-in
|
||||
// nothing but a retry once bootstrap has actually run.
|
||||
var userCount int
|
||||
if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if userCount == 0 {
|
||||
return 0, ssoNotBootstrapped
|
||||
}
|
||||
userID, err = createSSOUser(ctx, tx, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
Reference in New Issue
Block a user