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

AUTH · ABAC Фаза 16. Безопасность и авторизация

ABAC и владение ресурсом в NestJS

TypeScript · AccessPolicy · IDOR

Проверка конкретного пользователя и конкретного ресурса после RBAC.

1. RBAC оставляет IDOR

1 / 5

Alice имеет customer и вызывает cancel для заказа Bob. RolesGuard пропускает, потому что не знает владельца ресурса.

Alice · customerPOST /orders/bob/cancelRBAC passesIDOR risk

2. ABAC сравнивает атрибуты

2 / 5

actor.id, order.customerId, tenantId и status определяют доступ. Роль - только один из атрибутов.

Access decisionactor.id = aliceorder.customerId = bobtenant = same?deny

3. Handler получает доменный контекст

3 / 5

Заказ загружается из repository, затем сравнивается владелец. Guard без доменной модели не должен угадывать правила.

Load orderOwnership policycustomerId === actor.idAllow or 403

4. AccessPolicy делает правило явным

4 / 5

Если владение повторяется, его выносят в отдельную политику. Она тестируется на разных actor и resource без HTTP.

AccessPolicycanCancel(actor, order)pure domain checkUnit tests

5. Admin тоже получает аудит

5 / 5

Support override допустим только по явной политике и пишется в audit log. Роль admin не делает изменения невидимыми.

Admin overridePolicy allowssame transactionAudit record

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

Почему 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

Выбирают один из способов — не оба одновременно. Дублирование проверок расходится при изменении модели.

Коллекции: фильтр вместо проверки

Для списка проверка «свой или чужой» на каждом элементе уже поздно: чужие строки прочитаны из базы, и остаётся только надеяться, что фильтр ничего не пропустит. Владелец должен быть условием запроса:

// плохо: прочитали всё, отфильтровали в памяти
const all = await this.orders.findAll();
return all.filter((o) => o.customerId === principal.sub);
// хорошо: чужие строки не читаются вовсе
return this.orders.findByCustomer(principal.sub, page);

Разница не только в объёме данных: во втором варианте ошибка в фильтре невозможна, потому что фильтра нет, а есть предикат запроса, который покрыт тестом репозитория.

Состояние объекта — тоже атрибут

Владение это не единственный атрибут, по которому принимают решение. Отменить можно только свой заказ и только пока он не отгружен, и второе условие такой же атрибут объекта, как первое:

async canCancel(orderId: string, principal: Principal): Promise<Order> {
  const order = await this.orders.findByIdAndCustomer(orderId, principal.sub);
  if (!order) throw new NotFoundException();                       // чужой и несуществующий неотличимы
  if (!['NEW', 'PAID'].includes(order.status)) {
    throw new OrderNotCancellableError(orderId, order.status);     // состояние проверено на тех же данных
  }
  return order;
}

Обе проверки сделаны на одной загруженной строке, и между ними нет окна: загрузка с условием владельца и проверка статуса идут в одной транзакции, а при SELECT FOR UPDATE ещё и под блокировкой.

Почему проверку нельзя класть в контроллер

Частый соблазн — написать проверку прямо в контроллере, пока 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-команд.

Тест, который ловит IDOR

Самый дешёвый тест безопасности в проекте: два покупателя, заказ одного, запрос от имени другого, ожидание 404 без единого байта чужих данных:

it('чужой заказ не найден', async () => {
  const foreign = await createOrder({ customerId: 'customer-2' });

  const response = await request(app.getHttpServer())
    .get(`/orders/${foreign.id}`)
    .set('Authorization', `Bearer ${tokenFor('customer-1')}`);

  expect(response.status).toBe(404);
  expect(response.text).not.toContain('customer-2');
});

Такой тест пишут на каждый эндпоинт с идентификатором в пути и держат в одном файле: когда появляется новый ресурс, в файл добавляют один it, а не вспоминают о проверке на инциденте.

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. Это разные ситуации.

Что пощупать

Владение ресурсом во взрослом каталоге практикума remodov/marketplace-system-nodejs: роль из токена отвечает, что продавцу можно вообще, а requireOwnership в сценарии - можно ли ему вот этот товар; чужой товар выглядит несуществующим, 404 OWN_PRODUCT_REQUIRED, а не 403, чтобы перебором нельзя было узнать чужие идентификаторы. Та же проверка стоит перед выдачей подписанной ссылки на загрузку фото.

Код: ownership.ts, request-image-upload.ts, image-upload.spec.ts.

Сделаем сами

Ветки step-07-adult-catalog и step-12-tokens-and-files - в обеих чужой товар должен отдавать 404.

Что почитать дальше