Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
527 lines
13 KiB
Plaintext
527 lines
13 KiB
Plaintext
---
|
|
status: "published"
|
|
label: 'RBAC: Roles and Permissions'
|
|
title: 'Role-Based Access Control (RBAC) in Next.js Supabase'
|
|
description: 'Implement granular permissions with roles, hierarchy levels, and the app_permissions enum. Use has_permission in RLS policies and application code.'
|
|
order: 6
|
|
---
|
|
|
|
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.
|
|
|
|
{% sequence title="RBAC Implementation" description="Set up and use roles and permissions" %}
|
|
|
|
[Understand the data model](#rbac-data-model)
|
|
|
|
[Add custom permissions](#adding-custom-permissions)
|
|
|
|
[Enforce in RLS policies](#using-permissions-in-rls)
|
|
|
|
[Check permissions in code](#checking-permissions-in-application-code)
|
|
|
|
[Show/hide UI elements](#client-side-permission-checks)
|
|
|
|
{% /sequence %}
|
|
|
|
## RBAC Data Model
|
|
|
|
### The roles Table
|
|
|
|
Defines available roles and their hierarchy:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql {% title="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;
|
|
```
|
|
|
|
{% alert type="warning" title="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`.
|
|
{% /alert %}
|
|
|
|
### Step 2: Assign to Roles
|
|
|
|
```sql
|
|
-- 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)
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```sql
|
|
public.has_permission(
|
|
user_id uuid,
|
|
account_id uuid,
|
|
permission_name app_permissions
|
|
) returns boolean
|
|
```
|
|
|
|
### Read Access Policy
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql {% title="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)
|
|
|
|
```tsx {% title="apps/web/lib/server/tasks/create-task.action.ts" %}
|
|
'use server';
|
|
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
import * as 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:
|
|
|
|
```tsx {% title="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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx {% title="apps/web/app/[locale]/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
|
|
|
|
```tsx {% title="apps/web/app/[locale]/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:
|
|
|
|
```tsx {% title="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:
|
|
|
|
```tsx
|
|
<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
|
|
|
|
```tsx {% title="apps/web/app/[locale]/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:
|
|
|
|
```sql {% title="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](/docs/next-supabase-turbo/development/database-tests) for more testing patterns.
|
|
|
|
## Related Resources
|
|
|
|
- [Database Functions](/docs/next-supabase-turbo/development/database-functions) for the `has_permission` function
|
|
- [Database Schema](/docs/next-supabase-turbo/development/database-schema) for creating tables with RLS
|
|
- [Database Tests](/docs/next-supabase-turbo/development/database-tests) for testing permissions
|
|
- [Row Level Security](/docs/next-supabase-turbo/security/row-level-security) for RLS patterns
|