Cours NestJS · Module 4: Authentification

Role-based Authorization - la hiérarchie de l'Empire

6 min de lecture
Dans cette leçon5

Le légionnaire a montré son laissez-passer et il est entré dans le camp. Nous savons déjà qui il est - c'est l'authentification, ce dont nous nous sommes occupés jusqu'ici. Mais a-t-il le droit d'entrer au trésor ? De dissoudre une cohorte ? De verser la solde ?

C'est une autre question : non pas "qui est-ce", mais "que lui est-il permis". Nous l'appelons autorisation, et dans l'Empire c'est le grade qui y répond. Une recrue et un légat portent le même laissez-passer, mais n'ont pas les mêmes droits. Cette leçon porte sur la façon d'inscrire la hiérarchie romaine dans le code.

Les rôles comme une échelle

Commençons par énumérer les grades - de la recrue au commandant de légion :

1export enum Role {
2  TIRO = 'TIRO',           // recrue
3  MILES = 'MILES',         // soldat
4  CENTURION = 'CENTURION', // commandant de centurie
5  LEGATUS = 'LEGATUS',     // commandant de légion
6}

L'enum seul suffirait si les rôles s'excluaient mutuellement. Mais dans une armée, ce n'est pas le cas : un centurion peut tout ce que peut un soldat, et davantage. Un grade n'est pas une étiquette mais un barreau d'échelle - et une échelle s'écrit avec des nombres :

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}

Toute la hiérarchie se ramène à une seule comparaison : >=. Un légat (90) passe là où l'on exige un centurion (80), car il se tient plus haut sur l'échelle. Une recrue (40) ne passe nulle part au-delà de son propre barreau.

Note les écarts entre les nombres - 40, 50, 80, 90 plutôt que 1, 2, 3, 4. C'est délibéré : quand un grade entre soldat et centurion apparaîtra l'an prochain, tu lui donneras 60 sans toucher au reste. Numéroter de dix en dix est un détail qui épargne bien des réécritures.

Le décorateur - inscrire l'exigence sur une méthode

Puisque nous savons comparer les grades, il reste à inscrire à chaque endpoint quel grade il exige. Un décorateur maison s'en charge :

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

Ce sont trois lignes, mais il s'y produit une chose qu'il vaut la peine de nommer. SetMetadata ne vérifie rien - il ne fait qu'épingler une note à la méthode du contrôleur, sous la clé ROLES_KEY. La note est là et attend ; quelqu'un d'autre devra la lire et en tirer les conséquences.

L'écriture (...roles: Role[]) est un paramètre du reste - elle permet d'appeler @Roles(Role.CENTURION) ou @Roles(Role.CENTURION, Role.LEGATUS), et les rôles arrivent sous forme de tableau.

Le guard - lire la note et décider

La note est lue par un guard. Dans ce dispositif, c'est lui la sentinelle qui compare le grade du visiteur au grade exigé à la porte :

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}

Parcourons-le dans l'ordre, car c'est ici que toute la leçon se noue. Reflector est l'outil de NestJS pour lire les métadonnées - celles-là mêmes qu'a épinglées SetMetadata. La méthode getAllAndOverride cherche la note à deux endroits : d'abord sur la méthode (getHandler()), puis sur la classe du contrôleur (getClass()), et ce qui est le plus proche de la méthode l'emporte. Cela te permet de fixer une exigence pour tout un contrôleur et de la redéfinir sur un seul endpoint.

L'absence de note signifie que l'endpoint n'impose aucune exigence - alors return true laisse entrer toute personne connectée. Ce comportement compte : un guard qui bloquerait l'accès en l'absence de métadonnées fermerait l'application entière.

context.switchToHttp().getRequest() récupère la requête, et dans celle-ci user - l'objet que la stratégie JWT y a laissé auparavant. Et l'on voit ici pourquoi l'ordre importe : RolesGuard ne fonctionne que si un guard d'authentification s'est exécuté avant lui. Sans cela, user serait vide. L'autorisation suit toujours l'authentification - d'abord nous savons qui est le visiteur, ensuite seulement nous vérifions son grade.

La dernière ligne renvoie true ou false, et c'est la seule chose que NestJS attend d'un guard : false arrête la requête avec une réponse 403, et la méthode du contrôleur ne s'exécute pas du tout. La méthode some suffit car une liste de rôles est une alternative - satisfaire l'une des exigences suffit.

Utilisation dans un contrôleur

Nous relions les deux pièces au niveau des 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}

L'ordre dans @UseGuards(JwtAuthGuard, RolesGuard) n'est pas arbitraire - les guards s'exécutent de gauche à droite, nous établissons donc d'abord l'identité, ensuite le grade. Inverser cette paire laisserait RolesGuard chercher un user qui n'existe pas encore.

Remarque aussi que findAll() n'a pas de décorateur @Roles - et c'est précisément pourquoi tout légionnaire connecté peut le voir. Un retrait exige un centurion, la dissolution d'une cohorte un légat. Le même trésor, trois seuils différents.

Récapitulatif

La hiérarchie de l'Empire est inscrite et les sentinelles sont en place :

  • l'authentification répond à qui est le visiteur ; l'autorisation - à ce qu'il peut faire,
  • les rôles forment une échelle de nombres, et tout l'héritage des privilèges se réduit à une comparaison >=,
  • numérote de dix en dix pour pouvoir insérer un nouveau grade plus tard sans réécriture,
  • SetMetadata ne fait qu'épingler une note à la méthode - il ne vérifie rien,
  • Reflector.getAllAndOverride lit la note sur la méthode et sur la classe, et la plus proche de la méthode l'emporte,
  • l'absence de métadonnées doit signifier "laisse entrer", sinon le guard ferme toute l'application,
  • un guard renvoie true ou false ; false termine la requête par un 403,
  • dans @UseGuards(), les guards s'exécutent de gauche à droite - celui qui authentifie doit précéder celui qui autorise, car c'est lui qui laisse user dans la requête.

Dans la prochaine leçon, nous nous occuperons du renouvellement des laissez-passer, c'est-à-dire des refresh tokens - car même le laissez-passer d'un centurion finit par expirer. En attendant, retiens ceci : un rôle est un barreau d'échelle, le décorateur épingle une note sur la hauteur exigée, et le guard compare l'un à l'autre.

Code de cette leçon : src/auth/roles.guard.ts
1// Autorisation basée sur les rôles - la hiérarchie dans l'Imperium
2import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
3import { Reflector } from '@nestjs/core';
4
5// Enum des rôles dans l'Imperium
6export enum Role {
7  TIRO = 'TIRO',
8  MILES = 'MILES',
9  DECANUS = 'DECANUS',
10  OPTIO = 'OPTIO',
11  CENTURION = 'CENTURION',
12  LEGATUS = 'LEGATUS',
13  ADMIN = 'ADMIN',
14}
15
16// Hiérarchie des rôles
17export const ROLE_HIERARCHY: Record<Role, number> = {
18  [Role.ADMIN]: 100,
19  [Role.LEGATUS]: 90,
20  [Role.CENTURION]: 80,
21  [Role.OPTIO]: 70,
22  [Role.DECANUS]: 60,
23  [Role.MILES]: 50,
24  [Role.TIRO]: 40,
25};
26
27// TODO: Implémente une fonction qui vérifie les permissions
28// Compare le niveau de rôle de l'utilisateur avec le rôle requis
29export function hasPermission(userRole: Role, requiredRole: Role): boolean {
30  // TODO: Renvoie true si userRole >= requiredRole dans la hiérarchie
31  return false;
32}
33
34// Décorateur @Roles()
35import { SetMetadata } from '@nestjs/common';
36export const ROLES_KEY = 'roles';
37export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
38
39// TODO: Implémente RolesGuard
40// 1. Utilise Reflector pour récupérer les rôles requis
41// 2. Récupère l'utilisateur depuis request.user
42// 3. Vérifie si l'utilisateur possède le rôle requis
43@Injectable()
44export class RolesGuard implements CanActivate {
45  constructor(private reflector: Reflector) {}
46
47  canActivate(context: ExecutionContext): boolean {
48    // TODO: Récupère les rôles requis depuis les métadonnées
49    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(
50      ROLES_KEY,
51      [context.getHandler(), context.getClass()],
52    );
53
54    if (!requiredRoles) {
55      return true; // Aucune restriction
56    }
57
58    // TODO: Récupère l'utilisateur depuis la requête
59    const { user } = context.switchToHttp().getRequest();
60
61    // TODO: Vérifie si l'utilisateur possède l'un des rôles requis
62    // Utilise hasPermission() ou requiredRoles.some()
63    return false;
64  }
65}
66
67console.log("RolesGuard - contrôle d'accès basé sur les rôles");
68console.log('Roles: TIRO < MILES < DECANUS < OPTIO < CENTURION < LEGATUS < ADMIN');
69

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel est l'objectif principal de l'autorisation basée sur les rôles (RBAC) ?

Tâches pratiques dans le jeu

  • Clique dans l'ordre

    Placez les rôles du plus bas au plus élevé :