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
This commit is contained in:
Weei Han
2026-07-30 20:38:38 +08:00
co-authored by Claude Sonnet 5
parent f539d43136
commit 1b3ba67468
2 changed files with 56 additions and 7 deletions
+11 -3
View File
@@ -3,6 +3,11 @@
-- "A Postgres function copies role/org_id into JWT claims (custom -- "A Postgres function copies role/org_id into JWT claims (custom
-- access token hook) so RLS can check them cheaply." -- 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). -- Run this AFTER schema.sql (needs the profiles table to exist).
-- After running, enable the hook in the Supabase dashboard (see bottom -- After running, enable the hook in the Supabase dashboard (see bottom
-- of this file for the exact steps) — running the SQL alone does not -- of this file for the exact steps) — running the SQL alone does not
@@ -17,10 +22,11 @@ as $$
claims jsonb; claims jsonb;
user_role public.user_role; user_role public.user_role;
user_org_id uuid; user_org_id uuid;
user_department_id uuid;
begin begin
-- Look up this user's role and org_id from profiles. -- Look up this user's role, org_id, and department_id from profiles.
select role, org_id select role, org_id, department_id
into user_role, user_org_id into user_role, user_org_id, user_department_id
from public.profiles from public.profiles
where id = (event->>'user_id')::uuid; where id = (event->>'user_id')::uuid;
@@ -29,9 +35,11 @@ as $$
if user_role is not null then if user_role is not null then
claims := jsonb_set(claims, '{role}', to_jsonb(user_role)); claims := jsonb_set(claims, '{role}', to_jsonb(user_role));
claims := jsonb_set(claims, '{org_id}', to_jsonb(user_org_id)); claims := jsonb_set(claims, '{org_id}', to_jsonb(user_org_id));
claims := jsonb_set(claims, '{department_id}', to_jsonb(user_department_id));
else else
claims := jsonb_set(claims, '{role}', 'null'); claims := jsonb_set(claims, '{role}', 'null');
claims := jsonb_set(claims, '{org_id}', 'null'); claims := jsonb_set(claims, '{org_id}', 'null');
claims := jsonb_set(claims, '{department_id}', 'null');
end if; end if;
event := jsonb_set(event, '{claims}', claims); event := jsonb_set(event, '{claims}', claims);
+45 -4
View File
@@ -24,12 +24,19 @@
-- access via RLS is only granted where the matrix explicitly says so -- access via RLS is only granted where the matrix explicitly says so
-- (insert own, read/write, etc). Everything else is mutated exclusively -- (insert own, read/write, etc). Everything else is mutated exclusively
-- by service-role /api routes, which bypass RLS entirely. -- by service-role /api routes, which bypass RLS entirely.
--
-- All checks below read role, org_id, and department_id from JWT claims
-- (auth.jwt() ->> '...') set by db/auth-hook.sql. No policy in this file
-- ever queries the profiles table — the sops/sop_versions staff-scoping
-- checks use the department_id claim directly instead of joining
-- profiles, avoiding any cross-table RLS dependency.
-- ============================================================ -- ============================================================
-- orgs (not in matrix — read own org only, no client write) -- orgs (not in matrix — read own org only, no client write)
-- ============================================================ -- ============================================================
alter table orgs enable row level security; alter table orgs enable row level security;
drop policy if exists orgs_read on orgs;
create policy orgs_read on orgs create policy orgs_read on orgs
for select using ( for select using (
id = (auth.jwt() ->> 'org_id')::uuid id = (auth.jwt() ->> 'org_id')::uuid
@@ -41,23 +48,27 @@ create policy orgs_read on orgs
-- ============================================================ -- ============================================================
alter table departments enable row level security; alter table departments enable row level security;
drop policy if exists departments_read on departments;
create policy departments_read on departments create policy departments_read on departments
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
); );
drop policy if exists departments_insert_admin on departments;
create policy departments_insert_admin on departments create policy departments_insert_admin on departments
for insert with check ( for insert with check (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') = 'admin' and (auth.jwt() ->> 'role') = 'admin'
); );
drop policy if exists departments_update_admin on departments;
create policy departments_update_admin on departments create policy departments_update_admin on departments
for update using ( for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') = 'admin' and (auth.jwt() ->> 'role') = 'admin'
); );
drop policy if exists departments_delete_admin on departments;
create policy departments_delete_admin on departments create policy departments_delete_admin on departments
for delete using ( for delete using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -74,11 +85,25 @@ create policy departments_delete_admin on departments
-- ============================================================ -- ============================================================
alter table profiles enable row level security; alter table profiles enable row level security;
-- CRITICAL: the JWT claims hook (db/auth-hook.sql) reads role/org_id/
-- department_id from this table on every login/token refresh, running
-- as supabase_auth_admin. Enabling RLS above blocks that read unless
-- this policy exists — without it, the hook silently returns null
-- claims and every other policy in this file fails closed. This grant
-- + policy is idempotent (grant is safe to re-run; the policy already
-- exists from db/auth-hook.sql, not recreated here to avoid a
-- duplicate-policy error).
grant select
on table public.profiles
to supabase_auth_admin;
drop policy if exists profiles_read on profiles;
create policy profiles_read on profiles create policy profiles_read on profiles
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
); );
drop policy if exists profiles_update_admin on profiles;
create policy profiles_update_admin on profiles create policy profiles_update_admin on profiles
for update using ( for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -92,6 +117,7 @@ create policy profiles_update_admin on profiles
-- ============================================================ -- ============================================================
alter table sops enable row level security; alter table sops enable row level security;
drop policy if exists sops_read on sops;
create policy sops_read on sops create policy sops_read on sops
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -103,20 +129,21 @@ create policy sops_read on sops
and exists ( and exists (
select 1 select 1
from sop_assignments sa from sop_assignments sa
join profiles p on p.department_id = sa.department_id
where sa.sop_id = sops.id where sa.sop_id = sops.id
and p.id = auth.uid() and sa.department_id = (auth.jwt() ->> 'department_id')::uuid
) )
) )
) )
); );
drop policy if exists sops_insert_editor on sops;
create policy sops_insert_editor on sops create policy sops_insert_editor on sops
for insert with check ( for insert with check (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('editor','admin') and (auth.jwt() ->> 'role') in ('editor','admin')
); );
drop policy if exists sops_update_editor on sops;
create policy sops_update_editor on sops create policy sops_update_editor on sops
for update using ( for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -132,6 +159,7 @@ create policy sops_update_editor on sops
-- ============================================================ -- ============================================================
alter table sop_versions enable row level security; alter table sop_versions enable row level security;
drop policy if exists sop_versions_read on sop_versions;
create policy sop_versions_read on sop_versions create policy sop_versions_read on sop_versions
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -142,9 +170,8 @@ create policy sop_versions_read on sop_versions
and exists ( and exists (
select 1 select 1
from sop_assignments sa from sop_assignments sa
join profiles p on p.department_id = sa.department_id
where sa.sop_id = sop_versions.sop_id where sa.sop_id = sop_versions.sop_id
and p.id = auth.uid() and sa.department_id = (auth.jwt() ->> 'department_id')::uuid
) )
) )
) )
@@ -155,12 +182,14 @@ create policy sop_versions_read on sop_versions
-- ============================================================ -- ============================================================
alter table approvals enable row level security; alter table approvals enable row level security;
drop policy if exists approvals_read on approvals;
create policy approvals_read on approvals create policy approvals_read on approvals
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('editor','approver','admin') and (auth.jwt() ->> 'role') in ('editor','approver','admin')
); );
drop policy if exists approvals_insert on approvals;
create policy approvals_insert on approvals create policy approvals_insert on approvals
for insert with check ( for insert with check (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -174,6 +203,7 @@ create policy approvals_insert on approvals
-- ============================================================ -- ============================================================
alter table sop_assignments enable row level security; alter table sop_assignments enable row level security;
drop policy if exists sop_assignments_read on sop_assignments;
create policy sop_assignments_read on sop_assignments create policy sop_assignments_read on sop_assignments
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -185,12 +215,14 @@ create policy sop_assignments_read on sop_assignments
-- ============================================================ -- ============================================================
alter table acknowledgements enable row level security; alter table acknowledgements enable row level security;
drop policy if exists ack_insert_own on acknowledgements;
create policy ack_insert_own on acknowledgements create policy ack_insert_own on acknowledgements
for insert with check ( for insert with check (
user_id = auth.uid() user_id = auth.uid()
and org_id = (auth.jwt() ->> 'org_id')::uuid and org_id = (auth.jwt() ->> 'org_id')::uuid
); );
drop policy if exists ack_read on acknowledgements;
create policy ack_read on acknowledgements create policy ack_read on acknowledgements
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -203,17 +235,20 @@ create policy ack_read on acknowledgements
-- ============================================================ -- ============================================================
alter table sop_translations enable row level security; alter table sop_translations enable row level security;
drop policy if exists translations_read on sop_translations;
create policy translations_read on sop_translations create policy translations_read on sop_translations
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
); );
drop policy if exists translations_insert_editor on sop_translations;
create policy translations_insert_editor on sop_translations create policy translations_insert_editor on sop_translations
for insert with check ( for insert with check (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('editor','admin') and (auth.jwt() ->> 'role') in ('editor','admin')
); );
drop policy if exists translations_update_editor on sop_translations;
create policy translations_update_editor on sop_translations create policy translations_update_editor on sop_translations
for update using ( for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -226,12 +261,14 @@ create policy translations_update_editor on sop_translations
-- ============================================================ -- ============================================================
alter table audit_log enable row level security; alter table audit_log enable row level security;
drop policy if exists audit_read on audit_log;
create policy audit_read on audit_log create policy audit_read on audit_log
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('approver','admin') and (auth.jwt() ->> 'role') in ('approver','admin')
); );
drop policy if exists audit_insert on audit_log;
create policy audit_insert on audit_log create policy audit_insert on audit_log
for insert with check ( for insert with check (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -243,6 +280,7 @@ create policy audit_insert on audit_log
-- ============================================================ -- ============================================================
alter table ai_log enable row level security; alter table ai_log enable row level security;
drop policy if exists ai_log_read_admin on ai_log;
create policy ai_log_read_admin on ai_log create policy ai_log_read_admin on ai_log
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -255,12 +293,14 @@ create policy ai_log_read_admin on ai_log
-- ============================================================ -- ============================================================
alter table incidents enable row level security; alter table incidents enable row level security;
drop policy if exists incidents_insert_own on incidents;
create policy incidents_insert_own on incidents create policy incidents_insert_own on incidents
for insert with check ( for insert with check (
reporter_id = auth.uid() reporter_id = auth.uid()
and org_id = (auth.jwt() ->> 'org_id')::uuid and org_id = (auth.jwt() ->> 'org_id')::uuid
); );
drop policy if exists incidents_read on incidents;
create policy incidents_read on incidents create policy incidents_read on incidents
for select using ( for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid
@@ -268,6 +308,7 @@ create policy incidents_read on incidents
or (auth.jwt() ->> 'role') in ('editor','approver','admin') ) or (auth.jwt() ->> 'role') in ('editor','approver','admin') )
); );
drop policy if exists incidents_update on incidents;
create policy incidents_update on incidents create policy incidents_update on incidents
for update using ( for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid org_id = (auth.jwt() ->> 'org_id')::uuid