M0: DB schema, RLS policies, JWT claims hook, seed, Supabase clients

Adds db/schema.sql (12 tables), db/policies.sql (RLS on all 12,
audit_log append-only), db/auth-hook.sql (role/org_id into JWT per
AD-2), db/seed.sql (org + 3 departments, part 2 deferred to M1 auth).
Wires lib/supabase/{client,server,service}.ts per AD-3 and adds
/db-check page confirming DB connectivity and RLS deny-by-default.

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 19:00:24 +08:00
co-authored by Claude Sonnet 5
parent 680b6a0194
commit f539d43136
10 changed files with 762 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
-- 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."
--
-- 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;
begin
-- Look up this user's role and org_id from profiles.
select role, org_id
into user_role, user_org_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));
else
claims := jsonb_set(claims, '{role}', 'null');
claims := jsonb_set(claims, '{org_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.
-- ============================================================
+275
View File
@@ -0,0 +1,275 @@
-- RLS Policies — all 12 tables
-- Source: docs/04-database-schema.md section 3 (role matrix below), extended
-- consistently to 4 tables the matrix doesn't cover (orgs, departments,
-- sop_assignments, ai_log) — marked "(not in matrix)" at each one.
--
-- Coverage table (docs/04-database-schema.md section 3):
--
-- | Table | staff | editor | approver | admin |
-- |--------------------|--------------------------------|--------------------|---------------------|-------|
-- | sops (read) | published + assigned dept only | all in org | all in org | all |
-- | sops (write) | - | insert/update drafts | - | all |
-- | sop_versions | read if assigned | read | read | all |
-- | acknowledgements | insert own; read own | read | read | read |
-- | profiles | read own + names in org | read org | read org | all |
-- | audit_log | - | - | read | read |
-- | approvals | - | read | insert/read | all |
-- | translations | read | read/write | read | all |
-- | incidents | insert own; read own | read org | read/update org | all |
--
-- Workflow mutations (publish, approve, assign) run via the service-role
-- client inside /api routes so drafts can be snapshotted and audit rows
-- written in one transaction; RLS remains the safety net for direct reads.
-- Default pattern in this file: RLS grants READ access per role; WRITE
-- 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.
-- ============================================================
-- orgs (not in matrix — read own org only, no client write)
-- ============================================================
alter table orgs enable row level security;
create policy orgs_read on orgs
for select using (
id = (auth.jwt() ->> 'org_id')::uuid
);
-- no insert/update/delete policy: single org, managed manually in Stage 1.
-- ============================================================
-- departments (not in matrix — read all in org, admin writes, FR-1.3)
-- ============================================================
alter table departments enable row level security;
create policy departments_read on departments
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
);
create policy departments_insert_admin on departments
for insert with check (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') = 'admin'
);
create policy departments_update_admin on departments
for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') = 'admin'
);
create policy departments_delete_admin on departments
for delete using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') = 'admin'
);
-- "delete blocked if department has users" (FR-1.3) is enforced by the
-- profiles.department_id foreign key, not by RLS.
-- ============================================================
-- profiles — own row always; org-wide read (table has no sensitive
-- fields beyond role/department, so org-wide read is a low-risk
-- simplification); admin writes via RLS as a safety net (actual
-- invites/role-changes go through the service-role /api/users route).
-- ============================================================
alter table profiles enable row level security;
create policy profiles_read on profiles
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
);
create policy profiles_update_admin on profiles
for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') = 'admin'
);
-- no insert policy: profile rows are created by the signup flow
-- (service-role), not inserted directly by a client.
-- ============================================================
-- sops
-- ============================================================
alter table sops enable row level security;
create policy sops_read on sops
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (
(auth.jwt() ->> 'role') in ('admin','approver','editor')
or (
(auth.jwt() ->> 'role') = 'staff'
and status = 'published'
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()
)
)
)
);
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')
);
create policy sops_update_editor on sops
for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (
(auth.jwt() ->> 'role') = 'admin'
or ( (auth.jwt() ->> 'role') = 'editor' and status = 'draft' )
)
);
-- ============================================================
-- sop_versions — read only via RLS; every version is written by the
-- service-role publish route (AD-4), never directly, not even by admin.
-- ============================================================
alter table sop_versions enable row level security;
create policy sop_versions_read on sop_versions
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (
(auth.jwt() ->> 'role') in ('admin','approver','editor')
or (
(auth.jwt() ->> 'role') = 'staff'
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()
)
)
)
);
-- ============================================================
-- approvals — a decision, once made, is never editable or deletable.
-- ============================================================
alter table approvals enable row level security;
create policy approvals_read on approvals
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('editor','approver','admin')
);
create policy approvals_insert on approvals
for insert with check (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('approver','admin')
and decided_by = auth.uid()
);
-- ============================================================
-- sop_assignments (not in matrix — read all in org, no client write;
-- set only via the service-role /api/sops/:id/assign route)
-- ============================================================
alter table sop_assignments enable row level security;
create policy sop_assignments_read on sop_assignments
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
);
-- ============================================================
-- acknowledgements — immutable once written (FR-4.3): insert only,
-- never update or delete.
-- ============================================================
alter table acknowledgements enable row level security;
create policy ack_insert_own on acknowledgements
for insert with check (
user_id = auth.uid()
and org_id = (auth.jwt() ->> 'org_id')::uuid
);
create policy ack_read on acknowledgements
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and ( user_id = auth.uid()
or (auth.jwt() ->> 'role') in ('admin','approver','editor') )
);
-- ============================================================
-- sop_translations
-- ============================================================
alter table sop_translations enable row level security;
create policy translations_read on sop_translations
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
);
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')
);
create policy translations_update_editor on sop_translations
for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('editor','admin')
);
-- ============================================================
-- audit_log — append-only. INSERT + SELECT policies only.
-- No UPDATE policy. No DELETE policy. Ever. (FR-3.4)
-- ============================================================
alter table audit_log enable row level security;
create policy audit_read on audit_log
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('approver','admin')
);
create policy audit_insert on audit_log
for insert with check (
org_id = (auth.jwt() ->> 'org_id')::uuid
);
-- ============================================================
-- ai_log (not in matrix — admin-only read for cost tracking, no FRD
-- screen for it; no client write, only the service-role AI routes write)
-- ============================================================
alter table ai_log enable row level security;
create policy ai_log_read_admin on ai_log
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') = 'admin'
);
-- ============================================================
-- incidents — FR-7.1 says "staff+" (any role) can report, so insert-own
-- is granted to every role, not just staff.
-- ============================================================
alter table incidents enable row level security;
create policy incidents_insert_own on incidents
for insert with check (
reporter_id = auth.uid()
and org_id = (auth.jwt() ->> 'org_id')::uuid
);
create policy incidents_read on incidents
for select using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and ( reporter_id = auth.uid()
or (auth.jwt() ->> 'role') in ('editor','approver','admin') )
);
create policy incidents_update on incidents
for update using (
org_id = (auth.jwt() ->> 'org_id')::uuid
and (auth.jwt() ->> 'role') in ('approver','admin')
);
+157
View File
@@ -0,0 +1,157 @@
create type user_role as enum ('admin','approver','editor','staff');
create type sop_status as enum ('draft','submitted','approved','published','archived');
create type lang_code as enum ('en','ms','zh');
create table orgs (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz default now()
);
create table departments (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs(id),
name text not null,
unique (org_id, name)
);
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
org_id uuid not null references orgs(id),
full_name text not null,
role user_role not null default 'staff',
department_id uuid references departments(id),
preferred_language lang_code not null default 'en',
active boolean not null default true,
created_at timestamptz default now()
);
create table sops (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs(id),
code text not null, -- e.g. WH-PICK-001
title text not null,
department_id uuid references departments(id),
category text,
owner_id uuid references profiles(id),
status sop_status not null default 'draft',
review_months int not null default 12 check (review_months in (3,6,12,24)),
draft_content jsonb not null default '{}'::jsonb, -- working copy (sections, steps)
current_version_id uuid, -- FK added after sop_versions exists
published_at timestamptz,
created_by uuid references profiles(id),
created_at timestamptz default now(),
updated_at timestamptz default now(),
unique (org_id, code)
);
create table sop_versions (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs(id),
sop_id uuid not null references sops(id) on delete cascade,
version_label text not null, -- '1.0', '1.1', '2.0'
content jsonb not null, -- FROZEN snapshot of sections at publish
change_note text,
is_major boolean not null default false,
published_by uuid references profiles(id),
published_at timestamptz default now(),
unique (sop_id, version_label)
);
alter table sops
add constraint fk_current_version
foreign key (current_version_id) references sop_versions(id);
create table approvals (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs(id),
sop_id uuid not null references sops(id) on delete cascade,
decision text not null check (decision in ('approved','rejected')),
comment text,
decided_by uuid not null references profiles(id),
decided_at timestamptz default now()
);
create table sop_assignments (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs(id),
sop_id uuid not null references sops(id) on delete cascade,
department_id uuid not null references departments(id),
assigned_by uuid references profiles(id),
assigned_at timestamptz default now(),
unique (sop_id, department_id)
);
create table acknowledgements (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs(id),
sop_version_id uuid not null references sop_versions(id),
user_id uuid not null references profiles(id),
language_viewed lang_code not null,
typed_name text not null, -- the "signature"
acknowledged_at timestamptz default now(),
unique (sop_version_id, user_id) -- one ack per user per version
);
create table sop_translations (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs(id),
sop_version_id uuid not null references sop_versions(id) on delete cascade,
language lang_code not null,
content jsonb not null,
machine boolean not null default true,
reviewed_by uuid references profiles(id),
reviewed_at timestamptz,
created_at timestamptz default now(),
unique (sop_version_id, language)
);
create table audit_log (
id bigint generated always as identity primary key,
org_id uuid not null references orgs(id),
actor_id uuid references profiles(id),
action text not null, -- 'sop.published', 'ack.recorded', ...
entity_type text not null, -- 'sop','user','acknowledgement'
entity_id uuid,
detail jsonb,
created_at timestamptz default now()
);
-- Append-only: grant INSERT/SELECT; never UPDATE/DELETE (enforced by grants + no policy).
create table ai_log (
id bigint generated always as identity primary key,
org_id uuid not null references orgs(id),
user_id uuid references profiles(id),
kind text not null check (kind in ('draft','translate')),
input_chars int, output_chars int,
input_tokens int, output_tokens int,
model text, success boolean, error text,
created_at timestamptz default now()
);
create type incident_severity as enum ('low','medium','high');
create type incident_status as enum ('open','reviewed','closed');
create table incidents (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references orgs(id),
reporter_id uuid not null references profiles(id),
department_id uuid references departments(id), -- copied from reporter at insert
sop_id uuid references sops(id), -- optional link
sop_version_id uuid references sop_versions(id), -- optional, set when reported from viewer
description text not null,
severity incident_severity not null default 'medium',
photo_path text, -- Storage path, optional
status incident_status not null default 'open',
resolution_note text,
reviewed_by uuid references profiles(id),
closed_at timestamptz,
created_at timestamptz default now()
);
create index idx_incidents_status on incidents(org_id, status, severity);
create index idx_incidents_sop on incidents(sop_id);
create index idx_sops_org_status on sops(org_id, status);
create index idx_ack_version on acknowledgements(sop_version_id);
create index idx_audit_entity on audit_log(entity_type, entity_id);
+50
View File
@@ -0,0 +1,50 @@
-- Seed data
-- Source: docs/04-database-schema.md section 4:
-- "One org ('<Your Company>'), 3 departments (Inbound, Outbound, Admin),
-- the founder as admin, 2 test staff, 2 sample SOPs (one draft, one
-- published v1.0 with BM translation), a few acknowledgements —
-- enough for the dashboard to show real numbers on day one."
--
-- NOTE: profiles.id references auth.users(id). Auth users don't exist yet
-- (M1 builds login/signup). Part 1 below is safe to run now (M0). Part 2
-- is provided for later — after M1 creates real auth users via Supabase
-- Auth, replace the placeholder UUIDs with their real auth.users ids and
-- run it then. Do not run Part 2 yet.
-- ============================================================
-- Part 1 — safe to run now (no auth dependency)
-- ============================================================
insert into orgs (id, name)
values ('00000000-0000-0000-0000-000000000001', 'Demo Warehouse Co');
insert into departments (id, org_id, name) values
('00000000-0000-0000-0000-000000000011', '00000000-0000-0000-0000-000000000001', 'Inbound'),
('00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000001', 'Outbound'),
('00000000-0000-0000-0000-000000000013', '00000000-0000-0000-0000-000000000001', 'Admin');
-- ============================================================
-- Part 2 — run after M1 (auth users exist). Replace the id values
-- below with the real auth.users.id for each account.
-- ============================================================
-- insert into profiles (id, org_id, full_name, role, department_id, preferred_language) values
-- ('<founder-auth-user-id>', '00000000-0000-0000-0000-000000000001', 'Founder Name', 'admin', '00000000-0000-0000-0000-000000000013', 'en'),
-- ('<staff-1-auth-user-id>', '00000000-0000-0000-0000-000000000001', 'Staff One', 'staff', '00000000-0000-0000-0000-000000000011', 'ms'),
-- ('<staff-2-auth-user-id>', '00000000-0000-0000-0000-000000000001', 'Staff Two', 'staff', '00000000-0000-0000-0000-000000000012', 'en');
-- insert into sops (id, org_id, code, title, department_id, category, owner_id, status, review_months, draft_content, created_by) values
-- ('00000000-0000-0000-0000-000000000021', '00000000-0000-0000-0000-000000000001', 'WH-PICK-001', 'Order Picking', '00000000-0000-0000-0000-000000000012', 'Warehouse', '<founder-auth-user-id>', 'draft', 12, '{}'::jsonb, '<founder-auth-user-id>'),
-- ('00000000-0000-0000-0000-000000000022', '00000000-0000-0000-0000-000000000001', 'WH-INB-001', 'Inbound Receiving', '00000000-0000-0000-0000-000000000011', 'Warehouse', '<founder-auth-user-id>', 'published', 12, '{}'::jsonb, '<founder-auth-user-id>');
-- insert into sop_versions (id, org_id, sop_id, version_label, content, published_by) values
-- ('00000000-0000-0000-0000-000000000031', '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000022', '1.0', '{"purpose":"...","steps":[]}'::jsonb, '<founder-auth-user-id>');
-- update sops set current_version_id = '00000000-0000-0000-0000-000000000031', published_at = now()
-- where id = '00000000-0000-0000-0000-000000000022';
-- insert into sop_translations (org_id, sop_version_id, language, content, machine) values
-- ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000031', 'ms', '{"purpose":"...","steps":[]}'::jsonb, true);
-- insert into acknowledgements (org_id, sop_version_id, user_id, language_viewed, typed_name) values
-- ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000031', '<staff-1-auth-user-id>', 'ms', 'Staff One');