После того как JWT-токен проверен и подлинность пользователя установлена, приходит следующий вопрос: а что этому пользователю разрешено делать? Именно здесь начинается авторизация. Самый распространённый подход — RBAC.
Что такое RBAC и в чём проблема без него
RBAC расшифровывается как Role-Based Access Control — управление доступом на основе ролей. Идея простая: каждому пользователю присваивается роль (customer, admin, seller), и доступ к конкретным действиям зависит от роли, а не от конкретного человека.
Без RBAC разработчики часто пишут проверки прямо внутри обработчиков:
async createOrder(userId: string) {
const user = await this.users.findById(userId);
if (user.type !== 'customer') throw new Error('Forbidden');
// ...
}
Таких проверок становится много, они разбросаны по коду, и легко что-то пропустить. RBAC выносит эту логику на один уровень — в Guard, который срабатывает до любого обработчика.
Откуда берутся роли: JwtStrategy.validate
Роли пользователя приходят в JWT-токене. У Keycloak они лежат в поле realm_access.roles:
{
"sub": "user-42",
"realm_access": {
"roles": ["customer"]
}
}
У стандартного OAuth2-сервера роли могут быть в поле scope в виде строки через пробел.
Единственное место, где мы разбираем токен и извлекаем роли — метод validate в JwtStrategy. Он возвращает объект Principal, который NestJS кладёт в request.user и делает доступным во всех guards и контроллерах:
export interface JwtClaims {
sub: string;
realm_access?: { roles: string[] };
scope?: string;
}
export interface Principal {
sub: string;
roles: string[];
}
function extractRoles(claims: JwtClaims): string[] {
if (claims.realm_access?.roles) {
return claims.realm_access.roles;
}
return claims.scope ? claims.scope.split(' ') : [];
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: AppConfig) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
algorithms: ['RS256'],
audience: config.auth.audience,
issuer: config.auth.issuer,
secretOrKeyProvider: passportJwtSecret({
jwksUri: config.auth.jwksUri,
cache: true,
cacheMaxAge: 300_000,
}),
});
}
validate(claims: JwtClaims): Principal {
return { sub: claims.sub, roles: extractRoles(claims) };
}
}
Важная деталь: в отличие от Spring Security, здесь нет префикса ROLE_. Роли сравниваются как обычные строки — 'customer', не 'ROLE_CUSTOMER'.
RolesGuard и декоратор @Roles
Guard в NestJS — это класс, который решает: пропустить запрос дальше или вернуть ошибку. RolesGuard читает, какие роли требует endpoint (через метаданные декоратора), и сравнивает с тем, что есть у пользователя:
export const Roles = Reflector.createDecorator<string[]>();
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.get(Roles, ctx.getHandler());
if (!required) {
throw new ForbiddenException();
}
const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
if (!required.some((r) => user.roles.includes(r))) {
throw new ForbiddenException();
}
return true;
}
}
Обратите внимание: если у метода нет @Roles(...) и нет маркера @Public(), guard выбрасывает ForbiddenException. Это защита от случайно незащищённого endpoint — молчать и пропускать всех было бы опаснее.
Для публичных endpoints (например, GET /products) нужен отдельный маркер @Public():
export const IS_PUBLIC = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC, true);
Порядок регистрации guards важен
Guards регистрируются глобально через APP_GUARD. Порядок обязателен: сначала JwtAuthGuard проверяет токен и возвращает 401 если его нет, и только потом RolesGuard проверяет роли и возвращает 403:
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 401: нет токена
{ provide: APP_GUARD, useClass: RolesGuard }, // 403: нет нужной роли
],
})
export class AppModule {}
Если поменять их местами, RolesGuard получит request.user = undefined и сломается.
Как применять @Roles в контроллере
Декоратор ставится на каждый метод. Можно указать несколько ролей — guard пропустит, если у пользователя есть хотя бы одна из них:
@Controller('orders')
export class OrderController {
constructor(private readonly dispatcher: UseCaseDispatcher) {}
@Post()
@Roles(['customer'])
create(
@Body() body: CreateOrderRequest,
@CurrentUser() principal: Principal,
): Promise<OrderResponse> {
return this.dispatcher.dispatch(new CreateOrderCommand(body, principal));
}
@Get(':id')
@Roles(['customer', 'admin'])
findById(
@Param('id') id: string,
@CurrentUser() principal: Principal,
): Promise<OrderResponse> {
return this.dispatcher.dispatch(new GetOrderByIdQuery(id, principal));
}
}
@CurrentUser() — вспомогательный декоратор, который достаёт request.user:
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext): Principal =>
ctx.switchToHttp().getRequest<{ user: Principal }>().user,
);
Чтобы строки ролей не рассыпались по всему коду, удобно вынести их в константы:
export const ROLES = {
CUSTOMER: 'customer',
SELLER: 'seller',
ADMIN: 'admin',
SYSTEM: 'system',
} as const;
Сколько ролей нужно и когда остановиться
Типичный минимальный каталог ролей для продуктового приложения:
| Роль | Кто | Что делает |
|---|---|---|
customer | Конечный пользователь | Создаёт и читает свои заказы |
seller | Продавец | Управляет своими товарами, видит заказы своих позиций |
admin | Внутренний оператор | Полный доступ, каждое действие — в журнале |
system | Другой сервис | Межсервисные вызовы через Client Credentials |
Когда команда начинает добавлять customer-premium, partner-seller, junior-admin — это обычно признак, что RBAC пытается решить задачу, которая ему не по силам. Разберём частые случаи:
- «VIP-клиентам другие лимиты» — это не новая роль, а атрибут
customer.tier. Проверяется в обработчике команды по значению поля. - «B2B-клиенты работают с оптовыми товарами» — скорее всего, это отдельный контекст со своей моделью, а не новая роль.
- «Менеджер видит данные клиента, но не может удалять» — это система разрешений (permissions), не RBAC.
Чем меньше ролей, тем проще их поддерживать. Если ролей становится много, они начинают пересекаться и противоречить друг другу.
RBAC отвечает не на все вопросы — и это нормально
RBAC хорошо решает один конкретный вопрос: «может ли роль customer вообще вызвать этот endpoint?»
Но он не решает вопрос: «может ли этот конкретный customer отменить именно этот заказ?» Это уже ABAC (Attribute-Based Access Control) — проверка на уровне конкретного ресурса.
// Контроллер: RBAC — endpoint-level
@Post(':id/cancel')
@Roles(['customer', 'admin'])
cancel(@Param('id') id: string, @CurrentUser() principal: Principal) {
return this.dispatcher.dispatch(new CancelOrderCommand(id, principal));
}
// Обработчик: ABAC — resource-level
async execute(cmd: CancelOrderCommand): Promise<void> {
const order = await this.orders.byId(cmd.orderId);
if (!cmd.principal.roles.includes('admin') && order.customerId !== cmd.principal.sub) {
throw new ForbiddenError(cmd.orderId);
}
order.cancel();
await this.orders.save(order);
}
Оба слоя нужны: @Roles снаружи не заменяет проверку владельца внутри обработчика.
Коротко
- RBAC — управление доступом по роли, реализуется через
RolesGuardи@Rolesв NestJS. - Роли извлекаются из JWT один раз в
JwtStrategy.validateи попадают вPrincipal.roles. - Keycloak кладёт роли в
realm_access.roles, стандартный OAuth2 — вscope. - Guard-ы регистрируются через
APP_GUARD: сначала аутентификация (401), потом авторизация (403). - Endpoint без
@Rolesи без@Public()— guard блокирует каждый запрос, даже аутентифицированный. - Минимальный каталог ролей:
customer,seller,admin,system. Больше ролей — признак проблемы в дизайне. - RBAC отвечает на вопрос «может ли роль вызвать endpoint», ABAC — «может ли этот пользователь работать с этим ресурсом».
Что почитать дальше
- JWT-валидация в NestJS — как настроить
JwtStrategyс проверкой ключей. - ABAC: владение ресурсом — следующий слой после RBAC.
- Аудит admin-команд — почему роль
adminвсегда требует журнала действий. - Service-to-service — Client Credentials и роль
system.