package store import ( "context" "crypto/rand" "errors" "fmt" "github.com/jackc/pgx/v5" ) // ErrSubjectRegistered is returned by RegisterUser when the Dex subject already // maps to a tapir user. Registration is explicit and once-per-subject (ADR-012). var ErrSubjectRegistered = errors.New("store: subject already registered") // UserBySubject resolves a Dex subject to its tapir user_id via the un-RLS'd // user_identities map. It runs as a plain pool query WITHOUT withUser: this is // the pre-scope lookup whose result becomes the GUC for every subsequent // user-scoped access, so it cannot itself depend on that GUC being set. found is // false (no error) when the subject has no mapping yet — the caller routes such // requests to registration. func (s *Store) UserBySubject(ctx context.Context, subject string) (userID string, found bool, err error) { err = s.pool.QueryRow(ctx, `SELECT user_id FROM user_identities WHERE dex_subject = $1`, subject).Scan(&userID) if errors.Is(err, pgx.ErrNoRows) { return "", false, nil } if err != nil { return "", false, fmt.Errorf("store: user by subject: %w", err) } return userID, true, nil } // RegisterUser creates the tapir user for a Dex subject and the identity mapping // that points to it, returning the new user_id. It errors with // ErrSubjectRegistered if the subject already maps. // // Bootstrapping note (generate-uuid-then-scope): the users table is FORCE'd RLS // with a WITH CHECK that defaults to the USING predicate id = // current_setting('tapir.current_user_id') (migration 003). A users row can // therefore only be inserted while the connection is ALREADY scoped to that // row's own id — a chicken-and-egg if the id were DB-generated. So we generate // the UUID app-side, scope to it via withUser(newID, ...), and insert the users // row inside that scope so the WITH CHECK passes. The user_identities row is // un-RLS'd auth plumbing; it is written in the SAME transaction so a user and // its mapping are always consistent. func (s *Store) RegisterUser(ctx context.Context, subject, displayName string) (userID string, err error) { newID, err := newUUIDv4() if err != nil { return "", fmt.Errorf("store: register user: %w", err) } // Fast, clear rejection of a re-registration. The dex_subject PRIMARY KEY is // the authoritative guard (a concurrent insert would still violate it); this // check just turns the common case into a meaningful error instead of a raw // constraint violation. if _, found, err := s.UserBySubject(ctx, subject); err != nil { return "", err } else if found { return "", fmt.Errorf("%w: %q", ErrSubjectRegistered, subject) } if err := s.withUser(ctx, newID, func(tx pgx.Tx) error { if _, err := tx.Exec(ctx, `INSERT INTO users (id, display_name) VALUES ($1, $2)`, newID, displayName); err != nil { return fmt.Errorf("store: insert user: %w", err) } if _, err := tx.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, newID); err != nil { return fmt.Errorf("store: insert identity: %w", err) } return nil }); err != nil { return "", err } return newID, nil } // newUUIDv4 returns a random RFC-4122 v4 UUID string. Generated app-side (stdlib // crypto/rand, no new dependency) so the id is known before the row is scoped and // inserted — see RegisterUser's bootstrapping note. func newUUIDv4() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("generate uuid: %w", err) } b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant 10 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil }