← назад к разделу

После того как 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.