Role-Based Access Control (RBAC) in Tanstack Start 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 (0) and member (2)
-- 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 Functions)

apps/web/lib/server/tasks/create-task.functions.ts

import { createServerFn } from '@tanstack/react-start';
import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import * as z from 'zod';
const schema = z.object({
accountId: z.uuid(),
title: z.string().min(1),
});
export const createTaskFunction = createServerFn({ method: 'POST' })
.middleware(authFunctionMiddleware) // context.user is authenticated
.validator(schema)
.handler(async ({ data, context: { user } }) => {
const supabase = getSupabaseServerClient();
// 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;
});

Call the function from the client with useServerFn + TanStack Query (useMutation).

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 workspace context provides permissions for UI rendering. The _authenticated route loads the active-account workspace once (via fetchWorkspace) and flows it down through WorkspaceContextProvider. Components read it with the useWorkspace hook. For a team account, account.permissions lists the current user's permissions (empty for a personal account).

Accessing Permissions

apps/web/src/routes/_authenticated/dashboard/tasks.tsx

import { createFileRoute } from '@tanstack/react-router';
import { TasksPageContent } from '#/components/tasks/tasks-page-content.tsx';
export const Route = createFileRoute('/_authenticated/dashboard/tasks')({
component: TasksPage,
});
function TasksPage() {
return <TasksPageContent />;
}

Conditional UI Rendering

apps/web/src/components/tasks/tasks-page-content.tsx

import { useWorkspace } from '#/components/workspace-context.tsx';
export function TasksPageContent() {
const { account } = useWorkspace();
// account.permissions is string[] of permission names the user has
const canWrite = account.permissions.includes('tasks.write');
const canDelete = account.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/src/components/permission-gate.tsx

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>

Route-Level Access Control

Gate a route in its beforeLoad by throwing redirect from @tanstack/react-router. The workspace is already loaded by the parent _authenticated route and exposed on the router context, so read it from there:

apps/web/src/routes/_authenticated/dashboard/admin.tsx

import { createFileRoute, redirect } from '@tanstack/react-router';
import { AdminDashboard } from '#/components/admin/admin-dashboard.tsx';
export const Route = createFileRoute('/_authenticated/dashboard/admin')({
beforeLoad: ({ context }) => {
if (!context.workspace.account.permissions?.includes('settings.manage')) {
throw redirect({ to: '/dashboard' });
}
},
component: 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.