Files
myeasycms-v2/docs/development/permissions-and-roles.mdoc
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
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
2026-03-24 13:40:38 +08:00

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