|
| 1 | +-- Re-lock the SECURITY DEFINER functions: their EXECUTE revokes were lost in the dev2 move. |
| 2 | +-- |
| 3 | +-- 20260814030009, 20260814032259 and 20260814030037/032942 (#247) revoked EXECUTE on every |
| 4 | +-- SECURITY DEFINER function in schema public from anon (and, for internal ones, from |
| 5 | +-- authenticated) and bound the user-callable ones to auth.uid(). The bodies survived the |
| 6 | +-- move to the self-hosted dev2 stack, but the ACLs did not (the restore did not carry |
| 7 | +-- privileges), so on 2026-10-26 all 53 SECURITY DEFINER functions were executable by the |
| 8 | +-- public anon key again. |
| 9 | +-- |
| 10 | +-- That matters more than it looks, because the #247 bodies treat `auth.uid() IS NULL` as |
| 11 | +-- "trusted server caller" (the service role has no uid). With anon allowed back in, anon |
| 12 | +-- also has no uid, so every one of those checks waved anon through. The worst cases: |
| 13 | +-- * upsert_user_public_key(target, key): `IF auth.uid() != target` is NULL for anon, so the |
| 14 | +-- anon key could REPLACE ANY USER'S PUBLIC KEY (silent man-in-the-middle of E2EE). |
| 15 | +-- * fn_get_user_message_content(msg, user): another user's encrypted message envelope. |
| 16 | +-- * get_user_call_sessions / fn_get_user_active_deliveries / get_user_call_history / |
| 17 | +-- get_user_active_calls: anyone's call sessions, deliveries and call history. |
| 18 | +-- * get_conversations_with_archive_* / get_user_archived_conversations: anyone's |
| 19 | +-- conversation list. get_sms_stats, log_sms_event, cleanup_*: internal. |
| 20 | +-- * sync_user_with_auth / fix_* / update_canonical_auth_user_ids: rewrite identity rows. |
| 21 | +-- |
| 22 | +-- Function BODIES are unchanged here (messaging behaviour stays identical). Only grants: |
| 23 | +-- the same matrix #247 shipped, re-asserted idempotently, plus fn_cleanup_expired_messages |
| 24 | +-- moved to service_role only (the app only calls it with the service role). |
| 25 | +-- |
| 26 | +-- Also in this migration (rule: users must not write privileged columns of their own row): |
| 27 | +-- * users.account_type and users.operator_user_id join the immutable list. An |
| 28 | +-- 'anonymous' account could PATCH itself to 'verified' and get the free daily phone |
| 29 | +-- minutes that src/lib/server/phone.js withholds from anonymous accounts. |
| 30 | +-- * blog_posts_bak_20261014 had RLS off and full anon/authenticated grants. |
| 31 | +-- * key_access_log / sms_notifications INSERT policies `WITH CHECK (auth.uid() IS NULL)` |
| 32 | +-- were meant for "the system" but matched anon; the service role bypasses RLS anyway, |
| 33 | +-- so they now apply to service_role only. |
| 34 | +-- * Default privileges: new functions are no longer executable by PUBLIC or anon. |
| 35 | + |
| 36 | +-- 1. Internal / server-only: service_role only. |
| 37 | +DO $$ |
| 38 | +DECLARE |
| 39 | + fn text; |
| 40 | + sig text; |
| 41 | + names text[] := ARRAY[ |
| 42 | + 'backfill_all_message_status','bump_autoblog_integration','calculate_message_expiration', |
| 43 | + 'check_message_status_coverage','check_sms_rate_limit','cleanup_expired_messages', |
| 44 | + 'cleanup_sms_audit_logs','cleanup_sms_rate_limits','delete_encrypted_data_only', |
| 45 | + 'enforce_users_immutable_columns','fix_canonical_auth_user_ids', |
| 46 | + 'fix_missing_conversation_participants','fn_cleanup_expired_messages', |
| 47 | + 'fn_create_message_recipients','fn_create_message_status_entries', |
| 48 | + 'fn_get_user_message_content','fn_messages_ready_for_gc', |
| 49 | + 'get_conversations_with_archive_data','get_conversations_with_archive_support', |
| 50 | + 'get_sms_stats','get_storage_bucket_limits','get_user_archived_conversations', |
| 51 | + 'get_user_call_sessions','is_user_inactive','log_otp_verification_attempt', |
| 52 | + 'log_sms_event','nuclear_delete_user_data','set_message_expiration','set_user_offline', |
| 53 | + 'sync_user_with_auth','trigger_create_message_status','update_canonical_auth_user_ids', |
| 54 | + 'update_message_has_files' |
| 55 | + ]; |
| 56 | +BEGIN |
| 57 | + FOREACH fn IN ARRAY names LOOP |
| 58 | + FOR sig IN |
| 59 | + SELECT format('public.%I(%s)', p.proname, pg_get_function_identity_arguments(p.oid)) |
| 60 | + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace |
| 61 | + WHERE n.nspname = 'public' AND p.proname = fn AND p.prosecdef |
| 62 | + LOOP |
| 63 | + EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon, authenticated', sig); |
| 64 | + EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', sig); |
| 65 | + END LOOP; |
| 66 | + END LOOP; |
| 67 | +END $$; |
| 68 | + |
| 69 | +-- 2. Signed-in users call these (browser, websocket handlers, user-session server |
| 70 | +-- clients, RLS policies). Each scopes to auth.uid(); none may be called by anon. |
| 71 | +DO $$ |
| 72 | +DECLARE |
| 73 | + fn text; |
| 74 | + sig text; |
| 75 | + names text[] := ARRAY[ |
| 76 | + 'archive_conversation','unarchive_conversation', |
| 77 | + 'create_call_session','establish_call_session', |
| 78 | + 'find_user_by_unique_identifier','fn_create_deliveries_for_message', |
| 79 | + 'fn_get_user_active_deliveries','fn_mark_message_read','fn_mark_read', |
| 80 | + 'log_sms_notification','update_user_activity','get_user_public_key', |
| 81 | + 'upsert_user_public_key','get_inactive_participants','get_user_call_history', |
| 82 | + 'get_user_active_calls','current_app_user_id','is_conversation_creator', |
| 83 | + 'is_conversation_participant','shares_conversation_with' |
| 84 | + ]; |
| 85 | +BEGIN |
| 86 | + FOREACH fn IN ARRAY names LOOP |
| 87 | + FOR sig IN |
| 88 | + SELECT format('public.%I(%s)', p.proname, pg_get_function_identity_arguments(p.oid)) |
| 89 | + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace |
| 90 | + WHERE n.nspname = 'public' AND p.proname = fn AND p.prosecdef |
| 91 | + LOOP |
| 92 | + EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon', sig); |
| 93 | + EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO authenticated, service_role', sig); |
| 94 | + END LOOP; |
| 95 | + END LOOP; |
| 96 | +END $$; |
| 97 | + |
| 98 | +-- 3. Any SECURITY DEFINER function in public not named above: lock it to service_role too, |
| 99 | +-- so a function added later without a grant review is not left open by this list. |
| 100 | +DO $$ |
| 101 | +DECLARE |
| 102 | + sig text; |
| 103 | +BEGIN |
| 104 | + FOR sig IN |
| 105 | + SELECT format('public.%I(%s)', p.proname, pg_get_function_identity_arguments(p.oid)) |
| 106 | + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace |
| 107 | + WHERE n.nspname = 'public' AND p.prosecdef |
| 108 | + AND has_function_privilege('anon', p.oid, 'EXECUTE') |
| 109 | + LOOP |
| 110 | + EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon, authenticated', sig); |
| 111 | + EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', sig); |
| 112 | + END LOOP; |
| 113 | +END $$; |
| 114 | + |
| 115 | +-- 4. New functions: not executable by PUBLIC or anon unless granted on purpose. |
| 116 | +ALTER DEFAULT PRIVILEGES FOR ROLE postgres REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; |
| 117 | +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM anon; |
| 118 | + |
| 119 | +-- 5. users: account_type and operator_user_id are server-set, like the identity columns. |
| 120 | +CREATE OR REPLACE FUNCTION public.enforce_users_immutable_columns() |
| 121 | +RETURNS trigger |
| 122 | +LANGUAGE plpgsql |
| 123 | +SECURITY DEFINER |
| 124 | +SET search_path TO '' |
| 125 | +AS $function$ |
| 126 | +BEGIN |
| 127 | + IF current_setting('role', true) = 'service_role' |
| 128 | + OR auth.uid() IS NULL THEN |
| 129 | + RETURN NEW; |
| 130 | + END IF; |
| 131 | + |
| 132 | + IF NEW.id IS DISTINCT FROM OLD.id |
| 133 | + OR NEW.auth_user_id IS DISTINCT FROM OLD.auth_user_id |
| 134 | + OR NEW.phone_number IS DISTINCT FROM OLD.phone_number |
| 135 | + OR NEW.unique_identifier IS DISTINCT FROM OLD.unique_identifier |
| 136 | + OR NEW.created_at IS DISTINCT FROM OLD.created_at |
| 137 | + OR NEW.salt IS DISTINCT FROM OLD.salt |
| 138 | + OR NEW.account_type IS DISTINCT FROM OLD.account_type |
| 139 | + OR NEW.operator_user_id IS DISTINCT FROM OLD.operator_user_id |
| 140 | + THEN |
| 141 | + RAISE EXCEPTION |
| 142 | + 'Identity columns of users are not client-writable (id, auth_user_id, phone_number, unique_identifier, created_at, salt, account_type, operator_user_id)'; |
| 143 | + END IF; |
| 144 | + |
| 145 | + RETURN NEW; |
| 146 | +END; |
| 147 | +$function$; |
| 148 | + |
| 149 | +REVOKE EXECUTE ON FUNCTION public.enforce_users_immutable_columns() FROM PUBLIC, anon, authenticated; |
| 150 | + |
| 151 | +-- 6. Leftover backup table with RLS off. |
| 152 | +ALTER TABLE IF EXISTS public.blog_posts_bak_20261014 ENABLE ROW LEVEL SECURITY; |
| 153 | +REVOKE ALL ON TABLE public.blog_posts_bak_20261014 FROM anon, authenticated; |
| 154 | + |
| 155 | +-- 7. "System" insert policies that matched anon. |
| 156 | +DROP POLICY IF EXISTS "System can insert key access logs" ON public.key_access_log; |
| 157 | +CREATE POLICY "System can insert key access logs" ON public.key_access_log |
| 158 | + FOR INSERT TO service_role WITH CHECK (true); |
| 159 | + |
| 160 | +DROP POLICY IF EXISTS "System can insert SMS notifications" ON public.sms_notifications; |
| 161 | +CREATE POLICY "System can insert SMS notifications" ON public.sms_notifications |
| 162 | + FOR INSERT TO service_role WITH CHECK (true); |
| 163 | + |
| 164 | +NOTIFY pgrst, 'reload schema'; |
0 commit comments