Когда роли не достаточно — нужно проверить, чей именно ресурс. Разберём, как устроен этот второй слой авторизации и почему без него любой авторизованный пользователь может читать чужие данные.
Почему RBAC без ABAC — дыра в безопасности
Допустим, у вас есть эндпоинт POST /orders/:id/cancel. Вы поставили RolesGuard — доступ только для роли customer. Звучит безопасно.
Но представьте: у клиента alice есть JWT с ролью customer. Она знает id заказа пользователя bob и посылает запрос на его отмену. RolesGuard видит роль customer — всё в порядке, пропускает. Заказ bob отменён alice.
Это называется IDOR (Insecure Direct Object Reference) — когда по id объекта можно добраться до чужих данных, зная только идентификатор.
RBAC (Role-Based Access Control) отвечает на вопрос «может ли эта роль вызывать этот эндпоинт». ABAC (Attribute-Based Access Control) отвечает на вопрос «может ли этот конкретный пользователь работать с этим конкретным ресурсом». Два разных слоя, оба обязательны.
Как устроена проверка владения
Суть простая: загружаем ресурс из базы, смотрим на поле customerId (или ownerId, userId — зависит от модели) и сравниваем с principal.sub — идентификатором пользователя из токена.
const order = await this.orders.byId(orderId);
if (order.customerId !== principal.sub) {
throw new ForbiddenError(orderId);
}
Важно: проверяем после загрузки агрегата, не до. По одному id без чтения из базы нельзя знать, кому принадлежит ресурс.
Два способа реализовать ABAC
Способ 1: отдельный AccessPolicy
Удобен для простых случаев — когда нужно только сравнить владельца. Логику выносим в отдельный инжектируемый класс, Handler её использует.
// core/order/access/order-access.policy.ts
@Injectable()
export class OrderAccessPolicy {
constructor(private readonly orders: OrderRepository) {}
async canEdit(orderId: string, principal: Principal): Promise<boolean> {
if (principal.roles.includes('admin')) return true;
const order = await this.orders.byId(orderId);
return order?.customerId === principal.sub;
}
async canView(orderId: string, principal: Principal): Promise<boolean> {
return this.canEdit(orderId, principal);
}
}
// core/order/handlers/cancel-order.handler.ts
@Injectable()
export class CancelOrderHandler {
constructor(
private readonly orders: OrderRepository,
private readonly policy: OrderAccessPolicy,
) {}
async execute(cmd: CancelOrder, principal: Principal): Promise<void> {
const allowed = await this.policy.canEdit(cmd.orderId, principal);
if (!allowed) throw new ForbiddenError(cmd.orderId);
const order = await this.orders.byId(cmd.orderId);
order.cancel();
await this.orders.save(order);
}
}
Все проверки для Order собраны в OrderAccessPolicy. Когда модель изменится — меняем в одном месте.
Способ 2: проверка внутри Handler
Подходит, когда нужно объединить проверку владения с бизнес-правилом на одном объекте. Например: загружаем заказ с блокировкой для записи (FOR UPDATE), тут же проверяем владельца и статус.
@Injectable()
export class CancelOrderHandler {
constructor(private readonly orders: OrderRepository) {}
async execute(cmd: CancelOrder, principal: Principal): Promise<void> {
const order = await this.orders.byIdForUpdate(cmd.orderId);
if (
!principal.roles.includes('admin') &&
order.customerId !== principal.sub
) {
throw new ForbiddenError(cmd.orderId);
}
if (!order.canCancel()) {
throw new OrderCannotBeCancelledError(order.id, order.status);
}
order.cancel();
await this.orders.save(order);
}
}
Агрегат загружается один раз — и проверка доступа, и бизнес-правило работают на одном объекте без повторных запросов к базе.
Когда какой способ выбрать
| Ситуация | Способ |
|---|---|
Простое сравнение ownerId === principal.sub | AccessPolicy |
Нужна загрузка с блокировкой (FOR UPDATE) | Handler-check |
| Проверка владения + проверка состояния агрегата | Handler-check |
| Read-endpoint, данные не меняются | AccessPolicy |
| Несколько агрегатов (например, admin или продавец связанного товара) | Handler-check |
Выбирают один из способов — не оба одновременно. Дублирование проверок расходится при изменении модели.
Почему проверку нельзя класть в контроллер
Частый соблазн — написать проверку прямо в контроллере, пока Handler ещё не написан:
// Плохо: проверка в контроллере
@Post(':id/cancel')
async cancel(@Param('id') id: string, @CurrentUser() principal: Principal) {
const order = await this.orders.byId(id);
if (order.customerId !== principal.sub) {
throw new ForbiddenException();
}
return this.cancelOrder.execute({ orderId: id }, principal);
}
Проблема: когда появится второй эндпоинт для той же операции, или модель изменится (например, добавится совместное владение), придётся обновлять каждый контроллер отдельно. Бизнес-логика утекает из слоя домена в HTTP-слой. Контроллер должен быть тонким: принять запрос, передать в Handler, вернуть ответ.
Admin обходит проверку, но оставляет след
Администратор поддержки может отменить заказ любого пользователя — это нужно для работы. Но каждое такое действие должно быть зафиксировано в журнале аудита. Без этого невозможно расследовать инциденты и выполнять требования по безопасности.
@Injectable()
export class CancelOrderHandler {
constructor(
private readonly orders: OrderRepository,
private readonly auditLog: AuditLogService,
private readonly clock: Clock,
) {}
async execute(cmd: CancelOrder, principal: Principal): Promise<void> {
const order = await this.orders.byIdForUpdate(cmd.orderId);
const isAdmin = principal.roles.includes('admin');
if (!isAdmin && order.customerId !== principal.sub) {
throw new ForbiddenError(cmd.orderId);
}
const previousStatus = order.status;
order.cancel();
await this.orders.save(order);
if (isAdmin) {
await this.auditLog.record({
actorId: principal.sub,
action: 'cancel-order',
aggregateType: 'Order',
aggregateId: order.id,
occurredAt: this.clock.now(),
metadata: { previousStatus },
});
}
}
}
Admin поддержки может отменить заказ customer-99, но в orders_audit_log остаётся строка: кто, что, когда и какой был статус до изменения. Это закрывает вопросы при разборе инцидентов.
Подробнее о структуре журнала аудита — Audit admin-команд.
ForbiddenError и UnauthorizedException — разные вещи
ForbiddenError (403) — пользователь аутентифицирован, но не имеет доступа к этому ресурсу. UnauthorizedException (401) — пользователь вообще не аутентифицирован (нет токена или токен недействителен). Путать их нельзя: при 401 клиент должен получить новый токен, при 403 — это не поможет.
Используйте типизированный доменный ForbiddenError, а не ForbiddenException из NestJS напрямую. Доменная ошибка обрабатывается в Exception Filter и превращается в HTTP-ответ — это единственное место, где домен знает о HTTP-кодах.
Коротко
- RBAC проверяет роль, ABAC проверяет конкретный ресурс. Оба слоя обязательны — RBAC без ABAC оставляет IDOR-уязвимость.
- Проверку владения делают после загрузки агрегата из базы, сравнивая поле владельца с
principal.sub. - Два способа:
AccessPolicyдля простого сравнения, Handler-check когда нужна загрузка с блокировкой или составная логика. Используют один из двух. - Вся ABAC-логика для одного агрегата — в одном месте (
AccessPolicyили Handler). Не в контроллере. - Admin обходит ABAC, но каждое такое действие фиксируется в журнале аудита.
ForbiddenError→ 403,UnauthorizedException→ 401. Это разные ситуации.
Что почитать дальше
- RBAC: маппинг ролей — слой до ABAC:
RolesGuardи декоратор@Roles. - Где какая проверка — разбор: Guard, AccessPolicy, Handler, Domain Service.
- Audit admin-команд — структура журнала аудита для admin-действий.
- JWT-валидация —
JwtStrategy,jwks-rsa,UnauthorizedException→ 401.