A URL rule guards the door by verb and path — it can't say "only your own order", so authorization moves onto the method

java dev.to

Earlier days secured OrderHub at the URL: the filter chain says GET /api/** needs ROLE_USER and POST /api/** needs ROLE_ADMIN. That's a great coarse gate, but it has two blind spots. It only sees the HTTP request — verb and path — so it cannot say "a plain user may read only their OWN orders", because ownership depends on the method's arguments and the row that comes back, not the URL. And it only guards the HTTP edge: a call that reaches the service from a Kafka listener, the saga, or a scheduled job never passes through that filter at all. Day 40 fixes both by moving authorization onto the service method itself.

@PreAuthorize and @PostAuthorize — SpEL on the method

Each OrderService method carries a rule written in Spring Expression Language. @PreAuthorize is checked before the body runs — the safe choice for writes and argument rules. @PostAuthorize runs the body then vets the result — for reads and return-value rules. Reads need USER, writes need ADMIN.

@PreAuthorize("hasRole('ADMIN')")            // WRITE
public Order placeOrder(String customer, String item, int quantity) { ... }

@PreAuthorize("hasRole('USER')")             // READ (admin reaches it via the hierarchy)
public Order getOrder(String id) { ... }
Enter fullscreen mode Exit fullscreen mode

Because the rule sits on the bean method, it holds no matter how the call arrives — HTTP, a @KafkaListener, the saga, a job. A URL filter can't guard any of those non-HTTP paths.

Ownership — the rule a URL can never express

This is the whole point. @PreAuthorize can read the method's arguments by name with #ownerId, and @PostAuthorize can read the return value with returnObject. So an admin may list any owner's orders, but a plain user only their own — where the requested owner must equal their authenticated username.

// PRE — decide from the ARGUMENT, before the body runs
@PreAuthorize("hasRole('ADMIN') or #ownerId == authentication.name")
public List<Order> listOrdersForOwner(String ownerId) { ... }

// POST — decide from the RETURN VALUE, after the body runs
@PostAuthorize("hasRole('ADMIN') or returnObject.customer == authentication.name")
public Order getOrderForOwner(String id) { ... }
Enter fullscreen mode Exit fullscreen mode

The @PreAuthorize version denies a user asking for someone else's data before the repository is ever touched; the @PostAuthorize version loads the order, then throws it away with a 403 if it isn't the caller's. (And a 403 is not a 401: 401 means "who are you?", 403 means "I know exactly who you are, and no".)

Role hierarchy — ADMIN > USER, declared once

Rather than granting every admin an explicit ROLE_USER, declare the relationship once. Then hasRole('USER') is satisfied by anyone holding ROLE_ADMIN — an admin inherits every user permission. The subtle part is wiring it into method security: a RoleHierarchy bean is picked up by URL rules automatically, but for the @PreAuthorize SpEL you must set it on the expression handler so hasRole() inside the expression consults it too.

@Bean static RoleHierarchy roleHierarchy() {
  return RoleHierarchyImpl.withDefaultRolePrefix()
      .role("ADMIN").implies("USER")     // ROLE_ADMIN reaches ROLE_USER
      .build();
}
Enter fullscreen mode Exit fullscreen mode

The annotation is inert until the advisor exists

@PreAuthorize is just metadata — it does nothing on its own. @EnableMethodSecurity is what registers the AOP advisors that read it and enforce it. I put that on a MethodSecurityConfig gated by @ConditionalOnProperty(orderhub.security.method.enabled), default false. With the gate off, the config never loads, no advisor exists, and the annotations on OrderService are pure no-ops — so the service behaves byte-for-byte as before and every prior test stays green. The feature ships dark and turns on with one flag.

A @WithMockUser slice test proves the whole matrix with plain spring-security-test — no MockMvc needed, because the rule is on the method: a USER is denied a write, an ADMIN allowed, both ownership rules enforced, and — the proof of the hierarchy — an ADMIN holding only ROLE_ADMIN passes a hasRole('USER') read. The reactor goes 135 → 146, BUILD SUCCESS. URL security is the door lock; method security is the safe inside — together they're defence in depth. Pick a caller and watch who-can-call-what fill in:

https://dev48v.infy.uk/orderhub/day40-method-security.html

https://github.com/dev48v/order-hub-from-zero

Source: dev.to

arrow_back Back to Tutorials