diff --git a/internal/adapters/store/identity.go b/internal/adapters/store/identity.go new file mode 100644 index 0000000..a33fa64 --- /dev/null +++ b/internal/adapters/store/identity.go @@ -0,0 +1,91 @@ +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 +} diff --git a/internal/adapters/store/identity_test.go b/internal/adapters/store/identity_test.go new file mode 100644 index 0000000..1b9f6e0 --- /dev/null +++ b/internal/adapters/store/identity_test.go @@ -0,0 +1,103 @@ +package store_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +const ( + subjectA = "dex|alice-123" + subjectB = "dex|bob-456" +) + +func TestUserBySubjectUnknownReturnsNotFound(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + id, found, err := s.UserBySubject(ctx, subjectA) + require.NoError(t, err) + require.False(t, found) + require.Empty(t, id) +} + +func TestRegisterUserCreatesUserAndIdentity(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + + id, err := s.RegisterUser(ctx, subjectA, "Alice") + require.NoError(t, err) + require.NotEmpty(t, id) + + // Exactly one users row with the returned id and the given display name. + var users int + var name string + require.NoError(t, p.QueryRow(ctx, + `SELECT count(*), coalesce(max(display_name), '') FROM users WHERE id = $1`, id). + Scan(&users, &name)) + require.Equal(t, 1, users) + require.Equal(t, "Alice", name) + + // Exactly one identity row mapping the subject to that id. + var idents int + require.NoError(t, p.QueryRow(ctx, + `SELECT count(*) FROM user_identities WHERE dex_subject = $1 AND user_id = $2`, + subjectA, id).Scan(&idents)) + require.Equal(t, 1, idents) + + // And it now resolves straight through. + got, found, err := s.UserBySubject(ctx, subjectA) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, id, got) +} + +func TestRegisterUserRejectsDuplicateSubject(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + + first, err := s.RegisterUser(ctx, subjectA, "Alice") + require.NoError(t, err) + + _, err = s.RegisterUser(ctx, subjectA, "Alice Again") + require.Error(t, err) + require.True(t, errors.Is(err, store.ErrSubjectRegistered)) + + // No second user was created; the original mapping is intact. + var users, idents int + require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users)) + require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM user_identities`).Scan(&idents)) + require.Equal(t, 1, users) + require.Equal(t, 1, idents) + + got, found, err := s.UserBySubject(ctx, subjectA) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, first, got) +} + +func TestRegisterUserDistinctSubjectsGetDistinctUsers(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + + idA, err := s.RegisterUser(ctx, subjectA, "Alice") + require.NoError(t, err) + idB, err := s.RegisterUser(ctx, subjectB, "Bob") + require.NoError(t, err) + require.NotEqual(t, idA, idB) + + var users int + require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users)) + require.Equal(t, 2, users) +} diff --git a/internal/adapters/store/migrations/004_user_identities.down.sql b/internal/adapters/store/migrations/004_user_identities.down.sql new file mode 100644 index 0000000..4fb6f4f --- /dev/null +++ b/internal/adapters/store/migrations/004_user_identities.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_identities; diff --git a/internal/adapters/store/migrations/004_user_identities.up.sql b/internal/adapters/store/migrations/004_user_identities.up.sql new file mode 100644 index 0000000..3810f5e --- /dev/null +++ b/internal/adapters/store/migrations/004_user_identities.up.sql @@ -0,0 +1,22 @@ +-- Migration 004: the Dex-subject → tapir-user map (ADR-012 Stage 1, multi-user). +-- A Dex-authenticated subject is the login identity; the tapir user_id (UUID) is +-- what every user-owned, force-RLS table keys off. This table is the bridge: +-- resolve subject → user_id here (auth plumbing, pre-scope), THEN scope all data +-- access by that id via the store's withUser helper. +-- +-- INTENTIONALLY NOT RLS-ENABLED. The forced-RLS isolation (migration 003) guards +-- the user-OWNED data tables. user_identities holds no user data — only an opaque +-- (dex_subject ↔ user_id) pair — and must be readable BEFORE a user_id is known +-- (that lookup is what yields the id used to set tapir.current_user_id). Putting +-- RLS here would be a chicken-and-egg deadlock (you'd need the GUC to read the row +-- that tells you the GUC). Data isolation lives on the user-owned tables, not here. +CREATE TABLE user_identities ( + dex_subject TEXT PRIMARY KEY, + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE user_identities IS + 'Dex subject -> tapir user_id map. Auth plumbing, deliberately NOT RLS-enabled ' + '(no user data; must be read pre-scope to resolve the id used for RLS). ' + 'ON DELETE CASCADE so deleting a user cleans up its identity mapping.';