Skip to main content

Як реалізувати контроль доступу на основі ролей (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

Швидкий приклад

typescript
// 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 Forbidden

getAllAndOverride перевіряє спочатку обробник, потім клас. Тому @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 можна зареєструвати глобально, щоб він діяв скрізь без декорування окремих контролерів:

typescript
// 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):

typescript
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-дашбордах, де ролі перетинаються, але набори дозволів різні.

Перевірка права власності на ресурс

Іноді ролей недостатньо. Користувач повинен редагувати тільки свої пости, але адмін - будь-чиї. Для цього потрібна окрема перевірка власності:

typescript
@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

RBACABAC
Базується наСтатичних роляхАтрибутах (роль, час, IP, стан ресурсу)
СкладністьНизькаВисока
ГнучкістьВистачає для більшості застосунківПідтримує динамічні правила
ШвидкістьЧитання метаданих (мікросекунди)Сканування правил (мілісекунди)
Коли використовуватиCRUD API, SaaS-дашбордиПідприємства, compliance, AWS IAM-подібне

Практичне правило, яке я застосовую на проектах: якщо всі правила доступу можна записати на дошці за п'ять хвилин, RBAC вистачить. Якщо починаєте малювати стрілки між атрибутами користувача і станом ресурсу, час дивитися на Casbin.

Типові помилки

1. reflector.get замість getAllAndOverride

typescript
// Неправильно - пропускає @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 перед ним

typescript
// Неправильно - request.user undefined, guard падає в runtime @UseGuards(RolesGuard) // Правильно @UseGuards(AuthGuard('jwt'), RolesGuard)

AuthGuard('jwt') запускає Passport-стратегію, яка перевіряє токен і встановлює request.user. Без нього user?.roles це undefined і Guard або падає, або мовчки пропускає всіх.

3. Ролі як рядок у JWT замість масиву

typescript
// Токен: { 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.

Приклади

Базовий: захист адміністративних маршрутів

typescript
// 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 з умовною логікою

typescript
// 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 перевіряє ролі до виклику обробника. Розгалуження всередині обробника на основі ролей - нормальний підхід, не антипатерн.

Просунутий рівень: ієрархія ролей

typescript
// Карта ієрархії (в пам'яті, завантажується один раз) 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) скрізь. З нею - вказуєте мінімальну необхідну роль, решту вирішує успадкування.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?