Skip to content

Commit ca39cd9

Browse files
RandomTannenbaumManuelMoeri
authored andcommitted
Implement different db users to seperate the tenants from each other
1 parent e9caa16 commit ca39cd9

18 files changed

+780
-150
lines changed

backend/src/main/java/ch/puzzle/okr/multitenancy/FlywayMultitenantMigrationInitializer.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import jakarta.persistence.EntityNotFoundException;
44
import org.flywaydb.core.Flyway;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
57
import org.springframework.beans.factory.annotation.Value;
68
import org.springframework.stereotype.Component;
79

@@ -10,6 +12,8 @@ public class FlywayMultitenantMigrationInitializer {
1012
private final TenantConfigProviderInterface tenantConfigProvider;
1113
private final String[] scriptLocations;
1214

15+
private static final Logger logger = LoggerFactory.getLogger(FlywayMultitenantMigrationInitializer.class);
16+
1317
public FlywayMultitenantMigrationInitializer(TenantConfigProviderInterface tenantConfigProvider,
1418
final @Value("${spring.flyway.locations}") String[] scriptLocations) {
1519
this.tenantConfigProvider = tenantConfigProvider;
@@ -20,9 +24,11 @@ public void migrateFlyway() {
2024
this.tenantConfigProvider.getTenantConfigs().forEach((tenantConfig) -> {
2125
TenantConfigProvider.DataSourceConfig dataSourceConfig = this.tenantConfigProvider
2226
.getTenantConfigById(tenantConfig.tenantId())
23-
.map(TenantConfigProvider.TenantConfig::dataSourceConfig).orElseThrow(
27+
.map(TenantConfigProvider.TenantConfig::dataSourceConfigFlyway).orElseThrow(
2428
() -> new EntityNotFoundException("Cannot find tenant for configuring flyway migration"));
2529

30+
logUsedHibernateConfig(dataSourceConfig);
31+
2632
Flyway tenantSchemaFlyway = Flyway.configure() //
2733
.dataSource(dataSourceConfig.url(), dataSourceConfig.name(), dataSourceConfig.password()) //
2834
.locations(scriptLocations) //
@@ -32,6 +38,9 @@ public void migrateFlyway() {
3238

3339
tenantSchemaFlyway.migrate();
3440
});
41+
}
3542

43+
private void logUsedHibernateConfig(TenantConfigProvider.DataSourceConfig dataSourceConfig) {
44+
logger.error("use DbConfig: user={}", dataSourceConfig.name());
3645
}
3746
}

backend/src/main/java/ch/puzzle/okr/multitenancy/HibernateContext.java

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
package ch.puzzle.okr.multitenancy;
22

3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
35
import org.springframework.core.env.ConfigurableEnvironment;
46

57
import java.util.Properties;
68

9+
/**
10+
* Reads the (not tenant specific) hibernate configuration form the "hibernate.x" properties in the
11+
* applicationX.properties file. It then caches the configuration as DbConfig object. The data from the DbConfig object
12+
* is used by the SchemaMultiTenantConnectionProvider via getHibernateConfig() and getHibernateConfig(tenantId).
13+
*
14+
* <pre>
15+
* getHibernateConfig() returns the cached DbConfig as properties.
16+
* </pre>
17+
*
18+
* <pre>
19+
* getHibernateConfig(tenantId) patches the DbConfig data with tenant specific data (from
20+
* TenantConfigProvider) and returns the patched data as properties
21+
* </pre>
22+
*/
723
public class HibernateContext {
824
public static final String HIBERNATE_CONNECTION_URL = "hibernate.connection.url";
925
public static final String HIBERNATE_CONNECTION_USERNAME = "hibernate.connection.username";
@@ -14,6 +30,8 @@ public class HibernateContext {
1430
public static String SPRING_DATASOURCE_USERNAME = "spring.datasource.username";
1531
public static String SPRING_DATASOURCE_PASSWORD = "spring.datasource.password";
1632

33+
private static final Logger logger = LoggerFactory.getLogger(HibernateContext.class);
34+
1735
public record DbConfig(String url, String username, String password, String multiTenancy) {
1836

1937
public boolean isValid() {
@@ -29,20 +47,22 @@ private boolean hasEmptyValues() {
2947
}
3048
}
3149

50+
// general (not tenant specific) hibernate config
3251
private static DbConfig cachedHibernateConfig;
3352

53+
public static void extractAndSetHibernateConfig(ConfigurableEnvironment environment) {
54+
DbConfig dbConfig = extractHibernateConfig(environment);
55+
setHibernateConfig(dbConfig);
56+
logUsedHibernateConfig(dbConfig);
57+
}
58+
3459
public static void setHibernateConfig(DbConfig dbConfig) {
3560
if (dbConfig == null || !dbConfig.isValid()) {
3661
throw new RuntimeException("Invalid hibernate configuration " + dbConfig);
3762
}
3863
cachedHibernateConfig = dbConfig;
3964
}
4065

41-
public static void extractAndSetHibernateConfig(ConfigurableEnvironment environment) {
42-
DbConfig dbConfig = extractHibernateConfig(environment);
43-
HibernateContext.setHibernateConfig(dbConfig);
44-
}
45-
4666
private static DbConfig extractHibernateConfig(ConfigurableEnvironment environment) {
4767
String url = environment.getProperty(HibernateContext.HIBERNATE_CONNECTION_URL);
4868
String username = environment.getProperty(HibernateContext.HIBERNATE_CONNECTION_USERNAME);
@@ -60,7 +80,9 @@ public static Properties getHibernateConfig() {
6080
if (cachedHibernateConfig == null) {
6181
throw new RuntimeException("No cached hibernate configuration found");
6282
}
63-
return getConfigAsProperties(cachedHibernateConfig);
83+
var config = getConfigAsProperties(cachedHibernateConfig);
84+
logUsedHibernateConfig(config);
85+
return config;
6486
}
6587

6688
private static Properties getConfigAsProperties(DbConfig dbConfig) {
@@ -74,4 +96,48 @@ private static Properties getConfigAsProperties(DbConfig dbConfig) {
7496
properties.put(HibernateContext.SPRING_DATASOURCE_PASSWORD, dbConfig.password());
7597
return properties;
7698
}
99+
100+
public static Properties getHibernateConfig(String tenantIdentifier) {
101+
if (cachedHibernateConfig == null) {
102+
throw new RuntimeException("No cached hibernate configuration found (for tenant " + tenantIdentifier + ")");
103+
}
104+
var config = getConfigAsPropertiesAndPatch(cachedHibernateConfig, tenantIdentifier);
105+
logUsedHibernateConfig(tenantIdentifier, config);
106+
return config;
107+
}
108+
109+
private static Properties getConfigAsPropertiesAndPatch(DbConfig dbConfig, String tenantIdentifier) {
110+
Properties properties = getConfigAsProperties(dbConfig);
111+
return patchConfigAppForTenant(properties, tenantIdentifier);
112+
}
113+
114+
private static Properties patchConfigAppForTenant(Properties properties, String tenantIdentifier) {
115+
TenantConfigProvider.TenantConfig cachedTenantConfig = TenantConfigProvider
116+
.getCachedTenantConfig(tenantIdentifier);
117+
if (cachedTenantConfig == null) {
118+
throw new RuntimeException("No cached tenant configuration found (for tenant " + tenantIdentifier + ")");
119+
}
120+
121+
TenantConfigProvider.DataSourceConfig dataSourceConfigApp = cachedTenantConfig.dataSourceConfigApp();
122+
properties.put(HibernateContext.HIBERNATE_CONNECTION_USERNAME, dataSourceConfigApp.name());
123+
properties.put(HibernateContext.HIBERNATE_CONNECTION_PASSWORD, dataSourceConfigApp.password());
124+
properties.put(HibernateContext.SPRING_DATASOURCE_USERNAME, dataSourceConfigApp.name());
125+
properties.put(HibernateContext.SPRING_DATASOURCE_PASSWORD, dataSourceConfigApp.password());
126+
return properties;
127+
}
128+
129+
private static void logUsedHibernateConfig(DbConfig hibernateConfig) {
130+
logger.error("set DbConfig: user={}", hibernateConfig.username());
131+
}
132+
133+
private static void logUsedHibernateConfig(Properties hibernateConfig) {
134+
logger.error("use DbConfig: user={}",
135+
hibernateConfig.getProperty(HibernateContext.HIBERNATE_CONNECTION_USERNAME)); //
136+
}
137+
138+
private static void logUsedHibernateConfig(String tenantId, Properties hibernateConfig) {
139+
logger.error("use DbConfig: tenant={} user={}", tenantId,
140+
hibernateConfig.getProperty(HibernateContext.HIBERNATE_CONNECTION_USERNAME));
141+
}
142+
77143
}

backend/src/main/java/ch/puzzle/okr/multitenancy/SchemaMultiTenantConnectionProvider.java

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,35 @@
1515

1616
import static ch.puzzle.okr.multitenancy.TenantContext.DEFAULT_TENANT_ID;
1717

18+
/**
19+
* The central piece of code of multitenancy.
20+
*
21+
* <pre>
22+
* getConnection(tenantId) sets in each tenant request the specific db schema for the
23+
* tenant. This guarantees that each tenant always works in its own DB schema.
24+
*
25+
* getConnection(tenantId) -> Connection calls in the abstract super class the
26+
* getConnection(tenantId) -> Connection which calls the abstract
27+
* selectConnectionProvider(tenantIdentifier) -> ConnectionProvider which is implemented
28+
* in SchemaMultiTenantConnectionProvider.
29+
* </pre>
30+
*
31+
* <pre>
32+
* Some coding details:
33+
*
34+
* selectConnectionProvider(tenantId) -> ConnectionProvider returns for a tenant a
35+
* ConnectionProvider. It first checks if the ConnectionProvider for the tenant is already
36+
* cached (in connectionProviderMap). If the ConnectionProvider is cached, it returns it.
37+
* Otherwise it creates a ConnectionProvider for the tenant, cache it and return it.
38+
*
39+
* To create a ConnectionProvider for the tenant, it tries to load the configuration from
40+
* the hibernate properties. For this it uses 2 methods of HibernateContext:
41+
* getHibernateConfig() if the tenant is the DEFAULT_TENANT_ID (public) and
42+
* getHibernateConfig(tenantId) for all other tenants. With this information its then
43+
* possible to create and cache a ConnectionProvider for the tenant. If no matching
44+
* hibernate properties are found, then an exception is thrown.
45+
* </pre>
46+
*/
1847
public class SchemaMultiTenantConnectionProvider extends AbstractMultiTenantConnectionProvider<String> {
1948

2049
private static final Logger logger = LoggerFactory.getLogger(SchemaMultiTenantConnectionProvider.class);
@@ -31,15 +60,15 @@ public Connection getConnection(String tenantIdentifier) throws SQLException {
3160
return getConnection(tenantIdentifier, connection);
3261
}
3362

34-
protected Connection getConnection(String tenantIdentifier, Connection connection) throws SQLException {
63+
Connection getConnection(String tenantIdentifier, Connection connection) throws SQLException {
3564
String schema = convertTenantIdToSchemaName(tenantIdentifier);
3665
logger.debug("Setting schema to {}", schema);
3766

3867
connection.createStatement().execute(String.format("SET SCHEMA '%s';", schema));
3968
return connection;
4069
}
4170

42-
private String convertTenantIdToSchemaName(String tenantIdentifier) {
71+
String convertTenantIdToSchemaName(String tenantIdentifier) {
4372
return Objects.equals(tenantIdentifier, DEFAULT_TENANT_ID) ? tenantIdentifier
4473
: MessageFormat.format("okr_{0}", tenantIdentifier);
4574
}
@@ -54,13 +83,13 @@ protected ConnectionProvider selectConnectionProvider(String tenantIdentifier) {
5483
return getConnectionProvider(tenantIdentifier);
5584
}
5685

57-
protected ConnectionProvider getConnectionProvider(String tenantIdentifier) {
86+
ConnectionProvider getConnectionProvider(String tenantIdentifier) {
5887
return Optional.ofNullable(tenantIdentifier) //
5988
.map(connectionProviderMap::get) //
60-
.orElseGet(() -> createNewConnectionProvider(tenantIdentifier));
89+
.orElseGet(() -> createAndCacheNewConnectionProvider(tenantIdentifier));
6190
}
6291

63-
private ConnectionProvider createNewConnectionProvider(String tenantIdentifier) {
92+
private ConnectionProvider createAndCacheNewConnectionProvider(String tenantIdentifier) {
6493
return Optional.ofNullable(tenantIdentifier) //
6594
.map(this::createConnectionProvider) //
6695
.map(connectionProvider -> {
@@ -78,29 +107,25 @@ private ConnectionProvider createConnectionProvider(String tenantIdentifier) {
78107
.orElse(null);
79108
}
80109

81-
protected Properties getHibernatePropertiesForTenantIdentifier(String tenantIdentifier) {
82-
Properties properties = getHibernateProperties();
83-
if (properties == null || properties.isEmpty()) {
84-
throw new RuntimeException("Cannot load hibernate properties from application.properties)");
110+
Properties getHibernatePropertiesForTenantIdentifier(String tenantIdentifier) {
111+
Properties properties = getHibernateProperties(tenantIdentifier);
112+
if (properties.isEmpty()) {
113+
throw new RuntimeException("Cannot load hibernate properties from application.properties");
85114
}
86115
if (!Objects.equals(tenantIdentifier, DEFAULT_TENANT_ID)) {
87116
properties.put(AvailableSettings.DEFAULT_SCHEMA, MessageFormat.format("okr_{0}", tenantIdentifier));
88117
}
89118
return properties;
90119
}
91120

92-
private ConnectionProvider initConnectionProvider(Properties hibernateProperties) {
121+
ConnectionProvider initConnectionProvider(Properties hibernateProperties) {
93122
Map<String, Object> configProperties = convertPropertiesToMap(hibernateProperties);
94-
DriverManagerConnectionProviderImpl connectionProvider = getDriverManagerConnectionProviderImpl();
123+
DriverManagerConnectionProviderImpl connectionProvider = new DriverManagerConnectionProviderImpl();
95124
connectionProvider.configure(configProperties);
96125
return connectionProvider;
97126
}
98127

99-
protected DriverManagerConnectionProviderImpl getDriverManagerConnectionProviderImpl() {
100-
return new DriverManagerConnectionProviderImpl();
101-
}
102-
103-
private Map<String, Object> convertPropertiesToMap(Properties properties) {
128+
Map<String, Object> convertPropertiesToMap(Properties properties) {
104129
Map<String, Object> configProperties = new HashMap<>();
105130
for (String key : properties.stringPropertyNames()) {
106131
String value = properties.getProperty(key);
@@ -109,7 +134,10 @@ private Map<String, Object> convertPropertiesToMap(Properties properties) {
109134
return configProperties;
110135
}
111136

112-
protected Properties getHibernateProperties() {
113-
return HibernateContext.getHibernateConfig();
137+
private Properties getHibernateProperties(String tenantIdentifier) {
138+
if (tenantIdentifier.equals(DEFAULT_TENANT_ID)) {
139+
return HibernateContext.getHibernateConfig();
140+
}
141+
return HibernateContext.getHibernateConfig(tenantIdentifier);
114142
}
115143
}

backend/src/main/java/ch/puzzle/okr/multitenancy/TenantConfigProvider.java

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,62 @@
11
package ch.puzzle.okr.multitenancy;
22

3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
35
import org.springframework.beans.factory.annotation.Value;
46
import org.springframework.core.env.Environment;
57
import org.springframework.stereotype.Component;
68

79
import java.text.MessageFormat;
810
import java.util.*;
911

12+
/**
13+
* Reads the configuration of the tenants (as TenantConfig objects) from the applicationX.properties and caches each
14+
* TenantConfig in the TenantConfigs class.
15+
*/
1016
@Component
1117
public class TenantConfigProvider implements TenantConfigProviderInterface {
1218
private static final String EMAIL_DELIMITER = ",";
1319
private final Map<String, TenantConfig> tenantConfigs = new HashMap<>();
1420
private final Environment env;
1521

22+
private enum DbType {
23+
bootstrap, app, fly
24+
}
25+
26+
private static final Logger logger = LoggerFactory.getLogger(TenantConfigProvider.class);
27+
1628
public TenantConfigProvider(final @Value("${okr.tenant-ids}") String[] tenantIds, Environment env) {
1729
this.env = env;
1830
for (String tenantId : tenantIds) {
1931
OauthConfig c = readOauthConfig(tenantId);
20-
tenantConfigs.put(tenantId,
21-
createTenantConfig(c.jwkSetUri(), c.frontendClientIssuerUrl(), c.frontendClientId(), tenantId));
32+
TenantConfig tenantConfig = createTenantConfig(c.jwkSetUri(), c.frontendClientIssuerUrl(),
33+
c.frontendClientId(), tenantId);
34+
35+
tenantConfigs.put(tenantId, tenantConfig);
36+
cacheTenantConfig(tenantId, tenantConfig); // cache tenantConfig for Hibernate connections
2237
}
2338
}
2439

40+
private void cacheTenantConfig(String tenantId, TenantConfig tenantConfig) {
41+
TenantConfigs.add(tenantId, tenantConfig);
42+
logCachingTenantConfig(tenantId, tenantConfig);
43+
}
44+
45+
private void logCachingTenantConfig(String tenantId, TenantConfig tenantConfig) {
46+
logger.error("cache TenantConfig: tenantId={}, users={}", //
47+
tenantId, //
48+
tenantConfig.dataSourceConfigFlyway().name() + " | " + tenantConfig.dataSourceConfigApp().name());
49+
}
50+
51+
public static TenantConfigProvider.TenantConfig getCachedTenantConfig(String tenantId) {
52+
return TenantConfigs.get(tenantId);
53+
}
54+
55+
// for tests
56+
public static void clearTenantConfigsCache() {
57+
TenantConfigs.clear();
58+
}
59+
2560
private OauthConfig readOauthConfig(String tenantId) {
2661
return new OauthConfig(
2762
env.getProperty(MessageFormat.format("okr.tenants.{0}.security.oauth2.resourceserver.jwt.jwk-set-uri",
@@ -32,8 +67,11 @@ private OauthConfig readOauthConfig(String tenantId) {
3267

3368
private TenantConfig createTenantConfig(String jwkSetUriTemplate, String frontendClientIssuerUrl,
3469
String frontendClientId, String tenantId) {
35-
return new TenantConfig(tenantId, getOkrChampionEmailsFromTenant(tenantId), jwkSetUriTemplate,
36-
frontendClientIssuerUrl, frontendClientId, this.readDataSourceConfig(tenantId));
70+
71+
return new TenantConfig(tenantId, getOkrChampionEmailsFromTenant(tenantId), jwkSetUriTemplate, //
72+
frontendClientIssuerUrl, frontendClientId, //
73+
this.readDataSourceConfigFlyway(tenantId), //
74+
this.readDataSourceConfigApp(tenantId));
3775
}
3876

3977
private String[] getOkrChampionEmailsFromTenant(String tenantId) {
@@ -45,11 +83,19 @@ public List<TenantConfig> getTenantConfigs() {
4583
return this.tenantConfigs.values().stream().toList();
4684
}
4785

48-
private DataSourceConfig readDataSourceConfig(String tenantId) {
86+
private DataSourceConfig readDataSourceConfigFlyway(String tenantId) {
87+
return readDataSourceConfig(tenantId, DbType.fly);
88+
}
89+
90+
private DataSourceConfig readDataSourceConfigApp(String tenantId) {
91+
return readDataSourceConfig(tenantId, DbType.app);
92+
}
93+
94+
private DataSourceConfig readDataSourceConfig(String tenantId, DbType dbType) {
4995
return new DataSourceConfig(env.getProperty("okr.datasource.driver-class-name"),
5096
env.getProperty(MessageFormat.format("okr.tenants.{0}.datasource.url", tenantId)),
51-
env.getProperty(MessageFormat.format("okr.tenants.{0}.datasource.username", tenantId)),
52-
env.getProperty(MessageFormat.format("okr.tenants.{0}.datasource.password", tenantId)),
97+
env.getProperty(MessageFormat.format("okr.tenants.{0}.datasource.username." + dbType, tenantId)),
98+
env.getProperty(MessageFormat.format("okr.tenants.{0}.datasource.password." + dbType, tenantId)),
5399
env.getProperty(MessageFormat.format("okr.tenants.{0}.datasource.schema", tenantId)));
54100
}
55101

@@ -62,7 +108,7 @@ public Optional<String> getJwkSetUri(String tenantId) {
62108
}
63109

64110
public record TenantConfig(String tenantId, String[] okrChampionEmails, String jwkSetUri, String issuerUrl,
65-
String clientId, DataSourceConfig dataSourceConfig) {
111+
String clientId, DataSourceConfig dataSourceConfigFlyway, DataSourceConfig dataSourceConfigApp) {
66112
}
67113

68114
public record DataSourceConfig(String driverClassName, String url, String name, String password, String schema) {

0 commit comments

Comments
 (0)