RBAC Schema
Role Based Access Control (RBAC) lets you describe what users may do inside a team without hard-coding permission checks in Rust. This schema lives alongside the auth module and leans on auth.me() to ensure a user row exists before roles or permissions are granted. The final function in this chapter replaces the earlier auth.me() implementation so callers automatically receive roles and permissions alongside the user and team context.
Full schema
Paste this block after applying the Auth and Teams snippets. It creates the RBAC tables plus helpers that expose the current user's roles and permissions.
-- =========================================== -- SCHEMA -- =========================================== CREATE SCHEMA IF NOT EXISTS rbac; -- =========================================== -- TABLE: rbac.roles -- =========================================== CREATE TABLE IF NOT EXISTS rbac.roles ( id bigserial PRIMARY KEY, name text UNIQUE NOT NULL, description text ); -- =========================================== -- TABLE: rbac.permissions -- =========================================== CREATE TABLE IF NOT EXISTS rbac.permissions ( id bigserial PRIMARY KEY, name text UNIQUE NOT NULL, description text ); -- =========================================== -- TABLE: rbac.role_permissions -- =========================================== CREATE TABLE IF NOT EXISTS rbac.role_permissions ( role_id bigint NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE, permission_id bigint NOT NULL REFERENCES rbac.permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id) ); -- =========================================== -- TABLE: rbac.user_roles -- =========================================== CREATE TABLE IF NOT EXISTS rbac.user_roles ( user_id bigint NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, role_id bigint NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE, PRIMARY KEY (user_id, role_id) ); CREATE INDEX IF NOT EXISTS idx_rbac_user_roles_user ON rbac.user_roles (user_id); -- =========================================== -- FUNCTION: rbac.grant_role() -- =========================================== CREATE OR REPLACE FUNCTION rbac.grant_role(p_user_id bigint, p_role_name text) RETURNS VOID LANGUAGE plpgsql VOLATILE AS $$ DECLARE _role_id bigint; BEGIN SELECT id INTO _role_id FROM rbac.roles WHERE name = p_role_name; IF _role_id IS NULL THEN RAISE EXCEPTION 'Unknown role=%', p_role_name; END IF; INSERT INTO rbac.user_roles (user_id, role_id) VALUES (p_user_id, _role_id) ON CONFLICT DO NOTHING; END; $$; -- =========================================== -- FUNCTION: rbac.revoke_role() -- =========================================== CREATE OR REPLACE FUNCTION rbac.revoke_role(p_user_id bigint, p_role_name text) RETURNS VOID LANGUAGE plpgsql VOLATILE AS $$ DECLARE _role_id bigint; BEGIN SELECT id INTO _role_id FROM rbac.roles WHERE name = p_role_name; IF _role_id IS NULL THEN RETURN; END IF; DELETE FROM rbac.user_roles WHERE user_id = p_user_id AND role_id = _role_id; END; $$; -- =========================================== -- FUNCTION: auth.me(p_team_id) -- Extends the base helper to include roles and permissions -- =========================================== CREATE OR REPLACE FUNCTION auth.me(p_team_id bigint DEFAULT NULL) RETURNS TABLE ( user_row auth.users, team_id bigint, roles text[], permissions text[] ) LANGUAGE plpgsql VOLATILE AS $$ DECLARE _claims jsonb := auth.jwt(); _sub text; _email text; _first_name text; _last_name text; _user auth.users; _team_id bigint; _team_name text; BEGIN _sub := _claims->>'sub'; IF _sub IS NULL THEN RAISE EXCEPTION 'JWT missing sub claim'; END IF; _email := _claims->>'email'; _first_name := COALESCE(_claims->>'given_name', _claims->>'first_name'); _last_name := COALESCE(_claims->>'family_name', _claims->>'last_name'); INSERT INTO auth.users (external_id, email, first_name, last_name) VALUES ( _sub, _email, _first_name, _last_name ) ON CONFLICT (external_id) DO UPDATE SET email = COALESCE(EXCLUDED.email, auth.users.email), first_name = COALESCE(EXCLUDED.first_name, auth.users.first_name), last_name = COALESCE(EXCLUDED.last_name, auth.users.last_name), updated_at = now() RETURNING * INTO _user; IF p_team_id IS NOT NULL THEN _team_id := p_team_id; ELSE SELECT tm.team_id INTO _team_id FROM auth.team_members tm WHERE tm.user_id = _user.id ORDER BY tm.joined_at LIMIT 1; END IF; IF _team_id IS NULL THEN _team_name := format('%s''s team', COALESCE(NULLIF(trim(_first_name), ''), 'My')); INSERT INTO auth.teams (name, created_by) VALUES (_team_name, _user.id) RETURNING id INTO _team_id; INSERT INTO auth.team_members (team_id, user_id, role) VALUES (_team_id, _user.id, 'Owner') ON CONFLICT (team_id, user_id) DO NOTHING; ELSE INSERT INTO auth.team_members (team_id, user_id, role) VALUES (_team_id, _user.id, 'Member') ON CONFLICT (team_id, user_id) DO NOTHING; END IF; RETURN QUERY SELECT _user, _team_id, COALESCE(( SELECT array_agg(r.name ORDER BY r.name) FROM rbac.user_roles ur JOIN rbac.roles r ON r.id = ur.role_id WHERE ur.user_id = _user.id ), ARRAY[]::text[]), COALESCE(( SELECT array_agg(DISTINCT p.name ORDER BY p.name) FROM rbac.user_roles ur JOIN rbac.role_permissions rp ON rp.role_id = ur.role_id JOIN rbac.permissions p ON p.id = rp.permission_id WHERE ur.user_id = _user.id ), ARRAY[]::text[]); END; $$;
How it works
- Roles and permissions live in their own schema so you can manage them independently from
auth. rbac.grant_role()andrbac.revoke_role()are thin wrappers overrbac.user_roles. Use them in migrations or admin panels to keep logic in one place.auth.me()is now the single entry point for authentication, team membership, and RBAC context, returninguser_row,team_id,roles, andpermissionscolumns you can destructure anywhere in SQL.
Example workflow
BEGIN; SET LOCAL row_level_security.jwt = $${ "sub": "1234567890abcdef", "email": "daniel@example.com", "given_name": "Daniel", "family_name": "Purton" }$$; -- Make sure the user exists and capture RBAC context SELECT * FROM auth.me(); -- columns: user_row, team_id, roles, permissions -- Map a role to permissions INSERT INTO rbac.roles (name) VALUES ('Administrator') ON CONFLICT DO NOTHING; INSERT INTO rbac.permissions (name) VALUES ('ManageTeam') ON CONFLICT DO NOTHING; INSERT INTO rbac.role_permissions (role_id, permission_id) SELECT r.id, p.id FROM rbac.roles r, rbac.permissions p WHERE r.name = 'Administrator' AND p.name = 'ManageTeam' ON CONFLICT DO NOTHING; -- Grant the role to the current user SELECT rbac.grant_role(auth.id(), 'Administrator'); -- Hydrate roles + permissions again (now includes Administrator/ManageTeam) SELECT * FROM auth.me(); COMMIT;
Tie the results into row-level policies or feature flags with a quick CTE:
WITH ctx AS (SELECT * FROM auth.me()) SELECT 'ManageTeam' = ANY(ctx.permissions) FROM ctx;