Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як реалізувати контроль доступу на основі ролей (RBAC) у NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**RBAC у NestJS** - декоратор `@Roles()` позначає потрібні ролі на маршруті, а `RolesGuard` зчитує їх через `Reflector` і блокує запити, що не відповідають умовам. ```typescript @Controller('admin') @UseGuards(AuthGuard('jwt'), RolesGuard) export class AdminController { @Get('dashboard') @Roles(Role.Admin) getDashboard() { return { message: 'Admin dashboard' }; } } // Admin → 200 OK | Звичайний user → 403 Forbidden ``` **Головне:** завжди ставте `AuthGuard('jwt')` перед `RolesGuard`. JWT guard заповнює `request.user`; без нього Guard не має що перевіряти.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 | | RBAC | ABAC | |---|---|---| | Базується на | Статичних ролях | Атрибутах (роль, час, 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)` скрізь. З нею - вказуєте мінімальну необхідну роль, решту вирішує успадкування.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.