From 4e3d0efbed117d3edcd4140c4c4ede2b757a14e3 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sat, 22 Aug 2026 08:14:16 +0000 Subject: [PATCH] fix(#929): let the monolithic migration provision a newly-created Supabase project Applying supabase/migrations/20251006_complete_monolithic_setup.sql to a project created today aborted with 42501: permission denied for schema auth and, because the file is one BEGIN;...COMMIT;, left ZERO tables. Forking is supposed to end with the fork pointing at its own Supabase project, so this broke the one path every fork must take. One statement needed CREATE on schema auth: CREATE OR REPLACE FUNCTION auth.jwt() ... It exists only to forward-fill a stub for the LOCAL supabase/postgres image, where GoTrue has not booted and CREATE POLICY would otherwise fail at parse time. That reasoning is correct and still applies, so the statement is guarded rather than removed -- it now runs only where auth.jwt() is genuinely absent. Identical local behaviour; a no-op on Cloud, where the function already exists and is owned by supabase_auth_admin. Not the cause, though it looks like it: the on_auth_user_created trigger on auth.users is fine. postgres holds TRIGGER on that table, verified by a create/drop probe on a fresh project, and the trigger is present there after the fix. Verified end to end on a genuinely new project (us-east-1): 19 tables and 75 policies, identical counts to this repo's production project, plus 25 functions and 13 triggers. Also adds scripts/__tests__/migration-provisions-a-new-cloud-project.test.js, which splits the file into real top-level statements -- dollar-quote aware, comments stripped -- and fails if any of them creates or alters an object inside the auth schema. It carries its own falsifiers: auth.uid() in a policy body and a trigger ON auth.users must NOT trip it, a commented-out copy of the old statement must NOT trip it, and an unguarded CREATE ... auth.jwt() MUST. Mutation-tested against the real file: reverting the guard fails the suite, restoring it passes byte-identical. Closes #929 Co-Authored-By: Claude Opus 5 (1M context) --- ...ion-provisions-a-new-cloud-project.test.js | 208 ++++++++++++++++++ .../20251006_complete_monolithic_setup.sql | 42 +++- 2 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 scripts/__tests__/migration-provisions-a-new-cloud-project.test.js diff --git a/scripts/__tests__/migration-provisions-a-new-cloud-project.test.js b/scripts/__tests__/migration-provisions-a-new-cloud-project.test.js new file mode 100644 index 00000000..9480a4cb --- /dev/null +++ b/scripts/__tests__/migration-provisions-a-new-cloud-project.test.js @@ -0,0 +1,208 @@ +/** + * The monolithic migration must be applicable to a NEWLY CREATED Supabase Cloud + * project -- the one path every fork has to take, and the one path nothing else + * exercises (#929). + * + * WHAT WENT WRONG. A single statement created a function inside the `auth` + * schema: + * + * CREATE OR REPLACE FUNCTION auth.jwt() ... + * + * On a new cloud project `postgres` has USAGE but NOT CREATE on schema auth, so + * that aborted the whole single-transaction file with + * + * 42501: permission denied for schema auth + * + * leaving ZERO tables. It survived for months because it is a harmless no-op + * everywhere it was ever run: the local stack (where it is genuinely needed, before + * GoTrue's first boot) and the long-lived hosted projects (which already have the + * function, and were provisioned when `postgres` still had the privilege). + * + * WHY THIS IS A SOURCE TEST AND NOT AN INTEGRATION TEST. Actually provisioning a + * throwaway project per CI run costs a project slot on a two-project account and + * minutes of wall clock. The failure is fully determined by the SQL text, so the + * text is what gets checked. + * + * TWO WAYS A CHECK LIKE THIS FOOLS ITSELF, both avoided here: + * + * 1. It matches its own explanatory comment and passes with the code deleted. + * So comments are stripped before anything is matched. + * 2. It matches `auth.uid()` inside a CREATE POLICY body and flags 97 false + * positives. So it matches the object BEING CREATED, not any mention of the + * schema. `CREATE TRIGGER ... ON auth.users` is likewise fine and must stay + * allowed -- the trigger does not live in `auth`, and postgres holds TRIGGER + * on that table (verified on a fresh project). + * + * The guarded form -- a DO block that creates the function only when it is absent + * -- passes, because the DO block is the top-level statement and the CREATE lives + * inside its body. + */ + +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const MIGRATION = path.join( + __dirname, + '..', + '..', + 'supabase', + 'migrations', + '20251006_complete_monolithic_setup.sql' +); + +/** + * Strip SQL comments without touching string or dollar-quoted bodies, then split + * into top-level statements on semicolons that are not inside a quote. + */ +function topLevelStatements(sql) { + const out = []; + let cur = ''; + let i = 0; + let dollarTag = null; + let inSingle = false; + + while (i < sql.length) { + const rest = sql.slice(i); + + if (dollarTag) { + if (rest.startsWith(dollarTag)) { + cur += dollarTag; + i += dollarTag.length; + dollarTag = null; + continue; + } + cur += sql[i++]; + continue; + } + + if (inSingle) { + if (sql[i] === "'") inSingle = false; + cur += sql[i++]; + continue; + } + + // line comment + if (rest.startsWith('--')) { + const nl = sql.indexOf('\n', i); + i = nl === -1 ? sql.length : nl; + continue; + } + + // block comment + if (rest.startsWith('/*')) { + const end = sql.indexOf('*/', i + 2); + i = end === -1 ? sql.length : end + 2; + continue; + } + + // dollar-quote open, e.g. $$ or $guard$ + const dq = rest.match(/^\$[A-Za-z_]*\$/); + if (dq) { + dollarTag = dq[0]; + cur += dollarTag; + i += dollarTag.length; + continue; + } + + if (sql[i] === "'") { + inSingle = true; + cur += sql[i++]; + continue; + } + + if (sql[i] === ';') { + if (cur.trim()) out.push(cur.trim()); + cur = ''; + i++; + continue; + } + + cur += sql[i++]; + } + + if (cur.trim()) out.push(cur.trim()); + return out; +} + +const CREATES_IN_AUTH = + /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE|TABLE|VIEW|MATERIALIZED\s+VIEW|SEQUENCE|TYPE|DOMAIN|INDEX)\s+(?:IF\s+NOT\s+EXISTS\s+)?auth\s*\./i; + +const ALTERS_IN_AUTH = + /^\s*ALTER\s+(?:FUNCTION|PROCEDURE|TABLE|VIEW|SEQUENCE|TYPE|DOMAIN)\s+auth\s*\./i; + +function offenders(sql) { + return topLevelStatements(sql).filter( + (s) => CREATES_IN_AUTH.test(s) || ALTERS_IN_AUTH.test(s) + ); +} + +test('no top-level statement creates or alters an object inside the auth schema', () => { + const sql = fs.readFileSync(MIGRATION, 'utf8'); + const bad = offenders(sql); + assert.deepStrictEqual( + bad.map((s) => s.split('\n')[0].slice(0, 90)), + [], + 'These run as `postgres`, which has USAGE but NOT CREATE on schema auth on a ' + + 'newly created Supabase project. Wrap the statement in a DO block that skips ' + + 'it when the object already exists -- see #929.' + ); +}); + +test('the harness can actually fail — an unguarded auth.jwt() is caught', () => { + // The mutation: exactly the statement #929 was about, at top level. + const mutated = ` + CREATE TABLE public.fine (id int); + CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb LANGUAGE sql STABLE AS $$ + SELECT '{}'::jsonb + $$; + `; + assert.strictEqual( + offenders(mutated).length, + 1, + 'the detector must flag an unguarded CREATE ... auth.jwt()' + ); +}); + +test('the guarded form passes', () => { + const guarded = ` + DO $guard$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_proc) THEN + EXECUTE $fn$ CREATE FUNCTION auth.jwt() RETURNS jsonb LANGUAGE sql AS $body$ SELECT '{}'::jsonb $body$ $fn$; + EXECUTE 'ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin'; + END IF; + END + $guard$; + `; + assert.deepStrictEqual(offenders(guarded), []); +}); + +test('a comment describing the old statement does not trip the detector', () => { + // The trap: a guard that greps raw source matches its own explanation and keeps + // passing after the code it guards is deleted. + const commentOnly = ` + -- CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb ... (why this used to exist) + /* ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin; */ + SELECT 1; + `; + assert.deepStrictEqual(offenders(commentOnly), []); +}); + +test('auth.uid() inside a policy body is not an offender', () => { + const policy = ` + CREATE POLICY "own rows" ON public.things FOR SELECT + USING (auth.uid() = user_id); + `; + assert.deepStrictEqual(offenders(policy), []); +}); + +test('a trigger ON auth.users is allowed — it does not live in the auth schema', () => { + const trigger = ` + CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION create_user_profile(); + `; + assert.deepStrictEqual(offenders(trigger), []); +}); diff --git a/supabase/migrations/20251006_complete_monolithic_setup.sql b/supabase/migrations/20251006_complete_monolithic_setup.sql index 5c5c7e2f..d3ef3671 100644 --- a/supabase/migrations/20251006_complete_monolithic_setup.sql +++ b/supabase/migrations/20251006_complete_monolithic_setup.sql @@ -1162,13 +1162,41 @@ GRANT EXECUTE ON FUNCTION public.log_auth_event(TEXT, UUID, JSONB, BOOLEAN, TEXT -- initdb. Forward-fill a stub with the canonical implementation; GoTrue's -- CREATE OR REPLACE overwrites it at service boot. Ownership must be -- supabase_auth_admin or that REPLACE dies on 42501 (must be owner). -CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb LANGUAGE sql STABLE AS $$ - SELECT coalesce( - nullif(current_setting('request.jwt.claim', true), ''), - nullif(current_setting('request.jwt.claims', true), '') - )::jsonb -$$; -ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin; +-- GUARDED (#929): run this ONLY where auth.jwt() is genuinely absent -- i.e. the +-- local supabase/postgres image before GoTrue's first boot, which is the whole +-- reason the forward-fill exists. +-- +-- Unconditionally, this statement made the file unusable for its most important +-- job. On a NEWLY created Supabase Cloud project auth.jwt() already exists and is +-- owned by supabase_auth_admin, while `postgres` holds USAGE but NOT CREATE on +-- schema auth -- so the CREATE OR REPLACE aborted the entire single-transaction +-- migration with +-- 42501: permission denied for schema auth +-- leaving ZERO tables. Every fork pointing at its own new project hit this; the +-- long-lived projects never did, because they already had the function. +-- +-- Nothing else in this file needs CREATE on schema auth. The on_auth_user_created +-- trigger on auth.users is fine -- postgres has TRIGGER on that table. +DO $guard$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'auth' AND p.proname = 'jwt' + ) THEN + EXECUTE $fn$ + CREATE FUNCTION auth.jwt() RETURNS jsonb LANGUAGE sql STABLE AS $body$ + SELECT coalesce( + nullif(current_setting('request.jwt.claim', true), ''), + nullif(current_setting('request.jwt.claims', true), '') + )::jsonb + $body$ + $fn$; + EXECUTE 'ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin'; + END IF; +END +$guard$; -- ============================================================================ -- Admin authorization: SINGLE SOURCE OF TRUTH (#240)