Опирается на правила: 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.subAccessPolicy
Composite: ownership + проверка состояния агрегатаHandler-check
Read-endpoint, данные не модифицируютсяAccessPolicy
Write-endpoint, нужна загрузка FOR UPDATEHandler-check
Multi-aggregate: admin или seller связанного ProductHandler-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-resourceAUTH-10оба слоя обязательны
ABAC-проверка инлайн в контроллереAUTH-11AccessPolicy или Handler
Дубликат ABAC в AccessPolicy + Handler одновременноAUTH-11один из двух способов
Admin без записи в audit log при overrideAUTH-12auditLog.record(...) обязательно
ABAC проверяет только ownership и не обходит для adminAUTH-12isAdmin OR ownership
ForbiddenException из NestJS вместо доменного ForbiddenErrorAUTH-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.