RBAC — Role-Based Access Control
Overview
Access control is fully stored in the database. Adding a new role or permission requires only a DB insert — no code change, no redeploy.
Data model
User ──n:n── Role ──n:n── Permission
│ ▲
└───────────────────────n:n───┘ (UserPermission — direct overrides)
| Model | Description |
|---|---|
Role |
Named role — e.g. admin, moderator, user |
Permission |
Named capability — e.g. resources:read, users:delete |
UserRole |
Assigns a role to a user (join table) |
RolePermission |
Assigns a permission to a role (join table) |
UserPermission |
Direct permission override on a user — bypasses role assignment |
Permission resolution
A user's effective permissions are the union of:
- All permissions granted via their roles (
RolePermission) - All permissions directly assigned to them (
UserPermission)
Users with the admin role bypass all permission checks.
Default roles (seed)
| Role | Permissions |
|---|---|
user |
resources:read |
moderator |
resources:read, resources:update |
admin |
resources:create, resources:read, resources:update, resources:delete |
Guards
@RequirePermissions()
Fine-grained check. The user must hold all listed permissions.
import { RequirePermissions } from '../guards/permissions.guard.js';
@RequirePermissions('resources:update')
@Patch(':id')
update(@Param() params: IdParamsDto, @Body() dto: UpdateResourceDto) { ... }
// Multiple permissions — user must have ALL of them
@RequirePermissions('resources:create', 'resources:read')
@Post()
create(@Body() dto: CreateResourceDto) { ... }
@Roles()
Coarse role check. The user must hold at least one of the listed roles.
import { Roles } from '../guards/roles.decorator.js';
import { RolesGuard } from '../guards/roles.guard.js';
@UseGuards(RolesGuard)
@Roles('admin', 'moderator')
@Get('admin-area')
adminArea() { ... }
OwnershipGuard
Ensures the authenticated user owns the resource before allowing modification. Extend OwnershipGuard and implement getOwnerId() to adapt it to any resource.
Guard order
Always apply AuthGuard (JWT) first, then business guards:
@UseGuards(AuthGuard('jwt'), PermissionsGuard)
@Controller('api/resources')
export class ResourceController { ... }
AuthGuard populates request.user; subsequent guards depend on it being present.
Redis caching
When REDIS_URL is set, resolved permission sets are cached per user with a 5-minute TTL:
- Cache key:
permissions:{userId} - Cache hit → no DB query, instant resolution
- Cache miss → DB query, result stored in cache
Invalidation: call CacheService.del('permissions:{userId}') whenever a user's roles or direct permissions change.
When Redis is unavailable, the guard falls back silently to the database on every request.
Adding a new permission
- Insert a row in the
permissionstable:{ name: 'invoices:send', description: '...' } - Assign it to a role via
role_permissions, or directly to a user viauser_permissions - Use
@RequirePermissions('invoices:send')on the route
No code change or migration required.
Adding a new role
- Insert a row in the
rolestable:{ name: 'billing', label: 'Billing Manager' } - Assign permissions via
role_permissions - Assign users via
user_roles