fix(auth): default empty role/aud on auth.users → 'authenticated'
Found on ambrosio after the first real self-registration: GoTrue v2.158.1 persists OIDC-provisioned users with empty-string role and aud. PostgREST then dies with `role "" does not exist` on every REST call because its per-request `SET LOCAL role = $claim` can't resolve ''. Dev never caught this because seed.sql hardcodes both columns. - supabase/migrations/013_auth_users_role_default.sql: backfill existing rows + BEFORE INSERT trigger on auth.users that fills empty/NULL role + aud with 'authenticated'. Idempotent, preserves explicit values. - supabase/tests/008_auth_user_role_default.sql: pgTAP coverage — function + trigger exist, backfill left nothing empty, empty-string and NULL values get defaulted, explicit non-empty role is preserved. - CLAUDE.md: status line updated to 255 tests (was 248), new gotcha #19 documenting the GoTrue quirk + that users in existing sessions must re-login once for a fresh JWT after the fix lands on any given env. Total: 41 pgTAP (was 34) + 140 integration + 15 unit + 58 Playwright + 1 gated rate-limit. Full `just test-all` green.
This commit is contained in:
46
supabase/migrations/013_auth_users_role_default.sql
Normal file
46
supabase/migrations/013_auth_users_role_default.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- Migration 013: default `role` and `aud` to 'authenticated' on auth.users.
|
||||
--
|
||||
-- Background: GoTrue v2.158.1, when creating a user via the OIDC path
|
||||
-- (Keycloak external provider), persists the user with empty-string `role`
|
||||
-- and `aud` columns. The JWT it subsequently issues carries the empty role,
|
||||
-- and PostgREST's per-request `SET LOCAL role = $role_claim` then fails with
|
||||
-- `role "" does not exist` on every REST call — including RPCs.
|
||||
--
|
||||
-- The seed users in `supabase/seed.sql` hardcode role='authenticated' and
|
||||
-- aud='authenticated', which is why dev never surfaced this. Prod (ambrosio)
|
||||
-- hit it on the first real self-registration.
|
||||
--
|
||||
-- Two parts:
|
||||
-- 1. Backfill any already-persisted rows missing role/aud.
|
||||
-- 2. BEFORE INSERT trigger that fills empty/NULL role + aud with
|
||||
-- 'authenticated' so future self-registrations are safe without
|
||||
-- requiring a GoTrue upgrade.
|
||||
|
||||
-- ── 1. Backfill ──────────────────────────────────────────────────────────
|
||||
UPDATE auth.users
|
||||
SET
|
||||
role = COALESCE(NULLIF(role, ''), 'authenticated'),
|
||||
aud = COALESCE(NULLIF(aud, ''), 'authenticated')
|
||||
WHERE role IS NULL OR role = '' OR aud IS NULL OR aud = '';
|
||||
|
||||
-- ── 2. Trigger function + trigger ────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION auth.ensure_user_role_default()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.role IS NULL OR NEW.role = '' THEN
|
||||
NEW.role := 'authenticated';
|
||||
END IF;
|
||||
IF NEW.aud IS NULL OR NEW.aud = '' THEN
|
||||
NEW.aud := 'authenticated';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS ensure_role_default ON auth.users;
|
||||
CREATE TRIGGER ensure_role_default
|
||||
BEFORE INSERT ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION auth.ensure_user_role_default();
|
||||
89
supabase/tests/008_auth_user_role_default.sql
Normal file
89
supabase/tests/008_auth_user_role_default.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
-- pgTAP: auth.users role/aud defaulting (migration 013)
|
||||
-- Run with: psql -U postgres -d postgres -f supabase/tests/008_auth_user_role_default.sql
|
||||
--
|
||||
-- Covers the bug found on ambrosio (2026-04-15): GoTrue v2.158.1 inserts
|
||||
-- OIDC-created users into auth.users with empty-string `role` and `aud`,
|
||||
-- which later makes PostgREST's `SET LOCAL role = ''` fail with
|
||||
-- `role "" does not exist`. Migration 013 adds a BEFORE INSERT trigger +
|
||||
-- backfills the existing rows.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
BEGIN;
|
||||
SELECT plan(7);
|
||||
|
||||
-- ── Existence assertions ────────────────────────────────────────────────
|
||||
SELECT has_function(
|
||||
'auth', 'ensure_user_role_default', ARRAY[]::text[],
|
||||
'ensure_user_role_default trigger function exists in the auth schema'
|
||||
);
|
||||
|
||||
SELECT has_trigger(
|
||||
'auth', 'users', 'ensure_role_default',
|
||||
'BEFORE INSERT trigger ensure_role_default exists on auth.users'
|
||||
);
|
||||
|
||||
-- ── Backfill assertion ──────────────────────────────────────────────────
|
||||
SELECT ok(
|
||||
(SELECT count(*) FROM auth.users WHERE role IS NULL OR role = '' OR aud IS NULL OR aud = '') = 0,
|
||||
'No pre-existing auth.users row has an empty role or aud'
|
||||
);
|
||||
|
||||
-- ── Trigger behavior ────────────────────────────────────────────────────
|
||||
-- Insert a synthetic user with empty role/aud; the trigger should fill both.
|
||||
-- Drop the AFTER trigger locally so we don't exercise handle_new_user's
|
||||
-- side-effects (tested elsewhere, not the focus here).
|
||||
ALTER TABLE auth.users DISABLE TRIGGER on_auth_user_created;
|
||||
|
||||
-- NEW row with empty strings
|
||||
INSERT INTO auth.users (instance_id, id, email, role, aud)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'f0000000-0000-0000-0000-000000000001',
|
||||
'empty-role@test.local',
|
||||
'',
|
||||
''
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT role FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000001'),
|
||||
'authenticated',
|
||||
'Empty role on INSERT is defaulted to authenticated'
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT aud FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000001'),
|
||||
'authenticated',
|
||||
'Empty aud on INSERT is defaulted to authenticated'
|
||||
);
|
||||
|
||||
-- NEW row with NULL values
|
||||
INSERT INTO auth.users (instance_id, id, email, role, aud)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'f0000000-0000-0000-0000-000000000002',
|
||||
'null-role@test.local',
|
||||
NULL,
|
||||
NULL
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT role FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000002'),
|
||||
'authenticated',
|
||||
'NULL role on INSERT is defaulted to authenticated'
|
||||
);
|
||||
|
||||
-- Explicit non-empty role is preserved (trigger should only fill when empty).
|
||||
INSERT INTO auth.users (instance_id, id, email, role, aud)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'f0000000-0000-0000-0000-000000000003',
|
||||
'custom-role@test.local',
|
||||
'service_role',
|
||||
'authenticated'
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT role FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000003'),
|
||||
'service_role',
|
||||
'Explicit role is preserved by the trigger'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user