The application already knows who showed up and what role they have. But that is not enough: you also need to make sure the user is allowed to work with this specific object — their order, their profile, their file. This is exactly what ABAC does.
Why a role alone is not enough
Imagine an online store. A user with the customer role can call GET /orders/{id} — they are a buyer, so they are allowed to view orders. But what stops them from substituting someone else's id and reading another person's order? Only one thing: a check in the code that asks, "does this order belong to you?"
Without such a check, any holder of a token with the right role gets access to someone else's data. In the security world this is called IDOR — Insecure Direct Object Reference, access to an object by identifier without a permission check.
RBAC (Role-Based Access Control) answers the question "which operation is this role allowed to perform." ABAC (Attribute-Based Access Control) adds a second question: "is this specific role allowed to work with this specific object."
Both layers work together: first RBAC ("you are a buyer, the operation is allowed"), then ABAC ("is this order really yours?").
Approach 1 — an access bean and @PreAuthorize
A simple and transparent approach: create a Spring component with check methods and call them directly in the @PreAuthorize annotation.
@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);
}
}
This bean is named access (via @Component("access")), so the annotation can reference it as @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));
}
}
The expression hasRole('admin') or @access.canEditOrder(...) means: an administrator passes without the ownership check, everyone else — only if the order is theirs.
This approach works well when the check is simple: compare the owner's identifier in the database with the identifier from the token. No business logic — just a comparison.
Approach 2 — a check inside the command handler
When the operation is more complex — for example, you first need to load the object with a lock, then check its state, and only then check ownership — it makes more sense to move the check into the command 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);
}
}
Note that the object is loaded with FOR UPDATE — this matters for write operations so there is no race between the check and the change. If the access bean loaded the object first, and then the handler loaded it again with a lock, you would end up with two queries instead of one.
When to choose which approach
Both approaches are correct. The choice depends on the situation:
- The access bean fits when the check is simple — comparing a single identifier. Good for read operations and simple write operations where no lock is needed.
- The handler fits when the ownership check sits alongside business logic, a check of the object's state, or a lock needed when loading.
The key point is not to use both approaches at once for a single operation. If the check exists both in @PreAuthorize and in the handler, they can drift apart: you change one place and forget the other.
Where to keep the check logic
A common mistake is to write the check directly in the controller:
// Don't do this
@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));
}
The problem: such a check will appear in every controller that works with an order — in the cancel method, the edit method, the view method. When the model changes (for example, an order gets a co-owner), you will have to update several places and not forget a single one.
The right approach is to gather all the check logic for one resource type in one place: either in the access bean's methods or in the handler. Then a model change means one place, one edit.
The administrator: access without a check, but with a log
An administrator must be able to work with any resource — for support, compliance, incident investigation. That is why the ABAC check is bypassed for them.
But every such action must be recorded in a log: who, what, whose resource, when:
@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;
}
An administrator cancelled someone else's order — the log entry says "admin-7 cancelled order-12345 at such-and-such time." This is needed for the security team's audit, for incident analysis, and for meeting regulatory requirements.
In short
- RBAC answers "the role is allowed," ABAC adds "this object belongs to you." Without ABAC, any holder of a token with the right role reads and changes someone else's data.
- Two correct places for the check: the access bean (
@Component("access")+@PreAuthorize) for simple cases and the command handler for operations with business logic or a lock. - The two approaches are not mixed for a single operation — otherwise the checks will drift apart when the model changes.
- The check is not written in the controller — it gets duplicated across all methods and is hard to maintain.
- The administrator bypasses ABAC, but every action they take on someone else's resource is recorded in a log.
What to read next
- Where the auth check goes: Gateway, BFF, or Domain — how to distribute responsibility across layers.
- RBAC: role mapping — the layer before ABAC.
- Auditing admin commands — more on the administrator action log.