diff --git a/scripts/ops/users_github_id_unique_index.sql b/scripts/ops/users_github_id_unique_index.sql new file mode 100644 index 0000000000..6478f8004f --- /dev/null +++ b/scripts/ops/users_github_id_unique_index.sql @@ -0,0 +1,84 @@ +-- Required pre-deploy step: build the GitHub identity index without blocking +-- writes. Run this file with psql, which executes each statement in autocommit +-- mode and supports the conditional recovery command below. In SQL Editor, +-- check for and drop an invalid same-named index first, then run the CREATE +-- INDEX statement and validation block separately. CREATE INDEX CONCURRENTLY +-- cannot run inside a transaction. +-- +-- Example (psql): +-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \ +-- -f scripts/ops/users_github_id_unique_index.sql + +-- Keep recovery, creation, and validation in one serialized session. An +-- invalid index can also be a concurrent build that another deploy is still +-- running, so a second invocation must wait instead of dropping it. +SELECT pg_catalog.pg_advisory_lock( + pg_catalog.hashtextextended('public.users_github_id_key', 0) +); + +-- A failed concurrent build leaves an invalid same-named index. Generate a +-- concurrent drop only for that recoverable state; valid indexes remain intact. +SELECT format( + 'DROP INDEX CONCURRENTLY %I.%I', + index_namespace.nspname, + idx.relname +) +FROM pg_catalog.pg_class AS idx +INNER JOIN pg_catalog.pg_namespace AS index_namespace + ON idx.relnamespace = index_namespace.oid +INNER JOIN pg_catalog.pg_index AS index_meta + ON idx.oid = index_meta.indexrelid +WHERE index_namespace.nspname = 'public' + AND idx.relname = 'users_github_id_key' + AND NOT index_meta.indisvalid +\gexec + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS users_github_id_key + ON public.users (github_id); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS idx + INNER JOIN pg_catalog.pg_namespace AS idx_ns + ON idx.relnamespace = idx_ns.oid + INNER JOIN pg_catalog.pg_index AS index_meta + ON idx.oid = index_meta.indexrelid + INNER JOIN pg_catalog.pg_class AS indexed_table + ON index_meta.indrelid = indexed_table.oid + INNER JOIN pg_catalog.pg_namespace AS table_ns + ON indexed_table.relnamespace = table_ns.oid + INNER JOIN pg_catalog.pg_attribute AS indexed_column + ON indexed_table.oid = indexed_column.attrelid + AND indexed_column.attname = 'github_id' + AND NOT indexed_column.attisdropped + INNER JOIN pg_catalog.pg_am AS access_method + ON idx.relam = access_method.oid + WHERE idx_ns.nspname = 'public' + AND idx.relname = 'users_github_id_key' + AND table_ns.nspname = 'public' + AND indexed_table.relname = 'users' + AND index_meta.indisvalid + AND index_meta.indisready + AND index_meta.indisunique + AND index_meta.indislive + AND NOT index_meta.indnullsnotdistinct + AND index_meta.indpred IS NULL + AND index_meta.indexprs IS NULL + AND index_meta.indnkeyatts = 1 + AND index_meta.indnatts = 1 + AND index_meta.indkey[0] = indexed_column.attnum + AND access_method.amname = 'btree' + ) THEN + RAISE EXCEPTION '%', + 'Index public.users_github_id_key is not a valid, ready, unique ' + || 'btree index on public.users(github_id) with NULLS DISTINCT. ' + || 'Drop it and rerun this script.'; + END IF; +END +$$; + +SELECT pg_catalog.pg_advisory_unlock( + pg_catalog.hashtextextended('public.users_github_id_key', 0) +); diff --git a/supabase/migrations/20260802171300_enforce_unique_github_id.sql b/supabase/migrations/20260802171300_enforce_unique_github_id.sql new file mode 100644 index 0000000000..a5016141f9 --- /dev/null +++ b/supabase/migrations/20260802171300_enforce_unique_github_id.sql @@ -0,0 +1,100 @@ +-- Wait for the concurrent prebuild session to finish before inspecting its +-- index. The transaction lock is released automatically with this migration. +SELECT pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended('public.users_github_id_key', 0) +); + +DO $$ +DECLARE + duplicate_github_id bigint; + duplicate_count bigint; +BEGIN + SELECT github_id, COUNT(*) + INTO duplicate_github_id, duplicate_count + FROM public.users + WHERE github_id IS NOT NULL + GROUP BY github_id + HAVING COUNT(*) > 1 + ORDER BY github_id + LIMIT 1; + + IF duplicate_github_id IS NOT NULL THEN + RAISE EXCEPTION + 'Cannot enforce unique GitHub account links: ' + 'github_id % is linked to % users', + duplicate_github_id, + duplicate_count; + END IF; +END +$$; + +-- Production operators must prebuild this index concurrently with +-- scripts/ops/users_github_id_unique_index.sql before applying the migration. +-- Refuse the blocking fallback on every populated database. Fresh local/test +-- databases are empty here, so they remain self-contained without allowing a +-- missed production pre-deploy step to silently lock writes. +DO $$ +BEGIN + IF pg_catalog.to_regclass('public.users_github_id_key') IS NULL + AND EXISTS (SELECT 1 FROM public.users LIMIT 1) + THEN + RAISE EXCEPTION '%', + 'Prebuild public.users_github_id_key concurrently with ' + || 'scripts/ops/users_github_id_unique_index.sql before applying ' + || 'this migration to a populated database.'; + END IF; +END +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS users_github_id_key +ON public.users (github_id); + +-- Keep this index-shape validation synchronized with +-- scripts/ops/users_github_id_unique_index.sql. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS idx + INNER JOIN pg_catalog.pg_namespace AS idx_ns + ON idx.relnamespace = idx_ns.oid + INNER JOIN pg_catalog.pg_index AS index_meta + ON idx.oid = index_meta.indexrelid + INNER JOIN pg_catalog.pg_class AS indexed_table + ON index_meta.indrelid = indexed_table.oid + INNER JOIN pg_catalog.pg_namespace AS table_ns + ON indexed_table.relnamespace = table_ns.oid + INNER JOIN pg_catalog.pg_attribute AS indexed_column + ON indexed_table.oid = indexed_column.attrelid + AND indexed_column.attname = 'github_id' + AND NOT indexed_column.attisdropped + INNER JOIN pg_catalog.pg_am AS access_method + ON idx.relam = access_method.oid + WHERE idx_ns.nspname = 'public' + AND idx.relname = 'users_github_id_key' + AND table_ns.nspname = 'public' + AND indexed_table.relname = 'users' + AND index_meta.indisvalid + AND index_meta.indisready + AND index_meta.indisunique + AND index_meta.indislive + AND NOT index_meta.indnullsnotdistinct + AND index_meta.indpred IS NULL + AND index_meta.indexprs IS NULL + AND index_meta.indnkeyatts = 1 + AND index_meta.indnatts = 1 + AND index_meta.indkey[0] = indexed_column.attnum + AND access_method.amname = 'btree' + ) THEN + RAISE EXCEPTION '%', + 'Index public.users_github_id_key is not a valid, ready, unique ' + || 'btree index on public.users(github_id) with NULLS DISTINCT. ' + || 'Drop it and rerun scripts/ops/users_github_id_unique_index.sql ' + || 'before applying this migration.'; + END IF; +END +$$; + +ALTER TABLE public.users +ADD CONSTRAINT users_github_id_key +UNIQUE USING INDEX users_github_id_key; diff --git a/supabase/tests/65_test_github_user_id.sql b/supabase/tests/65_test_github_user_id.sql index 119ef3eea5..dbd1a504f2 100644 --- a/supabase/tests/65_test_github_user_id.sql +++ b/supabase/tests/65_test_github_user_id.sql @@ -1,6 +1,31 @@ BEGIN; -SELECT plan(3); +SELECT plan(9); + +SELECT tests.create_supabase_user( + 'github_id_unique_first', + 'github_id_unique_first@test.local' +); +SELECT tests.create_supabase_user( + 'github_id_unique_second', + 'github_id_unique_second@test.local' +); + +INSERT INTO public.users (id, email, created_at, updated_at) +VALUES + ( + tests.get_supabase_uid('github_id_unique_first'), + 'github_id_unique_first@test.local', + NOW(), + NOW() + ), + ( + tests.get_supabase_uid('github_id_unique_second'), + 'github_id_unique_second@test.local', + NOW(), + NOW() + ) +ON CONFLICT (id) DO NOTHING; SELECT ok( EXISTS ( @@ -37,6 +62,101 @@ SELECT is( 'users.github_id is nullable' ); +SELECT is( + ( + SELECT contype::text + FROM pg_constraint + WHERE conrelid = 'public.users'::regclass + AND conname = 'users_github_id_key' + ), + 'u', + 'users.github_id has the named unique constraint' +); + +SELECT ok( + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_constraint AS unique_constraint + INNER JOIN pg_catalog.pg_index AS index_meta + ON unique_constraint.conindid = index_meta.indexrelid + INNER JOIN pg_catalog.pg_attribute AS indexed_column + ON unique_constraint.conrelid = indexed_column.attrelid + AND indexed_column.attname = 'github_id' + AND NOT indexed_column.attisdropped + INNER JOIN pg_catalog.pg_class AS idx + ON index_meta.indexrelid = idx.oid + INNER JOIN pg_catalog.pg_am AS access_method + ON idx.relam = access_method.oid + WHERE unique_constraint.conrelid = 'public.users'::regclass + AND unique_constraint.conname = 'users_github_id_key' + AND index_meta.indisvalid + AND index_meta.indisready + AND index_meta.indisunique + AND index_meta.indislive + AND NOT index_meta.indnullsnotdistinct + AND index_meta.indpred IS NULL + AND index_meta.indexprs IS NULL + AND index_meta.indnkeyatts = 1 + AND index_meta.indnatts = 1 + AND index_meta.indkey[0] = indexed_column.attnum + AND access_method.amname = 'btree' + ), + 'the constraint uses the validated GitHub ID index' +); + +UPDATE public.users +SET github_id = 123456789 +WHERE id = tests.get_supabase_uid('github_id_unique_first'); + +SELECT throws_ok( + $$ + UPDATE public.users + SET github_id = 123456789 + WHERE id = tests.get_supabase_uid('github_id_unique_second'); + $$, + '23505', + 'duplicate key value violates unique constraint "users_github_id_key"', + 'different users cannot save the same non-null GitHub ID' +); + +SELECT lives_ok( + $$ + UPDATE public.users + SET github_id = NULL + WHERE id IN ( + tests.get_supabase_uid('github_id_unique_first'), + tests.get_supabase_uid('github_id_unique_second') + ); + $$, + 'multiple users may have a null GitHub ID' +); + +UPDATE public.users +SET github_id = 123456789 +WHERE id = tests.get_supabase_uid('github_id_unique_first'); + +SELECT lives_ok( + $$ + UPDATE public.users + SET github_id = 123456789 + WHERE id = tests.get_supabase_uid('github_id_unique_first'); + $$, + 'a user may re-save their own GitHub ID' +); + +SELECT lives_ok( + $$ + UPDATE public.users + SET github_id = NULL + WHERE id = tests.get_supabase_uid('github_id_unique_first'); + + UPDATE public.users + SET github_id = 123456789 + WHERE id = tests.get_supabase_uid('github_id_unique_second'); + $$, + 'clearing a GitHub link lets another user claim that ID' +); + SELECT * FROM finish(); -- noqa: AM04 ROLLBACK;