RBAC (Roles and Permissions)
How to use the Role-Based Access Control system to protect endpoints, check permissions in components, and manage custom roles.
Overview
Roxabi uses a flat, per-organization RBAC model. Each organization (tenant) has its own set of roles, and each role is an explicit list of resource:action permission strings. There is no role inheritance -- every role declares exactly what it can do.
The system has two layers:
| Layer | Scope | Stored in | Example |
|---|---|---|---|
| Platform | Global, cross-tenant | users.role | superadmin, admin, user |
| Tenant | Per-organization | roles + role_permissions tables | Owner, Admin, Member, Viewer, custom roles |
Platform superadmin users bypass all permission checks. Tenant-level roles are resolved from the user's membership in the active organization.
For the full design rationale, see the RBAC analysis at artifacts/analyses/24-rbac.mdx.
Default Roles
Every organization is seeded with four built-in roles on creation. These roles cannot be deleted, but their permissions can be customized (except Owner, which always has all permissions).
| Role | Slug | Purpose | Permissions |
|---|---|---|---|
| Owner | owner | Full control + org administration | All permissions including organizations:delete |
| Admin | admin | Manage members, roles, invitations | All except organizations:delete, users:delete |
| Member | member | Standard read access | Read-only on all resources |
| Viewer | viewer | Read-only access | Same as Member (read-only baseline) |
The exact permission lists are defined in apps/api/src/rbac/rbac.constants.ts:
export const DEFAULT_ROLES: DefaultRoleDefinition[] = [
{
name: 'Owner',
slug: 'owner',
description: 'Full access — organization owner',
permissions: [
'users:read',
'users:write',
'users:delete',
'organizations:read',
'organizations:write',
'organizations:delete',
'members:read',
'members:write',
'members:delete',
'invitations:read',
'invitations:write',
'invitations:delete',
'roles:read',
'roles:write',
'roles:delete',
'api_keys:read',
'api_keys:write',
],
},
// Admin, Member, Viewer follow the same pattern...
]Permission Format
Permissions follow the resource:action pattern. The valid combinations are defined in packages/types/src/rbac.ts:
export type PermissionResource = 'users' | 'organizations' | 'members' | 'invitations' | 'roles' | 'api_keys'
export type PermissionAction = 'read' | 'write' | 'delete'
export type PermissionString = `${PermissionResource}:${PermissionAction}`This gives you type-safe permission strings throughout the stack. New resources and actions can be added by extending these union types -- every consuming file gets compile-time checking automatically.
Protecting API Endpoints
The @Permissions() Decorator
Use the @Permissions() decorator on controller methods to require specific permissions. The decorator is defined in apps/api/src/auth/decorators/permissions.decorator.ts:
import { SetMetadata } from '@nestjs/common'
import type { PermissionString } from '@repo/types'
export const Permissions = (...permissions: PermissionString[]) =>
SetMetadata('PERMISSIONS', permissions)Using @Permissions() implicitly requires an active organization context (equivalent to @RequireOrg()). If no active organization is set on the session, the guard returns 403 Forbidden.
Usage in Controllers
Apply the decorator to any controller method. All listed permissions are required (AND logic):
import { Permissions } from '../auth/decorators/permissions.decorator.js'
@Controller('api/roles')
export class RbacController {
@Get()
@Permissions('roles:read')
async listRoles() {
return this.rbacService.listRoles()
}
@Post()
@Permissions('roles:write')
async createRole(@Body(new ZodValidationPipe(createRoleSchema)) body: CreateRoleDto) {
return this.rbacService.createRole(body)
}
@Delete(':id')
@Permissions('roles:delete')
async deleteRole(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.rbacService.deleteRole(id)
}
@Patch('members/:id/role')
@Permissions('members:write')
async changeMemberRole(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body(new ZodValidationPipe(changeMemberRoleSchema)) body: ChangeMemberRoleDto
) {
return this.rbacService.changeMemberRole(id, body.roleId)
}
}Guard Evaluation Order
The AuthGuard (registered globally via APP_GUARD) checks metadata in this order:
PUBLIC-- if@AllowAnonymous()is set, allow immediately- Fetch session from Better Auth (includes resolved permissions)
OPTIONAL_AUTH-- if set and no session, allow- No session --
401 Unauthorized ROLES-- check global user role (users.role) --403 Forbiddenif mismatchREQUIRE_ORG-- check active organization --403 Forbiddenif missingPERMISSIONS-- check tenant-scoped permissions --403 Forbiddenif insufficient
The permission check in the guard (apps/api/src/auth/auth.guard.ts):
private checkPermissions(context: ExecutionContext, session: AuthSession) {
const requiredPermissions = this.reflector.getAllAndOverride<string[]>('PERMISSIONS', [
context.getHandler(),
context.getClass(),
])
if (!requiredPermissions?.length) return
const orgId = session.session.activeOrganizationId
if (!orgId) {
throw new ForbiddenException('No active organization')
}
// Platform superadmins bypass all permission checks
if (session.user.role === 'superadmin') return
// Permissions are already resolved by AuthService.getSession()
const hasAll = requiredPermissions.every((p) => session.permissions.includes(p))
if (!hasAll) {
throw new ForbiddenException('Insufficient permissions')
}
}Key details:
- Permissions are resolved once during session fetch, not on every request
superadminusers bypass permission checks entirely at the guard level- All required permissions must be present (AND logic, not OR)
Checking Permissions in the Frontend
Permission Utilities
The frontend provides three helper functions in apps/web/src/lib/permissions.ts:
import type { PermissionString } from '@repo/types'
// Check a single permission
export function hasPermission(
session: SessionWithPermissions | null | undefined,
permission: PermissionString
): boolean {
if (!session?.permissions) return false
return session.permissions.includes(permission)
}
// Check that ALL permissions are present
export function hasAllPermissions(
session: SessionWithPermissions | null | undefined,
permissions: PermissionString[]
): boolean {
return permissions.every((p) => hasPermission(session, p))
}
// Check that ANY permission is present
export function hasAnyPermission(
session: SessionWithPermissions | null | undefined,
permissions: PermissionString[]
): boolean {
return permissions.some((p) => hasPermission(session, p))
}Usage in Components
Use hasPermission() with the session from useSession() to conditionally render UI elements:
import { useSession } from '@/lib/authClient'
import { hasPermission } from '@/lib/permissions'
function OrgMembersPage() {
const { data: session } = useSession()
const canManage = hasPermission(session, 'members:write')
return (
<>
<h1>Members</h1>
{/* Only show the invite button if user can manage members */}
{canManage && (
<Button onClick={openInviteDialog}>Invite Member</Button>
)}
{/* Conditionally render management controls */}
{canManage && member.role !== 'owner' && (
<Button variant="ghost" onClick={() => removeMember(member.id)}>
Remove
</Button>
)}
</>
)
}This pattern is used in the actual codebase. For example, in apps/web/src/routes/org/settings.tsx (simplified):
const canDeleteOrg = hasPermission(session, 'organizations:delete')
// Disable form inputs for users without write permissions
<Input disabled={!canDeleteOrg || saving} />
// Only show the danger zone for users who can delete the org
{canDeleteOrg && (
<Card>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<Button variant="destructive">Delete Organization</Button>
</Card>
)}Session Shape
Permissions are resolved server-side and included in the session response. No extra API calls are needed:
type AuthSession = {
user: { id: string; role?: Role }
session: { id: string; activeOrganizationId?: string | null }
permissions: string[] // e.g. ["users:read", "members:write", ...]
}When the user switches organizations, the session refreshes and permissions update automatically.
Route-Level Protection
Use enforceRoutePermission from apps/web/src/lib/routePermissions.ts to redirect users who lack the required permissions before the route renders. Declare the required permission in staticData and assign the guard to beforeLoad — no manual session fetch needed:
import { createFileRoute } from '@tanstack/react-router'
import { enforceRoutePermission } from '@/lib/routePermissions'
export const Route = createFileRoute('/admin/billing')({
staticData: { permission: 'organizations:write' },
beforeLoad: enforceRoutePermission,
component: BillingPage,
})The guard reads the session from root context (ctx.context.session), which is populated once by the root route's beforeLoad — no per-route API call is made. This pattern prevents the component from rendering at all when the user lacks permissions, rather than showing a flash of forbidden content.
Adding New Permissions
To add a new resource or action to the permission system, follow these steps:
Step 1: Update the Type Definitions
Add the new resource or action to the union types in packages/types/src/rbac.ts:
// Before (current state includes api_keys)
export type PermissionResource = 'users' | 'organizations' | 'members' | 'invitations' | 'roles' | 'api_keys'
// After (adding a "billing" resource)
export type PermissionResource = 'users' | 'organizations' | 'members' | 'invitations' | 'roles' | 'api_keys' | 'billing'Step 2: Create a Database Migration
Add the new permission rows to the permissions table. Permissions are global (not tenant-scoped) and seeded via migration:
INSERT INTO permissions (id, resource, action, description)
VALUES
(gen_random_uuid(), 'billing', 'read', 'View billing and subscription information'),
(gen_random_uuid(), 'billing', 'write', 'Manage billing settings and payment methods');Step 3: Update Default Role Definitions
If default roles should include the new permissions, update apps/api/src/rbac/rbac.constants.ts:
{
name: 'Owner',
slug: 'owner',
description: 'Full access — organization owner',
permissions: [
// ... existing permissions
'billing:read',
'billing:write',
],
},Step 4: Use the Permission
Apply the new permission in controllers and components:
// Backend
@Permissions('billing:read')
@Get('billing')
async getBilling() { ... }
// Frontend
const canManageBilling = hasPermission(session, 'billing:write')Custom Roles
Organizations can create custom roles through the RBAC API. Custom roles are tenant-scoped (isolated by RLS) and work exactly like default roles.
Creating a Custom Role
POST /api/roles with roles:write permission:
{
"name": "Billing Manager",
"description": "Can manage billing but not members",
"permissions": ["organizations:read", "billing:read", "billing:write"]
}The slug is auto-generated from the name (billing-manager). Duplicate slugs within the same organization are rejected with 409 Conflict.
Updating a Custom Role
PATCH /api/roles/:id with roles:write permission:
{
"name": "Billing Admin",
"permissions": ["organizations:read", "billing:read", "billing:write", "members:read"]
}Deleting a Custom Role
DELETE /api/roles/:id with roles:delete permission. Default roles cannot be deleted. When a custom role is deleted, all members with that role are automatically reassigned to Viewer.
Changing a Member's Role
PATCH /api/admin/members/:memberId with members:write permission:
{
"roleId": "uuid-of-target-role"
}Deprecated:
PATCH /api/roles/members/:id/roleis deprecated. UsePATCH /api/admin/members/:memberIdinstead.
The system enforces that at least one Owner must exist at all times. Attempting to change the last Owner's role will fail with an ownership constraint error.
Ownership Transfer
Ownership can be transferred from the current Owner to an Admin:
POST /api/roles/transfer-ownership with members:write permission:
{
"targetMemberId": "uuid-of-admin-member"
}This atomically swaps roles: the current Owner becomes Admin, and the target Admin becomes Owner. The target must already have the Admin role.
Note: Although the endpoint requires
members:writeat the guard level, the service layer additionally verifies the requesting user holds the Owner role. Only the current Owner can transfer ownership -- Admins withmembers:writecannot.
How Roles are Seeded
When a new organization is created, the RbacListener handles the ORGANIZATION_CREATED event:
- Seeds all four default roles for the organization (Owner, Admin, Member, Viewer)
- Assigns the Owner role to the organization creator
This is implemented in apps/api/src/rbac/rbac.listener.ts:
@OnEvent(ORGANIZATION_CREATED)
async handleOrganizationCreated(event: OrganizationCreatedEvent) {
// Seed default roles with their permission sets
await this.rbacService.seedDefaultRoles(event.organizationId)
// Assign Owner role to the creator
await this.tenantService.queryAs(event.organizationId, async (tx) => {
const [ownerRole] = await tx
.select({ id: roles.id })
.from(roles)
.where(and(eq(roles.tenantId, event.organizationId), eq(roles.slug, 'owner')))
.limit(1)
if (ownerRole) {
await tx
.update(members)
.set({ roleId: ownerRole.id })
.where(
and(
eq(members.userId, event.creatorUserId),
eq(members.organizationId, event.organizationId)
)
)
}
})
}Database Schema
The RBAC system uses three tables defined in apps/api/src/database/schema/rbac.schema.ts:
| Table | Scope | Purpose |
|---|---|---|
permissions | Global | All available resource:action pairs. Seeded via migration. |
roles | Tenant-scoped | Role definitions per organization. Has tenant_id + RLS. |
role_permissions | Tenant-scoped (via FK) | Join table mapping roles to permissions. |
The members table (managed by Better Auth) has a roleId column that references roles.id, linking each membership to an organization-specific role.
PermissionService
The PermissionService (apps/api/src/rbac/permission.service.ts) resolves permissions for authentication contexts. It intentionally uses the raw database connection (not TenantService) because it runs during session validation -- before tenant context is established:
@Injectable()
export class PermissionService {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async getPermissions(userId: string, organizationId: string): Promise<string[]> {
// Look up the member's roleId
const member = await this.db
.select({ roleId: members.roleId })
.from(members)
.where(and(eq(members.userId, userId), eq(members.organizationId, organizationId)))
.limit(1)
const roleId = member[0]?.roleId
if (!roleId) return []
// Resolve role -> permissions via join table
const rows = await this.db
.select({ resource: permissions.resource, action: permissions.action })
.from(rolePermissions)
.innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
.where(eq(rolePermissions.roleId, roleId))
return rows.map((r) => `${r.resource}:${r.action}`)
}
}Testing RBAC
Backend: Testing the @Permissions() Guard
Controller tests verify that each endpoint delegates to the correct service method. Mock the service and instantiate the controller directly (see apps/api/src/rbac/rbac.controller.test.ts):
import { describe, expect, it, vi } from 'vitest'
const mockRbacService = {
listRoles: vi.fn(),
createRole: vi.fn(),
deleteRole: vi.fn(),
transferOwnership: vi.fn(),
} as unknown as RbacService
describe('RbacController', () => {
const controller = new RbacController(mockRbacService, mockPermissionService)
it('should call rbacService.listRoles', async () => {
vi.mocked(mockRbacService.listRoles).mockResolvedValue([])
const result = await controller.listRoles()
expect(result).toEqual([])
expect(mockRbacService.listRoles).toHaveBeenCalled()
})
it('should call rbacService.transferOwnership with correct args', async () => {
const session = { user: { id: 'user-1' } }
const body = { targetMemberId: 'member-2' }
vi.mocked(mockRbacService.transferOwnership).mockResolvedValue({ transferred: true })
const result = await controller.transferOwnership(session, body)
expect(result).toEqual({ transferred: true })
expect(mockRbacService.transferOwnership).toHaveBeenCalledWith('user-1', 'member-2')
})
})Frontend: Testing hasPermission()
Unit tests verify the permission helpers handle all session states (see apps/web/src/lib/permissions.test.ts):
import { describe, expect, it } from 'vitest'
import { hasPermission, hasAllPermissions, hasAnyPermission } from './permissions'
describe('hasPermission', () => {
it('should return true when session has the permission', () => {
const session = { permissions: ['members:read', 'roles:write'] }
expect(hasPermission(session, 'members:read')).toBe(true)
})
it('should return false for null session', () => {
expect(hasPermission(null, 'members:read')).toBe(false)
})
})
describe('hasAllPermissions', () => {
it('should return false when session is missing one permission', () => {
const session = { permissions: ['members:read'] }
expect(hasAllPermissions(session, ['members:read', 'roles:write'])).toBe(false)
})
})Related Documentation
- RBAC analysis (
artifacts/analyses/24-rbac.mdx) -- Full design rationale and architectural decisions - RBAC architecture -- System architecture and module boundaries
- Authentication guide -- Auth guard, session management, and decorators
- Multi-tenant guide -- How tenant context and RLS interact with RBAC
Security Guide
Threat model, authentication, API key management, rate limiting, security headers, secrets, and CI security checks for Roxabi.
Navigation Guards
How frontend route guards protect pages based on authentication state, permissions, and roles — with a complete decision matrix and route inventory.