The full picture
// Level 1: RBAC — fine for admin panels
@PreAuthorize("hasRole('SUPPORT')")
public List<Ticket> openTickets() { … }
// Level 2: object-level — the real world
@PreAuthorize("@orderAuth.canView(authentication, #orderId)")
public Order view(OrderId orderId) { … }
@Component("orderAuth")
class OrderAuthorization {
public boolean canView(Authentication auth, OrderId id) {
var user = (AppPrincipal) auth.getPrincipal();
var order = orderRepo.ownershipOf(id); // ONE place, testable,
return order.customerId().equals(user.customerId())
|| user.hasAuthority("orders:read-all"); // logs decisions, evolvable
}
}
// Level 3: don't authorize rows one-by-one — SCOPE THE QUERY
@Query("select o from Order o where o.customerId = :#{principal.customerId}")
List<Order> myOrders(); // tenant isolation at the data layer- Proxy caveats apply: private methods and
this.calls skip checks silently; secure the service boundary, and treat controller-only security as incomplete (anything else calling the service bypasses it). - @PostAuthorize/@PostFilter exist (check/filter returned objects) but filtering big lists in-memory is an authorization and performance smell — push predicates into queries.
- Roles vs permissions: model fine-grained permissions (
orders:refund) grouped into roles at the identity layer — code checks permissions, admins remix roles without redeploys. - Testing:
@WithMockUser/custom annotations make authorization rules unit-testable — authorization logic without tests is a compliance finding waiting to happen.