We use cookies to enhance your experience on the site
CodeWorlds

Role-based Authorization - the hierarchy of the Empire

The legionary showed his pass and entered the camp. We already know who he is - that is authentication, what we have dealt with so far. But is he allowed into the treasury? To disband a cohort? To pay out wages?

That is a different question: not "who is this", but "what may he do". We call it authorization, and in the Empire it is answered by rank. A recruit and a legate carry the same pass but not the same rights. This lesson is about writing the Roman hierarchy into code.

Roles as a ladder

Let's start by listing the ranks - from recruit to legion commander:

1export enum Role {
2  TIRO = 'TIRO',           // recruit
3  MILES = 'MILES',         // soldier
4  CENTURION = 'CENTURION', // centuria commander
5  LEGATUS = 'LEGATUS',     // legion commander
6}

The enum alone would be enough if roles were mutually exclusive. But in an army they are not: a centurion may do everything a soldier may, and more. A rank is not a label but a rung on a ladder - and a ladder is written with numbers:

1export const ROLE_HIERARCHY = {
2  [Role.LEGATUS]: 90,
3  [Role.CENTURION]: 80,
4  [Role.MILES]: 50,
5  [Role.TIRO]: 40,
6};
7
8export function hasRequiredRole(userRole: Role, requiredRole: Role): boolean {
9  return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
10}

The whole hierarchy comes down to a single comparison:

>=
. A legate (90) passes where a centurion (80) is required, because he stands higher on the ladder. A recruit (40) passes nowhere beyond his own rung.

Note the gaps between the numbers - 40, 50, 80, 90 rather than 1, 2, 3, 4. That is deliberate: when a rank between soldier and centurion appears next year, you give it 60 and touch nothing else. Numbering by tens is a small thing that saves a lot of rewriting.

The decorator - recording the requirement on a method

Since we know how to compare ranks, we still need to record at each endpoint which rank it requires. A custom decorator does that:

1import { SetMetadata } from '@nestjs/common';
2
3export const ROLES_KEY = 'roles';
4export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

That is three lines, but something worth naming happens in them.

SetMetadata
checks nothing - it merely pins a note to the controller method, under the
ROLES_KEY
key. The note lies there and waits; somebody else will have to read it and draw conclusions.

The

(...roles: Role[])
notation is a rest parameter - it lets you call
@Roles(Role.CENTURION)
or
@Roles(Role.CENTURION, Role.LEGATUS)
, and the roles arrive as an array.

The guard - reading the note and deciding

The note is read by a guard. In this arrangement it is the sentry who compares the visitor's rank with the rank required at the door:

1@Injectable()
2export class RolesGuard implements CanActivate {
3  constructor(private reflector: Reflector) {}
4
5  canActivate(context: ExecutionContext): boolean {
6    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
7      context.getHandler(),
8      context.getClass(),
9    ]);
10
11    if (!requiredRoles) {
12      return true;
13    }
14
15    const { user } = context.switchToHttp().getRequest();
16
17    return requiredRoles.some((role) => hasRequiredRole(user.role, role));
18  }
19}

Let's walk through it in order, because the whole lesson ties together here.

Reflector
is the NestJS tool for reading metadata - the very metadata
SetMetadata
pinned on. The
getAllAndOverride
method looks for the note in two places: first on the method (
getHandler()
), then on the controller class (
getClass()
), and whatever is closer to the method wins. That lets you set a requirement for a whole controller and override it at a single endpoint.

The absence of a note means the endpoint imposes no requirement - then

return true
admits anyone logged in. That behaviour matters: a guard that blocked access when metadata is missing would shut down the entire application.

context.switchToHttp().getRequest()
reaches for the request, and from it for
user
- the object the JWT strategy left there earlier. And here you see why order matters: RolesGuard only works if an authenticating guard ran before it. Without that,
user
would be empty. Authorization always follows authentication - first we know who the visitor is, only then do we check his rank.

The last line returns

true
or
false
, and that is the only thing NestJS expects from a guard:
false
stops the request with a 403 response, and the controller method does not run at all. The
some
method is enough because a list of roles is an alternative - satisfying one of the requirements suffices.

Use in a controller

We tie both pieces together at the endpoints:

1@Controller('treasury')
2@UseGuards(JwtAuthGuard, RolesGuard)
3export class TreasuryController {
4  @Get()
5  findAll() {
6    return this.treasuryService.findAll();
7  }
8
9  @Post('withdraw')
10  @Roles(Role.CENTURION)
11  withdraw(@Body() dto: WithdrawDto) {
12    return this.treasuryService.withdraw(dto);
13  }
14
15  @Delete('cohort/:id')
16  @Roles(Role.LEGATUS)
17  disbandCohort(@Param('id') id: string) {
18    return this.treasuryService.disbandCohort(id);
19  }
20}

The order in

@UseGuards(JwtAuthGuard, RolesGuard)
is not arbitrary - guards run left to right, so we establish identity first and rank second. Reversing that pair would leave
RolesGuard
looking for a
user
that does not exist yet.

Note too that

findAll()
has no
@Roles
decorator - which is exactly why every logged-in legionary can see it. A withdrawal requires a centurion, disbanding a cohort a legate. The same treasury, three different thresholds.

Summary

The Empire's hierarchy is written down and the sentries are in place:

  • authentication answers who the visitor is; authorization - what he may do,
  • roles form a ladder of numbers, and the whole inheritance of privileges reduces to a
    >=
    comparison,
  • number by tens so a new rank can be slotted in later without rewriting,
  • SetMetadata
    only pins a note to the method - it checks nothing,
  • Reflector.getAllAndOverride
    reads the note from the method and the class, and the one closer to the method wins,
  • missing metadata must mean "admit", otherwise the guard shuts down the whole application,
  • a guard returns
    true
    or
    false
    ;
    false
    ends the request with a 403,
  • in
    @UseGuards()
    guards run left to right - the authenticating one must stand before the authorizing one, because it is what leaves
    user
    in the request.

In the next lesson we will take on renewing passes, that is refresh tokens - because even a centurion's pass expires eventually. For now remember: a role is a rung on a ladder, the decorator pins a note about the required height, and the guard compares one against the other.

Go to CodeWorlds