Опирается на правила:
AUTH-7,AUTH-8,AUTH-9из Auth Patterns Rules → раздел 3. RBAC: маппинг ролей.
Важно знать
- Роли в JWT —
realm_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.validate | AUTH-7 | один маппинг в validate, Principal.roles распространяется дальше |
| 10+ ролей в каталоге | AUTH-8 | customer/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 в Guard | AUTH-3 | RBAC endpoint + ABAC в Handler/AccessPolicy |
Guard без регистрации через APP_GUARD | AUTH-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.