Programming

Multi-tenancy Architecture With Shared Schema Strategy in Webapp Application Based on Spring-boot, Thymeleaf, and Posmulten-hibernate (Part 1)

Spread the love


Potential Problem and Solution

Let’s imagine that we are a SaaS solution provider.  Your customers are primarily companies that want to have their own space in the scope of your service where their users can work together around your service. In this article, we will refer to your clients as tenants.  The solution uses a relational database. During architecture design, when you already know that you will pick a relational database, you may face of choice between a single database or multi-tenant architecture.

Suppose there is a requirement that the application should allow sharing of data (CRUD) between tenants, and sharing by calling REST API or some queue solution is not acceptable from a performance standpoint. In that case, a single database solution seems to be the most reasonable in which you just need to execute SQL operations. Although with some compromise, you might achieve the same effect with multi-tenancy architecture that uses a shared schema strategy by defining some of the tables that should not be isolated by the discriminator column (described in the latter part of the article) and applying additional queries that are used for authorization. However, we must remember that isolating tenant data in the single database solution requires a lot of additional code that the developer has to implement to protect data. This approach might complicate implementation without mentioning sharing data, making developers work much harder. In case when sharing data between tenants is not our requirement. A single database solution can still be our choice, but the additional code we would have to implement increases the time and effort to deliver it. By choosing multi-tenancy architecture, we might get rid of this problem.  Of course, choosing this approach comes to some other challenges that we would not have with a single database, and they are different depending on the picked strategy, but this topic is for another article. To go ahead with multi-tenancy architecture, we must decide which strategy to pick. The most popular three strategies are separate database, separate-schema, and shared-schema. And also, in this case, each strategy has pros and cons. From an isolation standpoint, the strategy of a separate database guarantees the highest level. And shared schema strategy has the lowest level compared to mention two others. That is why it is important to ensure your potential customer agrees that his data will be stored in the same schema. As for performance, the separate database seems to be the best choice. Although the application would have managed a database connections pool for each tenant, operations on a single database would be executed only for one tenant. But also, please remember that by implementing the separate schema or shared schema strategy, we can combine it with a separate database. For example, by keeping one group of tenants in a single database with a separate/shared schema and one tenant, we have guaranteed the highest performance and data isolation level in a separate database. Such an approach would also require some wise routing strategy, but this topic is for another article.  The most significant advantage of the shared schema strategy is that it requires the least amount of resources, reducing costs. It would help if you considered other things when choosing the right strategy, like DDL operations execution, fault tolerance, and many others. But it is not the topic for this article.

Demo Application

You can find code for the demo app on the Github page repository. Our demo app is a web application where users can add posts. Users exist in the scope of the domain. And the domain is assigned to one tenant. The domain is part of the URL that represents resources specific tenant, for example:

/app/polish.dude.eu/home – home page for the “polish.dude.eu” domain 

/app/my.doc.com/home – home page for the “my.doc.com” domain

Database Model

The database model is not complicated, but it is worth mentioning what kind of table groups we have.

table groups

As you can see, our diagram is separated into two parts. On the right side of the diagram, we have a table that stores data isolated between tenants. On the left side, we have tables that can be accessed by all tenants. In our demo, we have only one table that stores the domain name for tenants and is shared by all tenants. But in the normal application, there can be dictionary tables that stores data that can be used by the tenants. One important note is that for demo purposes in our model, the tenant discriminator column is not a part of the primary key in tables that store tenants’ data. This is also an option even for production code, but It would be wise to consider a model where the tenant column is part of the primary key. It is worth considering this approach when one of the project requirements is that there should be a possibility to migrate tenant data to another database that also has some other tenants. In such a case, you wouldn’t have to worry about index uniqueness. Just probably, we would have to update sequence values. Other actions might be required, but this is a topic for another article.

Application Stack

The project uses Spring-Boot and Thymeleaf as template engines. As for the ORM solution project, it uses Hibernate. One of the important notes is that the project does not use the partitioned (discriminator) data feature to achieve a shared schema strategy but uses the posmulten-hibernate library. Both mechanisms depend on the tenant discriminator column, whose value is compared to the identifier of the current tenant in each SQL operation. The difference is that the posmulten-hibernate library uses an RLS policy mechanism based on which the Postgres database engine adds the tenant condition to every SQL query by itself.  When the Hibernate feature adds this condition on the application level. 

To learn more about the pros and cons, please check the below resources:

So if we use maven for project building, we have to add a dependency for the posmulten-hibernate library.

<dependency>
   <groupId>com.github.starnowski.posmulten.hibernate</groupId>
   <artifactId>core</artifactId>
   <version>0.1.1</version>
</dependency>

To see all project dependencies, please check the pom.xml file.

Code

First, we have to add hibernate configuration to the production code.

package com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.configurations;

import com.github.starnowski.posmulten.hibernate.core.connections.CurrentTenantPreparedStatementSetterInitiator;
import com.github.starnowski.posmulten.hibernate.core.connections.SharedSchemaConnectionProviderInitiatorAdapter;
import com.github.starnowski.posmulten.hibernate.core.context.CurrentTenantContext;
import com.github.starnowski.posmulten.hibernate.core.context.DefaultSharedSchemaContextBuilderProviderInitiator;
import org.hibernate.MultiTenancyStrategy;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Environment;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.hibernate5.HibernateTransactionManager;
import org.springframework.orm.hibernate5.LocalSessionFactoryBuilder;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.Properties;

import static java.lang.Boolean.TRUE;

@EnableTransactionManagement
@Configuration
@EnableJpaRepositories(basePackages = "com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.repositories", transactionManagerRef = "hibernateTransactionManager", entityManagerFactoryRef = "primary_session_factory")
public class PrimaryDataSourceConfiguration {

    public static final String SET_CURRENT_TENANT_FUNCTION_NAME = "set_pos_demo_tenant";

    @Bean
    @Primary
    @ConfigurationProperties("spring.datasource.primary")
    public DataSourceProperties primaryDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @Primary
    @ConfigurationProperties("spring.datasource.primary.configuration")
    public DataSource primaryDataSource() {
        return primaryDataSourceProperties().initializeDataSourceBuilder().build();
    }


    @Bean(name = "primary_session_factory")
    @Primary
    public SessionFactory sessionFactory(DataSource primaryDataSource) {

        LocalSessionFactoryBuilder builder
                = new LocalSessionFactoryBuilder(primaryDataSource);
        builder.getStandardServiceRegistryBuilder()
                .addInitiator(new SharedSchemaConnectionProviderInitiatorAdapter())
                .addInitiator(new DefaultSharedSchemaContextBuilderProviderInitiator())
                .addInitiator(new CurrentTenantPreparedStatementSetterInitiator())
                .applySettings(hibernateProperties())
                // https://stackoverflow.com/questions/39064124/unknownunwraptypeexception-cannot-unwrap-to-requested-type-javax-sql-datasourc
                .applySetting(Environment.DATASOURCE, primaryDataSource);
        builder.scanPackages("com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.model");
        //TODO Fix
        CurrentTenantContext.setCurrentTenant("XXXXX");
        return builder.buildSessionFactory();
    }

    private final Properties hibernateProperties() {
        Properties hibernateProperties = new Properties();
        hibernateProperties.setProperty(
                "hibernate.hbm2ddl.auto", "none");
        hibernateProperties.setProperty(
                "hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect");
        hibernateProperties.setProperty(
                Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA.name());
        hibernateProperties.setProperty(
                Environment.MULTI_TENANT_CONNECTION_PROVIDER, "com.github.starnowski.posmulten.hibernate.core.connections.SharedSchemaMultiTenantConnectionProvider");
        hibernateProperties.setProperty(
                Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, "com.github.starnowski.posmulten.hibernate.core.CurrentTenantIdentifierResolverImpl");
        hibernateProperties.setProperty(
                "hibernate.archive.autodetection", "class");
        hibernateProperties.setProperty(
                "hibernate.format_sql", TRUE.toString());
        hibernateProperties.setProperty(
                "hibernate.show_sql", TRUE.toString());
        hibernateProperties.setProperty(
                "hibernate.posmulten.schema.builder.provider", "lightweight");
        hibernateProperties.setProperty(
                Environment.PERSISTENCE_UNIT_NAME, "pu");
        hibernateProperties.setProperty(
                com.github.starnowski.posmulten.hibernate.core.Properties.SET_CURRENT_TENANT_FUNCTION_NAME, SET_CURRENT_TENANT_FUNCTION_NAME);
        return hibernateProperties;
    }


    @Bean(name = "hibernateTransactionManager")
    @Primary
    public PlatformTransactionManager hibernateTransactionManager(@Qualifier("primary_session_factory") SessionFactory sessionFactory) {
        HibernateTransactionManager transactionManager
                = new HibernateTransactionManager();
        transactionManager.setSessionFactory(sessionFactory);
        return transactionManager;
    }
}

The above configuration is not going to generate the required RLS policies.  Its purpose is to set the correct database connection using a shared schema strategy.

We add another hibernate configuration to generate RLS policies in the tests package.

package com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.configurations;

import com.github.starnowski.posmulten.hibernate.core.context.DefaultSharedSchemaContextBuilderMetadataEnricherProviderInitiator;
import com.github.starnowski.posmulten.hibernate.core.context.DefaultSharedSchemaContextBuilderProviderInitiator;
import com.github.starnowski.posmulten.hibernate.core.context.metadata.PosmultenUtilContextInitiator;
import com.github.starnowski.posmulten.hibernate.core.schema.PosmultenSchemaManagementTool;
import com.github.starnowski.posmulten.hibernate.core.schema.SchemaCreatorStrategyContextInitiator;
import org.hibernate.MultiTenancyStrategy;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Environment;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.hibernate5.HibernateTransactionManager;
import org.springframework.orm.hibernate5.LocalSessionFactoryBuilder;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.Properties;

import static java.lang.Boolean.TRUE;

@EnableTransactionManagement
@Configuration
public class OwnerDataSourceConfiguration {

    public static final String OWNER_TRANSACTION_MANAGER = "ownerTransactionManager";
    public static final String OWNER_DATA_SOURCE = "ownerDataSource";
    public static final String SET_CURRENT_TENANT_FUNCTION_NAME = "set_pos_demo_tenant";
    public static final String TENANT_PROPERTY_NAME = "posdemo.tenant";

    @Bean(name = "ownerDataSourceProperties")
    @ConfigurationProperties("spring.datasource.owner")
    public DataSourceProperties ownerDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean(name = "ownerDataSource")
    @ConfigurationProperties("spring.datasource.owner.configuration")
    public DataSource ownerDataSource() {
        return ownerDataSourceProperties().initializeDataSourceBuilder().build();
    }

    @Bean(name = "ownerJdbcTemplate")
    public JdbcTemplate ownerJdbcTemplate() {
        return new JdbcTemplate(ownerDataSource());
    }

    @Bean(name = "schema_session_factory")
    public SessionFactory sessionFactory(@Qualifier("ownerDataSource") DataSource ownerDataSource) {

        LocalSessionFactoryBuilder builder
                = new LocalSessionFactoryBuilder(ownerDataSource);
        builder.getStandardServiceRegistryBuilder()
                .addInitiator(new SchemaCreatorStrategyContextInitiator())
                .addInitiator(new DefaultSharedSchemaContextBuilderProviderInitiator())
                .addInitiator(new DefaultSharedSchemaContextBuilderMetadataEnricherProviderInitiator())
                .addInitiator(new PosmultenUtilContextInitiator())
                .applySettings(hibernateProperties())
                // https://stackoverflow.com/questions/39064124/unknownunwraptypeexception-cannot-unwrap-to-requested-type-javax-sql-datasourc
                .applySetting(Environment.DATASOURCE, ownerDataSource);
        builder.scanPackages("com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.model");
        return builder.buildSessionFactory();
    }

    private final Properties hibernateProperties() {
        Properties hibernateProperties = new Properties();
        hibernateProperties.setProperty(
                "hibernate.hbm2ddl.auto", "create");
        hibernateProperties.setProperty(
                "hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect");
        hibernateProperties.setProperty(
                Environment.MULTI_TENANT, MultiTenancyStrategy.NONE.name());
        hibernateProperties.setProperty(
                "hibernate.archive.autodetection", "class");
        hibernateProperties.setProperty(
                "hibernate.schema_management_tool", PosmultenSchemaManagementTool.class.getName());
        hibernateProperties.setProperty(
                "hibernate.format_sql", TRUE.toString());
        hibernateProperties.setProperty(
                "hibernate.show_sql", TRUE.toString());
        hibernateProperties.setProperty(
                "hibernate.posmulten.grantee", "posmhib4sb-user");
        hibernateProperties.setProperty(
                com.github.starnowski.posmulten.hibernate.core.Properties.SET_CURRENT_TENANT_FUNCTION_NAME, SET_CURRENT_TENANT_FUNCTION_NAME);
        hibernateProperties.setProperty(
                com.github.starnowski.posmulten.hibernate.core.Properties.ID_PROPERTY, TENANT_PROPERTY_NAME);
        return hibernateProperties;
    }

    @Bean(name = OWNER_TRANSACTION_MANAGER)
    public PlatformTransactionManager hibernateTransactionManager(@Qualifier("schema_session_factory") SessionFactory sessionFactory) {
        HibernateTransactionManager transactionManager
                = new HibernateTransactionManager();
        transactionManager.setSessionFactory(sessionFactory);
        return transactionManager;
    }
}

In the “how to prepare a database” section, you can find scripts to prepare the database and its users. You can find the description of why the particular users are used in one configuration and not another in the “hibernate configurations” section.

Another important configuration is the web configuration for projects.

package com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.configurations;

import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.filters.*;
import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.util.DomainResolver;
import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.web.DomainAwareSavedRequestAwareAuthenticationSuccessHandler;
import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.web.DomainLoginUrlAuthenticationEntryPoint;
import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.web.DomainLogoutSuccessHandler;
import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.web.DomainUrlAuthenticationFailureHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.SecurityContextPersistenceFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import javax.servlet.DispatcherType;

import static com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.web.DomainLoginUrlAuthenticationEntryPoint.DOMAIN_URL_PART;


@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests().antMatchers("/posts", "/posts/**", "/users", "/users/**", "/v2/api-docs", "/swagger-ui.html")
                .permitAll()
                .and()
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/app/*/login").permitAll()
                .antMatchers("/app/*/logout").permitAll()
                .antMatchers("/app/*/j_spring_security_check").permitAll()
                .antMatchers("/app/*/posts", "/app/*/posts/").hasAnyRole("AUTHOR", "ADMIN")//TODO Change to all authenticated
//                .antMatchers("/app/*/add-posts").hasAnyRole("AUTHOR", "ADMIN")//TODO Change to all authenticated
                .antMatchers("/app/*/config", "/app/*/config/").hasRole("ADMIN") // TODO No such resource yet
                .antMatchers("/app/*/users", "/app/*/users/").hasRole("ADMIN")
                .antMatchers("/app/**").authenticated()
                .and()
                .formLogin().loginProcessingUrl("/app/*/j_spring_security_check")
                    .successHandler(domainAwareSavedRequestAwareAuthenticationSuccessHandler())
                    .failureHandler(domainUrlAuthenticationFailureHandler())
                    .permitAll()
                .and()
                .logout()
                    .logoutUrl("/app/*/logout")
//                .logoutRequestMatcher("/app/*/logout")
                    .logoutSuccessHandler(domainLogoutSuccessHandler())
//                    .logoutSuccessUrl("/welcome")
                    .and()
                .exceptionHandling().defaultAuthenticationEntryPointFor(domainLoginUrlAuthenticationEntryPoint(), new AntPathRequestMatcher("/app/**"));
        http.addFilterBefore(currentTenantResolverFilter(), UsernamePasswordAuthenticationFilter.class);
        http.addFilterAfter(correctTenantContextFilter(), SecurityContextPersistenceFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

//    @Autowired
//    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
//        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
//    }

    @Bean
    public AuthenticationEntryPoint domainLoginUrlAuthenticationEntryPoint() {
        return new DomainLoginUrlAuthenticationEntryPoint("/app/" + DOMAIN_URL_PART + "/login");
    }

    @Bean
    public DomainResolver domainResolver() {
        return new DomainResolver("/app/");
    }

    @Bean
    public FilterRegistrationBean correctTenantContextFilterRegistrationBean() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(correctTenantContextFilter());
        registration.setEnabled(true);
        registration.addUrlPatterns("/app/*", "/app/**");
        registration.setOrder(3);
        registration.setDispatcherTypes(DispatcherType.ERROR, DispatcherType.FORWARD, DispatcherType.REQUEST);
        return registration;
    }

    @Bean
    public FilterRegistrationBean currentTenantResolverFilterRegistrationBean() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(currentTenantResolverFilter());
        registration.setEnabled(true);
        registration.addUrlPatterns("/app/*", "/app/**");
        registration.setOrder(2);
        registration.setDispatcherTypes(DispatcherType.ERROR, DispatcherType.FORWARD, DispatcherType.REQUEST);
        return registration;
    }

    @Bean
    public FilterRegistrationBean domainExistCheckFilterRegistrationBean() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(domainExistCheckFilter());
        registration.setEnabled(true);
        registration.addUrlPatterns("/app/*", "/app/**");
        registration.setOrder(1);
        registration.setDispatcherTypes(DispatcherType.ERROR, DispatcherType.FORWARD, DispatcherType.REQUEST);
        return registration;
    }

    @Bean
    public FilterRegistrationBean tenantFilterRegistrationBean() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(tenantFilter());
        registration.setEnabled(true);
        registration.addUrlPatterns("/posts");
        registration.addUrlPatterns("/posts/*");
        registration.addUrlPatterns("/users");
        registration.addUrlPatterns("/users/*");
        registration.setOrder(1);
        registration.setDispatcherTypes(DispatcherType.ERROR, DispatcherType.FORWARD, DispatcherType.REQUEST);
        return registration;
    }

    @Bean
    public FilterRegistrationBean optionalTenantFilterRegistrationBean() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(optionalTenantFilterRegistration());
        registration.setEnabled(true);
        registration.addUrlPatterns("/internal/domains");
        registration.setOrder(1);
        registration.setDispatcherTypes(DispatcherType.ERROR, DispatcherType.FORWARD, DispatcherType.REQUEST);
        return registration;
    }

    @Bean
    public CorrectTenantContextFilter correctTenantContextFilter() {
        return new CorrectTenantContextFilter();
    }

    @Bean
    public CurrentTenantResolverFilter currentTenantResolverFilter() {
        return new CurrentTenantResolverFilter();
    }

    @Bean
    public DomainExistCheckFilter domainExistCheckFilter() {
        return new DomainExistCheckFilter();
    }

    @Bean
    public RequiredTenantFilter tenantFilter() {
        return new RequiredTenantFilter();
    }

    @Bean
    public OptionalTenantFilter optionalTenantFilterRegistration() {
        return new OptionalTenantFilter();
    }

    @Bean
    public AuthenticationFailureHandler domainUrlAuthenticationFailureHandler() {
        return new DomainUrlAuthenticationFailureHandler("/login?error", "/app/" + DomainUrlAuthenticationFailureHandler.DOMAIN_URL_PART + "/login?error", domainResolver());
    }

    @Bean
    public DomainAwareSavedRequestAwareAuthenticationSuccessHandler domainAwareSavedRequestAwareAuthenticationSuccessHandler() {
        return new DomainAwareSavedRequestAwareAuthenticationSuccessHandler("/app/" + DomainAwareSavedRequestAwareAuthenticationSuccessHandler.DOMAIN_URL_PART + "https://dzone.com/");
    }

    @Bean
    public DomainLogoutSuccessHandler domainLogoutSuccessHandler()
    {
        return new DomainLogoutSuccessHandler("/app/" + DomainLogoutSuccessHandler.DOMAIN_URL_PART + "/login");
    }
}

Brief description of this configuration.

The “com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.web” package contains mostly handlers that resolve the correct URL for the expected domain in case of errors or the successful login process.

As for the com.github.starnowski.posmulten.demos.posmultenhibernate5 springboot thymeleaf.filters package, it contains Http filters object. It is worth checking all of them, but the most important filter is CurrentTenantResolverFilter.

package com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.filters;

import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.model.TenantInfo;
import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.repositories.TenantInfoRepository;
import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.security.SecurityServiceImpl;
import com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.util.DomainResolver;
import org.springframework.beans.factory.annotation.Autowired;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

import static com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.security.TenantUser.ROOT_TENANT_ID;
import static com.github.starnowski.posmulten.hibernate.core.context.CurrentTenantContext.setCurrentTenant;

/**
 * Filter that set correct tenant identifier based on domain part in URL.
 * For example if request is being set for resource "/app/some.domain/login" then filter tries to find out tenant that is related to domain "some.domain".
 * If tenant exist then the filter sets tenant identifier.
 * In other case the filter sets {@link com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.security.TenantUser.ROOT_TENANT_ID}
 */
public class CurrentTenantResolverFilter implements Filter {

    @Autowired
    private TenantInfoRepository tenantInfoRepository;
    @Autowired
    private SecurityServiceImpl securityService;
    @Autowired
    private DomainResolver domainResolver;


    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String domain = domainResolver.resolve(httpServletRequest);
        if (domain != null) {
            setCurrentTenant(ROOT_TENANT_ID);
            TenantInfo domainTenant = tenantInfoRepository.findByDomain(domain);
            if (domainTenant != null) {
                setCurrentTenant(domainTenant.getTenantId());
            } else {
                setCurrentTenant(ROOT_TENANT_ID);
            }

        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {

    }
}

It is responsible for setting the correct tenant identifiers based on a domain that is part of the URL address. Please be in mind that there can be other strategies based on which current tenant identifier should be set. For example, it can be an HTTP header or other attributes returned by some SSO solution. It may depend if we plan to implement API or web applications, but there are multiple options.

As for other packages like:

  • com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.dto
  • com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.forms
  • com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.mappers
  • com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.model
  • com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.repositories
  • com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.services
  • com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.security
  • com.github.starnowski.posmulten.demos.posmultenhibernate5springbootthymeleaf.controllers

they contain mostly business logic. And besides the usage of TenantTable annotation in the model package, you would not see any things that you would not see in other Spring-boot projects.

Fill the Database With Data.

Before filling the database with data, it is worth building the project with the tests.

We need to build a project with tests because the project does not use tools that apply changes to the database, like Liquibase or Flyway

After generating the schema, we can fill the database using the script mentioned in the “fill database with data” section.

SELECT set_pos_demo_tenant('no_such_tenant');-- TODO Set correct tenant
--- tenant xds1

SELECT set_pos_demo_tenant('xds');
INSERT INTO user_info (user_id, username, tenant_id, password) VALUES ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'starnowski', 'xds', '$2a$10$xsaKSi2sqp9DnWlgG8Ah9.DNwxCA9zblyGAAbYub4AAs1LBN6CUnO');
INSERT INTO user_info (user_id, username, tenant_id, password) VALUES ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'dude', 'xds', '$2a$10$xsaKSi2sqp9DnWlgG8Ah9.DNwxCA9zblyGAAbYub4AAs1LBN6CUnO');

INSERT INTO user_role (id, role, user_id, tenant_id) VALUES (nextval( 'hibernate_sequence' ), 'ADMIN', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'xds'); -- starnowski as ADMIN
INSERT INTO user_role (id, role, user_id, tenant_id) VALUES (nextval( 'hibernate_sequence' ), 'ADMIN', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'xds'); -- dude as ADMIN

INSERT INTO posts (id, userId, text) VALUES (nextval( 'hibernate_sequence' ), 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'This is a text content');
INSERT INTO posts (id, userId, text) VALUES (nextval( 'hibernate_sequence' ), 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'Post post and post');

--- tenant xds1
SELECT set_pos_demo_tenant('xds1');
INSERT INTO user_info (user_id, username, tenant_id, password) VALUES ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'mcaine', 'xds1', '$2a$10$xsaKSi2sqp9DnWlgG8Ah9.DNwxCA9zblyGAAbYub4AAs1LBN6CUnO');
INSERT INTO user_info (user_id, username, tenant_id, password) VALUES ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'starnowski', 'xds1', '$2a$10$xsaKSi2sqp9DnWlgG8Ah9.DNwxCA9zblyGAAbYub4AAs1LBN6CUnO');
INSERT INTO user_info (user_id, username, tenant_id, password) VALUES ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15', 'dude', 'xds1', '$2a$10$xsaKSi2sqp9DnWlgG8Ah9.DNwxCA9zblyGAAbYub4AAs1LBN6CUnO');

INSERT INTO user_role (id, role, user_id, tenant_id) VALUES (nextval( 'hibernate_sequence' ), 'ADMIN', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a14', 'xds1'); -- starnowski as ADMIN
INSERT INTO user_role (id, role, user_id, tenant_id) VALUES (nextval( 'hibernate_sequence' ), 'AUTHOR', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15', 'xds1'); -- dude as AUTHOR

INSERT INTO posts (id, userId, text) VALUES (nextval( 'hibernate_sequence' ), 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15', 'First post in application for xds1');
INSERT INTO posts (id, userId, text) VALUES (nextval( 'hibernate_sequence' ), 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15', 'Second post in application for xds1');

--- tenant_info
INSERT INTO tenant_info (tenant_id, domain) VALUES ('xds', 'my.doc.com');
INSERT INTO tenant_info (tenant_id, domain) VALUES ('xds1', 'polish.dude.eu');

After executing this script in the database, we will have two tenants with existed domains and users.

Running application

To run the application, we have to execute the below maven command:

Quick note, from your favorite IDE, you can also run the Application class to start the application.

After executing the above command, the Tomcat server should listen to port 8080.

We can log in to the “polish.dude.eu” domain as the starnowski user with password “pass”.

So try to go to page http://localhost:8080/app/polish.dude.eu/posts. We will be redirected to http://localhost:8080/app/polish.dude.eu/login where you will have to log in as starnowski user.

After correct login, we should see the posts list page.

posts list page

Try to add new posts to the domain. After adding posts, we should be redirected to the posts list page again and be able to see recent posts.

Let’s try now to log in to the second domain, “my.doc.com” as a dude user with a password pass.

Let’s go to page http://localhost:8080/app/my.doc.com/posts. After correct login, you will see the posts list page without posts that we added to the “polish.dude.eu” domain.

posts list page

That is all for this part. The next part of this series will focus on asynchronous operations execution of shared schema strategy. If you would have some suggestions about what cases are worth presenting with a demo for shared schema strategy, please add a comment or create a ticket for the posmulten-demos project. 

If you would like to get to know more about how the integration of posmulten and hibernate works, then visit projects:



Source link

Related Articles

Leave a Reply

Your email address will not be published.

Back to top button