Spring Boot

Simplify the management of user roles in Spring Boot

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:

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:

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:

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:

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

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:

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())

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:

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.

Create a custom annotation to hide irrelevant details

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:

  1. @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.
  2. @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.
  3. @PreAuthorizethe original annotation that I want to remove from the controller code.

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();
    }
…

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:

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());
    }
…

Read more on simplifying roles management in Spring

Photo by Kindel Media from Pexels

little_pinecone

Recent Posts

Create a custom annotation to configure Spring Boot tests

A custom annotation in Spring Boot tests is an easy and flexible way to provide…

3 years ago

Keycloak with Spring Boot #4 – Simple guide for roles and authorities

Delegating user management to Keycloak allows us to better focus on meeting the business needs…

3 years ago

Keycloak with Spring Boot #3 – How to authorize requests in Swagger UI

Swagger offers various methods to authorize requests to our Keycloak secured API. I'll show you…

3 years ago

Keycloak with Spring Boot #2 – Spring Security instead of Keycloak in tests

Configuring our Spring Boot API to use Keycloak as an authentication and authorization server can…

3 years ago

Keycloak with Spring Boot #1 – Configure Spring Security with Keycloak

Keycloak provides simple integration with Spring applications. As a result, we can easily configure our…

3 years ago

Kecloak in Docker #7 – How to authorize requests via Postman

Postman comes with a wide variety of OAuth 2.0 compliant configuration options that allow us…

3 years ago