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

Когда роли не достаточно — нужно проверить, чей именно ресурс. Разберём, как устроен этот второй слой авторизации и почему без него любой авторизованный пользователь может читать чужие данные.

Почему 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.subAccessPolicy
Нужна загрузка с блокировкой (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.