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 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 roles
insert 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

RolePermissions
ownerAll permissions
membersettings.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 enum
alter 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;

Step 2: Assign to Roles

-- Owners get all task permissions
insert into public.role_permissions (role, permission) values
('owner', 'tasks.read'),
('owner', 'tasks.write'),
('owner', 'tasks.delete');
-- Members can read and write but not delete
insert into public.role_permissions (role, permission) values
('member', 'tasks.read'),
('member', 'tasks.write');

Step 3: Add Custom Roles (Optional)

-- Add a new role
insert into public.roles (name, hierarchy_level) values
('admin', 1); -- Between owner (2) and member (1)
-- Assign permissions to the new role
insert 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 boolean

Read 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 table
create 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 RLS
alter table public.tasks enable row level security;
-- RLS policies
create 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:

PatternExamples
resource.readtasks.read, reports.read
resource.writetasks.write, settings.write
resource.deletetasks.delete, members.delete
resource.managebilling.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 account
select tests.create_supabase_user('test-user');
select tests.authenticate_as('test-user');
-- Get the user's personal account
select 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 insert
select 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 delete
select 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.