0

I am writing a Spring Boot Webflux app which needs to be Multi-tenant. The underlying database is MongoDB and we plan on using tenantId field in each of the collections. Consequently, every query needs to be scoped with the requesting user's tenant.

To reduce bolierplate as well as to provide safety against classic forgotten WHERE tenantId = ? problem, I am looking to

  1. Have a way to intercept the queries and add the filter on accountId on all read queries.
  2. Automatically set the tenantId on save.
  3. Reuse the Spring Boot JPA magic of writing just interfaces.

So if I execute roleRepository.findByName("something") in the context of a request, it should automatically scope it to the current tenant.

Is there a way using AspectJ to achieve this? I wasn't able to find much resource on this kind of multitenancy in spring boot even though it is a fairly common approach with libraries in other stacks.

So far I have created the structure as follows.

Model Structure:

public interface TenantScopedModel {
  public String getAccountId();

  public void setAccountId(String accountId);
}
@Data
public abstract class OptionallyTenantScopedModel implements TenantScopedModel {

  @Id
  @MongoId(FieldType.OBJECT_ID)
  protected String id;

  @Getter
  @Setter
  @Field(targetType = FieldType.OBJECT_ID)
  @Indexed
  protected String accountId;

  @Getter
  @Setter
  protected boolean isDeleted = false;

  public OptionallyTenantScopedModel() {
    this.id = new ObjectId().toString();
  }
}

Example Model:

@EqualsAndHashCode(callSuper = true)
@Data
@Valid
@Document(collection = "roles")
public class Role extends OptionallyTenantScopedModel {

  @NotBlank
  private String roleName;

  @NotBlank
  private boolean systemDefined = false;

}

TenantScopedRepository

@NoRepositoryBean
public interface TenantScopedRepository<T extends TenantScopedModel, ID> extends ReactiveMongoRepository<T, ID> {
}

1 Answers1

0

So if I execute roleRepository.findByName("something") in the context of a request, it should automatically scope it to the current tenant"

The best way to do this, in my opinion, is to override/intercept ReactiveMongoTemplate enhance find or other methods query parameter. Pass tenant id in all requests using thread local by using reactive context switching libraries (detailed below).

SimpleMongoRepository is default implementation for repository interface for MongoDB and it internally calls ReactiveMongoTemplate for all queries.

So, steps to automatically scope current tenant for find query methods should be

  1. Create CustomReactiveMongoTemplate extending ReactiveMongoTemplate to extend any desired methods to enhance queries with account id.
public class CustomReactiveMongoTemplate extends ReactiveMongoTemplate{
    public CustomReactiveMongoTemplate(ReactiveMongoDatabaseFactory mongoDatabaseFactory) {
        super(mongoDatabaseFactory);
    }
    //custom methods
...
  1. Create Configuration to call custom template by returning custom instance of class for Bean ReactiveMongoRepository
@Configuration
public class MongoConfig {

    @Autowired
    ReactiveMongoDatabaseFactory reactiveMongoDatabaseFactory;

    @Bean
    public ReactiveMongoTemplate reactiveMongoTemplate(){
        CustomReactiveMongoTemplate template = new CustomReactiveMongoTemplate(reactiveMongoDatabaseFactory);
        return template;
    }
...
  1. Add custom filter to add account id to request reactive pipeline context
public class TenantContextWebFilter implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        return chain.filter(exchange).contextWrite(Context.of("TENANT_ID", *value from header/request etc.*));
...
  1. Ensure context to local thread switching library is in scope. (io.micrometer)
public class TenantContext {

    public static ThreadLocal<String> TENANT_ID = new ThreadLocal<>();
    
}
...
    public static void main(String[] args) {
        Hooks.enableAutomaticContextPropagation();
        ContextRegistry
            .getInstance()
            .registerThreadLocalAccessor("TENANT_ID", TenantContext.TENANT_ID::get, TenantContext.TENANT_ID::set, TenantContext.TENANT_ID::remove);
    
... 
  1. Override all required methods in CustomReactiveMongoTemplate to add account id from ThreadLocal
    @Override
    <S, T> Flux<T> doFind(String collectionName, CollectionPreparer<MongoCollection<Document>> collectionPreparer,
            Document query, Document fields, Class<S> sourceClass, Class<T> targetClass,
            FindPublisherPreparer preparer) {
        query.append("TENANT_ID", TenantContext.TENANT_ID.get());
        return super.doFind(collectionName, collectionPreparer, query, fields, sourceClass, targetClass, preparer);
    }

(This is package private method called when you use find query methods like findByName etc. Since this is package private best way to intercept these is Spring aspects instead of overriding class)

In my opinion, it is not worth it, doing all this to automatically add tenant id in all queries because

  1. There are many different methods called in MongoTemplate depending on type of query. There are methods for count, distinct, upsert, delete , findandmondify etc. To ensure that we add tenant id in all scenarios is error prone.
  2. Passing tenant id using context switching using ThreadLocal should work in most of scnearios with complex operators etc. but might not cover each and every edge cases. It will be very difficult to catch defects where tenant id is incorrectly associated or blank because of thread switches.
  3. Last but not least , when someone reads methods like findByName, they expect find by name only and not find by name and by tenant id.
  • Thanks for your detailed answer. However automatic Shared Database, Shared Schema multi-tenancy is a standard feature in most frameworks nowadays. In Rails we have ActsAsTenant, in Django we have django-multitenant and so on. I was looking for a reactive library, so I chose Spring Boot Webflux, but looks like I would reconsider this choice for my MVP. – Arpan Mukherjee Jun 30 '23 at 05:56