Files
OG/db/auth-hook.sql
T
Weei HanandClaude Sonnet 5 1b3ba67468 M0: add department_id JWT claim, rewrite RLS to avoid profiles queries
auth-hook.sql now copies department_id into JWT claims alongside
role/org_id, so RLS can scope Staff reads without joining profiles
from another table's policy. policies.sql: sops/sop_versions Staff
read policies now use the department_id claim directly; every create
policy paired with a matching drop policy if exists so the file is
safe to re-run. Folds in the supabase_auth_admin profiles-select grant
so policies.sql is self-contained.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcktbLXSSXzx23GCue813e
2026-07-30 20:38:38 +08:00

88 lines
3.0 KiB
PL/PgSQL

-- Custom Access Token Hook
-- Implements AD-2 from docs/03-architecture.md:
-- "A Postgres function copies role/org_id into JWT claims (custom
-- access token hook) so RLS can check them cheaply."
--
-- Also copies department_id — needed so RLS policies can scope Staff
-- reads to "published + assigned to their department" (04-database-
-- schema.md section 3) using JWT claims only, without any policy ever
-- querying the profiles table from another table's policy.
--
-- Run this AFTER schema.sql (needs the profiles table to exist).
-- After running, enable the hook in the Supabase dashboard (see bottom
-- of this file for the exact steps) — running the SQL alone does not
-- activate it.
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role public.user_role;
user_org_id uuid;
user_department_id uuid;
begin
-- Look up this user's role, org_id, and department_id from profiles.
select role, org_id, department_id
into user_role, user_org_id, user_department_id
from public.profiles
where id = (event->>'user_id')::uuid;
claims := event->'claims';
if user_role is not null then
claims := jsonb_set(claims, '{role}', to_jsonb(user_role));
claims := jsonb_set(claims, '{org_id}', to_jsonb(user_org_id));
claims := jsonb_set(claims, '{department_id}', to_jsonb(user_department_id));
else
claims := jsonb_set(claims, '{role}', 'null');
claims := jsonb_set(claims, '{org_id}', 'null');
claims := jsonb_set(claims, '{department_id}', 'null');
end if;
event := jsonb_set(event, '{claims}', claims);
return event;
end;
$$;
-- The hook runs as the supabase_auth_admin role, not as the logged-in
-- user, so it needs explicit permission to call the function and read
-- profiles. Everyone else is explicitly blocked from calling it directly.
grant usage on schema public to supabase_auth_admin;
grant execute
on function public.custom_access_token_hook
to supabase_auth_admin;
revoke execute
on function public.custom_access_token_hook
from authenticated, anon, public;
grant select
on table public.profiles
to supabase_auth_admin;
create policy "Allow auth admin to read profiles for JWT hook"
on public.profiles
as permissive
for select
to supabase_auth_admin
using (true);
-- ============================================================
-- Dashboard steps to enable this hook (run the SQL above first):
--
-- 1. Supabase dashboard → Authentication → Hooks (left sidebar,
-- under "Configuration").
-- 2. Find "Customize Access Token (JWT) Claims hook".
-- 3. Choose "Postgres function" as the hook type.
-- 4. Select public.custom_access_token_hook from the dropdown.
-- 5. Enable the hook (toggle on) and save.
-- 6. Existing logged-in sessions keep their OLD token until they
-- refresh/re-login — log out and back in to see new claims.
-- ============================================================