Role-Based Access Control with @PreAuthorize
Roles and permissions control what authenticated users can do. This article implements a complete RBAC system — from URL-level rules to method-level security and resource ownership checks.
Roles vs Permissions
Roles are coarse-grained groupings (USER, MANAGER, ADMIN).
Permissions are fine-grained actions (READ_ORDERS, WRITE_PRODUCTS, DELETE_USERS).
Assign permissions to roles:
ADMIN → all permissions
MANAGER → READ_ORDERS, WRITE_ORDERS, READ_PRODUCTS, WRITE_PRODUCTS
USER → READ_OWN_ORDERS, WRITE_OWN_ORDERS, READ_PRODUCTS
Model permissions as a typed enum:
public enum Permission {
// Order permissions
READ_ORDERS,
WRITE_ORDERS,
DELETE_ORDERS,
READ_OWN_ORDERS,
WRITE_OWN_ORDERS,
// Product permissions
READ_PRODUCTS,
WRITE_PRODUCTS,
DELETE_PRODUCTS,
// User management
READ_USERS,
WRITE_USERS,
DELETE_USERS
}
public enum Role {
USER(Set.of(
Permission.READ_PRODUCTS,
Permission.READ_OWN_ORDERS,
Permission.WRITE_OWN_ORDERS
)),
MANAGER(Set.of(
Permission.READ_PRODUCTS,
Permission.WRITE_PRODUCTS,
Permission.READ_ORDERS,
Permission.WRITE_ORDERS
)),
ADMIN(Set.of(Permission.values())); // all permissions
private final Set<Permission> permissions;
Role(Set<Permission> permissions) { this.permissions = permissions; }
public Set<Permission> getPermissions() { return permissions; }
}
Grant authorities from both roles and permissions:
// In User entity
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> authorities = new HashSet<>();
for (Role role : roles) {
// Add role itself
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.name()));
// Add all permissions for this role
role.getPermissions().forEach(p ->
authorities.add(new SimpleGrantedAuthority(p.name()))
);
}
return authorities;
}
URL-Level Authorization
Configure coarse-grained rules in SecurityFilterChain:
.authorizeHttpRequests(auth -> auth
// Public
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
// Permission-based
.requestMatchers(HttpMethod.POST, "/api/products/**")
.hasAuthority("WRITE_PRODUCTS")
.requestMatchers(HttpMethod.DELETE, "/api/products/**")
.hasAuthority("DELETE_PRODUCTS")
// Role-based
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "MANAGER")
// All other requests
.anyRequest().authenticated()
)
Method-Level Security with @PreAuthorize
Enable it once:
@Configuration
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class MethodSecurityConfig {}
Now annotate service methods:
@Service
public class OrderService {
// Any authenticated user can call — controller-level auth handles the rest
@PreAuthorize("isAuthenticated()")
public Order findById(UUID id) { ... }
// Requires ADMIN role
@PreAuthorize("hasRole('ADMIN')")
public List<Order> findAll(Pageable pageable) { ... }
// Requires specific permission
@PreAuthorize("hasAuthority('READ_ORDERS')")
public Page<Order> findByCustomer(UUID customerId, Pageable pageable) { ... }
// Multiple conditions
@PreAuthorize("hasRole('ADMIN') or hasAuthority('WRITE_ORDERS')")
public Order confirm(UUID id) { ... }
// Delete: admin only
@PreAuthorize("hasRole('ADMIN')")
public void delete(UUID id) { ... }
}
Resource Ownership Checks
The most common real-world check: a user can only access their own resources.
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(authentication, #id)")
public Order getOrder(UUID id) { ... }
The @orderSecurity bean evaluates ownership:
@Component("orderSecurity")
@RequiredArgsConstructor
public class OrderSecurityService {
private final OrderRepository orderRepository;
public boolean isOwner(Authentication auth, UUID orderId) {
if (auth == null || !auth.isAuthenticated()) return false;
String username = auth.getName();
return orderRepository.findById(orderId)
.map(order -> {
// Assuming Order has a reference to the customer username
return order.getCustomerUsername().equals(username);
})
.orElse(false);
}
public boolean canAccessCustomerData(Authentication auth, UUID customerId) {
if (hasRole(auth, "ADMIN")) return true;
UserDetails user = (UserDetails) auth.getPrincipal();
// User can access their own customer record
return ((User) user).getId().equals(customerId);
}
private boolean hasRole(Authentication auth, String role) {
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_" + role));
}
}
@PostAuthorize — Check After the Method Runs
// Return the order only if the caller owns it (or is admin)
@PostAuthorize("hasRole('ADMIN') or returnObject.customerUsername == authentication.name")
public Order getOrder(UUID id) {
return orderRepository.findById(id).orElseThrow();
}
@PostAuthorize is useful when you need to check properties of the returned object. The method runs first, the check happens after. Use sparingly — it wastes the method call if the check fails.
@PreFilter and @PostFilter
Filter a collection before/after the method:
// Only pass items the user has permission to modify
@PreFilter("hasRole('ADMIN') or filterObject.customerId == authentication.principal.id")
public List<Order> bulkConfirm(List<Order> orders) { ... }
// Remove items the user shouldn't see from the result
@PostFilter("hasRole('ADMIN') or filterObject.customerId == authentication.principal.id")
public List<Order> findAllForUser(UUID customerId) {
return orderRepository.findByCustomerId(customerId);
}
Use @PostFilter for small result sets — it loads everything from the DB then filters in memory. For large datasets, filter in the query instead.
Custom Security Expressions
Create domain-specific security expressions by extending SecurityExpressionRoot:
public class CustomSecurityExpressionRoot extends SecurityExpressionRoot
implements MethodSecurityExpressionOperations {
private final OrderRepository orderRepository;
private Object filterObject;
private Object returnObject;
private Object target;
public CustomSecurityExpressionRoot(Authentication auth,
OrderRepository orderRepository) {
super(auth);
this.orderRepository = orderRepository;
}
// Custom expression: @security.ownsOrder(#orderId)
public boolean ownsOrder(UUID orderId) {
return orderRepository.findById(orderId)
.map(o -> o.getCustomerUsername().equals(getAuthentication().getName()))
.orElse(false);
}
// Custom expression: @security.hasTeamAccess(#teamId)
public boolean hasTeamAccess(UUID teamId) {
UserDetails user = (UserDetails) getAuthentication().getPrincipal();
return ((User) user).getTeamId() != null
&& ((User) user).getTeamId().equals(teamId);
}
@Override
public void setFilterObject(Object filterObject) { this.filterObject = filterObject; }
@Override
public Object getFilterObject() { return filterObject; }
@Override
public void setReturnObject(Object returnObject) { this.returnObject = returnObject; }
@Override
public Object getReturnObject() { return returnObject; }
@Override
public Object getThis() { return target; }
}
Register the custom root:
@Component
public class CustomMethodSecurityExpressionHandler
extends DefaultMethodSecurityExpressionHandler {
private final OrderRepository orderRepository;
@Override
protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
Authentication authentication, MethodInvocation invocation) {
var root = new CustomSecurityExpressionRoot(authentication, orderRepository);
root.setPermissionEvaluator(getPermissionEvaluator());
root.setTrustResolver(getTrustResolver());
root.setRoleHierarchy(getRoleHierarchy());
return root;
}
}
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
CustomMethodSecurityExpressionHandler handler) {
return handler;
}
}
Usage:
@PreAuthorize("ownsOrder(#orderId) or hasRole('ADMIN')")
public Order getOrder(UUID orderId) { ... }
Role Hierarchy
Define inheritance so ADMIN implicitly has all MANAGER permissions:
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_MANAGER
ROLE_MANAGER > ROLE_USER
""");
}
@Bean
public DefaultWebSecurityExpressionHandler expressionHandler() {
DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy());
return handler;
}
Now hasRole('USER') is satisfied by ADMIN, MANAGER, and USER — no need to list all three.
Testing Security
Test authorization in isolation with @WithMockUser:
@WebMvcTest(OrderController.class)
class OrderSecurityTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
@WithMockUser(roles = "USER")
void userCannotDeleteOrders() throws Exception {
mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteOrders() throws Exception {
doNothing().when(orderService).delete(any());
mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID()))
.andExpect(status().isNoContent());
}
@Test
void unauthenticatedUserCannotAccessOrders() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "alice", roles = "USER")
void userCanAccessTheirOwnOrders() throws Exception {
UUID orderId = UUID.randomUUID();
Order order = new Order();
order.setCustomerUsername("alice");
when(orderService.getOrder(orderId)).thenReturn(OrderResponse.from(order));
mockMvc.perform(get("/api/orders/{id}", orderId))
.andExpect(status().isOk());
}
}
Test with a custom security context:
@Test
void shouldDenyAccessToOtherUsersOrders() throws Exception {
UUID orderId = UUID.randomUUID();
// Order belongs to 'bob', but 'alice' is making the request
when(orderService.getOrder(orderId))
.thenThrow(new AccessDeniedException("Access denied"));
mockMvc.perform(get("/api/orders/{id}", orderId)
.with(user("alice").roles("USER")))
.andExpect(status().isForbidden());
}
What You’ve Learned
- Separate roles (coarse) from permissions (fine-grained) — roles bundle permissions
- Grant both
ROLE_Xand permission authorities fromgetAuthorities()— enables flexible checks @PreAuthorizewith SpEL expressions for method-level security —hasRole(),hasAuthority(),#param@Componentbeans referenced with@in SpEL (@orderSecurity.isOwner(...)) for resource ownership- Role hierarchy (
ADMIN > MANAGER > USER) prevents permission explosion - Test security with
@WithMockUserand.with(user(...))
Next: Article 27 — OAuth2 Resource Server — validate JWTs from an external auth provider (Keycloak, Auth0, Okta).