Як реалізувати контроль доступу на основі ролей (RBAC) у NestJS?
RBAC (контроль доступу на основі ролей) у NestJS - це підхід, де Guard зчитує метадані ролей з обробника або контролера, порівнює їх з user.roles із запиту і пропускає або блокує виклик до того, як виконається бізнес-логіка.
Теорія
TL;DR
- Аналогія:
@Roles()ставить вимогу на вході;RolesGuardперевіряє браслет на кожному запиті - Ядро:
SetMetadataзаписує потрібні ролі в метадані маршруту;Reflectorзчитує їх всередині Guard - Завжди ставте
AuthGuard('jwt')передRolesGuard- JWT guard заповнюєrequest.user; без ньогоRolesGuardвпаде - Зберігайте ролі як масив у JWT (
roles: ['admin', 'moderator']), не як рядок - один рядок зламає логіку для користувачів з кількома ролями - Менше 100 ролей і немає правил по часу або локації: RBAC вистачить. Складніша логіка - дивіться на Casbin
Швидкий приклад
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), // спочатку метадані обробника
context.getClass(), // потім метадані класу як запасний варіант
]);
if (!requiredRoles) return true; // немає декоратора = публічний маршрут
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.roles?.includes(role));
}
}
// admin.controller.ts
@Controller('admin')
@UseGuards(AuthGuard('jwt'), RolesGuard) // порядок важливий
export class AdminController {
@Get('dashboard')
@Roles(Role.Admin, Role.SuperAdmin)
getDashboard() {
return { message: 'Admin dashboard' };
}
}
// Запит адміна → 200 OK
// Звичайний user → 403 ForbiddengetAllAndOverride перевіряє спочатку обробник, потім клас. Тому @Roles(Role.Admin) на контролері захищає всі маршрути всередині нього, якщо не перевизначити на рівні обробника.
Як це працює
NestJS завантажує reflect-metadata при виклику NestFactory.create(). Декоратор @Roles() викликає SetMetadata(ROLES_KEY, roles), який записує масив ролей як метадані на обробник або клас. Коли приходить запит, RolesGuard.canActivate() виконується в ланцюжку middleware Express після того, як AuthGuard вже перевірив JWT і прикріпив user до request. Reflector зчитує метадані, Guard порівнює їх з user.roles і кидає ForbiddenException при розбіжності. Все це відбувається до того, як функція-обробник взагалі викликається.
Глобальне налаштування Guard
Guard можна зареєструвати глобально, щоб він діяв скрізь без декорування окремих контролерів:
// app.module.ts
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}При глобальному підключенні публічні маршрути отримають 403, якщо не обробити випадок без метаданих. Рядок if (!requiredRoles) return true у Guard вище вирішує цю проблему автоматично. Або додайте декоратор @Public() як сигнал пропуску.
Доступ на основі дозволів
Ролі підходять для грубого контролю. Якщо потрібно "адміни можуть писати пости, але не видаляти користувачів", варто зіставити ролі з конкретними дозволами (permission):
export enum Permission {
ReadUsers = 'read:users',
WriteUsers = 'write:users',
DeleteUsers = 'delete:users',
ManageSettings = 'manage:settings',
}
const rolePermissions: Record<Role, Permission[]> = {
[Role.User]: [Permission.ReadUsers],
[Role.Moderator]: [Permission.ReadUsers, Permission.WriteUsers],
[Role.Admin]: [Permission.ReadUsers, Permission.WriteUsers, Permission.ManageSettings],
[Role.SuperAdmin]: Object.values(Permission), // усі дозволи
};
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata('permissions', permissions);
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Permission[]>(
'permissions', [context.getHandler(), context.getClass()],
);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
const userPermissions = user.roles
.flatMap((role: Role) => rolePermissions[role] ?? []);
return required.every((perm) => userPermissions.includes(perm));
}
}Цей підхід добре працює в SaaS-дашбордах, де ролі перетинаються, але набори дозволів різні.
Перевірка права власності на ресурс
Іноді ролей недостатньо. Користувач повинен редагувати тільки свої пости, але адмін - будь-чиї. Для цього потрібна окрема перевірка власності:
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(private postsService: PostsService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const { user, params } = context.switchToHttp().getRequest();
if (user.roles.includes(Role.Admin)) return true; // адміни обходять перевірку
const post = await this.postsService.findOne(params.id);
return post?.authorId === user.id;
}
}
// Комбінуйте тільки коли потрібні обидві перевірки
@Put(':id')
@UseGuards(AuthGuard('jwt'), RolesGuard, OwnershipGuard)
update(@Param('id') id: string, @Body() dto: UpdatePostDto) {
return this.postsService.update(id, dto);
}RBAC проти ABAC
| RBAC | ABAC | |
|---|---|---|
| Базується на | Статичних ролях | Атрибутах (роль, час, IP, стан ресурсу) |
| Складність | Низька | Висока |
| Гнучкість | Вистачає для більшості застосунків | Підтримує динамічні правила |
| Швидкість | Читання метаданих (мікросекунди) | Сканування правил (мілісекунди) |
| Коли використовувати | CRUD API, SaaS-дашборди | Підприємства, compliance, AWS IAM-подібне |
Практичне правило, яке я застосовую на проектах: якщо всі правила доступу можна записати на дошці за п'ять хвилин, RBAC вистачить. Якщо починаєте малювати стрілки між атрибутами користувача і станом ресурсу, час дивитися на Casbin.
Типові помилки
1. reflector.get замість getAllAndOverride
// Неправильно - пропускає @Roles() на рівні класу
const roles = this.reflector.get(ROLES_KEY, context.getHandler());
// Правильно
const roles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);Якщо @Roles(Role.Admin) стоїть на класі контролера, а reflector.get дивиться тільки на обробник, всі маршрути в контролері залишаться незахищеними. Це найпоширеніший RBAC-баг на Stack Overflow.
2. RolesGuard без AuthGuard перед ним
// Неправильно - request.user undefined, guard падає в runtime
@UseGuards(RolesGuard)
// Правильно
@UseGuards(AuthGuard('jwt'), RolesGuard)AuthGuard('jwt') запускає Passport-стратегію, яка перевіряє токен і встановлює request.user. Без нього user?.roles це undefined і Guard або падає, або мовчки пропускає всіх.
3. Ролі як рядок у JWT замість масиву
// Токен: { role: 'admin' } ← ламає логіку з кількома ролями
// Токен: { roles: ['admin', 'moderator'] } ← правильноКоли згодом потрібно призначити дві ролі одному користувачу, рядок не дає варіантів. Починайте з масиву одразу.
4. Глобальний Guard без обробки публічних маршрутів
Глобальний RolesGuard без @Roles() на /health поверне 403 і поламає health check при деплої. Рядок if (!requiredRoles) return true у Guard вирішує це автоматично.
5. Запит ролей з БД на кожен запит
На 1000 запитів/с це стає вузьким місцем. Кладіть ролі в JWT payload. При зміні ролей використовуйте короткий термін дії токена (15 хвилин) і refresh token, який підтягне нові ролі з БД при оновленні.
Де зустрічається
- Офіційна документація NestJS: Guards + Reflector як стандартний шаблон RBAC
@nestjs/passport+ JWT: ролі в payload токена, перевіряються один раз при логіні- Prisma + NestJS: роль запитується один раз, зберігається в токені, без запитів до БД на кожен запит
- Casbin +
nest-casbin: коли комбінацій правил RBAC стає більше 10-15 - Мультитенантні API: додайте
tenantIdв перевірку ролей (admin:tenant1), Guard порівнюєuser.tenantIdзreq.tenantId
Питання на співбесіді
Q: Як реалізувати ієрархію ролей, де superadmin успадковує права admin?
A: Розширте Guard картою ієрархії: { superadmin: ['admin', 'moderator', 'user'] }. В canActivate перевіряйте hierarchy[userRole]?.includes(required) перед поверненням false. Зберігайте карту в пам'яті - запити до БД не потрібні.
Q: Що відбувається, якщо ролі користувача змінились під час активної сесії?
A: Короткий термін дії JWT (15 хвилин) і refresh token, який перечитує ролі з БД при кожному оновленні. Уникайте довгоживучих токенів, якщо потрібне оперативне скасування ролей.
Q: Як тестувати RolesGuard з Jest?
A: Підставте Reflector.getAllAndOverride щоб повертав ['admin'], змокайте ExecutionContext де switchToHttp().getRequest() повертає { user: { roles: ['admin'] } }, потім викликайте guard.canActivate(mockContext) і перевіряйте результат. Тестуйте обидва випадки: збіг і незбіг. Це питання задають на більшості NestJS-співбесід рівня senior.
Q: Як застосувати RBAC у мікросервісах?
A: Кладіть ролі в JWT payload. Кожен сервіс читає токен незалежно і запускає свій RolesGuard. Запити між сервісами для перевірки ролей не потрібні. При зміні ролей оновлення поширюється через refresh flow автоматично.
Q: Коли RBAC перестає підходити?
A: Коли правила доступу залежать від атрибутів крім ролі: час доби, географічний регіон, стан ресурсу. Ось коли переходять на ABAC або policy engine на кшталт Casbin.
Приклади
Базовий: захист адміністративних маршрутів
// role.enum.ts
export enum Role {
User = 'user',
Moderator = 'moderator',
Admin = 'admin',
SuperAdmin = 'superadmin',
}
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), context.getClass(),
]);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.some((role) => user?.roles?.includes(role));
}
}
// admin.controller.ts
@Controller('admin')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class AdminController {
@Get('dashboard')
@Roles(Role.Admin, Role.SuperAdmin)
getDashboard() { return { message: 'Admin dashboard' }; }
@Delete('users/:id')
@Roles(Role.SuperAdmin) // тільки superadmin може видаляти
deleteUser(@Param('id') id: string) {
return this.usersService.delete(id);
}
}Адмін звертається до /admin/dashboard - 200. Звичайний користувач - 403. SuperAdmin видаляє /admin/users/42 - 200. Адмін намагається зробити те саме - 403.
Середній рівень: замовлення в e-commerce з умовною логікою
// orders.controller.ts
@Controller('orders')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class OrdersController {
@Get()
@Roles(Role.User, Role.Admin)
findAll(@Req() req) {
// адміни бачать всі замовлення; користувачі - тільки свої
return req.user.roles.includes(Role.Admin)
? this.ordersService.findAll()
: this.ordersService.findByUser(req.user.id);
}
@Patch(':id/refund')
@Roles(Role.Admin) // тільки адміни можуть робити повернення
refund(@Param('id') id: string) {
return this.ordersService.refund(id);
// Admin → { status: 'refunded' }
// Moderator → 403 Forbidden
}
}JWT-стратегія прикріплює user.roles з payload токена. Guard перевіряє ролі до виклику обробника. Розгалуження всередині обробника на основі ролей - нормальний підхід, не антипатерн.
Просунутий рівень: ієрархія ролей
// Карта ієрархії (в пам'яті, завантажується один раз)
const roleHierarchy: Record<string, string[]> = {
superadmin: ['admin', 'moderator', 'user'],
admin: ['moderator', 'user'],
moderator: ['user'],
};
@Injectable()
export class HierarchyRolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), context.getClass(),
]);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.some((r) => this.hasRole(user.roles, r));
}
private hasRole(userRoles: string[], required: string): boolean {
if (userRoles.includes(required)) return true;
return userRoles.some((r) => roleHierarchy[r]?.includes(required));
}
}
// superadmin автоматично проходить @Roles(Role.Admin)
@Get('reports')
@Roles(Role.Admin)
getReports() { ... }
// user.roles = ['superadmin'] → true
// user.roles = ['moderator'] → falseБез перевірки ієрархії доведеться писати @Roles(Role.Admin, Role.SuperAdmin) скрізь. З нею - вказуєте мінімальну необхідну роль, решту вирішує успадкування.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.