RLS example 3
Jump to navigation
Jump to search
RLS pattern for PostgreSQL that uses the JWT sub as the external identity, maps it to an internal UUID user_id, and supports per-group CRUD permissions on todos. It’s PostgREST-friendly (uses request.jwt.claims) and keeps trust/authorization entirely in the DB.
1) Extensions, schema, roles
-- One-time setup
create extension if not exists pgcrypto; -- gen_random_uuid()
-- App schemas
create schema if not exists app;
create schema if not exists todo;
-- Typical PostgREST role model:
-- - `api_owner` owns objects
-- - `web_anon` is the run-time role used by PostgREST for authenticated users
do $$
begin
if not exists (select 1 from pg_roles where rolname = 'api_owner') then
create role api_owner login;
end if;
if not exists (select 1 from pg_roles where rolname = 'web_anon') then
create role web_anon nologin;
end if;
end$$;
grant usage on schema app, todo to web_anon;
2) Identity mapping (internal UUID ⇄ external sub)
-- Internal users table:
create table if not exists app.users (
id uuid primary key default gen_random_uuid(),
external_subject text not null unique, -- the JWT `sub` (opaque string, not always a UUID)
display_name text,
email text,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
-- Resolve the current user's internal UUID from the JWT claims.
-- Optionally auto-provision a user row if first seen.
create or replace function app.current_user_id()
returns uuid
stable
security definer
set search_path = public, pg_temp, app
language plpgsql
as $$
declare
claims jsonb;
sub_txt text;
uid uuid;
begin
claims := nullif(current_setting('request.jwt.claims', true), '')::jsonb;
if claims is null then
return null;
end if;
sub_txt := claims->>'sub';
if sub_txt is null then
return null;
end if;
-- Try to find existing mapping
select u.id into uid
from app.users u
where u.external_subject = sub_txt
and u.is_active;
if uid is not null then
return uid;
end if;
-- Optional "auto-provision on first use"
insert into app.users (external_subject, display_name, email)
values (
sub_txt,
coalesce(claims->>'name', null),
coalesce(claims->>'email', null)
)
returning id into uid;
return uid;
end
$$;
revoke all on function app.current_user_id() from public;
grant execute on function app.current_user_id() to web_anon;
grant select on app.users to web_anon; -- (optional: allow users to read their own profile via RLS below)
3) Groups & membership
-- Groups
create table if not exists app.groups (
id uuid primary key default gen_random_uuid(),
name text not null unique,
created_by uuid not null references app.users(id) on delete restrict,
created_at timestamptz not null default now()
);
-- Group membership
create table if not exists app.group_members (
group_id uuid not null references app.groups(id) on delete cascade,
user_id uuid not null references app.users(id) on delete cascade,
role text not null check (role in ('owner','manager','member')),
added_at timestamptz not null default now(),
primary key (group_id, user_id)
);
create index if not exists idx_group_members_user on app.group_members(user_id);
create index if not exists idx_group_members_group on app.group_members(group_id);
-- Core todos create table if not exists todo.todos ( id uuid primary key default gen_random_uuid(), owner_id uuid not null references app.users(id) on delete restrict, title text not null, body text, is_done boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_todos_owner on todo.todos(owner_id); -- Shares: grant group access to a todo with simple, explicit action flags -- (r = read, w = update, d = delete). You can normalize further if preferred. create table if not exists todo.todo_group_shares ( todo_id uuid not null references todo.todos(id) on delete cascade, group_id uuid not null references app.groups(id) on delete cascade, can_read boolean not null default true, can_write boolean not null default false, can_del boolean not null default false, primary key (todo_id, group_id) ); create index if not exists idx_todo_shares_todo on todo.todo_group_shares(todo_id); create index if not exists idx_todo_shares_group on todo.todo_group_shares(group_id);
5) RLS policies
Enable RLS:
alter table app.users enable row level security; alter table app.groups enable row level security; alter table app.group_members enable row level security; alter table todo.todos enable row level security; alter table todo.todo_group_shares enable row level security;
app.users (each user sees themselves; optional admin bypass if you have one)
-- SELECT: only yourself create policy users_select_self on app.users for select to web_anon using (id = app.current_user_id()); -- UPDATE: let users edit their own profile (optional) create policy users_update_self on app.users for update to web_anon using (id = app.current_user_id()) with check (id = app.current_user_id());
app.groups
-- CREATE a group: creator becomes owner via trigger or API-side insert
create policy groups_insert_any on app.groups
for insert
to web_anon
with check (created_by = app.current_user_id());
-- SELECT groups if you're a member
create policy groups_select_member on app.groups
for select
to web_anon
using (
exists (
select 1
from app.group_members gm
where gm.group_id = app.groups.id
and gm.user_id = app.current_user_id()
)
);
-- UPDATE/DELETE groups only if you're an owner
create policy groups_update_owner on app.groups
for update
to web_anon
using (
exists (
select 1 from app.group_members gm
where gm.group_id = app.groups.id
and gm.user_id = app.current_user_id()
and gm.role = 'owner'
)
)
with check (
exists (
select 1 from app.group_members gm
where gm.group_id = app.groups.id
and gm.user_id = app.current_user_id()
and gm.role = 'owner'
)
);
create policy groups_delete_owner on app.groups
for delete
to web_anon
using (
exists (
select 1 from app.group_members gm
where gm.group_id = app.groups.id
and gm.user_id = app.current_user_id()
and gm.role = 'owner'
)
);
app.group_members
-- Anyone can see members of their groups
create policy gm_select_member on app.group_members
for select
to web_anon
using (
exists (
select 1 from app.group_members gm2
where gm2.group_id = group_members.group_id
and gm2.user_id = app.current_user_id()
)
);
-- Add members only if you're owner/manager of that group
create policy gm_insert_mgr on app.group_members
for insert
to web_anon
with check (
exists (
select 1 from app.group_members gm2
where gm2.group_id = group_members.group_id
and gm2.user_id = app.current_user_id()
and gm2.role in ('owner','manager')
)
);
-- Update member roles (promote/demote) only if owner/manager
create policy gm_update_mgr on app.group_members
for update
to web_anon
using (
exists (
select 1 from app.group_members gm2
where gm2.group_id = group_members.group_id
and gm2.user_id = app.current_user_id()
and gm2.role in ('owner','manager')
)
)
with check (
exists (
select 1 from app.group_members gm2
where gm2.group_id = group_members.group_id
and gm2.user_id = app.current_user_id()
and gm2.role in ('owner','manager')
)
);
-- Remove members only if owner/manager (or yourself to leave)
create policy gm_delete_mgr_or_self on app.group_members
for delete
to web_anon
using (
(user_id = app.current_user_id()) or
exists (
select 1 from app.group_members gm2
where gm2.group_id = group_members.group_id
and gm2.user_id = app.current_user_id()
and gm2.role in ('owner','manager')
)
);
todo.todos
-- READ if you own it OR you are in a group that has read share
create policy todos_select_owner_or_shared on todo.todos
for select
to web_anon
using (
owner_id = app.current_user_id()
or exists (
select 1
from todo.todo_group_shares s
join app.group_members gm
on gm.group_id = s.group_id
and gm.user_id = app.current_user_id()
where s.todo_id = todos.id
and s.can_read
)
);
-- CREATE: only as yourself (owner_id must equal current user)
create policy todos_insert_self on todo.todos
for insert
to web_anon
with check (owner_id = app.current_user_id());
-- UPDATE if owner OR group share allows write
create policy todos_update_owner_or_shared on todo.todos
for update
to web_anon
using (
owner_id = app.current_user_id()
or exists (
select 1
from todo.todo_group_shares s
join app.group_members gm
on gm.group_id = s.group_id
and gm.user_id = app.current_user_id()
where s.todo_id = todos.id
and s.can_write
)
)
with check (
owner_id = app.current_user_id()
or exists (
select 1
from todo.todo_group_shares s
join app.group_members gm
on gm.group_id = s.group_id
and gm.user_id = app.current_user_id()
where s.todo_id = todos.id
and s.can_write
)
);
-- DELETE if owner OR group share allows delete
create policy todos_delete_owner_or_shared on todo.todos
for delete
to web_anon
using (
owner_id = app.current_user_id()
or exists (
select 1
from todo.todo_group_shares s
join app.group_members gm
on gm.group_id = s.group_id
and gm.user_id = app.current_user_id()
where s.todo_id = todos.id
and s.can_del
)
);
-- Visible if you are the todo owner OR member of the shared group create policy shares_select_visible on todo.todo_group_shares for select to web_anon using ( exists (select 1 from todo.todos t where t.id = todo_group_shares.todo_id and t.owner_id = app.current_user_id()) or exists (select 1 from app.group_members gm where gm.group_id = todo_group_shares.group_id and gm.user_id = app.current_user_id()) ); -- Only the todo owner (or group owner/manager, if you prefer) can create/update/delete shares create policy shares_cud_todo_owner on todo.todo_group_shares for insert to web_anon with check ( exists (select 1 from todo.todos t where t.id = todo_group_shares.todo_id and t.owner_id = app.current_user_id()) ); create policy shares_update_todo_owner on todo.todo_group_shares for update to web_anon using ( exists (select 1 from todo.todos t where t.id = todo_group_shares.todo_id and t.owner_id = app.current_user_id()) ) with check ( exists (select 1 from todo.todos t where t.id = todo_group_shares.todo_id and t.owner_id = app.current_user_id()) ); create policy shares_delete_todo_owner on todo.todo_group_shares for delete to web_anon using ( exists (select 1 from todo.todos t where t.id = todo_group_shares.todo_id and t.owner_id = app.current_user_id()) );
6) Grants
-- Base privileges (RLS still applies) grant select, insert, update, delete on app.users to web_anon; grant select, insert, update, delete on app.groups to web_anon; grant select, insert, update, delete on app.group_members to web_anon; grant select, insert, update, delete on todo.todos to web_anon; grant select, insert, update, delete on todo.todo_group_shares to web_anon; -- Sequences (none; we use UUIDs)
7) (Nice to have) Triggers & helpers
Auto-maintain updated_at on todos:
create or replace function todo.bump_updated_at() returns trigger language plpgsql as $$ begin new.updated_at := now(); return new; end$$; drop trigger if exists trg_todos_updated_at on todo.todos; create trigger trg_todos_updated_at before update on todo.todos for each row execute function todo.bump_updated_at();
8) How to use (PostgREST / JWT)
- PostgREST forwards JWT claims to
current_setting('request.jwt.claims', true). - Your token must include a
sub. Example payload:{ "sub": "c2d1ae8a-...-external-id-or-opaque", "email": "alice@example.com", "name": "Alice Example", "exp": 1900000000 } - First request from a
subwill auto-provision a row inapp.usersand return a stable internalidUUID for joins.
9) Notes & options
- Don’t use
subas PK: different IdPs may change/format it differently. Keepusers.id(UUID) as canonical, storeexternal_subject(TEXT) as the mapping. - Group authority in DB: we intentionally ignore any
groupsclaim for authorization. JWT can lie or drift; DB is the source of truth. - Granularity: the
todo_group_sharesflags (can_read,can_write,can_del) are easy to reason about. If you prefer, model apermenum (read,write,delete,share) or a bitmask.