I am trying to implement multi-tenancy in spring. I ma using JWT, and I have the tenant_id stored in the JWT, I have configured my class MultiTenantDataSourceRouter extending AbstractRoutingDataSource to have the lookup key to be the tenant_id.
@Override
protected Object determineCurrentLookupKey() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication!= null && authentication.getPrincipal() instanceof User user) {
return user.getTenant_id();
}
return null;
}
I also have a controller method that logins in a user. Upon login, I want to create an object of the user and store it in the database that is suppose to be particular to that Tenant
public ResponseEntity<AuthenticationResponse> loginUser(AuthenticateRequest authenticateRequest) {
//authenticate the User
authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(authenticateRequest.getEmail(),authenticateRequest.getPassword()));
//this line of code runs oly if authentication is successful
User user = userRepository.findByEmail(authenticateRequest.getEmail()).orElseThrow();
String jwt = jwtService.generateToken(user);
//set the security context to allow routing of db and to know the tenantID and also set the datasource
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(user.getUsername(),null,user.getAuthorities()));
var mds = new MultiTenantDataSourceRouter();
mds.setTargetDataSources(dataSourceConfig.loadAllDataSources());
mds.afterPropertiesSet();
//create an Employee Object for the user once
Employee employee = employeeRepository.findByEmail(user.getEmail());
if(employee!= null){
Employee newEmployee = Employee.builder()
.email(user.getEmail())
.name(user.getName())
.build();
}
return ResponseEntity.ok().body(new AuthenticationResponse(jwt));
}
Upon start of the app I have the datasources loaded (dataSourceConfig.loadAllDataSources()) this is a bean that returns the Map of tenant_id and the corresponding datasources.But when I call this method the default datasource loaded in the app through app.properties is where the employee object is where the findbyEmail() method is being looked for. I am expecting that it looks for it inside the tenant associated with that user but it doesnt
This results in an error because my default database does not have employee table.
I have edited the TenantRouterClass. I now have the class below. The idea is that when the ap starts up, it will check the default tenant store, which contains information used to create the other datasources available.
package dev.iyanu.multitenancy.security_config;
import com.zaxxer.hikari.HikariDataSource;
import dev.iyanu.multitenancy.entities.Tenants;
import dev.iyanu.multitenancy.repository.TenantRepository;
import dev.iyanu.multitenancy.users.User;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.security.core.context.SecurityContextHolder;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Configuration
@Slf4j
public class TenantManager {
private final TenantRepository tenantRepository;
private final Map<Object,Object> tenantDataSources = new ConcurrentHashMap<>();
private final DataSource dataSource;
@Getter
private Object currentTenant;
private AbstractRoutingDataSource dataRouter;
public TenantManager(TenantRepository tenantRepository, DataSource dataSource) {
this.tenantRepository = tenantRepository;
this.dataSource = dataSource;
}
//before you do a save or interact with the db call this method
public void setCurrentTenant() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if(auth != null && auth.getPrincipal() instanceof User user){
this.currentTenant = user.getTenantId();
}
}
@PostConstruct
public DataSource loadAllDataSources(){
dataRouter = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return currentTenant;
}
};
List<Tenants> tenantsList = tenantRepository.findAll();
if(!tenantsList.isEmpty()){
for(Tenants tenant : tenantsList){
DataSource tenantDatasource = createDataSource(tenant);
tenantDataSources.put(tenant.getTenantId(),tenantDatasource);
}
}
tenantDataSources.forEach((tenantId, ds)->{
var schemaInitializer = new ResourceDatabasePopulator(new ClassPathResource("schema.sql"));
schemaInitializer.execute((DataSource) ds);
});
dataRouter.setDefaultTargetDataSource(dataSource);
System.out.println("Default DataSource: "+dataSource);
dataRouter.setTargetDataSources(tenantDataSources);
System.out.println("DataSources loaded from my repo: "+tenantDataSources);
dataRouter.afterPropertiesSet();
return dataRouter;
}
public void addTenant(Tenants tenants){
//create the Db for the Client
createDatabase(tenants);
//create datasource for the client
DataSource newDataSource = createDataSource(tenants);
//just to check that the new tenant added already has his datasource active
try(Connection c = newDataSource.getConnection()){
tenantDataSources.put(tenants.getTenantId(),newDataSource);
dataRouter.afterPropertiesSet();
System.out.println("The new tenant datasource is live");
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private DataSource createDataSource(Tenants tenants){
var properties = new DataSourceProperties();
properties.setPassword("root");
properties.setUsername("root");
properties.setDriverClassName("com.mysql.cj.jdbc.Driver");
properties.setUrl("jdbc:mysql://localhost:3306/"+tenants.getDatabaseName());
return properties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
private void createDatabase(Tenants tenants){
try(Connection connection = dataSource.getConnection()){
var databaseName = StringUtils.substringBefore(tenants.getName()," ");
Statement statement = connection.createStatement();
statement.executeUpdate("CREATE DATABASE "+databaseName);
log.info("Database for {} created successfully", tenants.getName());
}catch (SQLException e){
e.printStackTrace();
throw new RuntimeException();
}
}
}
So when a user tries to login, I authenticate the user, set the securitycontextholder and the call the setCurrentTenant() method before making a call to the Emloyee.findByEmail(email) method. I expect the method to call the datasource associated with the call but it didnt, it still routed to the defaultDatabase that has been set
public ResponseEntity<AuthenticationResponse> loginUser(AuthenticateRequest authenticateRequest) {
//authenticate the User
authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(authenticateRequest.getEmail(),authenticateRequest.getPassword()));
//this line of code runs oly if authentication is successful
User user = userRepository.findByEmail(authenticateRequest.getEmail()).orElseThrow();
String jwt = jwtService.generateToken(user);
//set the security context to allow routing of db and to know the tenantID and also set the datasource
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities()));
tenantManager.setCurrentTenant();
Object currentTenant = tenantManager.getCurrentTenant();
System.out.println(currentTenant);
//create an Employee Object for the user once
Employee employee = employeeRepository.findByEmail(user.getEmail());
if(employee!= null){
Employee newEmployee = Employee.builder()
.email(user.getEmail())
.name(user.getName())
.build();
}
return ResponseEntity.ok().body(new AuthenticationResponse(jwt));
}
The currentTenant being printed is the the one associated with the datasource that I am expecting but it is still not routing to the right database and routes to the default targetDataSource.
I believe you're missing a step here where you tell your application which tenant it needs to use for your employee. You're giving your multi-tenant datasource some datasources with your dataSourceConfig.loadAllDataSources(), but you're not telling it which one to use, so it uses the default one.