DEV Community

Tochukwu Nwosa
Tochukwu Nwosa

Posted on • Originally published at dev.to

Role vs Permission: Why Your RBAC Shouldn't Use Role Checks

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');
}
Enter fullscreen mode Exit fullscreen mode

This works... until your client asks: "Can I add a Manager role? They should delete products but not manage users."

Now you're stuck. Your code checks roles, not permissions. Time to refactor everything.

I learned this the hard way building Mytreda, and here's what I wish I knew from day one.


Why Role Checks Break Down

Problem 1: Role Explosion

You start with 2 roles: Admin, Sales Rep. Clean and simple.

Then the client wants: Manager, Accountant, Warehouse Staff. Each role needs slightly different permissions.

Your code becomes:

if (role === 'admin' || role === 'manager' || role === 'accountant') {
  // delete product
}
Enter fullscreen mode Exit fullscreen mode

Now imagine 10 roles. 50 endpoints. Good luck.

Problem 2: Hard to Change

Business logic is scattered across controllers. Want to let Sales Reps update products?

You'll search the entire codebase for user.role === 'sales_rep' checks. Every change risks breaking something else.

Problem 3: No Granular Control

"Can sales reps view reports but not delete sales?"

Roles are too broad. Permissions are specific. That's the difference.


The Solution: Permission-Based Access Control

Stop asking "What's your role?" Start asking "Do you have permission to do this?"

Step 1: Define Permissions (Not Roles)

// 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',
}
Enter fullscreen mode Exit fullscreen mode

Key insight: These are actions, not identities. Anyone can have any combination of these.

Step 2: Map Roles to Permissions

export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
  admin: [
    // Admins get everything
    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: [
    // Sales reps are limited
    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;
}
Enter fullscreen mode Exit fullscreen mode

Now roles are just bundles of permissions. Want to add a Manager? Just create a new bundle. No code changes.

Step 3: Check Permissions, Not Roles

// Before (role check)
if (user.role === 'admin') {
  await deleteProduct(id);
}

// After (permission check)
if (hasPermission(user.role, Permission.DELETE_PRODUCT)) {
  await deleteProduct(id);
}
Enter fullscreen mode Exit fullscreen mode

Implementing in NestJS

You can wrap this in a guard + decorator for clean controller code:

// common/guards/permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredPermissions = this.reflector.get<Permission[]>(
      'permissions',
      context.getHandler(),
    );

    if (!requiredPermissions) return true;

    const { user } = context.switchToHttp().getRequest();

    return requiredPermissions.every(permission =>
      hasPermission(user.role, permission)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now your controllers look like this:

@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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Self-documenting code. You know exactly what each endpoint requires.


Why This is Better

1. Add New Roles in One Place

Client: "Add a Manager role."

You: Update one file.

manager: [
  Permission.VIEW_PRODUCTS,
  Permission.UPDATE_PRODUCT,
  Permission.DELETE_PRODUCT,
  Permission.VIEW_SALES,
],
Enter fullscreen mode Exit fullscreen mode

Done. No controller changes. No grep'ing the codebase.

2. Change Permissions Without Touching Controllers

Client: "Sales reps should update products now."

You: Add Permission.UPDATE_PRODUCT to the sales_rep array.

No code refactor. No testing 50 endpoints. Just update the map.

3. Easy Testing

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);
});
Enter fullscreen mode Exit fullscreen mode

Your authorization logic is now testable in isolation. Beautiful.


When to Use Each Approach

Use Role Checks When:

  • You have 1-2 fixed roles that will NEVER change
  • Very simple app (todo list, personal blog)
  • Prototype/MVP (but plan to refactor)

Use Permission Checks When:

  • Business requirements might change (they always do)
  • More than 2 roles
  • Enterprise/production apps
  • Client might ask: "Can we add a new role?"

If you're not sure, use permissions. It's easier to start with permissions than to refactor from roles later.


TL;DR

Don't do this:

if (user.role === 'admin') { /* do thing */ }
Enter fullscreen mode Exit fullscreen mode

Do this:

if (hasPermission(user.role, Permission.DO_THING)) { /* do thing */ }
Enter fullscreen mode Exit fullscreen mode

Why:

  • Define permissions (VIEW, CREATE, UPDATE, DELETE)
  • Map roles to permissions in ONE place
  • Check permissions, not roles
  • New role? Just update the permissions map

Your turn: Look at your current codebase. How many places do you check user.role === 'something'? That's how many places will break when you add a new role.

Refactor now, thank yourself later.


Questions? Drop a comment below.

Want to connect?

Top comments (0)