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

Приложение уже знает, кто пришёл и какую роль он имеет. Но этого мало: нужно ещё убедиться, что пользователь имеет право работать именно с этим конкретным объектом — своим заказом, своим профилем, своим файлом. Именно этим занимается ABAC.

Почему одной роли недостаточно

Представьте интернет-магазин. Пользователь с ролью customer может вызвать GET /orders/{id} — он же покупатель, ему разрешено просматривать заказы. Но что мешает ему подставить чужой id и прочитать заказ другого человека? Только то, что в коде есть проверка: «этот заказ принадлежит тебе?»

Без такой проверки любой holder токена с правильной ролью получит доступ к чужим данным. В мире безопасности это называется IDOR — Insecure Direct Object Reference, доступ к объекту по идентификатору без проверки прав.

RBAC (Role-Based Access Control) отвечает на вопрос «какую операцию разрешено выполнять этой роли». ABAC (Attribute-Based Access Control) добавляет второй вопрос: «а разрешено ли этой конкретной роли работать именно с этим конкретным объектом».

Оба слоя работают вместе: сначала RBAC («ты покупатель, операция разрешена»), потом ABAC («этот заказ действительно твой?»).

Способ 1 — access-бин и @PreAuthorize

Простой и прозрачный способ: создать Spring-компонент с методами проверки и вызывать их прямо в аннотации @PreAuthorize.

@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);
    }
}

Этот бин называется access (через @Component("access")), поэтому в аннотации можно ссылаться на него как @access:

@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') or @access.canEditOrder(...) означает: администратор проходит без проверки владения, остальные — только если заказ их.

Этот способ хорошо работает, когда проверка несложная: сравнить идентификатор владельца в базе с идентификатором из токена. Никакой бизнес-логики — только сравнение.

Способ 2 — проверка внутри обработчика команды

Когда операция сложнее — например, нужно сначала загрузить объект с блокировкой, затем проверить его состояние и только потом владение — логичнее вынести проверку в обработчик команды:

@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);
    }
}

Обратите внимание: объект загружается с FOR UPDATE — это важно для операций записи, чтобы не было гонки между проверкой и изменением. Если бы объект сначала загружал access-бин, а потом обработчик загружал его повторно с блокировкой, вышло бы два запроса вместо одного.

Когда какой способ выбрать

Оба способа правильные. Выбор зависит от ситуации:

  • Access-бин подходит, когда проверка простая — сравнить один идентификатор. Хорошо для операций чтения и простых операций записи, где не нужна блокировка.
  • Обработчик подходит, когда рядом с проверкой владения есть бизнес-логика, проверка состояния объекта или нужна блокировка при загрузке.

Главное — не использовать оба способа одновременно для одной операции. Если проверка есть и в @PreAuthorize, и в обработчике, они могут разойтись: изменили в одном месте, забыли в другом.

Где хранить логику проверки

Частая ошибка — писать проверку прямо в контроллере:

// Так не надо
@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));
}

Проблема: такая проверка появится в каждом контроллере, который работает с заказом — в методе отмены, в методе редактирования, в методе просмотра. Когда модель изменится (например, у заказа появится совладелец), придётся обновлять несколько мест и не забыть ни одно.

Правильный подход — собрать всю логику проверки для одного типа ресурса в одном месте: либо в методах access-бина, либо в обработчике. Тогда изменение модели — одно место, одна правка.

Администратор: доступ без проверки, но с журналом

Администратор должен иметь возможность работать с любым ресурсом — для поддержки, соблюдения регламентов, расследования инцидентов. Поэтому ABAC-проверку для него обходят.

Но каждое такое действие нужно фиксировать в журнале: кто, что, с чьим ресурсом, когда:

@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-7 отменил order-12345 в такое-то время». Это нужно и для аудита службы безопасности, и для разбора инцидентов, и для соблюдения регуляторных требований.

Коротко

  • RBAC отвечает «роль разрешена», ABAC добавляет «этот объект принадлежит тебе». Без ABAC любой держатель токена с нужной ролью читает и меняет чужие данные.
  • Два правильных места для проверки: access-бин (@Component("access") + @PreAuthorize) для простых случаев и обработчик команды для операций с бизнес-логикой или блокировкой.
  • Оба способа не смешивают для одной операции — иначе проверки разойдутся при изменении модели.
  • Проверку не пишут в контроллере — она дублируется по всем методам и трудно поддерживается.
  • Администратор обходит ABAC, но каждое его действие над чужим ресурсом фиксируется в журнале.

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

  • Где делать auth-проверку: Gateway, BFF или Domain — как распределить ответственность по слоям.
  • RBAC: маппинг ролей — слой до ABAC.
  • Audit admin-команд — подробнее о журнале действий администратора.