Опирается на правила: AUTH-10AUTH-12 из Auth Patterns Style Guide → раздел 4. ABAC: владение ресурсом.

Важно знать

  • ABAC обязателен для каждой команды/запроса, работающего с агрегатом по id.
  • Два способа: @PreAuthorize("@access.canEditOrder(#id, authentication.principal)") или проверка внутри Handler.
  • ABAC-логика — в @Component("access") бине или в Handler. Не размазывается по контроллерам.
  • Роль admin обходит ABAC (полный доступ), но каждое действие пишется в audit log.
  • Без ABAC — RBAC-only endpoint становится IDOR (Insecure Direct Object Reference) уязвимостью.
  • Любой GET /orders/{id} без ownership-check позволяет user-42 читать заказы user-99.

ABAC (Attribute-Based Access Control) — второй слой авторизации. RBAC говорит «роль customer может вызывать GET /orders/{id}», ABAC говорит «но этот customer может читать только свои orders». Без ABAC любой holder JWT с правильной ролью получает доступ ко всем ресурсам — классический IDOR.

Два способа

AUTH-10: @PreAuthorize-бин или handler-check.

Способ 1: @access бин для простых случаев

@Component("access")
@RequiredArgsConstructor
public class AccessChecker {

    private final OrderRepository orderRepository;

    public boolean canEditOrder(Long orderId, Object principal) {
        var userId = Long.valueOf(principal.toString());
        return orderRepository.findById(orderId)
            .map(order -> order.getCustomerId().equals(userId))
            .orElse(false);
    }

    public boolean canViewOrder(Long orderId, Object principal) {
        return canEditOrder(orderId, principal);
    }
}
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {

    @PostMapping("/{id}/cancel")
    @PreAuthorize("hasAnyRole('customer', 'admin')"
        + " and (hasRole('admin') or @access.canEditOrder(#id, authentication.name))")
    public OrderResponse cancel(@PathVariable Long id) {
        return dispatcher.dispatch(new CancelOrderCommand(id));
    }
}

hasRole('admin') — admin обходит ABAC.

Хороший подход для простых ownership checks (один-к-одному, никакой бизнес-логики кроме сравнения).

Способ 2: проверка внутри Handler

@UseCase
@RequiredArgsConstructor
public class CancelOrderHandler implements UseCaseHandler<CancelOrderCommand, Order> {

    private final OrderRepository orderRepository;
    private final AuthenticatedUserProvider userProvider;

    @Override
    @Transactional
    public Order handle(CancelOrderCommand command) {
        var order = orderRepository.findById(command.orderId(), SelectMode.FOR_UPDATE)
            .orElseThrow(() -> new OrderNotFoundException(command.orderId()));

        var user = userProvider.current();
        if (!user.isAdmin() && !order.getCustomerId().equals(user.id())) {
            throw new ForbiddenException("Order does not belong to current user");
        }

        if (!order.canCancel()) {
            throw new OrderCannotBeCancelledException(order.id(), order.status());
        }

        order.cancel();
        return orderRepository.save(order);
    }
}

Хороший подход для случаев со сложной логикой: проверка не только ownership, но и состояния, бизнес-правил.

Когда какой способ

СлучайСпособ
Простая ownership order.customerId == jwt.sub@access бин
Composite: ownership + ещё одно условие в stateHandler-check
Reading endpoints, где данные потом не модифицируются@access бин
Write endpoints, где надо FOR UPDATE load + checkHandler-check
Multi-aggregate проверка (admin or seller of related product)Handler-check

Главное — один из двух, никогда оба одновременно. Дублирование проверок ведёт к расхождению (изменили в одном, забыли в другом).

ABAC централизована, не размазана

AUTH-11: правила про размещение.

Корректно:

  • @access.canEditOrder — единый бин, единая логика ownership для Order.
  • Все ABAC-методы Order в одном месте — легко найти, легко изменить.

Неверно:

// ПЛОХО — ABAC размазан по контроллерам
@PostMapping("/{id}/cancel")
@PreAuthorize("hasRole('customer')")
public OrderResponse cancel(@PathVariable Long id, Authentication auth) {
    var order = orderRepository.findById(id).orElseThrow();
    if (!order.getCustomerId().toString().equals(auth.getName())) {
        throw new ResponseStatusException(HttpStatus.FORBIDDEN);
    }
    return dispatcher.dispatch(new CancelOrderCommand(id));
}

Что не так:

  • Логика проверки дублируется в каждом endpoint (read, update, cancel).
  • Контроллер делает бизнес-проверку (нарушение принципа thin controller).
  • При изменении модели (например, co-ownership) надо обновлять N мест.

Корректно: централизация в @Component("access") или в Handler через AccessChecker.

Admin обходит ABAC + audit

AUTH-12: admin может всё, но след остаётся.

@UseCase
@RequiredArgsConstructor
public class CancelOrderHandler implements UseCaseHandler<CancelOrderCommand, Order> {

    private final OrderRepository orderRepository;
    private final AuthenticatedUserProvider userProvider;
    private final AdminAuditLog auditLog;

    @Override
    @Transactional
    public Order handle(CancelOrderCommand command) {
        var order = orderRepository.findById(command.orderId(), SelectMode.FOR_UPDATE)
            .orElseThrow(() -> new OrderNotFoundException(command.orderId()));

        var user = userProvider.current();
        if (!user.isAdmin() && !order.getCustomerId().equals(user.id())) {
            throw new ForbiddenException("Order does not belong to current user");
        }
        order.cancel();
        var saved = orderRepository.save(order);

        if (user.isAdmin()) {
            auditLog.record(AdminAction.builder()
                .actorId(user.id())
                .action("cancel-order")
                .resourceType("Order")
                .resourceId(order.id())
                .occurredAt(Instant.now())
                .metadata(Map.of("originalStatus", order.previousStatus().name()))
                .build());
        }
        return saved;
    }
}

Admin может отменить чужой заказ (support, compliance, ops-задачи), но в *_audit_log остаётся запись «admin-7 cancelled order-12345 в момент X». Это покрывает compliance, расследование инцидентов, audit от security team.

Подробнее — Audit admin-команд.

Что запрещено

АнтипаттернПравилоЧто взамен
Endpoint с RBAC, но без ABAC для own-resourceAUTH-10оба слоя обязательны
ABAC проверка в контроллере inlineAUTH-11@access бин или Handler
Дубликат ABAC в @PreAuthorize + HandlerAUTH-11один из способов
Admin без audit log при overrideAUTH-12auditLog.record(...) обязательно
ABAC проверяет только customerId == sub, игнорирует adminAUTH-12user.isAdmin() OR ownership
@access.canEditOrder без FOR UPDATE для writeAUTH-10use Handler для write с lock
ABAC падает с RuntimeException, не ForbiddenExceptionAUTH-6typed exception → 403
ABAC игнорирует deleted/inactive ресурсыAUTH-10check status + ownership

Куда дальше