diff --git a/db/auth-hook.sql b/db/auth-hook.sql index 3797382..a5894f4 100644 --- a/db/auth-hook.sql +++ b/db/auth-hook.sql @@ -3,6 +3,11 @@ -- "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 @@ -17,10 +22,11 @@ as $$ claims jsonb; user_role public.user_role; user_org_id uuid; + user_department_id uuid; begin - -- Look up this user's role and org_id from profiles. - select role, org_id - into user_role, user_org_id + -- 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; @@ -29,9 +35,11 @@ as $$ 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); diff --git a/db/policies.sql b/db/policies.sql index 1311b51..d840f2c 100644 --- a/db/policies.sql +++ b/db/policies.sql @@ -24,12 +24,19 @@ -- access via RLS is only granted where the matrix explicitly says so -- (insert own, read/write, etc). Everything else is mutated exclusively -- 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) -- ============================================================ alter table orgs enable row level security; +drop policy if exists orgs_read on orgs; create policy orgs_read on orgs for select using ( id = (auth.jwt() ->> 'org_id')::uuid @@ -41,23 +48,27 @@ create policy orgs_read on orgs -- ============================================================ alter table departments enable row level security; +drop policy if exists departments_read on departments; create policy departments_read on departments for select using ( org_id = (auth.jwt() ->> 'org_id')::uuid ); +drop policy if exists departments_insert_admin on departments; create policy departments_insert_admin on departments for insert with check ( org_id = (auth.jwt() ->> 'org_id')::uuid and (auth.jwt() ->> 'role') = 'admin' ); +drop policy if exists departments_update_admin on departments; create policy departments_update_admin on departments for update using ( org_id = (auth.jwt() ->> 'org_id')::uuid and (auth.jwt() ->> 'role') = 'admin' ); +drop policy if exists departments_delete_admin on departments; create policy departments_delete_admin on departments for delete using ( 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; +-- 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 for select using ( org_id = (auth.jwt() ->> 'org_id')::uuid ); +drop policy if exists profiles_update_admin on profiles; create policy profiles_update_admin on profiles for update using ( 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; +drop policy if exists sops_read on sops; create policy sops_read on sops for select using ( org_id = (auth.jwt() ->> 'org_id')::uuid @@ -103,20 +129,21 @@ create policy sops_read on sops and exists ( select 1 from sop_assignments sa - join profiles p on p.department_id = sa.department_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 for insert with check ( org_id = (auth.jwt() ->> 'org_id')::uuid and (auth.jwt() ->> 'role') in ('editor','admin') ); +drop policy if exists sops_update_editor on sops; create policy sops_update_editor on sops for update using ( 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; +drop policy if exists sop_versions_read on sop_versions; create policy sop_versions_read on sop_versions for select using ( org_id = (auth.jwt() ->> 'org_id')::uuid @@ -142,9 +170,8 @@ create policy sop_versions_read on sop_versions and exists ( select 1 from sop_assignments sa - join profiles p on p.department_id = sa.department_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; +drop policy if exists approvals_read on approvals; create policy approvals_read on approvals for select using ( org_id = (auth.jwt() ->> 'org_id')::uuid and (auth.jwt() ->> 'role') in ('editor','approver','admin') ); +drop policy if exists approvals_insert on approvals; create policy approvals_insert on approvals for insert with check ( 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; +drop policy if exists sop_assignments_read on sop_assignments; create policy sop_assignments_read on sop_assignments for select using ( 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; +drop policy if exists ack_insert_own on acknowledgements; create policy ack_insert_own on acknowledgements for insert with check ( user_id = auth.uid() and org_id = (auth.jwt() ->> 'org_id')::uuid ); +drop policy if exists ack_read on acknowledgements; create policy ack_read on acknowledgements for select using ( 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; +drop policy if exists translations_read on sop_translations; create policy translations_read on sop_translations for select using ( 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 for insert with check ( org_id = (auth.jwt() ->> 'org_id')::uuid 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 for update using ( 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; +drop policy if exists audit_read on audit_log; create policy audit_read on audit_log for select using ( org_id = (auth.jwt() ->> 'org_id')::uuid and (auth.jwt() ->> 'role') in ('approver','admin') ); +drop policy if exists audit_insert on audit_log; create policy audit_insert on audit_log for insert with check ( 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; +drop policy if exists ai_log_read_admin on ai_log; create policy ai_log_read_admin on ai_log for select using ( 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; +drop policy if exists incidents_insert_own on incidents; create policy incidents_insert_own on incidents for insert with check ( reporter_id = auth.uid() and org_id = (auth.jwt() ->> 'org_id')::uuid ); +drop policy if exists incidents_read on incidents; create policy incidents_read on incidents for select using ( 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') ) ); +drop policy if exists incidents_update on incidents; create policy incidents_update on incidents for update using ( org_id = (auth.jwt() ->> 'org_id')::uuid