Когда роли не достаточно — нужно проверить, чей именно ресурс. Разберём, как устроен этот второй слой авторизации и почему без него любой авторизованный пользователь может читать чужие данные.
Почему 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 |
Выбирают один из способов — не оба одновременно. Дублирование проверок расходится при изменении модели.
Коллекции: фильтр вместо проверки
Для списка проверка «свой или чужой» на каждом элементе уже поздно: чужие строки прочитаны из базы, и остаётся только надеяться, что фильтр ничего не пропустит. Владелец должен быть условием запроса:
// плохо: прочитали всё, отфильтровали в памяти
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.
Что почитать дальше
- RBAC: маппинг ролей — слой до ABAC:
RolesGuardи декоратор@Roles. - Где какая проверка — разбор: Guard, AccessPolicy, Handler, Domain Service.
- Audit admin-команд — структура журнала аудита для admin-действий.
- JWT-валидация —
JwtStrategy,jwks-rsa,UnauthorizedException→ 401.