Опирается на правила: AUTH-7, AUTH-8, AUTH-9 из Auth Patterns Rules → раздел 3. RBAC: маппинг ролей.

Важно знать

  • Роли в JWTrealm_access.roles (Keycloak) или scope (стандартный OAuth2); маппинг в JwtStrategy.validate.
  • Principal.roles — типизированный массив, нет spring-образного префикса ROLE_; RolesGuard сравнивает строки напрямую.
  • Каталог ролей UCP: customer, seller, admin, system. Любая новая роль — пересмотр Bounded Context.
  • @Roles(...) обязателен на каждом endpoint. Endpoint без @Roles и без явного @Public() — критическое нарушение.
  • Глобальные guard'ы регистрируются через APP_GUARD: сначала JwtAuthGuard (401), затем RolesGuard (403). Порядок важен.
  • RBAC отвечает endpoint-level: «может ли роль customer вообще обратиться к этому endpoint». Вопрос «этот ли его order» — это ABAC.
  • @Public() — единственный способ открыть endpoint; отсутствие @Roles без @Public() не делает его публичным.

RBAC (Role-Based Access Control) — первый слой авторизации после JWT-валидации. В NestJS он реализуется через RolesGuard и декоратор @Roles: Guard читает из рефлектора список требуемых ролей, сравнивает с principal.roles и бросает ForbiddenException если не совпадает. UCP задаёт минимальный каталог ролей — избыток ролей означает либо плохую модель, либо ABAC, спрятанный за маской роли.

Маппинг ролей: JwtStrategy.validate

AUTH-7: роли из claim маппятся в Principal.roles в единственном месте — JwtStrategy.validate.

// adapters/in/http/security/jwt.strategy.ts
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) };
  }
}

Для Keycloak JWT выходит:

{
  "sub": "customer-42",
  "realm_access": {
    "roles": ["customer"]
  }
}

principal.roles = ['customer']. Никакого префикса ROLE_ — NestJS не требует его в отличие от Spring Security.

RolesGuard и @Roles

AUTH-9: декларативная проверка роли на каждом endpoint.

// adapters/in/http/security/roles.guard.ts
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;
  }
}

@Public() — отдельный декоратор-маркер, чтобы JwtAuthGuard пропускал endpoint без токена:

// adapters/in/http/security/public.decorator.ts
export const IS_PUBLIC = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC, true);

Регистрация глобальных guard'ов в AppModule (AUTH-2):

@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },   // AUTH-1/AUTH-6: 401
    { provide: APP_GUARD, useClass: RolesGuard },      // AUTH-9: 403
  ],
})
export class AppModule {}

JwtAuthGuard регистрируется первым — аутентификация до авторизации. RolesGuard получает уже готовый principal из request.user.

Каталог ролей UCP

AUTH-8: четыре разрешённые роли.

РольКтоЧто делает
customerКонечный пользовательСоздаёт и читает свои заказы, оплачивает
sellerПродавецУправляет своими товарами Product, видит заказы своих позиций
adminВнутренний операторПолный доступ, каждое действие — в audit log
systemСервис-к-сервисуCross-service вызовы через Client Credentials или mTLS

Любая новая роль — повод пересмотреть Bounded Context. Если появляется customer-premium, partner-seller, junior-admin — обычно это не роль, а атрибут на существующей роли. Проверяется в Handler по атрибуту, не через RBAC. Это уже ABAC.

Типичные ловушки:

  • «У VIP-customers другие лимиты» — атрибут customer.tier, проверяется в CreateOrderHandler.
  • «B2B-клиенты работают с оптовыми Product» — отдельный Bounded Context, не новая роль.
  • «Менеджер видит Customer без права удаления» — feature/permission-система, не роль.

@Roles на каждом endpoint

AUTH-9: применение в контроллере.

// adapters/in/http/order.controller.ts
@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));
  }

  @Post(':id/cancel')
  @Roles(['customer', 'admin'])
  cancel(
    @Param('id') id: string,
    @CurrentUser() principal: Principal,
  ): Promise<OrderResponse> {
    return this.dispatcher.dispatch(new CancelOrderCommand(id, principal));
  }
}

@Controller('admin/orders')
export class AdminOrderController {
  constructor(private readonly dispatcher: UseCaseDispatcher) {}

  @Post(':id/refund')
  @Roles(['admin'])
  refund(
    @Param('id') id: string,
    @CurrentUser() principal: Principal,
  ): Promise<OrderResponse> {
    return this.dispatcher.dispatch(new RefundOrderCommand(id, principal));
  }
}

@CurrentUser() — кастомный ParamDecorator, извлекающий request.user:

export const CurrentUser = createParamDecorator(
  (_: unknown, ctx: ExecutionContext): Principal =>
    ctx.switchToHttp().getRequest<{ user: Principal }>().user,
);

Endpoint без @Roles и без @Public()RolesGuard бросает ForbiddenException на каждый запрос, даже аутентифицированный. Это защита от «забыл аннотацию».

RBAC ≠ ABAC

RBAC отвечает endpoint-level: «может ли роль customer вызвать POST /orders/:id/cancel».

RBAC не отвечает: «может ли customer-42 отменить order-99». Это ABAC — нужно загрузить Order, проверить order.customerId === principal.sub. Разделение выглядит так:

// adapters/in/http/order.controller.ts
@Post(':id/cancel')
@Roles(['customer', 'admin'])              // RBAC: endpoint-level
cancel(@Param('id') id: string, @CurrentUser() principal: Principal) {
  return this.dispatcher.dispatch(new CancelOrderCommand(id, principal));
}

// core/order/handlers/cancel-order.handler.ts
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);  // ABAC: resource-level (AUTH-10)
  }
  order.cancel();
  await this.orders.save(order);
}

Два слоя — без одного из них дыра. @Roles снаружи не заменяет проверку в Handler.

Что запрещено

АнтипаттернПравилоЧто взамен
Endpoint без @Roles и без @Public()AUTH-9каждый endpoint имеет @Roles(...) или @Public()
Самодельный парсинг ролей внутри Handler вместо JwtStrategy.validateAUTH-7один маппинг в validate, Principal.roles распространяется дальше
10+ ролей в каталогеAUTH-8customer/seller/admin/system + ABAC по атрибутам
Новая роль для feature (customer-premium)AUTH-8атрибут на агрегате Customer, проверка в Handler
Строки ролей россыпью по контроллерамAUTH-9константы export const ROLES = { CUSTOMER: 'customer', ... }
@Roles(['admin']) на уровне класса без проверки методовAUTH-9@Roles на каждом методе, не на классе
RBAC на resource-level в GuardAUTH-3RBAC endpoint + ABAC в Handler/AccessPolicy
Guard без регистрации через APP_GUARDAUTH-2глобальная регистрация, порядок JwtAuthGuard → RolesGuard

Куда дальше

  • Auth → раздел 3. RBAC — нормативные формулировки.
  • JWT-валидация — конфиг JwtStrategy с jwks-rsa.
  • ABAC: владение ресурсом — следующий слой после RBAC.
  • Где какая проверка — Gateway vs BFF vs Domain Service.
  • Аудит admin-команд — роль admin всегда + audit log.
  • Service-to-service — Client Credentials и роль system.
  • Хранение токенов на клиенте — HttpOnly cookie, RT rotation.
  • PII и секреты — что не должно попадать в логи и события.
  • Идемпотентность — money-команды и Idempotency-Key.