RBAC: роли, guards и Reflector
RBAC живёт в guard: декоратор @Roles() штампует метаданные, RolesGuard читает их через Reflector. Но role guard не обеспечит «редактируй только свой пост» — владение требует загруженной записи, поэтому оно принадлежит policy guard или сервису.
Прилетает тикет: пользователь удалил черновик чужого поста. Смотришь код — он выглядит непробиваемо: каждый write-маршрут закрыт @Roles('editor') и RolesGuard, а провинившийся пользователь действительно editor. Значит, guard сделал свою работу безупречно: он подтвердил, что вызывающий — какой-то editor. Чего он никогда не спрашивал — чего он структурно не может спросить — это владеет ли этот editor именно этим черновиком. Role guard выполняется до хендлера, без загруженной записи; он знает роли пользователя и ничего не знает про строку 4192. Фикс — не более крупный guard. Фикс — понять, на какие вопросы авторизации guard способен ответить, а на какие может ответить только слой сервиса.
RBAC: декоратор @Roles() плюс guard, который его читает
Если ты хоть раз видел тикет «пользователь удалил чужой контент», и оказалось, что на маршруте просто забыли guard, — ты понимаешь, почему авторизацию стоит выстраивать системно, а не латать после инцидента. Role-based access control (управление доступом на основе ролей) сопоставляет каждому пользователю набор ролей, а каждой роли — набор прав. В Nest идиоматичная кодировка — это метаданные: декоратор @Roles() штампует требуемые роли на хендлер или контроллер, а RolesGuard читает их назад через Reflector. Декоратор — меньшая половина: SetMetadata (или Reflector.createDecorator для типизированного ключа) — это всё, что нужно:
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum'; // enum Role { Admin = 'admin', Editor = 'editor' }
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);Guard — это место, где живёт решение. Он читает метаданные через getAllAndOverride, сравнивая сначала метаданные хендлера и откатываясь на контроллер, а затем проверяет роли аутентифицированного пользователя:
// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
import { Role } from './role.enum';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), // @Roles() на маршруте побеждает...
context.getClass(), // ...дефолт контроллера
]);
if (!required) return true; // нет @Roles() на маршруте -> открыт
const { user } = context.switchToHttp().getRequest();
const ok = required.some((role) => user?.roles?.includes(role));
if (!ok) throw new ForbiddenException(); // чистый 403 вместо голого false
return ok;
}
}Две сеньорские детали. Первая: getAllAndOverride(key, [getHandler(), getClass()]) возвращает первое определённое значение в этом массиве — так что @Roles('admin') на уровне хендлера перекрывает дефолт уровня контроллера, и это ровно то старшинство, которое тебе нужно. (Его собрат getAllAndMerge вместо этого объединил бы их.) Вторая: возврат false даёт generic 403 без тела; выброс ForbiddenException позволяет отдать чистый 403 с сообщением — и твой exception filter отрисует его единообразно.
Композиция guards: сначала аутентификация, потом авторизация
RolesGuard читает user.roles. Этого поля не существует, пока не отработала аутентификация и не прикрепила req.user. Поэтому порядок — не косметика, а ограничение корректности: authn-guard (например, JwtAuthGuard) обязан выполниться до authz-guard, иначе RolesGuard прочитает undefined.roles и либо упадёт, либо откроется (fail open).
Когда ты привязываешь оба глобально через токен-провайдер APP_GUARD, Nest выполняет их в порядке объявления, так что перечисляй аутентификацию первой:
// app.module.ts
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 1. устанавливает req.user
{ provide: APP_GUARD, useClass: RolesGuard }, // 2. читает user.roles
],
})
export class AppModule {}Этот порядок также кодирует fail-closed дефолт: аутентифицируй глобально, и маршрут, который забыл добавить @Roles(), всё равно аутентифицирован — он просто открыт любому залогиненному пользователю, а не публике. Опасный обратный вариант — привязывать authz только по маршрутам: забудь привязку на одном хендлере, и у этого маршрута вообще нет гейта. Пропущенная привязка guard — это тихая дыра; предпочитай глобальную authn плюс явную authz.
Сеньорский предел: роли vs владение vs policy
Вот категориальная ошибка, которую прячет хук. RolesGuard выполняется во входной фазе, до хендлера, без загруженного доменного объекта. Он может оценить любой факт, уже лежащий на req.user, — роли, tenant id, scopes. Он не может оценить факт, который зависит от конкретной записи, потому что эта запись ещё не загружена. «Этот пользователь admin?» отвечается из одного req.user. «Владеет ли пользователь постом 4192?» требует SELECT ... WHERE id = 4192 и сравнения post.ownerId с user.id — запроса, который guard не должен делать, а одни параметры маршрута на это не ответят.
Поэтому resource/ownership-авторизация принадлежит туда, где загружен subject: в метод сервиса, который уже достал запись, или в policy guard, который загружает её намеренно. Втискивать владение в role guard — это не проблема тюнинга, которую чинят добавлением @Roles(): это означает задавать не тому слою вопрос, под который у него нет данных.
// владение живёт в сервисе, где запись уже в руках
async update(postId: number, dto: UpdatePostDto, user: AuthUser) {
const post = await this.posts.findOneOrFail(postId);
if (post.ownerId !== user.id && !user.roles.includes(Role.Admin)) {
throw new ForbiddenException('not your post'); // владение, решённое ВМЕСТЕ с записью
}
return this.posts.save({ ...post, ...dto });
}Когда роли начинают разрастаться — editor, senior-editor, editor-but-only-drafts, editor-of-section-X — это сигнал перейти от RBAC к policy-based / ABAC доступу. Вместо строк ты оцениваешь ability против subject. С CASL AbilityFactory строит правила на пользователя (включая record-shaped условия вроде { authorId: user.id }), а PoliciesGuard + @CheckPolicies() оценивает policy хендлера против этого ability:
// casl-ability.factory.ts — правила могут быть record-shaped
can(Action.Update, Article, { authorId: user.id }); // только свои статьи
can(Action.Read, 'all');
// policies.guard.ts — оценить policy маршрута против ability
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(private reflector: Reflector, private abilityFactory: CaslAbilityFactory) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const handlers = this.reflector.get<PolicyHandler[]>(CHECK_POLICIES_KEY, ctx.getHandler()) ?? [];
const { user } = ctx.switchToHttp().getRequest();
const ability = this.abilityFactory.createForUser(user);
return handlers.every((h) => (typeof h === 'function' ? h(ability) : h.handle(ability)));
}
}
// controller — объявляем policy, а не сырую роль
@Patch(':id')
@CheckPolicies((a: AppAbility) => a.can(Action.Update, 'Article'))
update() {/* ... */}Заметь честную оговорку: правило can(Action.Update, Article, { authorId: user.id }) обеспечивает владение лишь когда CASL передают реальный экземпляр Article для проверки. Проверка can(Action.Update, 'Article') против голого типа subject всё ещё не видит запись 4192 — ты либо сначала загружаешь статью и проверяешь экземпляр, либо проверка владения всё равно приземляется в сервис. ABAC поднимает выразительность выше; он не отменяет правило, что для суждения о владении нужна запись.
Какой слой владеет каким вопросом?
| Вопрос | Нужна запись? | Верный слой | Механизм |
|---|---|---|---|
| Аутентифицирован ли вызывающий? | Нет | Authn-guard (выполняется первым) | JwtAuthGuard ставит req.user |
| Есть ли у вызывающего роль? | Нет | RolesGuard (после authn) | @Roles() + Reflector.getAllAndOverride |
| Можно ли этой роли это действие над этим типом? | Нет | Policy guard (ABAC) | PoliciesGuard + ability.can(action, Type) |
| Владеет ли вызывающий ЭТОЙ записью? | Да | Сервис или policy guard, который её грузит | загрузить запись, сравнить ownerId с user.id |
Правило: каждый вопрос авторизации, на который можно ответить из одного req.user и метаданных маршрута, принадлежит guard; каждый вопрос, который зависит от содержимого конкретной строки, обязан подождать, пока эта строка загружена, — а значит, слой сервиса или намеренно загружающий ресурс policy guard.
▸Почему это работает
Почему guard не может просто сам загрузить запись? Может — но тогда он перестаёт быть универсальным переиспользуемым ролевым гейтом и становится связан с одним repository, одной сущностью и одним соглашением об извлечении id (id в пути? в теле? вложен?). Ещё ты платишь за запрос дважды, если хендлер перезагружает ту же запись. Policy guard, который загружает ресурс, — легитимный паттерн, но большинство команд находят, что проверка чище в сервисе, где запись достаётся один раз и сравнение владения сидит рядом с мутацией, которую оно охраняет. Грубый ролевой гейт остаётся переиспользуемым guard; record-shaped проверка идёт туда, где запись уже живёт.
Правило: пользователь может редактировать только СВОИ посты (admin может любой). Где обеспечивается это ограничение?
В RolesGuard что возвращает reflector.getAllAndOverride(ROLES_KEY, [context.getHandler(), context.getClass()]) и почему такой порядок массива?
Ты привязываешь RolesGuard глобально через APP_GUARD, но он падает на чтении user.roles из undefined. В чём причина?
- 01Пройди по канонической схеме RBAC в Nest: декоратор @Roles(), RolesGuard, getAllAndOverride и порядок относительно аутентификации.
- 02Объясни сеньорский предел: почему role guard не может обеспечить «пользователь может редактировать только свой пост» и куда принадлежит эта проверка.
RBAC в Nest — это метаданные плюс guard: декоратор @Roles() (SetMetadata или Reflector.createDecorator) штампует требуемые роли на хендлер или контроллер, а RolesGuard читает их через reflector.getAllAndOverride(ROLES_KEY, [getHandler(), getClass()]) — возвращая первое определённое значение, так что @Roles() уровня хендлера перекрывает дефолт контроллера, затем сравнивает с req.user.roles и выбрасывает ForbiddenException ради чистого 403. Порядок — ограничение корректности, а не вопрос стиля: req.user прикрепляется только после аутентификации, поэтому JwtAuthGuard должен выполниться до RolesGuard; с провайдерами APP_GUARD Nest выполняет guards в порядке объявления, что также даёт fail-closed дефолт, где маршрут, забывший @Roles(), всё равно аутентифицирован, а не публичен. Сеньорский предел — сердце урока: guard выполняется до хендлера без загруженной записи, поэтому он отвечает на «этот пользователь editor?», но структурно не отвечает на «владеет ли этот editor ЭТИМ черновиком?» — этому вопросу нужна доставленная строка и сравнение post.ownerId с user.id, и поэтому ownership-авторизация живёт в сервисе (рядом с мутацией) или в намеренно загружающем ресурс policy guard, но никогда в role guard. Когда роли множатся в record-shaped варианты, переходи к policy-based/ABAC доступу с CASL — AbilityFactory, PoliciesGuard и @CheckPolicies, оценивающий can(action, subject), — помня, что даже ABAC надо передать реальный экземпляр записи, чтобы обеспечить владение. Теперь, когда ты видишь тикет «пользователь изменил чужие данные», первый вопрос звучит так: guard проверял роль или запись? Это разные вопросы — и только один из них guard может ответить.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.