Spring Boot+Hibernate+Liquibase+Postgres行级安全性的共享数据库

banq


Postgres 或 SQLServer 等现代数据库提供了行级安全机制,其中可以根据各种标准以声明性和透明的方式将对各个行的访问限制为特定用户。因此可以用来实现租户之间的数据隔离。
定义行级策略
在 Postgres 中,这意味着,对于每个表

  1. 为表启用行级安全性简称RLS,以及
  2. 为表定义一个策略,引用tenant_id鉴别器列。

以下是 Liquibase 变更集:
- changeSet:
    id: product_row_level_security
    author: bjobes
    changes:
    -  sql:
        dbms: 'postgresql'
        sql: >-
            ALTER TABLE product ENABLE ROW LEVEL SECURITY;
            DROP POLICY IF EXISTS product_tenant_isolation_policy ON product;
            CREATE POLICY product_tenant_isolation_policy ON product
                USING (tenant_id = current_setting('app.tenant_id')::VARCHAR);


默认情况下,行级安全策略不适用于表所有者(这是有道理的,因为表所有者必须能够访问所有行以用于管理目的,例如备份)。因此,我们必须确保我们为应用程序使用不同的数据库用户来访问数据库(无论如何这是最佳实践)。

让我们将应用程序用户的创建添加到我们的 Liquibase 迁移中(其中用户名、密码、模式和数据库名称作为参数传入):

- changeSet:
    id: app_user
    author: bjobes
    changes:
    -  sql:
        dbms: 'postgresql'
        sql: >-
            CREATE USER ${username} WITH PASSWORD '${password}';
            GRANT CONNECT ON DATABASE ${database} TO app_user;
            ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES
                ON TABLES TO ${username};
            ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT USAGE ON SEQUENCES TO ${username};
            ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT EXECUTE ON FUNCTIONS TO ${username};

将租户与数据库连接关联
使用行级安全策略后,我们现在需要在每个数据库连接上设置当前的tenantId,然后再使用它,并在连接完成后删除tenantId。因此,我们需要一个 Tenant-Aware DataSource 来透明地管理连接上的tenantId 的修饰:
/**
 * Tenant-Aware Datasource that decorates Connections with
 * current tenant information.
 */

public class TenantAwareDataSource extends DelegatingDataSource {

    public TenantAwareDataSource(DataSource targetDataSource) {
        super(targetDataSource);
    }

    @Override
    public Connection getConnection() throws SQLException {
        final Connection connection = getTargetDataSource().getConnection();
        setTenantId(connection);
        return getTenantAwareConnectionProxy(connection);
    }

    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        final Connection connection = getTargetDataSource().getConnection(username, password);
        setTenantId(connection);
        return getTenantAwareConnectionProxy(connection);
    }

    private void setTenantId(Connection connection) throws SQLException {
        try (Statement sql = connection.createStatement()) {
            String tenantId = TenantContext.getTenantId();
            sql.execute(
"SET app.tenant_id TO '" + tenantId + "'");
        }
    }

    private void clearTenantId(Connection connection) throws SQLException {
        try (Statement sql = connection.createStatement()) {
            sql.execute(
"RESET app.tenant_id");
        }
    }

   
// Connection Proxy that intercepts close() to reset the tenant_id
    protected Connection getTenantAwareConnectionProxy(Connection connection) {
        return (Connection) Proxy.newProxyInstance(
                ConnectionProxy.class.getClassLoader(),
                new Class[] {ConnectionProxy.class},
                new TenantAwareDataSource.TenantAwareInvocationHandler(connection));
    }

   
// Connection Proxy invocation handler that intercepts close() to reset the tenant_id
    private class TenantAwareInvocationHandler implements InvocationHandler {
        private final Connection target;

        public TenantAwareInvocationHandler(Connection target) {
            this.target = target;
        }

        @Nullable
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            switch (method.getName()) {
                case
"equals":
                    return proxy == args[0];
                case
"hashCode":
                    return System.identityHashCode(proxy);
                case
"toString":
                    return
"Tenant-aware proxy for target Connection [" + this.target.toString() + "]";
                case
"unwrap":
                    if (((Class) args[0]).isInstance(proxy)) {
                        return proxy;
                    } else {
                        return method.invoke(target, args);
                    }
                case
"isWrapperFor":
                    if (((Class) args[0]).isInstance(proxy)) {
                        return true;
                    } else {
                        return method.invoke(target, args);
                    }
                case
"getTargetConnection":
                    return target;
                default:
                    if (method.getName().equals(
"close")) {
                        clearTenantId(target);
                    }
                    return method.invoke(target, args);
            }
        }
    }
}


配置数据源
我们需要配置两个数据源:一个用于 Liquibase 数据库迁移的主数据源,一个用于应用程序使用的租户感知数据源。
@Configuration
public class DataSourceConfiguration {

    @Bean
    @ConfigurationProperties("multitenancy.master.datasource")
    public DataSourceProperties masterDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @LiquibaseDataSource
    @ConfigurationProperties(
"multitenancy.master.datasource.hikari")
    public DataSource masterDataSource() {
        HikariDataSource dataSource = masterDataSourceProperties()
                .initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();
        dataSource.setPoolName(
"masterDataSource");
        return dataSource;
    }

    @Bean
    @Primary
    @ConfigurationProperties(
"multitenancy.tenant.datasource")
    public DataSourceProperties tenantDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @Primary
    @ConfigurationProperties(
"multitenancy.tenant.datasource.hikari")
    public DataSource tenantDataSource() {
        HikariDataSource dataSource = tenantDataSourceProperties()
                .initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();
        dataSource.setPoolName(
"tenantDataSource");
        return new TenantAwareDataSource(dataSource);
    }
}

和以前一样,由于我们将tenantDataSource 标记为@Primary,因此默认情况下,它将在任何自动装配DataSource 的组件中使用。

鉴别器列和 ENTITYLISTENER
创建新实体时设置tenantId的EntityListener机制保持不变:
public interface TenantAware {

    void setTenantId(String tenantId);
    
}

public class TenantListener {

    @PreUpdate
    @PreRemove
    @PrePersist
    public void setTenant(TenantAware entity) {
        final String tenantId = TenantContext.getTenantId();
        entity.setTenantId(tenantId);
    }
}

@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor
@EntityListeners(TenantListener.class)
public abstract class AbstractBaseEntity implements TenantAware, Serializable {
    private static final long serialVersionUID = 1L;

    @Size(max = 30)
    @Column(name = "tenant_id")
    private String tenantId;

    public AbstractBaseEntity(String tenantId) {
        this.tenantId = tenantId;
    }

}

和以前一样,所有实体都需要扩展AbstractBaseEntity:

@Entity
public class Product extends AbstractBaseEntity {
...
}

最后是 application.yml 中的外部化配置:

spring:
...
  liquibase:
    changeLog: classpath:db/changelog/db.changelog-tenant.yaml
    parameters:
      database: blog
      schema: public
      username: app_user
      password: secret
multitenancy:
  master:
    datasource:
      url: jdbc:postgresql://localhost:5432/blog
      username: postgres
      password: secret
      hikari:
        maximum-pool-size: 1
  tenant:
    datasource:
      url: ${multitenancy.master.datasource.url}
      username: app_user
      password: secret

我们现在有了一个非常简化的共享数据库与鉴别器列模式的实现。租户之间的数据隔离保证由 Postgres 中的行级安全机制提供(前提是我们永远不允许应用程序使用数据库所有者用户访问数据库)。该解决方案应该既健壮又具有高度可扩展性
可以在本博客系列的Github 存储库的[url=https://github.com/callistaenterprise/blog-multitenancy/tree/shared_database_postgres_rls]shared_database_postgres_rls 分支[/url]中找到一个完整工作的简约示例。