feat(store): user_identities map + UserBySubject/RegisterUser (ADR-012)
Add the Dex-subject -> tapir-user_id bridge for multi-user Stage 1. Migration 004 creates user_identities (dex_subject PK, user_id UNIQUE FK ON DELETE CASCADE). It is intentionally NOT RLS-enabled: it holds no user data and must be readable BEFORE a user_id is known (the lookup is what yields the id used to set tapir.current_user_id). RLS here would be a chicken-and-egg deadlock; data isolation stays on the user-owned tables. UserBySubject resolves subject -> user_id as a plain pool query (pre-scope, no withUser). RegisterUser generates the UUID app-side (stdlib crypto/rand, no new dep) so the forced-RLS WITH CHECK (id = GUC) passes, then inserts the users row via withUser(newID) and the identity row in the same transaction. Re-registration of a subject errors with ErrSubjectRegistered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS user_identities;
|
||||||
@@ -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.';
|
||||||
Reference in New Issue
Block a user