/ Java

Multi-Tenant Rest API With Spring Boot

In this post, I'll describe the necessary steps to set up a schema-based multi-tenant REST API with Spring Boot. The application relies on Flyway to automate provisioning and de-provisioning of tenants. Check out the sample project at https://github.com/aliprax/sboot-schema-multitenancy

Intro

Schema-based multi-tenancy

Multi-tenancy is a virtualization technique that allows to multiple groups for users (tenants) to share a single software instance. Dealing with multiple tenants force to decide the correct strategy to separate the data of different tenants. Such strategy has implications for cost, maintenance, security and development efforts.

There exist several strategies to implement multitenancy that differs from the level of isolation and scalability across tenants:

  • discriminator column: in all database tables a column tells the tenant that owns the row. Multi-tenancy is not transparent to the application, there is no isolation, difficult to scale.
  • schema-based multitenancy: database schemas are used to separate tenants. This approach is almost transparent to the application, i.e. queries can be written without thinking about tenants.
  • single database per tenant: most expensive but provides the best level of isolation and scalability.
    A mixed approach that preserves scalability is to shard a single database instance across different tenants but to keep multiple databases to scale in the number of tenants.

The request flow

The process to establish a multi-tenant communication is usually composed of 3 steps:

  1. accept the incoming connection, authenticating the user if necessary.
  2. identifying the tenant for which the user is issuing the request.
  3. establish a connection with the database and schema of the tenant.

Tenant identification is performed against a catalog, or a default schema, which contains connection information. A user can authenticate himself on an external service and then select the tenant by using an HTTP header or a specific Host.

Sample project

This example describes how to implement a schema-based multi-tenancy with spring boot. To keep things simple, it does not implement any kind of authentication. Tenant identification is implemented with a custom HTTP header. The following paragraphs discuss the most interesting steps.
It is available here: https://github.com/aliprax/sboot-schema-multitenancy

The default schema

It contains the information about all the tenants installed.

create table tenants (
    uuid varchar(255) primary key,
    tenant_name varchar(255),
    schema_name varchar(255),
    created_at timestamp,
    updated_at timestamp,
    constraint UC_TENANT_NAME unique (tenant_name) ,
    constraint UC_SCHEMA_NAME unique (schema_name)
);

Identifying tenants

A Spring Interceptor sets the tenant according to a HTTP header.
It uses the "default schema" to find the database schema associated to the tenant.
The selected tenant is stored in a ThreadLocal variable that is cleared after the request is completed.
It is configured in the WebMvcConfig class.

@Component
public class TenantInterceptor extends HandlerInterceptorAdapter {

    Logger logger = LoggerFactory.getLogger(getClass());
    {
        logger.debug("Creating TenantInterceptor interceptor");
    }

    @Autowired
    TenantRepository repository;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String tenantUuid = request.getHeader("tenant-uuid");
        String tenantSchema = tenantUuid!=null? repository.findById(tenantUuid)
                .orElseThrow(()->new RuntimeException("Tenant not found"))
                .getSchemaName() : null;
        logger.debug("Set TenantContext: {}",tenantSchema);
        TenantContext.setTenantSchema(tenantSchema);
        return true;
    }

    @Override
    public void postHandle(
            HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)
            throws Exception {
        logger.debug("Clear TenantContext: {}",TenantContext.getTenantSchema());
        TenantContext.setTenantSchema(null);
    }

}

Hibernate configuration

Hibernate configuration is made up by two pieces:

  • a CurrentTenantIdentifierResolver: it tells hibernate which is the current configured tenant. It uses the previous ThreadLocal variable set by the interceptor.
  • A MultiTenantConnectionProvider: creates the connection according to the tenant identifier.
  • A HibernateConfig class that compose the pieces and configures the LocalContainerEntityManagerFactoryBean.
@Component
public class TenantSchemaResolver implements CurrentTenantIdentifierResolver {

	private String defaultTenant ="DEFAULT_SCHEMA";

    @Override
	public String resolveCurrentTenantIdentifier() {
		String t =  TenantContext.getTenantSchema();
		if(t!=null){
			return t;
		} else {
			return defaultTenant;
		}
	}

	@Override
	public boolean validateExistingCurrentSessions() {
		return true;
	}

}
@Component
public class TenantConnectionProvider implements MultiTenantConnectionProvider {

	private static Logger logger = LoggerFactory.getLogger(TenantConnectionProvider.class);
	private String DEFAULT_TENANT = FlywayConfig.DEFAULT_SCHEMA;
	private DataSource datasource;

	public TenantConnectionProvider(DataSource dataSource) {
		this.datasource = dataSource;
	}

	@Override
	public Connection getAnyConnection() throws SQLException {
		return datasource.getConnection();
	}

	@Override
	public void releaseAnyConnection(Connection connection) throws SQLException {
		connection.close();
	}

	@Override
	public Connection getConnection(String tenantIdentifier) throws SQLException {
		logger.debug("Get connection for tenant {}", tenantIdentifier);
		final Connection connection = getAnyConnection();
		connection.setSchema(tenantIdentifier);
		return connection;
	}

	@Override
	public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
		logger.debug("Release connection for tenant {}", tenantIdentifier);
		connection.setSchema(DEFAULT_TENANT);
		releaseAnyConnection(connection);
	}

	@Override
	public boolean supportsAggressiveRelease() {
		return false;
	}

	@SuppressWarnings("rawtypes")
	@Override
	public boolean isUnwrappableAs(Class unwrapType) {
		return false;

	}

	@Override
	public <T> T unwrap(Class<T> unwrapType) {
		return null;
	}

}

Migrations

This is a configuration for Flyway that runs the migrations for the default schema of the API as well as the tenants' ones.

@Configuration
public class FlywayConfig {

    public static String DEFAULT_SCHEMA = "DEFAULT_SCHEMA";

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Bean
    public Flyway flyway(DataSource dataSource) {
        logger.info("Migrating default schema ");
        Flyway flyway = new Flyway();
        flyway.setLocations("db/migration/default");
        flyway.setDataSource(dataSource);
        flyway.setSchemas("DEFAULT_SCHEMA");
        flyway.migrate();
        return flyway;
    }

    @Bean
    public Boolean tenantsFlyway(TenantRepository repository, DataSource dataSource){
        repository.findAll().forEach(tenant -> {
            String schema = tenant.getSchemaName();
            Flyway flyway = new Flyway();
            flyway.setLocations("db/migration/tenants");
            flyway.setDataSource(dataSource);
            flyway.setSchemas(schema);
            flyway.migrate();
        });
        return true;
    }

}

Provisioning controller

With a simple rest controller we can automate the creation and the deletion of tenants.

@RestController
@RequestMapping(value = "/tenants")
public class TenantController {

    private TenantRepository repository;

    private DataSource dataSource;

    public TenantController(TenantRepository repository, DataSource dataSource) {
        this.repository = repository;
        this.dataSource = dataSource;
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseStatus(HttpStatus.CREATED)
    @Transactional
    public Tenant createTenant(@RequestBody Tenant tenant){
        tenant = repository.save(tenant);
        String schema = tenant.getSchemaName();
        if(schema==null) throw new RuntimeException("schema is null");
        Flyway flyway = new Flyway();
        flyway.setLocations("db/migration/tenants");
        flyway.setDataSource(dataSource);
        flyway.setSchemas(schema);
        flyway.migrate();
        return tenant;
    }

    @DeleteMapping("/{uuid}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteTenant(@RequestParam String uuid){
        repository.deleteById(uuid);
    }

    @GetMapping
    public Page<Tenant> getTenants(Pageable pageable){
        return repository.findAll(pageable);
    }

}