Skip to content

Commit f324374

Browse files
security(db): move custom_access_token_hook to app_private schema
Per Supabase best practices, SECURITY DEFINER functions must not live in exposed schemas. The hook was in public with EXECUTE granted to anon/authenticated/PUBLIC — any user could call it via the Data API. Moved to app_private schema with EXECUTE restricted to supabase_auth_admin only. Auth hook config updated via Management API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 75c1b35 commit f324374

1 file changed

Lines changed: 87 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
-- =============================================================================
2+
-- Move custom_access_token_hook to a private schema
3+
--
4+
-- Per Supabase security best practices, SECURITY DEFINER functions must NOT
5+
-- live in an exposed schema (public). The function runs as postgres and can
6+
-- read tenant_users, super_admins, etc. — any authenticated user could call
7+
-- it via the Data API if it stays in public.
8+
--
9+
-- Fix: Create app_private schema, recreate the function there, update grants,
10+
-- and drop the public version.
11+
-- =============================================================================
12+
13+
-- 1. Create private schema (not exposed via Data API)
14+
CREATE SCHEMA IF NOT EXISTS app_private;
15+
16+
-- 2. Recreate the hook in the private schema
17+
CREATE OR REPLACE FUNCTION app_private.custom_access_token_hook(event jsonb)
18+
RETURNS jsonb
19+
LANGUAGE plpgsql
20+
STABLE SECURITY DEFINER
21+
SET search_path TO ''
22+
AS $function$
23+
DECLARE
24+
claims jsonb;
25+
user_role public.app_role;
26+
v_tenant_id uuid;
27+
v_tenant_role text;
28+
v_is_super_admin boolean;
29+
BEGIN
30+
claims := event->'claims';
31+
32+
-- Fetch user role
33+
SELECT role INTO user_role
34+
FROM public.user_roles
35+
WHERE user_id = (event->>'user_id')::uuid
36+
LIMIT 1;
37+
38+
IF user_role IS NOT NULL THEN
39+
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role::text));
40+
ELSE
41+
claims := jsonb_set(claims, '{user_role}', to_jsonb('student'::text));
42+
END IF;
43+
44+
-- Check if super admin
45+
SELECT EXISTS(
46+
SELECT 1 FROM public.super_admins WHERE user_id = (event->>'user_id')::uuid
47+
) INTO v_is_super_admin;
48+
claims := jsonb_set(claims, '{is_super_admin}', to_jsonb(v_is_super_admin));
49+
50+
-- Get tenant context from raw_app_meta_data if available
51+
v_tenant_id := (event->'claims'->'app_metadata'->>'tenant_id')::uuid;
52+
53+
IF v_tenant_id IS NOT NULL THEN
54+
claims := jsonb_set(claims, '{tenant_id}', to_jsonb(v_tenant_id::text));
55+
56+
-- Get tenant role
57+
SELECT tu.role INTO v_tenant_role
58+
FROM public.tenant_users tu
59+
WHERE tu.user_id = (event->>'user_id')::uuid
60+
AND tu.tenant_id = v_tenant_id
61+
AND tu.status = 'active'
62+
LIMIT 1;
63+
64+
IF v_tenant_role IS NOT NULL THEN
65+
claims := jsonb_set(claims, '{tenant_role}', to_jsonb(v_tenant_role));
66+
ELSE
67+
claims := jsonb_set(claims, '{tenant_role}', to_jsonb('student'::text));
68+
END IF;
69+
END IF;
70+
71+
RETURN jsonb_set(event, '{claims}', claims);
72+
EXCEPTION
73+
WHEN OTHERS THEN
74+
RAISE WARNING 'Error in custom_access_token_hook: %', SQLERRM;
75+
RETURN event;
76+
END;
77+
$function$;
78+
79+
-- 3. Only supabase_auth_admin needs to call this hook
80+
REVOKE ALL ON FUNCTION app_private.custom_access_token_hook(jsonb) FROM PUBLIC;
81+
REVOKE ALL ON FUNCTION app_private.custom_access_token_hook(jsonb) FROM anon;
82+
REVOKE ALL ON FUNCTION app_private.custom_access_token_hook(jsonb) FROM authenticated;
83+
GRANT EXECUTE ON FUNCTION app_private.custom_access_token_hook(jsonb) TO supabase_auth_admin;
84+
GRANT USAGE ON SCHEMA app_private TO supabase_auth_admin;
85+
86+
-- 4. Drop the old public version (no longer needed)
87+
DROP FUNCTION IF EXISTS public.custom_access_token_hook(jsonb);

0 commit comments

Comments
 (0)