基于RLS的微服务租户隔离(Tenant Isolation)解决方案

发布时间:2022-07-03 发布网站:脚本宝典
脚本宝典收集整理的这篇文章主要介绍了基于RLS的微服务租户隔离(Tenant Isolation)解决方案脚本宝典觉得挺不错的,现在分享给大家,也给大家做个参考。

现在的软件设计中,微服务的解决方案越来越流行, 每个微服务负责一个特定的业务,大量的微服务相互协作通讯组成了一个完整的系统。 每个微服务通常都有自己管理的业务数据和对应的数据库。 通常每个公司或者称租户)的数据在数据库中的存储有两种设计的方案:

  1. 每个租户有一个自己的 Schema。每个Schema里面有一组相同的和这个业务有关的表。

2. 所有的租户共享一个Schema。每个租户的数据都存储在同一张表中, 然后每一张业务表都有一列(通常这一列叫做 TenantID)来表示当前的行数据是属于那个租户。

第一个方案的好处, 就是各个租户之间的数据是完全的隔离, 一个公司的用户只能访问自己数据库里面的数据, 实现的原理通常是根据当前的用户所在的公司, 获取对应的数据库的Connection,该Connection只能对自己公司的 Schema进行操作。缺点是数据库的资会消耗的比较多, 而且不易统一的升级管理, 需要对数据库逐一的进行升级,投入的维护人力会比较大。 通常这种解决方案适合数据量大, 对数据安全要求高的大公司

第二种方案的优势和第一种方案的缺点相对, 就是数据库是统一的升级和维护, 如果大量的租户是数据量不大的小公司, 那么把它们的数据放在同一张表里面无疑是非常合适的。 缺点也很明显, 那就是使用同一个数据库连接,如果不在应用层做额外的处理, 理论上登陆用户能访问到不同租户的数据。

第一种方案是传统的租户数据的存储方案, 在微服务流行之前, 广泛的存在于各个面向企业的应用程序软件中。 第二种方案, 则随着微服务的流行, 变得越来越普遍, 因为每个微服务通常是由一个小团队来负责, 他们通常都没有足够的人力来为每一个客户公司升级维护一套 Schema。

问题来了, 如果是用 Share Schema这样的多租户数据存储方案, 怎么如何来保证数据访问的安全性?

一种方式就是在应用层来做数据访问的控制。 在业界流行的基于SPRing-Boot的微服务的设计中, 通常使用两种数据库访问框架, 一个是 Mybatis, 这个在国内互联网公司比较的流行。另外一个是JPA, 这个在国外用的比较多。 JPA作为一个标准它有两个比较流行的实现一个是 hibernate, 一个是 EclipseLink。

可惜的是, Mybatis自己并没有针对Share Schema的多租户数据库在框架级别提供解决方案。 也就是说, 每个开发人员必须自己保证自己所写的SQL语句包含了对当前公司Tenant ID的过滤, 这对开发人员的要求就相应的比较高。 当然也有一些开源项目, 在 mybatis的基础之上, 增加了对 Share Schema的支持, 比如说 mybatis-plus(https://mp.baomidou.COM/guide/tenant.htML), 但是似乎他自身的限制还比较多。

JPA的标准包含了对 Share Schema的数据库表的支持, 它把存储TenantID的列定义为Tenant Discriminator。 JPA的实现需要将这个 Tenant Discriminator 放在最终生成的SQL语句中, 这样保证不同的用户, 使用相同的jqL, 访问的只能是自己公司的数据。 可惜的是, 只有EclipseLink目前对这个规范有比较好的支持, 流行最广的Hibernate要到7.0版本之后才能对此有支持(https://hibernate.atlassian.net/browse/HHH-6054)。

这样一来, 似乎在应用程序的数据库访问层来做租户数据隔离变成了一个不易完成的任务

幸运的是, 很多的数据库(可惜除了 MySQL), 提供了一种新的数据访问控制, 就是本文要介绍的 RLS(Row Level SecurITy)。 基于RLS, 微服务能够轻易的就实现租户的数据隔离。

以下以PostgreSQL为例,我们假设在Public schema下面有一张商品表,为了简化, 这张表现在只有ID,NamE, TENANT_ID, 创建时间和更新时间这些列。

-- create table
CREATE TABLE IF NOT EXISTS PRODUCT(
    ID UUID  Primary KEY,
    NAME VArchAR(256) unique NOT NULL,
    TENANT_ID VARCHAR(256) NOT NULL,
    CREATED_DATETIME TIMESTAMP NOT NULL,
    UPDATED_DATETIME TIMESTAMP NOT NULL
);

然后我们打开这张表的 RLS功能:

-- ALTER TABLE to enable row security
ALTER TABLE PRODUCT ENABLE ROW LEVEL SECURITY;

创建一个对 Product表进行访问控制的Product_policy, Product_Policy限制了只有tenant_id列的数据和当前的session变量myapp.current_tenant的值相同的那些行才能被访问。

-- create policy
CREATE POLICY product_policy ON product
	FOR ALL
  USING (current_setting('myapp.current_tenant') = tenant_id)

当然我们还要创建一个普通用户dev, 并授予他访问这张表的权利:

-- create normal user;
CREATE USER dev NOSUPERUSER PASSWORD 'dev';

-- grant privileges
GRANT ALL ON SCHEMA PUBLIC TO dev;
GRANT ALL ON TABLE PRODUCT TO dev;

这样数据库的RLS的功能已经被打开并且准备就绪了,接下来就是应用程序里面要做的事情。

众所周知,在SpringBoot中, 如果需要访问一个数据库, 那么需要在应用程序的配置文件中配好数据库的连接信息, 然后spring boot会自动的给我们配置一个DataSource 的实例, 并从这个实例中来获取到数据库的连接。

#application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/testdb
spring.datasource.username=dev
spring.datasource.password=dev

之前我们在定义 product_policy的时候, 我们看到了我们引用了一个myapp.current_tenant的session变量。 那么这个变量是什么时候被定义的呢? 答案是每次启动事务的时候 。 因为无论Spring事务管理的@Transactional注解或者是 Transactional Template, 它们都会调用DataSource实例的getConnection方法,所以我们可以定制一个DataSource, 这个DataSource是一个实际被使用的Datasource的代理, 只是它覆盖了getConnection方法, 在这个方法返回connection之前将myapp.current_tenant赋予了当前登陆用户所属的tenant的值。

// MultiTenantDataSource.java

import javax.sql.DataSource;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.Preparedstatement;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.util.LOGging.Logger;

public class MultiTenantDataSource implements DataSource {
    private static final LoggingUtil logger = LoggingUtilFactory.getLogger(MultiTenantDataSource.class);

    private final DataSource delegate;

    private final TenantIDProvider tenantIDProvider;

    public MultiTenantDataSource(DataSource delegate, TenantIDProvider tenantIDProvider) {
        this.delegate = delegate;
        this.tenantIDProvider = tenantIDProvider;
    }

    @override
    public Connection getConnection() throws SQLException {
        Connection connection = delegate.getConnection();
        enableTenantIsolation(connection);
        return connection;
    }

    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        Connection connection = delegate.getConnection(username, password);
        enableTenantIsolation(connection);
        return connection;
    }

    @Override
    public <T> T unwrap(Class<T> iface) throws SQLException {
        return delegate.unwrap(iface);
    }

    @Override
    public boolean isWrapperFor(Class<?> iface) throws SQLException {
        return delegate.isWrapperFor(iface);
    }

    //其他Datasource方法的实现这里略过,基本上和unwrap, isWrapperFor的相同, 都是交由delegate处理

    @Override
    public Logger getParentLogger() throws SQLFeatureNotSupportedException {
        return delegate.getParentLogger();
    }

    private void enableTenantIsolation(Connection connection) throws SQLException {
        String tenantID = tenantIDProvider.getTenantID();
        boolean originalAutoCommit = connection.getAutoCommit();
        try (
                PreparedStatement setTenantIDStatement = connection.prepareStatement("select set_config('myapp.current_tenant', ?, FALSE)")
        ) {
            logger.debug("MultiTenantDataSource::enableTenantIsolation", String.format("set current tenant to %s", tenantID));
            connection.setAutoCommit(false);
            setTenantIDStatement.setString(1, tenantID);
            setTenantIDStatement.execute();
            connection.commit();
        } catch (SQLException e) {
            connection.rollback();
            throw e;
        } finally {
            connection.setAutoCommit(originalAutoCommit);
        }
    }

}

可以看到我们的MultiTenantDataSource实现了DataSource接口, 在两个getConnection的方法返回之前,我们都调用了一个二手手游账号购买地图EnableTenantIsolation的方法, 这个方法只做了一件事情,就是在从当前的应用程序的上下文里面获得tenantID, 通过set_config语句将这个tenantID赋值给myapp.current_tenantSession变量。

最后我们在SpringBoot中定义这个DataSource的实例:

//MultiTenantDataSourceConfiguration.java


import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
public class MultiTenantDataSourceConfiguration {
    private static final LoggingUtil logger = LoggingUtilFactory.getLogger(MultiTenantDataSourceConfiguration.class);

    @Bean(name = "multiTenantDataSource")
    public DataSource multiTenantDataSource(
            @Value("${spring.datasource.url}") String jdbcUrl,
            @Value("${spring.datasource.driver-class-name}") String driver,
            @Value("${spring.datasource.username}") String userName,
            @Value("${spring.datasource.password}") String password,
            TenantIDProvider tenantIDProvider
    ) {
        try {
            org.apache.tomcat.jdbc.pool.DataSource pooledDataSource = new org.apache.tomcat.jdbc.pool.DataSource();
            pooledDataSource.setDriverclassname(driver);
            pooledDataSource.setUsername(userName);
            pooledDataSource.setPassword(password);
            pooledDataSource.setUrl(jdbcUrl);
            pooledDataSource.setInitialSize(5);
            pooledDataSource.setMaxActive(100);
            pooledDataSource.setMaxidle(5);
            pooledDataSource.setMinIdle(2);

            MultiTenantDataSource multiTenantDataSource = new MultiTenantDataSource(pooledDataSource, tenantIDProvider);
            return multiTenantDataSource;
        } catch (Exception e) {
            logger.error("JdbcConfiguration::multiTenantDataSource",
                    "Cannot create the multiTenantDataSource due to " + e.getMessage());
            throw new MyAppException(e);
        }
    }

}

&nbsp;

好, 现在一切就绪了。 SpringBoot在启用事务的时候,会调用我们的MultiTenantDataSource的getConnection方法,然后在当前的session中设置好myapp.current_tenant的值。 应用程序无论是用Mybatis或者是JPA, 都不需要自己显式的在sql语句或者JPQL中去指定tenant_id列的过滤条件。 每一个登陆用户他所有能操作的数据就一定是当前用户所属公司的数据。

更新:

第一次在知乎上发表文章,写的比较仓促, 也是疫情时刻闲来无事, 把之前的学习做了一下总结, 看到点收藏的人数居然是点赞的一倍:-), 这里感谢大家的认可, 也恳请大家能在收藏的时候一并点赞, 支持原创, 谢谢。

脚本宝典总结

以上是脚本宝典为你收集整理的基于RLS的微服务租户隔离(Tenant Isolation)解决方案全部内容,希望文章能够帮你解决基于RLS的微服务租户隔离(Tenant Isolation)解决方案所遇到的问题。

如果觉得脚本宝典网站内容还不错,欢迎将脚本宝典推荐好友。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
如您有任何意见或建议可联系处理。小编QQ:384754419,请注明来意。