RBAC with a Roles Guard

Authentication vs Authorization

Authentication answers "who are you?" (401 if unknown). Authorization answers "what may you do?" (403 if not allowed). RBAC (role-based access control) gives each user roles, and each route lists the roles it needs.

Routecustomerselleradmin
GET /productsYesYesYes
POST /productsNoYes (own store)Yes
GET /orders/:idOwn ordersOrders for own productsAll
PATCH /orders/:id/statusNoYes (ship)Yes
GET /admin/usersNoNoYes

The @Roles() decorator and RolesGuard were built in the medium level (Guards module). With the global JwtAuthGuard from the Authentication module running first, request.user.roles is always available to the roles guard.

Role Hierarchy

Example

Often an admin should pass every seller check. A small hierarchy keeps @Roles() lists short:

type Role = 'customer' | 'seller' | 'admin';
const inherits: Record<Role, Role[]> = { customer: [], seller: ['customer'], admin: ['seller', 'customer'] };

function expand(roles: Role[]): Set<Role> {
  const out = new Set<Role>();
  const visit = (r: Role) => { if (!out.has(r)) { out.add(r); inherits[r].forEach(visit); } };
  roles.forEach(visit);
  return out;
}

function canActivate(userRoles: Role[], required: Role[] | undefined): boolean {
  if (!required?.length) return true;
  const effective = expand(userRoles);
  return required.some((r) => effective.has(r));
}

const routes: [string, Role[] | undefined][] = [
  ['GET /products', undefined],
  ['POST /products', ['seller']],
  ['GET /admin/users', ['admin']],
];
for (const user of [['customer'], ['seller'], ['admin']] as Role[][]) {
  console.log(user[0].padEnd(8), routes.map(([r, req]) => r + '=' + (canActivate(user, req) ? 'allow' : '403')).join('  '));
}

Output

customer GET /products=allow  POST /products=403  GET /admin/users=403
seller   GET /products=allow  POST /products=allow  GET /admin/users=403
admin    GET /products=allow  POST /products=allow  GET /admin/users=allow