Role vs Permission: Why Your RBAC Shouldn't Use Role Checks
Source: Dev.to
You’re building a multi‑user app. You add an admin who can delete products and a sales rep who can’t. Your code looks like this:
// Bad approach – checking roles directly
if (user.role === 'admin') {
await deleteProduct(id);
} else {
throw new ForbiddenException('Only admins can delete');
}
This works… until your client asks: “Can I add a Manager role? They should delete products but not manage users.”
Now you’re stuck because your code checks roles, not permissions.
The Problem with Role Checks
- Scalability – Every new role forces you to hunt for
user.role === '…'checks across the codebase. - Maintenance – Changing a permission for a role means editing many places, risking regressions.
- Granularity – Roles are broad; permissions are fine‑grained (e.g., “view reports” vs. “delete sales”).
Why Permissions Are Better
Instead of asking “What’s your role?” ask “Do you have permission to do this?”
Permissions describe actions, not identities. Anyone can have any combination of them.
Define Permissions
// constants/permissions.ts
export enum Permission {
// Products
VIEW_PRODUCTS = 'VIEW_PRODUCTS',
CREATE_PRODUCT = 'CREATE_PRODUCT',
UPDATE_PRODUCT = 'UPDATE_PRODUCT',
DELETE_PRODUCT = 'DELETE_PRODUCT',
// Sales
VIEW_SALES = 'VIEW_SALES',
CREATE_SALE = 'CREATE_SALE',
DELETE_SALE = 'DELETE_SALE',
// Admin
INVITE_USERS = 'INVITE_USERS',
VIEW_ACTIVITY_LOGS = 'VIEW_ACTIVITY_LOGS',
}
Map Roles to Permissions
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permission.VIEW_PRODUCTS,
Permission.CREATE_PRODUCT,
Permission.UPDATE_PRODUCT,
Permission.DELETE_PRODUCT,
Permission.VIEW_SALES,
Permission.CREATE_SALE,
Permission.DELETE_SALE,
Permission.INVITE_USERS,
Permission.VIEW_ACTIVITY_LOGS,
],
sales_rep: [
Permission.VIEW_PRODUCTS,
Permission.CREATE_PRODUCT,
Permission.VIEW_SALES,
Permission.CREATE_SALE,
],
};
Helper Function
export function hasPermission(
role: UserRole,
permission: Permission,
): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}
Now roles are just bundles of permissions. Adding a new role only requires updating the map.
Refactoring to Permission Checks
Before (role check)
if (user.role === 'admin') {
await deleteProduct(id);
}
After (permission check)
if (hasPermission(user.role, Permission.DELETE_PRODUCT)) {
await deleteProduct(id);
}
You can encapsulate this logic in a guard and a decorator for clean controller code.
Permissions Guard
// common/guards/permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get<string[]>(
'permissions',
context.getHandler(),
);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.every(p => hasPermission(user.role, p));
}
}
Controller Example
@Controller('products')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ProductsController {
@Post()
@RequirePermission(Permission.CREATE_PRODUCT)
create(@Body() dto: CreateProductDto) {
return this.productsService.create(dto);
}
@Delete(':id')
@RequirePermission(Permission.DELETE_PRODUCT) // Only admins have this
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}
The code is now self‑documenting: each endpoint explicitly declares the permissions it requires.
Managing Roles and Permissions
-
Add a Manager role – just extend
ROLE_PERMISSIONS:manager: [ Permission.VIEW_PRODUCTS, Permission.UPDATE_PRODUCT, Permission.DELETE_PRODUCT, Permission.VIEW_SALES, ], -
Grant a new permission to an existing role – modify the array for that role (e.g., give
sales_reptheUPDATE_PRODUCTpermission). No controller changes, no grepping the codebase.
Testing Permission Logic
it('should allow admin to delete product', () => {
const canDelete = hasPermission('admin', Permission.DELETE_PRODUCT);
expect(canDelete).toBe(true);
});
it('should deny sales_rep from deleting product', () => {
const canDelete = hasPermission('sales_rep', Permission.DELETE_PRODUCT);
expect(canDelete).toBe(false);
});
Permission checks are now isolated and easily testable.
When to Use Role Checks vs. Permission Checks
Use Role Checks When
- You have 1–2 fixed roles that will never change.
- The app is very simple (e.g., a personal blog or todo list).
- You’re building a prototype/MVP and plan to refactor later.
Use Permission Checks When
- Business requirements might change (they always do).
- You have more than 2 roles.
- The project is an enterprise or production‑grade application.
- Clients may request new roles or altered capabilities.
If you’re unsure, start with permissions—they’re easier to evolve than refactoring role‑centric code later.
Quick Reference
❌ Bad:
if (user.role === 'admin') {
// do thing
}
✅ Good:
if (hasPermission(user.role, Permission.DO_THING)) {
// do thing
}
- Define permissions (
VIEW,CREATE,UPDATE,DELETE). - Map roles to permissions in one place.
- Check permissions, not roles.
- Adding a new role = just update the map.
Conclusion
Take a look at your current codebase: how many places contain user.role === '…'? Those are the spots that will break when you add a new role. Refactor to permission‑based checks now, and you’ll thank yourself later when the product grows.