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