Building a multitenant web application with Spring Boot
Tags: spring, security, webdev, programming
Published: 2023-03-20 19:54:27

Let's create a web application to serve multiple different clients or to host a range of sub-sites and platforms.  
Spring Boot will be our companion on this trip with JPA/Hibernate taking care of storage and persistence.

Tenants must be disconnected from each other, and users should be able to register to and contribute to the different tenants' websites transparently. In short, users should be oblivious to the fact that the tenants' web applications are part of the one single Spring application.

What this article will be focusing on:
* The web application experience. We want to ensure a maintainable application across different tenants' URLs, and security must be kept in check. Each tenant should have their own (sub)domain.
* We're using a [single database and shared schema](https://www.baeldung.com/multitenancy-with-spring-data-jpa). It allows us to reason about tenants on a design level and simplifies data source access. There are many articles and online sources that tackle [the](https://spring.io/blog/2022/07/31/how-to-integrate-hibernates-multitenant-feature-with-spring-data-jpa-in-a-spring-boot-application) [challenge](https://callistaenterprise.se/blogg/teknik/2022/03/26/multi-tenancy-with-spring-boot-part7/) [of](https://callistaenterprise.se/blogg/teknik/2022/03/26/multi-tenancy-with-spring-boot-part7/) [handling](https://medium.com/@konstde00/spring-boot-multi-tenant-architecture-overview-88198ea3991f) [multiple](https://github.com/sumanentc/multitenant) [data](https://jomatt.io/how-to-build-a-multi-tenant-saas-solution-sample-app/) [sources](https://techblog.geekyants.com/multi-tenancy-using-spring-boot-shared-schema-subdomain-identifier-architecture).

Our POC will use subdomains of localhost such as `tenant1.localhost` and `tenant2.localhost` to mimic the actual experience.
When I register an account at `tenant1.localhost`, I won't (yet) have an account at `tenant2.localhost`. In fact, as a user, I should have no clue that both tenants are even served by the same Spring application. I should be able to create an account at `tenant2` using the same email address as for `tenant1`.

The application itself will have "posts" on it. For example, you can think of them as blog posts or news posts.
* Unauthenticated users should be able to see all posts.
* Authenticated users should be able to contribute by adding new posts.
* Tenant administrators should be able to both add and delete posts.

There will be a global administrator role as well. There are no plans to implement anything meaningful for the global administrator in this POC. It could be expanded by adding features to create new tenants or upgrading users to tenant administrators.

Technically, we'll use Spring Web with MVC/Thymeleaf and a REST API. I've set up the project using Gradle, Spring Boot 3 (Spring 6), and PostgreSQL.

The end result of this journey can be found [here](https://gitlab.com/kdg-ti/integration-4/guides/multitenancy).

## Spring Security

Spring Security will provide a foundation for authentication and authorization. Let's tag a `@Configuration` class with `@EnableWebSecurity` and provide a bean of type `SecurityFilterChain`:
```java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
```
Static assets should fall outside of our security requirements, so we'll add matcher rules such as :
```java
antMatcher(HttpMethod.GET, "/js/**")).permitAll()
```
I'm assuming a basic knowledge of Spring Security. So far, nothing special yet.

### Tenant?... what tenant?

All HTTP traffic to `tenant1.localhost`, `tenant2.localhost`, and `localhost` will arrive at our Spring application (assuming port 8080). We need a way to distinguish between them and `OncePerRequestFilter` will be our tool of choice. I'll create one and call it `TenantFilter`. This request filter will grab the tenant from the URL and store it so that we can check it later when further processing the request (i.e., in a controller).
Each web request will be handled by a separate thread, so an excellent place to store this little piece of data is Java's `ThreadLocal`. (In the future, this should even be compatible with [Project Loom's](https://www.baeldung.com/openjdk-project-loom) virtual threads.)

One of only a few places in a Spring application where `static` can be used meaningfully:
```java
public class TenantContext {
    private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
    private static final ThreadLocal<Long> currentTenantId = new ThreadLocal<>();

    public static String getCurrentTenant() {
        return currentTenant.get();
    }

    public static void setCurrentTenant(String tenant) {
        currentTenant.set(tenant);
    }

    // More getters/setters
}
```

The filter will use the context class by calling its setters:
```java
public class TenantFilter extends OncePerRequestFilter {
    private final TenantRepository tenantRepository;

    public TenantFilter(TenantRepository tenantRepository) {
        this.tenantRepository = tenantRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        var tenant = getTenant(request);
        var tenantId = tenantRepository.findBySlug(tenant).map(Tenant::getId).orElse(null);
        if (tenant != null && tenantId == null) {
            response.setStatus(NOT_FOUND.value()); // Attempted access to non-existing tenant
            return;
        }
        TenantContext.setCurrentTenant(tenant);
        TenantContext.setCurrentTenantId(tenantId);
        chain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return request.getRequestURI().startsWith("/webjars/")
                || request.getRequestURI().startsWith("/css/")
                || request.getRequestURI().startsWith("/js/")
                || request.getRequestURI().endsWith(".ico");
    }

    private String getTenant(HttpServletRequest request) {
        var domain = request.getServerName();
        var dotIndex = domain.indexOf(".");

        String tenant = null;
        if (dotIndex != -1) {
            tenant = domain.substring(0, dotIndex);
        }

        return tenant;
    }
}
```
We must override two methods:
* `doFilterInternal`: This method's first -essential- responsibility is to determine if we want to continue processing this request. If the answer is yes, then we call `chain.doFilter`. Not calling the chain means the end of the road for this request. The other task is to get the tenant's code ([slug](https://stackoverflow.com/a/4357014)) from the request URL and to store it for later use. A repository is used to retrieve the ID of the tenant (handy for later!).
* `shouldNotFilter`: tells Spring when this filter is relevant.

We only want to support tenants that exist in our database as well as `null` (=no tenant). That's why we've added a 404 check.

### UserDetailsService

The first place where we will **read** this `ThreadLocal` information is from a custom `UserDetailsService`. The method to override here is `loadUserByUsername`:
```java
@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        var tenant = TenantContext.getCurrentTenant();

        if (tenant != null) {
            return loadUser(email, tenant);
        } else {
            return loadGeneralAdmin(email);
        }
    }

    private UserDetails loadUser(String email, String tenant) {
        var user =
                userRepository.findUser(email, tenant)
                        .orElseThrow(
                                () -> new UsernameNotFoundException(
                                        "'" + email + "' / '" + tenant +
                                                "' was not found."));

        var auths = new ArrayList<GrantedAuthority>();
        auths.add(new SimpleGrantedAuthority(user.getRole().getRoleName()));
        return new CustomUserDetails(user.getEmail(), user.getPassword(), user.getId(),
                user.getTenant().getId(), auths);
    }

    private UserDetails loadGeneralAdmin(String email) {
        var admin = userRepository.findGeneralAdmin(email).orElseThrow(
                () -> new UsernameNotFoundException(
                        "'" + email + "' was not found as a general admin."));
        var auths = new ArrayList<GrantedAuthority>();
        auths.add(new SimpleGrantedAuthority(ADMINISTRATOR.getRoleName()));
        return new CustomUserDetails(admin.getEmail(), admin.getPassword(), admin.getId(), null,
                auths);
    }
}
```

If the tenant is `null`, we know the user is accessing `localhost` without a subdomain. This part of our application will be used to host global administration pages.
If the tenant is _not_ `null`, then we must select a user filtering on **both** email _and_ tenant.
FYI, `userRepository.findGeneralAdmin` filters by email _and_ tenant equal to `null`.
I've created a `CustomUserDetails` class to extend Spring's `User`. It contains the user ID and tenant ID as well. This will come in handy later.

### Enabling the filter 

We can activate the request filter from within `WebSecurityConfig`:
```java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // More config here...
            .addFilterBefore(new TenantFilter(tenantRepository), UsernamePasswordAuthenticationFilter.class)
```
The _before_-part is essential here. `UserDetailsService` will read from `TenantContext`, so the filter must kick in _before_ authentication is done.

## A controller Example

Whenever someone creates a new post, the application needs to know two things:
* Who is the user (author) of this new post? Easy: we use `@AuthenticationPrincipal CustomUserDetails user`.
* Which tenant is this new post supposed to be a part of? Also easy: we call `TenantContext.getCurrentTenantId()`.

Like so:
```java
@PostMapping("add_post")
@PreAuthorize("isAuthenticated() && !hasRole('ADMINISTRATOR')")
public String addPost(@AuthenticationPrincipal CustomUserDetails user,
                      @Valid NewPostViewModel postVm) {
    var tenantId = TenantContext.getCurrentTenantId();
    postService.addPost(user.getUserId(), tenantId, postVm.getText());
    return "redirect:/posts";
}
```
(`@PreAuthorize` is part of [Spring Method Security](https://www.baeldung.com/spring-security-method-security))

However, `TenantContext.getCurrentTenantId` is way too much typing, and I'm way too lazy so...
We create a custom annotation:
```java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TenantId {
}
```

... a resolver:
```java
public class TenantResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(TenantId.class) != null &&
                        parameter.getParameterType().getTypeName().equals("long");
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        return TenantContext.getCurrentTenantId();
    }
}
```

... and activate it:
```java
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new TenantResolver());
    }
}
```

Now we can do this(!):
```java
@PostMapping("add_post")
@PreAuthorize("isAuthenticated() && !hasRole('ADMINISTRATOR')")
public String addPost(@TenantId long tenantId,
                      @AuthenticationPrincipal CustomUserDetails user,
                      @Valid NewPostViewModel postVm) {
    postService.addPost(user.getUserId(), tenantId, postVm.getText());
    return "redirect:/posts";
}
```

I've added support for `@Tenant String tenant` parameters as well. Very convenient because `@ControllerAdvice` can be used to add the tenant to the MVC model for _all_ controllers.
```java
@ControllerAdvice
public class GlobalControllerAdvice {
    private static final Logger LOGGER = Logger.getLogger(GlobalControllerAdvice.class.getName());

    @ModelAttribute("tenant")
    public String populateTenantName(@Tenant String tenant) {
        return tenant;
    }
}
```

On any of my Thymeleaf pages, I can now show the tenant:
```xml
<span th:text="${tenant}"></span>
```
... or test on it:
```xml
<span th:if="${tenant != null}"></span>
```

Check the full source [here](https://gitlab.com/kdg-ti/integration-4/guides/multitenancy).

## Cross-tenant security breaches

The web application works fine at this stage, but unfortunately, it's not fully secure yet. Once a user authenticates, she'll be handed a cookie called "JSESSIONID" that could appear as if it's tenant-specific, but it isn't. When a request is made using this cookie, Spring Security will check the validity of this cookie without taking tenants into account. In fact, Spring Security doesn't know what a tenant is.

Let's imagine I've authenticated against `tenant2`, where I'm a tenant administrator. I've been given a cookie (yum!) that I will now use to make hand-crafted requests against `tenant1`:
```http
DELETE http://tenant1.localhost:8080/api/posts/1
Cookie: JSESSIONID=DF07D6D7C7CB9652830ABB3E108F20C7
```
Without any additional checks, I'll be able to delete the posts of the other tenants. (CSRF has been left out of the equation in the example)

### More filters

This `TenantAuthorizationFilter` compares the **tenant of the request** (taken from the URL) against the **tenant of the user** (taken from `CustomUserDetails`).
```java
public class TenantAuthorizationFilter extends OncePerRequestFilter {
    private static final Logger LOGGER =
            Logger.getLogger(TenantAuthorizationFilter.class.getName());

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        var tenantId = TenantContext.getCurrentTenantId();
        var authentication = SecurityContextHolder.getContext().getAuthentication();
        var user = authentication == null ? null : (CustomUserDetails) authentication.getPrincipal();
        var userTenantId = user == null ? null : user.getTenantId();

        if (user == null || Objects.equals(tenantId, userTenantId)) {
            chain.doFilter(request, response);
        } else {
            LOGGER.warning("Attempted cross-tenant access.");
            response.setStatus(FORBIDDEN.value());
        }
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return request.getRequestURI().startsWith("/webjars/")
                || request.getRequestURI().startsWith("/css/")
                || request.getRequestURI().startsWith("/js/")
                || request.getRequestURI().endsWith(".ico");
    }
}
```

When should this filter kick in?
Indeed, **after** authentication has happened (because `CustomUserDetails` must be created first):
```java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // More config here...
            .addFilterBefore(new TenantFilter(tenantRepository), UsernamePasswordAuthenticationFilter.class)
            .addFilterAfter(new TenantAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
```

Speaking of cookies, my favorites are dinosaur coo--
I mean, cookies are domain-specific inside of a browser. If you have a cookie for `tenant1.localhost`, the browser will _not_ use it when making requests against `tenant2.localhost`. That's great because tenants' websites will feel separated and disconnected, as they should be.
That also means that the problem highlighted above _only_ applies to hand-crafted HTTP requests (Postman, `.http` file, ...).

Schematically, the situation that we've got ourselves into now looks like this:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x2v40b6y99evbw3k7jtr.png)

## Styling and customization

A quick and easy way to give tenants their own customizable style would be to do something like this:
```java
@RestController
@RequestMapping("/style")
public class StyleController {
    @GetMapping(value = "tenantStyle.css", produces = {"text/css"})
    public String getTenantStyle(@Tenant String tenant) {
        if (tenant == null) {
            return "body { background-color: #fcd2d2; }";
        } else if (tenant.equals("tenant1")) {
            return "body { background-color: #ebebfc; }";
        } else if (tenant.equals("tenant2")) {
            return "body { background-color: #edfceb; }";
        } else {
            return "";
        }
    }
}
```

Obviously, this is a _very_ hard-coded approach. Configurable colors could be stored in the database and retrieved from a service here. A `CssBuilder` could be invented to do the heavy lifting, and caching could be added since the CSS won't change much.

## Possible improvements and additions

* The `@Tenant` parameter annotation could be made compatible with a custom `TenantDetails` interface (similar to Spring Security's `UserDetails`). This interface could have `getTenant` and `getTenantId` methods. The `TenantDetails` class could be extended with a tenant name, logo (file path), etc.
* Error handling is quite basic right now. There's no form validation and error reporting. Redirecting after login success/failure has not been configured.
* Thymeleaf code could be cleaned up by moving the general administration navbar and pages into separate fragments and/or include files.
* For cleaner controller code, [Method Security Meta-Annotations](https://www.baeldung.com/spring-security-method-security#5-method-security-meta-annotation) such as `@AdminOnly` and `@TenantUserOnly` can be created.
* [Spring Caching](https://www.baeldung.com/spring-cache-tutorial) can be used to cache the lookup of the tenant ID (inside `TenantFilter`) and to cache calls to the `StyleController` because they trigger _a lot_! `@Cacheable` is incredible.

## Summary

As you know, all source code is [here](https://gitlab.com/kdg-ti/integration-4/guides/multitenancy).
All multitenant specifics are covered above, but I recommend pulling the repository for a more complete picture. Do make sure to read the `README.md` for instructions on setting up the database. All you need is docker and a JVM.

I hope this writeup has given you the information you were looking for! Any comments are appreciated! Definitely let me know if you have any suggestions or recommendations.