Sécuriser une API ne se limite pas à authentifier les utilisateurs : il faut aussi déterminer qui a le droit de faire quoi. Un client ne doit pas pouvoir supprimer des services, et seul un administrateur devrait pouvoir accéder à certaines routes sensibles. C'est précisément le rôle du RBAC (Role-Based Access Control). Dans ce tutoriel, nous allons implémenter un système RBAC complet dans NestJS, en combinant un décorateur personnalisé @Roles(), un guard dédié et l'authentification JWT.
Qu'est-ce que le RBAC ?
Le Role-Based Access Control est un modèle de sécurité dans lequel chaque utilisateur se voit attribuer un ou plusieurs rôles, et où chaque ressource (route, action, page) exige un rôle particulier pour être accessible. Plutôt que de gérer les permissions individuellement pour chaque utilisateur, on les centralise au niveau du rôle.
Dans une API NestJS classique, on retrouve généralement deux rôles :
- ADMIN : accès complet, gestion des ressources sensibles (création, modification, suppression).
- CLIENT : accès limité aux fonctionnalités utilisateur (consultation, réservations personnelles, etc.).
Prérequis : NestJS 10+, une stratégie JWT déjà en place (Passport + @nestjs/jwt), et TypeORM pour la persistance.
Étape 1 : définir les rôles avec un enum
La première étape consiste à modéliser les rôles disponibles. Un enum TypeScript est idéal car il garantit la cohérence dans toute l'application et est exploitable par TypeORM pour créer une colonne enum en base de données.
Dans notre entité User, on déclare l'enum UserRole et on l'utilise comme type de la colonne role :
import { Entity, Column, OneToMany } from 'typeorm';
import { BaseEntity } from './base.entity';
export enum UserRole {
ADMIN = 'admin',
CLIENT = 'client',
}
@Entity('users')
export class User extends BaseEntity {
@Column({ unique: true })
email: string;
@Column({ select: false })
password: string;
@Column({ name: 'first_name' })
firstName: string;
@Column({ name: 'last_name' })
lastName: string;
@Column({
type: 'enum',
enum: UserRole,
default: UserRole.CLIENT,
})
role: UserRole;
@Column({ name: 'is_active', default: true })
isActive: boolean;
}
Par défaut, tout nouvel utilisateur est créé avec le rôle CLIENT. La promotion en ADMIN se fait manuellement, via un seed, ou par une route protégée réservée aux administrateurs existants.
Étape 2 : créer le décorateur @Roles()
NestJS s'appuie sur le système de métadonnées de Reflect (reflect-metadata) pour attacher des informations à des handlers de route. Le décorateur @Roles() va simplement marquer une méthode avec la liste des rôles autorisés grâce à SetMetadata.
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../database/entities';
export const Roles = (...roles: UserRole[]) =>
SetMetadata('roles', roles);
Trois points importants ici :
- L'utilisation du rest operator (
...roles) permet de passer plusieurs rôles :@Roles(UserRole.ADMIN, UserRole.CLIENT). - La clé
'roles'sert d'identifiant pour récupérer la métadonnée plus tard. - Le typage strict avec
UserRole[]empêche de passer une chaîne arbitraire : c'est une sécurité au moment de la compilation.
Étape 3 : implémenter le RolesGuard
Le guard est le composant qui va lire les métadonnées et autoriser ou refuser la requête. Il implémente l'interface CanActivate et utilise Reflector (fourni par NestJS) pour accéder aux métadonnées attachées par @Roles().
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../../database/entities/user.entity';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. Récupérer les rôles requis sur le handler ciblé
const requiredRoles = this.reflector.get<UserRole[]>(
'roles',
context.getHandler(),
);
// 2. Pas de @Roles() => route accessible à tout utilisateur authentifié
if (!requiredRoles) {
return true;
}
// 3. Récupérer l'utilisateur injecté par JwtAuthGuard
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return false;
}
// 4. Vérifier que le rôle de l'utilisateur fait partie des rôles autorisés
return requiredRoles.includes(user.role);
}
}
Décortiquons le fonctionnement :
- Lecture des métadonnées :
reflector.get()récupère la liste de rôles attachée par@Roles(). Si rien n'est défini, on laisse passer (la route est protégée par JWT mais pas par rôle). - Récupération de l'utilisateur :
request.userest rempli en amont parJwtAuthGuardà partir du payload du token. Sans cet utilisateur, on refuse la requête. - Comparaison : on vérifie que
user.rolefigure dans la liste des rôles requis.
Astuce : on peut aussi utiliser reflector.getAllAndOverride() pour gérer une combinaison métadonnées au niveau classe ET handler, utile pour appliquer un rôle par défaut à tout un contrôleur.
Étape 4 : combiner JwtAuthGuard et RolesGuard
Le RolesGuard dépend de request.user, lui-même rempli par JwtAuthGuard. Il est donc impératif que JwtAuthGuard s'exécute en premier. NestJS exécute les guards dans l'ordre où ils sont déclarés.
Pour appliquer cette combinaison globalement, on enregistre les deux guards dans le module racine :
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
@Module({
providers: [
// 1. JWT d'abord : authentifie l'utilisateur et remplit request.user
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// 2. Rôles ensuite : utilise request.user pour vérifier l'autorisation
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
Avec ce setup :
- Toutes les routes sont protégées par défaut par JWT (sauf celles annotées
@Public()). - Les routes annotées
@Roles(...)exigent en plus un rôle précis. - Les routes sans
@Roles()sont accessibles à tout utilisateur authentifié.
Étape 5 : protéger les routes admin-only
Voyons un cas concret avec un contrôleur de gestion des services. Les lectures sont publiques (@Public()), tandis que les opérations d'écriture sont réservées aux administrateurs (@Roles(UserRole.ADMIN)).
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe } from '@nestjs/common';
import { Public } from '../../common/decorators/public.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from '../../database/entities/user.entity';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
@Controller('services')
export class ServicesController {
constructor(private readonly servicesService: ServicesService) {}
@Get()
@Public() // Accessible sans authentification
async findAll(): Promise<ApiResponseDto<GuidanceService[]>> {
const services = await this.servicesService.findAll();
return ApiResponseDto.success(services, 'Services retrieved successfully');
}
@Post()
@Roles(UserRole.ADMIN) // Réservé aux admins
async create(
@Body() createServiceDto: CreateServiceDto,
): Promise<ApiResponseDto<GuidanceService>> {
const service = await this.servicesService.create(createServiceDto);
return ApiResponseDto.success(service, 'Service created successfully');
}
@Patch(':id')
@Roles(UserRole.ADMIN)
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateServiceDto: UpdateServiceDto,
): Promise<ApiResponseDto<GuidanceService>> {
const service = await this.servicesService.update(id, updateServiceDto);
return ApiResponseDto.success(service, 'Service updated successfully');
}
@Delete(':id')
@Roles(UserRole.ADMIN)
async remove(
@Param('id', ParseUUIDPipe) id: string,
): Promise<ApiResponseDto<void>> {
await this.servicesService.remove(id);
return ApiResponseDto.success(undefined, 'Service deleted successfully');
}
}
Le résultat est immédiat :
- Un appel
GET /servicesfonctionne pour tout le monde. - Un appel
POST /servicesavec un token de rôleCLIENTretourne une erreur403 Forbidden. - Un appel
POST /servicessans token retourne401 Unauthorized(intercepté en amont par JwtAuthGuard).
Ordre d'exécution et pièges courants
Le bon fonctionnement du RBAC repose sur un chaînage strict :
- JwtAuthGuard valide le token et hydrate
request.userà partir du payload (incluantrole). - RolesGuard lit la métadonnée
roleset compare avecrequest.user.role.
Quelques pièges à éviter :
- Oublier d'inclure
roledans le payload JWT : si la stratégie JWT ne renvoie pas le rôle,user.roleseraundefinedet toutes les vérifications échoueront. - Inverser l'ordre des guards : RolesGuard avant JwtAuthGuard ne pourra jamais lire
request.user. - Ne pas mettre à jour le rôle dans le token : si l'admin rétrograde un utilisateur, son token actuel contient toujours
ADMIN. C'est ici que le champtokenVersionde l'entitéUserprend tout son sens : on incrémente cette valeur pour invalider les anciens tokens.
Conclusion
Vous disposez maintenant d'un système RBAC complet, propre et extensible dans NestJS. Avec un simple enum UserRole, un décorateur @Roles() basé sur SetMetadata, et un RolesGuard qui exploite Reflector, vous pouvez verrouiller finement chaque route de votre API. Combiné à JwtAuthGuard et enregistré globalement via APP_GUARD, ce dispositif protège votre application sans bruit.
Pour aller plus loin, vous pouvez explorer :
- Un système de permissions granulaires (CASL) pour gérer des cas comme « le client ne peut éditer que ses propres ressources ».
- La gestion de plusieurs rôles par utilisateur via une relation many-to-many.
- L'audit log : tracer chaque action sensible avec l'identité et le rôle de l'utilisateur.
Consultez la documentation officielle NestJS sur l'autorisation pour approfondir ces concepts.

Commentaires