Understanding Role-Based Access Control (RBAC)
Access control is one of those things that seems simple until you implement it for a real multi-tenant application. In this post I'll walk through designing a flexible RBAC system from scratch β database schema, NestJS guards, and testing edge cases.
What Is RBAC?
Role-Based Access Control assigns permissions to roles, and roles to users. Instead of checking "can user X do action Y?", you check "does user X have a role that allows action Y?"
User ββbelongs toβββΆ Role ββhasβββΆ Permission
This is much easier to manage than assigning permissions directly to users. When a new "Manager" role needs read-only reporting access, you update one role β not hundreds of users.
Database Schema
-- Users table (simplified)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Roles
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL, -- e.g. 'admin', 'manager', 'viewer'
description TEXT
);
-- Permissions (granular actions)
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
action VARCHAR(100) NOT NULL, -- e.g. 'invoice:create'
resource VARCHAR(100) NOT NULL, -- e.g. 'invoice'
UNIQUE(action, resource)
);
-- Many-to-many: roles <-> permissions
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- Many-to-many: users <-> roles (per tenant)
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL, -- multi-tenant support
PRIMARY KEY (user_id, role_id, tenant_id)
);
π‘ Tip: Storing permissions as action:resource strings (e.g. invoice:create, invoice:read) makes it trivial to add new resources without schema changes.
TypeORM Entities
// permission.entity.ts
@Entity('permissions')
export class Permission {
@PrimaryGeneratedColumn()
id: number;
@Column()
action: string;
@Column()
resource: string;
}
// role.entity.ts
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@ManyToMany(() => Permission, { eager: true })
@JoinTable({ name: 'role_permissions' })
permissions: Permission[];
}
// user.entity.ts
@Entity('users')
export class User extends BaseEntity {
@Column({ unique: true })
email: string;
@ManyToMany(() => Role, { eager: true })
@JoinTable({ name: 'user_roles' })
roles: Role[];
hasPermission(action: string, resource: string): boolean {
return this.roles.some(role =>
role.permissions.some(p => p.action === action && p.resource === resource)
);
}
}
Custom Decorator
Create a @RequirePermission() decorator to annotate controllers cleanly:
// decorators/require-permission.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'required_permission';
export const RequirePermission = (action: string, resource: string) =>
SetMetadata(PERMISSION_KEY, { action, resource });
The Permission Guard
// guards/permission.guard.ts
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get<{ action: string; resource: string }>(
PERMISSION_KEY,
context.getHandler(),
);
if (!required) return true; // No permission required
const { user } = context.switchToHttp().getRequest<Request & { user: User }>();
if (!user) return false;
return user.hasPermission(required.action, required.resource);
}
}
Applying to Controllers
@Controller('invoices')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class InvoicesController {
@Get()
@RequirePermission('read', 'invoice')
findAll() { ... }
@Post()
@RequirePermission('create', 'invoice')
create(@Body() dto: CreateInvoiceDto) { ... }
@Delete(':id')
@RequirePermission('delete', 'invoice')
remove(@Param('id') id: string) { ... }
}
Clean and declarative β the controller doesn't know anything about how permissions are checked.
Seeding Roles & Permissions
Always seed your RBAC data in a migration or seed script:
// seeds/rbac.seed.ts
const permissions = [
{ action: 'read', resource: 'invoice' },
{ action: 'create', resource: 'invoice' },
{ action: 'update', resource: 'invoice' },
{ action: 'delete', resource: 'invoice' },
{ action: 'read', resource: 'report' },
];
const roles = [
{ name: 'admin', permissions: permissions },
{ name: 'manager', permissions: permissions.filter(p =>
p.action !== 'delete'
)},
{ name: 'viewer', permissions: permissions.filter(p =>
p.action === 'read'
)},
];
Testing Edge Cases
| Scenario | Expected |
|---|---|
| No token | 401 Unauthorized |
| Valid token, no roles | 403 Forbidden |
| Role exists, permission missing | 403 Forbidden |
| Role exists, permission matches | 200 OK |
| Admin accessing any route | 200 OK |
| User in multiple roles | Permission from either role grants access |
// Test: user without permission gets 403
it('returns 403 when user lacks delete permission', async () => {
const viewerToken = await getTokenForRole('viewer');
return request(app.getHttpServer())
.delete('/invoices/some-id')
.set('Authorization', `Bearer ${viewerToken}`)
.expect(403);
});
Multi-Tenant Considerations
In a multi-tenant app, a user may be a manager in Tenant A and a viewer in Tenant B. Extract the tenant from the JWT or request header, then filter roles accordingly:
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const tenantId = request.headers['x-tenant-id'];
const user: User = request.user;
// Only consider roles belonging to this tenant
const tenantRoles = user.roles.filter(r => r.tenantId === tenantId);
return tenantRoles.some(role =>
role.permissions.some(p =>
p.action === required.action && p.resource === required.resource
)
);
}
Key Takeaways
- Never assign permissions directly to users β always go through roles
action:resourcepermission strings scale to any domain without schema changes- Keep guards thin β business logic belongs in services, not guards
- Seed your RBAC data deterministically so environments stay in sync
- Test every role combination β access control bugs are security vulnerabilities
β οΈ Warning: RBAC alone doesn't handle row-level security (e.g. "user can only edit their own invoices"). For that, you need additional ownership checks inside your service layer.