From ad054412dbc1705028075d4dc84d2f0109f25b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mei=20Zhang=20=F0=9F=A4=96?= Date: Sun, 31 May 2026 12:23:54 -0700 Subject: [PATCH] fix(#383): replace GUC storage with config table in kit_sync_setup_cron ALTER DATABASE SET app.kit_cron_secret is blocked on hosted Supabase (error 42501) even from a SECURITY DEFINER function, causing deploy to hard-fail at the Ensure kit-sync cron job step. Introduces a singleton config table (kit_sync_runtime_config) that stores the worker URL and cron secret, and rewrites kit_sync_setup_cron() to upsert there instead. The cron job body reads URL and secret from the table at runtime, so the secret is never inlined in cron.job.command. Changes: - New migration: kit_sync_runtime_config singleton table with RLS enabled; anon, authenticated, and service_role all explicitly denied table access so cron_secret is not readable via PostgREST regardless of role. SECURITY DEFINER function accesses as owner, no table grant needed. - CHECK (singleton IS TRUE) for explicit intent. - Cron body uses a single FROM row reference instead of two subqueries. - Best-effort RESET app.kit_cron_secret to clear stale GUC from prior migration (exception-handled; no-op if blocked on hosted Supabase). - pgTAP: config table, RLS, anon + authenticated + service_role privilege checks, upsert correctness, cron.job existence, secret non-inlining, and cron.unschedule cleanup; plan 8 -> 17. --- .../20260524000000_kit_sync_config_table.sql | 83 +++++++++++++++++++ supabase/tests/kit_sync_cron.test.sql | 72 +++++++++++++++- 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260524000000_kit_sync_config_table.sql diff --git a/supabase/migrations/20260524000000_kit_sync_config_table.sql b/supabase/migrations/20260524000000_kit_sync_config_table.sql new file mode 100644 index 00000000..53e8760e --- /dev/null +++ b/supabase/migrations/20260524000000_kit_sync_config_table.sql @@ -0,0 +1,83 @@ +-- Fix: replace GUC-based secret storage with a private config table. +-- ALTER DATABASE SET app.kit_cron_secret is blocked on hosted Supabase (error 42501) +-- even from a SECURITY DEFINER function. This migration introduces a singleton +-- config table and rewrites kit_sync_setup_cron() to upsert there instead. +-- The cron job body reads URL and secret from the table at runtime, so the +-- secret is never inlined in cron.job.command (same privacy guarantee as before). + +CREATE TABLE IF NOT EXISTS public.kit_sync_runtime_config ( + singleton boolean PRIMARY KEY DEFAULT true, + worker_url text NOT NULL, + cron_secret text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT singleton_only CHECK (singleton IS TRUE) +); + +ALTER TABLE public.kit_sync_runtime_config ENABLE ROW LEVEL SECURITY; + +-- Supabase's default privileges grant SELECT/INSERT/UPDATE/DELETE to anon and +-- authenticated on all new public tables. Revoke explicitly so the table is +-- inaccessible via PostgREST regardless of RLS policy state. +-- service_role is intentionally excluded: the SECURITY DEFINER function runs as +-- the owner and needs no table grant; omitting the service_role grant prevents +-- direct REST reads of cron_secret via the service key. +REVOKE ALL ON TABLE public.kit_sync_runtime_config FROM PUBLIC; +REVOKE ALL ON TABLE public.kit_sync_runtime_config FROM anon, authenticated; + +-- ── Updated cron registration helper ───────────────────────────────────────── +-- Replaces ALTER DATABASE SET with an upsert into kit_sync_runtime_config. +-- Cron job body selects worker_url and cron_secret from the table at runtime. + +CREATE OR REPLACE FUNCTION kit_sync_setup_cron( + p_url text, + p_secret text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = extensions, net, public +AS $$ +BEGIN + -- Existing databases may still have app.kit_cron_secret set by the prior GUC-based + -- migration. Best-effort reset so the stale value doesn't linger in pg_db_role_setting; + -- silently ignored if ALTER DATABASE RESET is blocked on hosted Supabase. + BEGIN + EXECUTE format('ALTER DATABASE %I RESET app.kit_cron_secret', current_database()); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + + INSERT INTO public.kit_sync_runtime_config (singleton, worker_url, cron_secret, updated_at) + VALUES (true, p_url, p_secret, now()) + ON CONFLICT (singleton) DO UPDATE SET + worker_url = EXCLUDED.worker_url, + cron_secret = EXCLUDED.cron_secret, + updated_at = EXCLUDED.updated_at; + + PERFORM cron.unschedule('kit-sync-worker') + WHERE EXISTS ( + SELECT 1 FROM cron.job WHERE jobname = 'kit-sync-worker' + ); + + PERFORM cron.schedule( + 'kit-sync-worker', + '*/5 * * * *', + $sql$ + SELECT net.http_post( + url := c.worker_url, + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || c.cron_secret + ), + body := '{}'::jsonb + ) + FROM public.kit_sync_runtime_config c + WHERE c.singleton IS TRUE + LIMIT 1 + $sql$ + ); +END; +$$; + +REVOKE ALL ON FUNCTION kit_sync_setup_cron(text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION kit_sync_setup_cron(text, text) TO service_role; diff --git a/supabase/tests/kit_sync_cron.test.sql b/supabase/tests/kit_sync_cron.test.sql index c0b5dfd4..58f1affd 100644 --- a/supabase/tests/kit_sync_cron.test.sql +++ b/supabase/tests/kit_sync_cron.test.sql @@ -1,6 +1,6 @@ BEGIN; -SELECT plan(8); +SELECT plan(17); -- 1. pg_cron extension exists SELECT has_extension('pg_cron', 'pg_cron extension is installed'); @@ -73,6 +73,76 @@ SELECT ok( 'kit_sync_dequeue returns pending row as processing' ); +-- 9. kit_sync_runtime_config table exists +SELECT has_table('public', 'kit_sync_runtime_config', 'kit_sync_runtime_config table exists'); + +-- 10. RLS is enabled on kit_sync_runtime_config +SELECT ok( + (SELECT c.relrowsecurity + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = 'kit_sync_runtime_config'), + 'kit_sync_runtime_config has RLS enabled' +); + +-- 11. anon role has no SELECT on kit_sync_runtime_config +SELECT ok( + NOT has_table_privilege('anon', 'public.kit_sync_runtime_config', 'SELECT'), + 'anon has no SELECT on kit_sync_runtime_config' +); + +-- 12. authenticated role has no SELECT on kit_sync_runtime_config +-- Supabase default privileges grant both anon and authenticated; test both. +SELECT ok( + NOT has_table_privilege('authenticated', 'public.kit_sync_runtime_config', 'SELECT'), + 'authenticated has no SELECT on kit_sync_runtime_config' +); + +-- 13. kit_sync_setup_cron executes without error and upserts config row +SELECT lives_ok( + $$ SELECT kit_sync_setup_cron('https://test.supabase.co/functions/v1/kit-sync', 'test-secret-abc') $$, + 'kit_sync_setup_cron executes without error' +); + +-- 14. config row contains the expected worker_url and cron_secret +SELECT ok( + EXISTS ( + SELECT 1 FROM public.kit_sync_runtime_config + WHERE singleton = true + AND worker_url = 'https://test.supabase.co/functions/v1/kit-sync' + AND cron_secret = 'test-secret-abc' + ), + 'kit_sync_runtime_config row has correct worker_url and cron_secret after setup' +); + +-- 15. cron.job row exists for kit-sync-worker +SELECT ok( + EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'kit-sync-worker'), + 'kit-sync-worker cron job row exists after setup' +); + +-- 16. cron.job.command for kit-sync-worker does not contain the literal secret +SELECT ok( + NOT EXISTS ( + SELECT 1 FROM cron.job + WHERE jobname = 'kit-sync-worker' + AND command LIKE '%test-secret-abc%' + ), + 'kit-sync-worker cron command does not inline the bearer secret' +); + +-- 17. service_role has no SELECT on kit_sync_runtime_config via table grant +-- The SECURITY DEFINER function accesses the table as owner; no grant needed. +SELECT ok( + NOT has_table_privilege('service_role', 'public.kit_sync_runtime_config', 'SELECT'), + 'service_role has no direct SELECT on kit_sync_runtime_config' +); + +-- Cleanup: unschedule the test cron job to avoid leaving stale rows in +-- cron.job on non-ephemeral databases (pg_cron commits independently of +-- the outer pgTAP transaction). +PERFORM cron.unschedule('kit-sync-worker'); + SELECT * FROM finish(); ROLLBACK;