# Multitenancy with Spring
Observation:
> All spring implementations are the same
| Feature | Multi Databases | RLS | Schema |
| -------------------- | ----------------------- | -------------- | --------------------------- |
| DB Migrations | 1 migration for each db | 1 migration | 1 migration for each schema |
| DB deployment | Create a new DB + migration | Create User | Create User + Schema + migration |
| Security | High | Normal | Normal Plus |
| Cost | High | Normal | Normal |
| Performance | High | Normal | Normal |
| Code Maintainability | Easy | Easy | Easy |
| Testing | Test each config | Test each config | Test each config |
| MS deployment | Lots of config | Lots of config | Lots of config |
## Pros/Cons
Pros of all cases:
1. Maintainability of code
i. Spring code is easy to mantain and do not affect current codebase
2. Improved security on data leaks between vendors
Cons of all cases:
1. Micro-services deployment aspects
i. All credentials should be mapped on helm chart, hence it will become very big
ii. Also include mapping of vendors to credentials on application.yaml
2. Testing
i. All tenants should be tested
Multi Databases:
1. Security
i. There is less chance of exposing all data at same time and data is very segregated.
ii. Possible source of security incidents: wrong configuration of vendor per database on microsservice
2. Performance vs Cost:
i. Most performant
ii. Most expensive
iii. There is data redundancy (on trained models and labels)
3. Maintainability of code
ii. New tests to consider multiple database case
4. Database deployment and migrations
i. Create a new database for each vendor
ii. Migration multiple databases (might requer new scripts, no standard on spring)
iii. Trained models and labels must be inserted on some/all databases, so we might need special ways of handling it
Row Level Security:
1. Security
i. Data is segregated on one database per multiple users
ii. Possible source of security incidents: wrong configuration of vendor per database on microsservice, wrong configuration on database, wrong queries on tables without RLS (labels - trained models)
iii. Care should be taken with admin user, as RLS does not apply to admin
iiii. Technically, on our architecture, one vendor could create config to another vendor
2. Performance vs Cost:
i. Performant
ii. There is no data redundancy
iii. Much cheaper and scallable
4. Database deployment and migrations
i. Create a new user for each vendor
iii. Activate RLS on new tables that depends on vendor_id
ii. Migration on 1 database only
Multi - Schema:
1. Security
i. Data is segregated on one database per multiple users
ii. Possible source of security incidents: wrong configuration of vendor per database on microsservice, wrong configuration on database, wrong queries on tables sharing schemas (if choosen to share schema)
iii. Care should be taken with admin user, as RLS does not apply to admin
2. Performance vs Cost:
i. Performant
ii. There is no data redundancy if schema is shared, otherwise there is redundancy
iii. Much cheaper and scallable
4. Database deployment and migrations
i. Create a new user and schema for each vendor
ii. Migration for each schema
iii. handling shared schema (if enabled) might become complex
## 1 - Row Level Secury
To setup the Row Level Security on the database, follow those steps:
1. Enable RLS on tables and include
``` sql
ALTER TABLE configs ENABLE ROW LEVEL SECURITY;
```
2. Create policy for filtering rows given the user
``` sql
CREATE POLICY config_vendor_id ON configs
USING (vendor_id = CAST (current_user AS uuid));
```
3. Create users for each vendor (the username of the user must be the vendor id)
``` sql
CREATE USER "1a5f8705-f07c-4f20-bc67-f379c94b0a90" WITH PASSWORD '123';
GRANT ALL ON ALL TABLES IN SCHEMA public TO "1a5f8705-f07c-4f20-bc67-f379c94b0a90";
```
Besides configuring the database, we also need to include the tenants on the application.yaml file:
``` yaml
multitenancy:
tenants:
- vendorId: 1a5f8705-f07c-4f20-bc67-f379c94b0a90
url: ${POSTGRESQL_CONNECTION_STRING}
username: 1a5f8705-f07c-4f20-bc67-f379c94b0a90
password: ${POSTGRESQL_PASSWORD_1}
- vendorId: 13b246ee-fda3-477c-9a32-69bd6a67794f
url: ${POSTGRESQL_CONNECTION_STRING}
username: 13b246ee-fda3-477c-9a32-69bd6a67794f
password: ${POSTGRESQL_PASSWORD_2}
```
That is enough !
**Warning**
> For testing purpose, I wasn't able to replicate that with testcontainers, but a practical test worked though.
>
## 2 - Multiple Databases
Create multiple tenants on the application.yaml (one for each database you have)
``` yaml
multitenancy:
tenants:
- vendorId: 1a5f8705-f07c-4f20-bc67-f379c94b0a90
url: ${POSTGRESQL_CONNECTION_1_STRING}
username: username1
password: ${POSTGRESQL_PASSWORD_1}
driverClassName: org.postgresql.Driver
- vendorId: 1a5f8705-f07c-4f20-bc67-f379c94b0a90
url: ${POSTGRESQL_CONNECTION_2_STRING}
username: username2
password: ${POSTGRESQL_PASSWORD_2}
driverClassName: org.postgresql.Driver
```
## 3 - Multiple schemas
Multiples schemas works very like the multiple databases
This SQL creates the new schema and gives access:
``` sql
CREATE SCHEMA IF NOT EXISTS myschema
GRANT USAGE ON SCHEMA myschema TO "1769e9ef-722b-4c19-b136-b3fc69ea15e8";
GRANT ALL ON ALL TABLES IN SCHEMA myschema TO "1769e9ef-722b-4c19-b136-b3fc69ea15e8";
```
``` yaml
multitenancy:
tenants:
- vendorId: 1a5f8705-f07c-4f20-bc67-f379c94b0a90
url: ${POSTGRESQL_CONNECTION_STRING}
username: username1
password: ${POSTGRESQL_PASSWORD_1}
schema: schema1
- vendorId: 13b246ee-fda3-477c-9a32-69bd6a67794f
url: ${POSTGRESQL_CONNECTION_STRING}
username: username2
password: ${POSTGRESQL_PASSWORD_2}
schema: schema2
```
## 0 - Java Setup
In spring, there is a feature called AbstractRoutingDataSource, which allows us to "route" resources given some variable. This is a very generic methodology that handles all cases.
In order make this work, I need to implement some classes:
1. Data class that has a ThreadLocal<String> variable with set/getter
2. Class that extends AbstractRoutingDataSource and provides the determineCurrentLookupKey
3. Bean that extends javax.servelet.Filter (and sets dataclass given the request)
4. Configuration class that provides the DataSource bean
5. Dataclasses to load properties from application.yaml
### Tenant Context
This class holds the information for which tenant should use for each thread, that is why I use ThreadLocal, otherwise one thread would interfere in others threads.
``` java
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static String getCurrentTenant() {
return CURRENT_TENANT.get();
}
public static void setCurrentTenant(String tenant) {
CURRENT_TENANT.set(tenant);
}
}
```
### Routing Data Source
This class connects our TenantContext with Spring's routing data source functionallities. Implementing this class is enough for Spring give each thread the correct connection with the database.
``` java
public class MultitenantDataSource extends AbstractRoutingDataSource {
@Override
protected String determineCurrentLookupKey() {
return TenantContext.getCurrentTenant();
}
public void close(){
Map<Object, DataSource> resolvedDataSources = this.getResolvedDataSources();
resolvedDataSources.forEach( (o, dataSource) -> ((HikariDataSource) dataSource).close() );
}
}
```
### Servelet Filter
One of this implementation's goals is to remove the logic of which tenant to use from the rest of our services business logic, so we don't incur in errors when querying the database for the wrong tenant. The best way to protect our services is to have a specific filter that acts before running our business logic, so we can create a filter that routes the correct database connection upon receiving a connection.
``` java
@Component
@AllArgsConstructor
@Order(1)
class TenantFilter implements Filter {
JwtParser jwtParser;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
final String authorization = req.getHeader(HttpHeaders.AUTHORIZATION);
String vendorId = jwtParser.parseVendorId(authorization);
if (StringUtils.isBlank(vendorId)) {
vendorId = req.getHeader(VENDOR_ID_HEADER);
}
TenantContext.setCurrentTenant(vendorId);
try {
chain.doFilter(request, response);
} finally {
TenantContext.setCurrentTenant("");
}
}
}
```
This class is able to get the tenant name from the header or from the token.
### Configuration
``` java
@Configuration
@EnableConfigurationProperties(MultitenancyConfigurationProperties.class)
public class MultitenancyConfiguration {
private final MultitenancyConfigurationProperties multitenancyProperties;
public MultitenancyConfiguration(MultitenancyConfigurationProperties multitenancyProperties) {
this.multitenancyProperties = multitenancyProperties;
}
@Primary
@Bean(destroyMethod = "close")
public DataSource dataSource() {
Map<Object, Object> resolvedDataSources = new HashMap<>();
for (TenantConfigurationProperty properties : this.multitenancyProperties.getTenants()) {
HikariConfig hikariConfig = new HikariConfig();
String tenantId = properties.getVendorId();
if (StringUtils.isBlank(properties.getDriverClassName())) {
hikariConfig.setDriverClassName("org.postgresql.Driver");
} else {
hikariConfig.setDriverClassName(properties.getDriverClassName());
}
hikariConfig.setUsername(properties.getUsername());
hikariConfig.setPassword(properties.getPassword());
hikariConfig.setJdbcUrl(properties.getUrl());
resolvedDataSources.put(tenantId, new HikariDataSource(hikariConfig));
}
MultitenantDataSource dataSource = new MultitenantDataSource();
dataSource.setDefaultTargetDataSource(resolvedDataSources.get("default"));
dataSource.setTargetDataSources(resolvedDataSources);
dataSource.afterPropertiesSet();
return dataSource;
}
}
```
### Properties
Those last classes are responsible for loading the properties from the application.yaml
``` java
@Data
@ToString
@ConfigurationProperties(prefix = "multitenancy")
@Validated
public class MultitenancyConfigurationProperties {
@NotEmpty
private List<TenantConfigurationProperty> tenants;
}
```
``` java
@Data
@ToString
@Validated
public class TenantConfigurationProperty {
@NotEmpty
private String vendorId;
@NotEmpty
private String url;
@NotEmpty
private String password;
@NotEmpty
private String username;
private String driverClassName;
}
```