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:
2026-04-15 00:23:00 +02:00
parent f5c8cb68be
commit 2294a31942
3 changed files with 138 additions and 1 deletions

View File

@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
**Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.oier.ovh (app + Supabase) + https://auth.oier.ovh (Keycloak); Let's Encrypt certs via host Caddy; PWA strategy switched to generateSW to unblock the prod build (see gotcha #16). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). 248 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
**Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.oier.ovh (app + Supabase) + https://auth.oier.ovh (Keycloak); Let's Encrypt certs via host Caddy; PWA strategy switched to generateSW to unblock the prod build (see gotcha #16). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. 255 tests green: 41 pgTAP + 140 Vitest integration + 15 Vitest unit + 58 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
@@ -108,6 +108,8 @@ Both are flagged in `plan/fase-7-collective-flow-tests.md` as future UI follow-u
18. **`.select()` after `.insert()` evaluates the SELECT RLS policy on the returned row, not just the INSERT policy.** PostgREST's chained `.insert(...).select().single()` adds `RETURNING *` to the INSERT, and Postgres then evaluates the table's SELECT policy against the fresh row. For policies that gate on sibling-table state (e.g., `is_member(id)` requiring a row in `collective_members`), this causes INSERTs to spuriously fail with "new row violates row-level security policy" even though the INSERT policy itself passed. Fix: drop the `.select()` if you don't need the row back, or (better) wrap the insert + any sibling-table sets in a `SECURITY DEFINER` RPC that atomically builds the membership state. F-08 in `rls-isolation.test.ts` has been silently masking this since Fase 1 because it only asserts the _spoof_ case; never verified the happy-path insert actually succeeded.
19. **GoTrue v2.158.1 inserts OIDC-provisioned users with empty-string `role` and `aud`.** For users created via the external provider path (Keycloak → GoTrue), `auth.users.role` and `auth.users.aud` are left as `''` instead of the expected `'authenticated'`. The JWT minted from that row carries the empty role, and PostgREST's per-request `SET LOCAL role = $role_claim` then fails with `role "" does not exist` — every REST call errors out before policies or RPCs even run. Dev never surfaced this because `seed.sql` hardcodes both columns. Migration `013_auth_users_role_default.sql` backfills existing rows and installs a BEFORE INSERT trigger on `auth.users` that fills empty/NULL role + aud with `'authenticated'`. Users in existing browser sessions must log out + back in once for a fresh JWT after the migration is applied.
### Fase 6+ — production deploy to ambrosio (2026-04-14)
Live URLs: **https://erosi.oier.ovh** (app + Supabase API, single-domain) + **https://auth.oier.ovh** (Keycloak). OVH VPS, Ubuntu 24, Docker 29.

View 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();

View 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;