Опирается на правила:
AUTH-10,AUTH-11,AUTH-12из Auth Patterns → раздел 4. ABAC: владение ресурсом.
Важно знать
- ABAC обязателен для каждого Handler/UseCase, работающего с агрегатом по id.
- Два способа:
@Injectable() AccessPolicyдля простых ownership-проверок или проверка внутри Handler для composite-случаев.- ABAC-логика — в
AccessPolicyили Handler. Не размазывается по контроллерам и не дублируется в Guard.- Роль
adminобходит ABAC (полный доступ), но каждое действие пишется в audit log.- Без ABAC — RBAC-only endpoint становится IDOR-уязвимостью: любой holder JWT с правильной ролью читает чужие ресурсы.
ForbiddenError→ 403;UnauthorizedException→ 401. Путать запрещено (AUTH-6).- Ownership-check для write-endpoint делается после загрузки агрегата из репозитория, не по id напрямую.
ABAC (Attribute-Based Access Control) — второй слой авторизации. RBAC говорит «роль customer может вызывать POST /orders/:id/cancel», ABAC говорит «но этот customer может отменить только свой заказ». Без ABAC RBAC-only endpoint — это IDOR. JwtAuthGuard + RolesGuard закрывают вход; ABAC закрывает доступ к конкретному ресурсу.
Два способа
AUTH-10: AccessPolicy-компонент или handler-check.
Способ 1: AccessPolicy для простых случаев
// core/order/access/order-access.policy.ts
import { Injectable } from '@nestjs/common';
import { OrderRepository } from '../ports/order.repository';
import { Principal } from '../../auth/principal';
@Injectable()
export class OrderAccessPolicy {
constructor(private readonly orders: OrderRepository) {}
async canEdit(orderId: string, principal: Principal): Promise<boolean> {
if (principal.roles.includes('admin')) return true; // AUTH-12
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
import { Injectable } from '@nestjs/common';
import { ForbiddenError } from '../../shared/errors';
import { OrderAccessPolicy } from '../access/order-access.policy';
@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); // AUTH-10
const order = await this.orders.byId(cmd.orderId);
order.cancel();
await this.orders.save(order);
}
}
Подходит для простой ownership: один-к-одному, без бизнес-условий кроме сравнения.
Способ 2: проверка внутри Handler
Когда нужно: загрузка агрегата для write (FOR UPDATE семантика), composite-условие, проверка статуса.
// core/order/handlers/cancel-order.handler.ts
@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); // AUTH-10
}
if (!order.canCancel()) {
throw new OrderCannotBeCancelledError(order.id, order.status);
}
order.cancel();
await this.orders.save(order);
}
}
Агрегат загружается один раз — ownership-check и бизнес-правило проверяются на одном объекте без повторного обращения к репозиторию.
Когда какой способ
| Случай | Способ |
|---|---|
Простая ownership order.customerId === principal.sub | AccessPolicy |
| Composite: ownership + проверка состояния агрегата | Handler-check |
| Read-endpoint, данные не модифицируются | AccessPolicy |
Write-endpoint, нужна загрузка FOR UPDATE | Handler-check |
Multi-aggregate: admin или seller связанного Product | Handler-check |
Правило одно: один из двух, никогда оба одновременно. Дублирование проверок расходится при изменении модели.
ABAC централизована, не размазана
AUTH-11: логика собрана в одном компоненте.
Корректно — вся ownership-логика Order в OrderAccessPolicy:
// ВЕРНО: централизованная политика, все методы Order в одном месте
@Injectable()
export class OrderAccessPolicy {
async canEdit(orderId: string, principal: Principal): Promise<boolean> { ... }
async canView(orderId: string, principal: Principal): Promise<boolean> { ... }
async canRefund(orderId: string, principal: Principal): Promise<boolean> { ... }
}
Неверно — проверка прямо в контроллере:
// НАРУШЕНИЕ AUTH-11 — ABAC в контроллере
@Post(':id/cancel')
@Roles('customer')
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);
}
Что не так: при изменении модели (co-ownership, организации) придётся обновлять N эндпоинтов вместо одного места. Контроллер становится тонким прокси-слоем с business-logic.
Admin обходит ABAC + audit
AUTH-12: admin может всё, но след остаётся.
// core/order/handlers/cancel-order.handler.ts
@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({ // AUTH-12 + AUTH-15
actorId: principal.sub,
action: 'cancel-order',
aggregateType: 'Order',
aggregateId: order.id,
occurredAt: this.clock.now(),
metadata: { previousStatus },
});
}
}
}
Admin поддержки может отменить заказ customer-99, но в orders_audit_log остаётся строка «admin-7 отменил order-12345 в момент T, статус был PENDING». Это закрывает compliance и расследование инцидентов.
Подробнее о структуре audit log — Audit admin-команд.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Endpoint с RBAC без ABAC для own-resource | AUTH-10 | оба слоя обязательны |
| ABAC-проверка инлайн в контроллере | AUTH-11 | AccessPolicy или Handler |
Дубликат ABAC в AccessPolicy + Handler одновременно | AUTH-11 | один из двух способов |
| Admin без записи в audit log при override | AUTH-12 | auditLog.record(...) обязательно |
| ABAC проверяет только ownership и не обходит для admin | AUTH-12 | isAdmin OR ownership |
ForbiddenException из NestJS вместо доменного ForbiddenError | AUTH-10 | типизированный доменный error → Exception Filter |
| Ownership-check до загрузки агрегата (по id без чтения из БД) | AUTH-10 | загрузить агрегат, сравнить поля |
AccessPolicy.canEdit без учёта deleted/inactive ресурса | AUTH-10 | проверять статус агрегата вместе с ownership |
Куда дальше
- RBAC: маппинг ролей — слой до ABAC:
RolesGuard+@Roles(...). - Где какая проверка — ABAC в Domain Service, не в Gateway.
- Audit admin-команд — обязательная пара к admin-override.
- JWT-валидация —
JwtStrategy+jwks-rsa,UnauthorizedException→ 401. - PII и секреты — что не попадает в логи и
problem.detail. - Идемпотентность —
Idempotency-Keyдля money-команд. - Хранение токенов на клиенте — HttpOnly cookie, не
localStorage. - Service-to-service — mTLS / Client Credentials Flow для outbound.