Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions supabase/migrations/20260524000000_kit_sync_config_table.sql
Original file line number Diff line number Diff line change
@@ -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)
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in f04ae3d. Added ALTER TABLE public.kit_sync_runtime_config ENABLE ROW LEVEL SECURITY; immediately after the table definition, matching the pattern from marketing_email_v1.sql.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a one-line comment that existing databases may still have app.kit_cron_secret set from the prior migration. Optional follow-up: EXECUTE format('ALTER DATABASE %I RESET app.kit_cron_secret', current_database()) here for hygiene so stale GUC values don't linger in pg_db_role_setting after deploy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a comment inside the function body about the stale GUC, plus a best-effort nested BEGIN...EXCEPTION WHEN OTHERS block that executes ALTER DATABASE ... RESET app.kit_cron_secret. The exception handler makes it a no-op on hosted Supabase where RESET may also be restricted, so it cannot break the deploy.

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;
72 changes: 71 additions & 1 deletion supabase/tests/kit_sync_cron.test.sql
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch revoking anon — the migration also revokes authenticated. Consider mirroring that here:

NOT has_table_privilege('authenticated', 'public.kit_sync_runtime_config', 'SELECT')

Supabase default privileges grant both roles on new public tables; testing only anon leaves a gap if someone regresses the second REVOKE.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added test 12 asserting authenticated has no SELECT (mirrors the anon check). Plan is now 17.

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling kit_sync_setup_cron inside a BEGIN … ROLLBACK pgTAP transaction may leave a real cron.job row behind — pg_cron registration often commits independently of the outer test transaction. If CI ever runs pgTAP against a long-lived local DB (not just ephemeral CI), this can accumulate stale jobs.

Consider an explicit PERFORM cron.unschedule('kit-sync-worker') after the assertions (still inside the test block), or document that this test requires a fresh DB.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added PERFORM cron.unschedule("kit-sync-worker") after the assertions (still inside the pgTAP block, before SELECT * FROM finish()). This cleans up the cron.job row even if pg_cron committed independently of the test transaction.

$$ 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;
Loading