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
88 lines
3.0 KiB
PL/PgSQL
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.
|
|
-- ============================================================
|