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.
Prerequsites
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:
- a REST controller with at least one endpoint,
- spring-boot-starter-security and spring-security-test dependencies.
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.
Create Enum for user roles
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.
Roles in a simple Enum
For a simple use case, I’m going to create the following UserRole
Enum with a single role:
1 2 3 4 5 6 |
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:
1 2 3 |
# 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:
Role name as Enum value
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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:
1 2 3 |
# application.properties … spring.security.user.roles=chief-operating-officer |
Security configuration
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.
Specify access rules in the HttpSecurity config
In the following HttpSecurity configuration, I’m going to allow only users with the required role to access POST endpoints:
1 2 3 4 5 6 7 8 9 10 11 12 |
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:
1 2 |
… .mvcMatchers(HttpMethod.POST, "/example").hasRole(UserRole.CHIEF_OPERATING_OFFICER.toString()) |
Verify user role at the method level
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:
1 2 3 4 5 6 7 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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.
Create a custom annotation to hide irrelevant details
Luckily, creating a meta annotation is simple:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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:
- @Target – puts constrains on the usage of the annotation. The TYPE
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. - @Retention – specifies how long an annotation is to be retained. The RUNTIME Retention Policy enables the annotation to be retained by the VM at run time.
- @PreAuthorize – the original annotation that I want to remove from the controller code.
As a result, all irrelevant details remain hidden from my controllers:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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(); } … |
Summary
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.
Tests
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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:
1 2 3 4 5 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 |
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()); } … |
Read more on simplifying roles management in Spring
- How to create typesafe user roles for Spring Security?
- Spring Security @PreAuthorization pass enums in directly
- Keycloak with Spring Boot #4 – Simple guide for roles and authorities
Photo by Kindel Media from Pexels