Interview Prep Zoneby CuriouserLabs

Question 3 of 5 · staff level

Method security: @PreAuthorize, SpEL, and designing authorization that scales past role checks.

🎤 Say this first

@EnableMethodSecurity turns on annotation-driven authorization via AOP (yes — proxies, so self-invocation bypasses it, same trap as @Transactional). @PreAuthorize("hasRole('ADMIN')") handles RBAC; SpEL reaches arguments (#orderId) and beans (@orderPermissions.canView(#orderId)) for object-level authorization — the thing URL rules can't do. At scale, don't scatter expressions: centralize decisions in a domain authorization service (or externalize policy — OPA/Cedar-style) and keep annotations as thin declarations. Defense in depth: URL rules (coarse) + method (service boundary) + data filtering (query-level tenant scoping).

The full picture

From role checks to domain authorization
// 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.

🔄 Likely follow-up questions

  • Where do you put tenant isolation so no developer can forget it? (query scoping, Hibernate filters, RLS)
  • When does externalized policy (OPA/Cedar) beat in-code authorization?
  • How do you audit authorization decisions for compliance?