Spring Security allows us to use role-based control to restrict access to API resources. However, inserting role names as simple strings can quickly become cumbersome and increase development cost. Fortunately, we can enclose role details in an Enum and use custom annotations to simplify management of user roles in a Spring Boot application. While it still doesn’t provide type-safe roles, most IDEs will be able to support changes in the code and simplify maintenance.
As an example for this article, I’m going to configure role-based access control. If you want to recreate the work described below, you’re going to need a Spring Boot project with:
You can find an example project in the spring-boot-user-roles-management repository.
Summarizing the work described below: first I will create an example role and add it to my default user via the application properties. Then I will restrict access to POST endpoints to only users with this role. Finally, I am going to include new access rules in my MVC tests.
I’m going to start with creating a class for user roles. In straightforward cases, a simple Enum will suffice. However, if we need to encapsulate a role name that does not follow the Enum naming convention, we need to add an additional field for the value. Below you’ll find solutions for both situations.
For a simple use case, I’m going to create the following UserRole
Enum with a single role:
package in.keepgrowing.springbootuserrolesmanagement.security.domain.model;
public enum UserRole {
CHIEF_OPERATING_OFFICER
}
In my sample project, I defined a default user with the spring.security.user.*
properties. Now, I have to add the role in my application.properties
file:
# application.properties
…
spring.security.user.roles=CHIEF_OPERATING_OFFICER
Thanks to this, the application starts with a default user who already has the authority and I will be able to quickly test my configuration later:
In case we don’t have control over the role names (e.g. they are created in an external authorization service like Keycloak and may look like chief-operation-officer
), we can still provide a convenient mapping. We just need to encapsulate the external name in an Enum value and override the toString()
method:
package in.keepgrowing.springbootuserrolesmanagement.security.domain.model;
public enum UserRole {
CHIEF_OPERATING_OFFICER("chief-operating-officer");
final String value;
UserRole(String value) {
this.value = value;
}
@Override
public String toString() {
return value;
}
}
As before, I need to add the role to my default user defined with the spring.security.user.*
properties:
# application.properties
…
spring.security.user.roles=chief-operating-officer
We can define role-based security rules in the HttpSecurity configuration or for each method / class. Below you’ll find example implementation for both cases.
In the following HttpSecurity configuration, I’m going to allow only users with the required role to access POST endpoints:
package in.keepgrowing.springbootuserrolesmanagement.security.config;
import in.keepgrowing.springbootuserrolesmanagement.security.domain.model.UserRole;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
…
protected void configure(HttpSecurity http) throws Exception {
…
.authorizeHttpRequests()
.antMatchers(HttpMethod.POST).hasRole(UserRole.CHIEF_OPERATING_OFFICER.toString())
.anyRequest().authenticated()
…
As a result, POST endpoints will be inaccessible to anyone except a COO
.
As a side note, I’m using the authorizeHttpRequests()
method because it seems that autorizeRequests() will be deprecated. Furthermore, you can use mvcMatchers()
instead of antMatchers()
if you need a different approach to pattern matching, like in the snippet below:
…
.mvcMatchers(HttpMethod.POST, "/example").hasRole(UserRole.CHIEF_OPERATING_OFFICER.toString())
When we need more precise access control, we can apply method level security. First, I’m going to enable method security using the following class:
package in.keepgrowing.springbootuserrolesmanagement.security.config;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@EnableMethodSecurity
public class CustomMethodSecurityConfig {
}
Annotate your class with @EnableGlobalMethodSecurity
if you use Spring Security version < 5.6.
Next, I’m going to add the @PreAuthorize
annotation onto the example POST endpoint:
package in.keepgrowing.springbootuserrolesmanagement.example.adapters.driving.api.controllers;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ExampleController {
@PostMapping
@PreAuthorize("hasRole(T(in.keepgrowing.springbootuserrolesmanagement.security.domain.model.UserRole).CHIEF_OPERATING_OFFICER.toString())")
public ResponseEntity<Void> post() {
return ResponseEntity.ok().build();
}
…
Unfortunately, we have to use the fully-qualified path in the SpEL expression. Obviously, I don’t want to clutter controllers with such low-level details. Therefore, I’m going to create a custom annotation.
Luckily, creating a meta annotation is simple:
package in.keepgrowing.springbootuserrolesmanagement.security.adapters.driving.spring.annotations;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize(value = "hasRole(T(in.keepgrowing.springbootuserrolesmanagement.security.domain.model.UserRole).CHIEF_OPERATING_OFFICER.toString())")
public @interface MustBeChiefOperatingOfficer {
}
Let’s take a look at the annotations I’m using here:
Element Type
allows using it on a class (e.g. controller classes), interface, Enum or record declaration. I’m also providing the METHOD as a possible target to allow using this annotation on a single endpoint.As a result, all irrelevant details remain hidden from my controllers:
package in.keepgrowing.springbootuserrolesmanagement.example.adapters.driving.api.controllers;
import in.keepgrowing.springbootuserrolesmanagement.security.adapters.driving.spring.annotations.MustBeChiefOperatingOfficer;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ExampleController {
@PostMapping
@MustBeChiefOperatingOfficer
public ResponseEntity<Void> post() {
return ResponseEntity.ok().build();
}
…
By keeping user roles in Enum and using them in annotations as described above, we can take advantage of our IDE when maintaining the authorities.
Unfortunately, I haven’t found a convenient method of using Enum when mocking a user role in tests. Therefore, as we can see in the snippet below, we still have to provide the required role as a simple String:
package in.keepgrowing.springbootuserrolesmanagement.example.adapters.driving.api.controllers;
import in.keepgrowing.springbootuserrolesmanagement.testing.annotations.WithMockChiefOperationOfficer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest
class ExampleControllerTest {
private static final String PATH = "/" + ExampleControllerPaths.EXAMPLE_PATH;
@Autowired
private MockMvc mvc;
@Test
@WithMockUser(roles = "CHIEF_OPERATING_OFFICER")
void shouldCallPostEndpoint() throws Exception {
mvc.perform(post(PATH)
.with(csrf()))
.andExpect(status().isOk());
}
…
To simplify maintenance of the tests, we can create our custom annotation to mock users with the CHIEF_OPERATING_OFFICER role:
package in.keepgrowing.springbootuserrolesmanagement.testing.annotations;
import org.springframework.security.test.context.support.WithMockUser;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(roles = "CHIEF_OPERATING_OFFICER")
public @interface WithMockChiefOperationOfficer {
}
For more advanced mapping, when we keep the actual user role in the Enum value:
package in.keepgrowing.springbootuserrolesmanagement.testing.annotations;
…
@WithMockUser(roles = "chief-operating-officer")
public @interface WithMockChiefOperationOfficer {
}
As a result, we don’t have to provide user role as a string in every test class or test method:
package in.keepgrowing.springbootuserrolesmanagement.example.adapters.driving.api.controllers;
import in.keepgrowing.springbootuserrolesmanagement.testing.annotations.WithMockChiefOperationOfficer;
…
@Test
@WithMockChiefOperationOfficer
void shouldDenyAccessToPostEndpoint() throws Exception {
mvc.perform(post(PATH)
.with(csrf()))
.andExpect(status().isForbidden());
}
…
Photo by Kindel Media from Pexels
A custom annotation in Spring Boot tests is an easy and flexible way to provide…
Delegating user management to Keycloak allows us to better focus on meeting the business needs…
Swagger offers various methods to authorize requests to our Keycloak secured API. I'll show you…
Configuring our Spring Boot API to use Keycloak as an authentication and authorization server can…
Keycloak provides simple integration with Spring applications. As a result, we can easily configure our…
Postman comes with a wide variety of OAuth 2.0 compliant configuration options that allow us…