Role-Based Access Control (RBAC) in Next.js Supabase
Implement granular permissions with roles, hierarchy levels, and the app_permissions enum. Use has_permission in RLS policies and application code.
Makerkit implements RBAC through three components: the roles table (defines role names and hierarchy), the role_permissions table (maps roles to permissions), and the app_permissions enum (lists all available permissions). Use the has_permission function in RLS policies and application code for granular access control.
RBAC Implementation
Set up and use roles and permissions
RBAC Data Model
The roles Table
Defines available roles and their hierarchy:
create table public.roles ( name varchar(50) primary key, hierarchy_level integer not null default 0);-- Default rolesinsert into public.roles (name, hierarchy_level) values ('owner', 1), ('member', 2);Hierarchy levels determine which roles can manage others. Lower numbers indicate higher privilege. Owners (level 1) can manage members (level 2), but members cannot manage owners.
The role_permissions Table
Maps roles to their permissions:
create table public.role_permissions ( id serial primary key, role varchar(50) references public.roles(name) on delete cascade, permission app_permissions not null, unique (role, permission));The app_permissions Enum
Lists all available permissions:
create type public.app_permissions as enum( 'roles.manage', 'billing.manage', 'settings.manage', 'members.manage', 'invites.manage');Default Permission Assignments
| Role | Permissions |
|---|---|
owner | All permissions |
member | settings.manage, invites.manage |
Adding Custom Permissions
Step 1: Add to the Enum
Create a migration to add new permissions:
apps/web/supabase/migrations/add_task_permissions.sql
-- Add new permissions to the enumalter type public.app_permissions add value 'tasks.read';alter type public.app_permissions add value 'tasks.write';alter type public.app_permissions add value 'tasks.delete';commit;Enum Values Cannot Be Removed
PostgreSQL enum values cannot be removed once added. Plan your permission names carefully. Use a consistent naming pattern like resource.action.
Step 2: Assign to Roles
-- Owners get all task permissionsinsert into public.role_permissions (role, permission) values ('owner', 'tasks.read'), ('owner', 'tasks.write'), ('owner', 'tasks.delete');-- Members can read and write but not deleteinsert into public.role_permissions (role, permission) values ('member', 'tasks.read'), ('member', 'tasks.write');Step 3: Add Custom Roles (Optional)
-- Add a new roleinsert into public.roles (name, hierarchy_level) values ('admin', 1); -- Between owner (2) and member (1)-- Assign permissions to the new roleinsert into public.role_permissions (role, permission) values ('admin', 'tasks.read'), ('admin', 'tasks.write'), ('admin', 'tasks.delete'), ('admin', 'members.manage'), ('admin', 'invites.manage');Using Permissions in RLS
The has_permission function checks if a user has a specific permission on an account.
Function Signature
public.has_permission( user_id uuid, account_id uuid, permission_name app_permissions) returns booleanRead Access Policy
create policy "Users with tasks.read can view tasks" on public.tasks for select to authenticated using ( public.has_permission(auth.uid(), account_id, 'tasks.read'::app_permissions) );Write Access Policy
create policy "Users with tasks.write can create tasks" on public.tasks for insert to authenticated with check ( public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions) );Update Policy
create policy "Users with tasks.write can update tasks" on public.tasks for update to authenticated using ( public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions) ) with check ( public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions) );Delete Policy
create policy "Users with tasks.delete can delete tasks" on public.tasks for delete to authenticated using ( public.has_permission(auth.uid(), account_id, 'tasks.delete'::app_permissions) );Complete Example
Here's a full schema with RLS:
apps/web/supabase/schemas/20-tasks.sql
-- Tasks tablecreate table if not exists public.tasks ( id uuid primary key default gen_random_uuid(), account_id uuid not null references public.accounts(id) on delete cascade, title text not null, description text, status text not null default 'pending', created_at timestamptz not null default now(), updated_at timestamptz not null default now());-- Enable RLSalter table public.tasks enable row level security;-- RLS policiescreate policy "tasks_select" on public.tasks for select to authenticated using (public.has_permission(auth.uid(), account_id, 'tasks.read'::app_permissions));create policy "tasks_insert" on public.tasks for insert to authenticated with check (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions));create policy "tasks_update" on public.tasks for update to authenticated using (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions)) with check (public.has_permission(auth.uid(), account_id, 'tasks.write'::app_permissions));create policy "tasks_delete" on public.tasks for delete to authenticated using (public.has_permission(auth.uid(), account_id, 'tasks.delete'::app_permissions));Checking Permissions in Application Code
Server-Side Check (Server Actions)
apps/web/lib/server/tasks/create-task.action.ts
'use server';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { z } from 'zod';const schema = z.object({ accountId: z.string().uuid(), title: z.string().min(1),});export async function createTask(data: z.infer<typeof schema>) { const supabase = getSupabaseServerClient(); // Get current user const { data: { user } } = await supabase.auth.getUser(); if (!user) { throw new Error('Not authenticated'); } // Check permission via RPC const { data: hasPermission } = await supabase.rpc('has_permission', { user_id: user.id, account_id: data.accountId, permission: 'tasks.write', }); if (!hasPermission) { throw new Error('You do not have permission to create tasks'); } // Create the task (RLS will also enforce this) const { data: task, error } = await supabase .from('tasks') .insert({ account_id: data.accountId, title: data.title, }) .select() .single(); if (error) { throw error; } return task;}Permission Check Helper
Create a reusable helper:
apps/web/lib/server/permissions.ts
import { getSupabaseServerClient } from '@kit/supabase/server-client';export async function checkPermission( accountId: string, permission: string,): Promise<boolean> { const supabase = getSupabaseServerClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { return false; } const { data: hasPermission } = await supabase.rpc('has_permission', { user_id: user.id, account_id: accountId, permission, }); return hasPermission ?? false;}export async function requirePermission( accountId: string, permission: string,): Promise<void> { const hasPermission = await checkPermission(accountId, permission); if (!hasPermission) { throw new Error(`Permission denied: ${permission}`); }}Usage:
import { requirePermission } from '~/lib/server/permissions';export async function deleteTask(taskId: string, accountId: string) { await requirePermission(accountId, 'tasks.delete'); // Proceed with deletion}Client-Side Permission Checks
The Team Account Workspace loader provides permissions for UI rendering.
Loading Permissions
apps/web/app/home/[account]/tasks/page.tsx
import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';interface Props { params: Promise<{ account: string }>;}export default async function TasksPage({ params }: Props) { const { account } = await params; const workspace = await loadTeamWorkspace(account); const permissions = workspace.account.permissions; // permissions is string[] of permission names the user has return ( <TasksPageClient permissions={permissions} /> );}Conditional UI Rendering
apps/web/app/home/[account]/tasks/_components/tasks-page-client.tsx
'use client';interface TasksPageClientProps { permissions: string[];}export function TasksPageClient({ permissions }: TasksPageClientProps) { const canWrite = permissions.includes('tasks.write'); const canDelete = permissions.includes('tasks.delete'); return ( <div> <h1>Tasks</h1> {canWrite && ( <Button onClick={openCreateDialog}> Create Task </Button> )} <TaskList onDelete={canDelete ? handleDelete : undefined} /> </div> );}Permission Gate Component
Create a reusable component:
apps/web/components/permission-gate.tsx
'use client';interface PermissionGateProps { permissions: string[]; required: string | string[]; children: React.ReactNode; fallback?: React.ReactNode;}export function PermissionGate({ permissions, required, children, fallback = null,}: PermissionGateProps) { const requiredArray = Array.isArray(required) ? required : [required]; const hasPermission = requiredArray.every((p) => permissions.includes(p)); if (!hasPermission) { return fallback; } return children;}Usage:
<PermissionGate permissions={permissions} required="tasks.delete"> <DeleteButton onClick={handleDelete} /></PermissionGate><PermissionGate permissions={permissions} required={['tasks.write', 'tasks.delete']} fallback={<span>Read-only access</span>}> <EditControls /></PermissionGate>Page-Level Access Control
apps/web/app/home/[account]/admin/page.tsx
import { redirect } from 'next/navigation';import { loadTeamWorkspace } from '~/home/[account]/_lib/server/team-account-workspace.loader';interface Props { params: Promise<{ account: string }>;}export default async function AdminPage({ params }: Props) { const { account } = await params; const workspace = await loadTeamWorkspace(account); const permissions = workspace.account.permissions; if (!permissions.includes('settings.manage')) { redirect('/home'); } return <AdminDashboard />;}Permission Naming Conventions
Use a consistent resource.action pattern:
| Pattern | Examples |
|---|---|
resource.read | tasks.read, reports.read |
resource.write | tasks.write, settings.write |
resource.delete | tasks.delete, members.delete |
resource.manage | billing.manage, roles.manage |
The .manage suffix typically implies all actions on that resource.
Testing Permissions
Test RLS policies with pgTAP:
apps/web/supabase/tests/tasks-permissions.test.sql
begin;select plan(3);-- Create test user and accountselect tests.create_supabase_user('test-user');select tests.authenticate_as('test-user');-- Get the user's personal accountselect set_config('test.account_id', (select id::text from accounts where primary_owner_user_id = tests.get_supabase_uid('test-user')), true);-- Test: User with tasks.write can insertselect lives_ok( $$ insert into tasks (account_id, title) values (current_setting('test.account_id')::uuid, 'Test Task') $$, 'User with tasks.write permission can create tasks');-- Test: User without tasks.delete cannot deleteselect throws_ok( $$ delete from tasks where account_id = current_setting('test.account_id')::uuid $$, 'User without tasks.delete permission cannot delete tasks');select * from finish();rollback;See Database Tests for more testing patterns.
Related Resources
- Database Functions for the
has_permissionfunction - Database Schema for creating tables with RLS
- Database Tests for testing permissions
- Row Level Security for RLS patterns