优雅的数据隔离:PostgreSQL 行级安全(RLS)

在多租户(Multi-Tenant)应用或者对数据安全性要求极高的系统中,"如何安全、高效地隔离数据"一直是核心难题 。传统的做法是在业务层(如 Spring Boot、Node.js)的每一个 SQL 后面手动拼接 WHERE tenant_id = x

这种做法虽然直观,但极易因为漏写条件导致重大的数据越权泄露事故

PostgreSQL 提供的 RLS(Row-Level Security,行级安全) 机制,直接在数据库层解决了这个问题 。它把权限控制的防火墙下移到了引擎层,无论业务层怎么写 SQL,PG 都会自动帮我们守住底线 。


一、 RLS 的核心工作原理

简单来说,RLS 就是一个"隐式过滤器" 。

当你在某张表上开启了 RLS 并配置了策略(Policy)后,任何用户对该表的查询、修改操作,都会被 PostgreSQL 的查询重写器(Query Rewriter)拦截 。

如上图所示,当应用层(或者不同的数据库 Role)发起一个简单的 SELECT * FROM clients; 时 :

  1. PG 识别到当前执行 SQL 的用户身份 。

  2. 自动调取该表上针对该身份定义的 Policy 表达式(例如 salesperson_id = current_user) 。

  3. 隐式地将该表达式拼接到原 SQL 的 WHERE 条件中 ,最终执行的其实是 : SELECT * FROM clients WHERE salesperson_id = '2';

这意味着,用户永远只能看到或修改他们"有权看到"的行 。


二、 RLS 核心语法与实战演练

下面我们以一个经典的多租户/销售管理系统为例,看看如何从零配置 RLS 。

1. 基础数据准备

首先,创建两名销售员工账号(数据库 Role),以及一张客户资产表 。

sql 复制代码
-- 创建两个普通的数据库用户(模拟不同的销售员)
CREATE USER alice WITH PASSWORD 'password123';
CREATE USER bob WITH PASSWORD 'password123';

-- 创建客户资产表
CREATE TABLE client_assets (
    id SERIAL PRIMARY KEY,
    client_name VARCHAR(100),
    asset_value NUMERIC,
    owner_name VARCHAR(50) -- 归属的销售员
);

-- 插入测试数据
INSERT INTO client_assets (client_name, asset_value, owner_name) VALUES
('Tesla', 5000000, 'alice'),
('Apple', 9000000, 'alice'),
('Microsoft', 8000000, 'bob');

-- 允许用户查询和修改这张表
GRANT SELECT, INSERT, UPDATE, DELETE ON client_assets TO alice, bob;

2. 开启 RLS

注意: 默认情况下,建表后 RLS 是关闭的,任何有权限的用户都能看到全表数据 。我们需要显式开启它 。

sql 复制代码
ALTER TABLE client_assets ENABLE ROW LEVEL SECURITY;

3. 创建安全策略(Policy)

现在,我们要实现:"销售员只能看到和操作属于自己的客户数据"

sql 复制代码
CREATE POLICY asset_owner_policy ON client_assets
    FOR ALL                        -- 覆盖 SELECT, INSERT, UPDATE, DELETE
    TO alice, bob                  -- 应用于这两位用户
    USING (owner_name = current_user); -- 核心过滤条件
  • USING 子句:用于过滤表中现有的行(决定你能 SELECT / UPDATE / DELETE 哪些行)。

  • current_user:PG 内元函数,返回当前执行 SQL 的数据库用户名 。

4. 效果验证

我们切换到 alice 的身份来执行查询 :

sql 复制代码
-- 切换到 alice 身份
SET ROLE alice;

-- 执行全表查询
SELECT * FROM client_assets;

执行结果:

id client_name asset_value owner_name
1 Tesla 5000000 alice
2 Apple 9000000 alice

完美! 属于 bob 的 Microsoft 数据直接被过滤掉了,没有报错,对 alice 来说就像不曾存在一样 。如果 alice 尝试修改 bob 的数据,受影响行数也会是 0 。


三、 高级进阶:配合 Web 应用的动态 RLS

上面的做法需要为每个员工建一个数据库用户 。但在实际的 Web 开发中,我们的应用(如 Java、Go、Python 后端)通常是通过一个统一的连接池用户 (如 my_app_user)连接数据库的 。

这时候该怎么做 RLS 隔离?

最佳实践:利用 GUC (全局配置变量)

PostgreSQL 允许我们在当前的事务会话(Session)中设置自定义变量 。我们可以利用这一点在应用层动态传递当前登录的 user_idtenant_id

sql 复制代码
-- 1. 创建策略:从会话变量 app.current_tenant_id 中读取当前租户
CREATE POLICY tenant_isolation_policy ON client_assets
    FOR ALL
    USING (owner_name = current_setting('app.current_tenant_id', true));

应用层伪代码流程:

每次从连接池捞出 Connection 后,在一个事务 中先执行设置变量,再执行业务 SQL : 使用 SET LOCAL 可以确保该变量只在当前事务中生效,事务结束后自动清空,不会污染连接池里的长连接 。


四、 Spring Boot 落地最佳工程实践

在构建基于 Spring Boot 的多租户应用时,如何优雅地将当前登录用户的租户信息(如 tenant_id)无感知地传递给 PostgreSQL 的会话变量(GUC),是实现动态行级安全(RLS)的关键 。

下面提供一套生产级、无侵入式的 Spring Boot + Spring Data JPA / MyBatis 的最佳工程实践方案 。

1. 核心架构设计

在 Spring Boot 中,为了确保每次数据库操作都能自动注入当前租户信息,我们需要解决两个核心问题 :

  1. 上下文传递 :使用 ThreadLocal 存储当前请求的租户 ID(通常在拦截器中解析 JWT 获得) 。

  2. 连接池适配 :在数据源或数据库连接生命周期的事务开始时 ,自动执行 SET LOCAL app.current_tenant_id = 'xxx'

2. 核心代码实现

A. 租户上下文持有者(TenantContext)

利用 ThreadLocal 在同一个线程(即同一次 HTTP 请求)中共享租户信息 。

java 复制代码
package com.example.rls.config;

public class TenantContext {
    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static String getTenantId() {
        return CURRENT_TENANT.get();
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}

B. HTTP 请求拦截器(TenantInterceptor)

从请求头(例如 JWT 或自定义 Header)中提取租户 ID 并存入上下文,请求结束后务必清理,防止线程复用导致的数据串透 。

java 复制代码
package com.example.rls.config;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 实际业务中应从 JWT Token 中解析
        String tenantId = request.getHeader("X-Tenant-Id"); 
        if (StringUtils.hasText(tenantId)) {
            TenantContext.setTenantId(tenantId);
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 关键点:必须清理 ThreadLocal,避免线程池污染
        TenantContext.clear();
    }
}

C. 自定义数据源代理:实现 RLS 变量自动注入

这是最关键 的一步 。如果我们手动在每个 Service 里面写 SET LOCAL,代码会非常臃肿 。通过重写 DataSource 的 getConnection() 方法,在每次获取连接并开启事务时,自动执行变量注入

java 复制代码
package com.example.rls.config;

import org.springframework.jdbc.datasource.DelegatingDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;

public class RlsAwareDataSource extends DelegatingDataSource {

    public RlsAwareDataSource(DataSource delegate) {
        super(delegate);
    }

    @Override
    public Connection getConnection() throws SQLException {
        Connection connection = super.getConnection();
        setTenantContext(connection);
        return connection;
    }

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

    private void setTenantContext(Connection connection) throws SQLException {
        String tenantId = TenantContext.getTenantId();
        if (tenantId != null) {
            // 使用 LOCAL 关键字,确保变量仅在当前事务(Transaction)中生效
            // 事务提交或回滚后自动失效,完美契合连接池复用机制
            try (Statement stmt = connection.createStatement()) {
                stmt.execute(String.format("SET LOCAL app.current_tenant_id = '%s';", tenantId));
            }
        }
    }
}

D. 注册配置类

将原生的数据源包装为我们自定义的 RlsAwareDataSource

java 复制代码
package com.example.rls.config;

import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.sql.DataSource;

@Configuration
public class RlsWebConfig implements WebMvcConfigurer {

    private final TenantInterceptor tenantInterceptor;

    public RlsWebConfig(TenantInterceptor tenantInterceptor) {
        this.tenantInterceptor = tenantInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tenantInterceptor).addPathPatterns("/**");
    }

    @Bean
    @Primary
    public DataSource dataSource(DataSourceProperties properties) {
        // 创建原生连接池(如 HikariCP)
        DataSource nativeDataSource = properties.initializeDataSourceBuilder().build();
        // 用 RLS 代理类进行包装
        return new RlsAwareDataSource(nativeDataSource);
    }
}

3. 业务层使用示例(以 Spring Data JPA 为例)

得益于上层的无侵入设计,你的实体类、Repository 和 Service 不需要任何特殊的隔离逻辑,直接编写普通的 CRUD 即可 。

实体类与 Repository

java 复制代码
package com.example.rls.entity;

import jakarta.persistence.*;
import lombok.Data;

@Data
@Entity
@Table(name = "client_assets")
public class ClientAsset {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String clientName;
    private Long assetValue;
    private String ownerName; // 对应 PG RLS 过滤的字段
}
java 复制代码
package com.example.rls.repository;

import com.example.rls.entity.ClientAsset;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ClientAssetRepository extends JpaRepository<ClientAsset, Long> {
    // 这里不需要写任何关于 tenant_id 或 owner_name 的过滤条件!
}

业务层声明事务

核心注意点 :由于我们依赖 SET LOCAL,所有的数据库读写操作必须包裹在声明式事务 @Transactional 。如果没有开启事务,SET LOCAL 在执行完单条语句后就会立刻失效 。

java 复制代码
package com.example.rls.service;

import com.example.rls.entity.ClientAsset;
import com.example.rls.repository.ClientAssetRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class AssetService {

    private final ClientAssetRepository repository;

    public AssetService(ClientAssetRepository repository) {
        this.repository = repository;
    }

    @Transactional(readOnly = true) // 必须开启事务
    public List<ClientAsset> getAllAssets() {
        // 哪怕这里调用的是 findAll(),PostgreSQL 也会根据 RLS 策略自动过滤
        return repository.findAll(); 
    }

    @Transactional
    public ClientAsset createAsset(ClientAsset asset) {
        return repository.save(asset);
    }
}

五、 避坑指南:RLS 容易踩的四大深坑

RLS 虽然优雅,但在生产环境中如果不注意以下几点,可能会带来灾难性的后果 :

1. 表所有者(Owner)和超级用户不受限制

  • 巨坑: 为什么我开启了 RLS,也写了 Policy,但是用 postgres 用户或者建表的用户去查,依然能看到全表数据 ?

  • 原因 :PostgreSQL 默认规定:表的拥有者(Owner)和超级用户(Superuser)会自动绕过 RLS 策略

  • 解法:如果你强制要求 Owner 也要遵守 RLS,必须执行 :

sql 复制代码
ALTER TABLE client_assets FORCE ROW LEVEL SECURITY;

2. 性能刺客:在 USING 中写复杂的 Subquery

如果你的 Policy 写成这样:

sql 复制代码
CREATE POLICY bad_policy ON orders 
USING (tenant_id IN (SELECT id FROM tenants WHERE status = 'active'));
  • 后果 :由于 RLS 会重写你的每一次查询,这意味着原表里的每一行数据在处理时,可能都会去触发执行一次里面的子查询(Subquery) 。如果数据量大,会导致全表扫描和严重的 CPU 暴涨 。

  • 解法 :尽量避免在 Policy 内部关联复杂的物理表 。推荐配合上述的 current_setting() 这种内存级变量,或者使用稳定的视图隔离 。

3. 外键约束(Foreign Key)引发的信息泄露

假设 orders 表开启了 RLS,用户 A 看不到订单 001 。但是 orders_items 表没有开 RLS 。如果用户 A 尝试往 orders_items 插入一条指向订单 001 的明细 :

  • 数据库会去校验外键是否存在 。如果订单 001 存在,校验通过;如果不存在,报错 。

  • 风险 :用户 A 可以通过不断尝试插入外键,来试探/推测出他本没有权限看到的订单 ID 是否存在 。

4. 逻辑备份(pg_dump)的权限缺失与异步线程隐患

  • 备份数据缺失风险 :如果备份数据的账号受到了 RLS 的限制,那么利用 pg_dump 备份出来的数据库文件,只会包含该备份账号有权看到的数据

    • 后果:这会导致你的生产备份数据严重丢失而不自知 。

    • 解法 :用于执行定时备份的数据库账号,必须是超级用户 或者拥有 BYPASSRLS 属性 :ALTER USER backup_user BYPASSRLS;

  • Spring 异步处理(@Async)的隐患ThreadLocal 默认是不能跨线程传递的 。如果你的 Service 中使用了 @Async 异步多线程,或者使用了响应式编程(WebFlux),TenantContext 会失效 。此时需要配置 TaskDecorator 以便在线程切换时复制 ThreadLocal 上下文 。

  • 配合数据库默认值 :为了防止在 INSERT 时漏掉租户字段,可以在 PostgreSQL 表结构中为该字段设置默认值 :ALTER TABLE client_assets ALTER COLUMN owner_name SET DEFAULT current_setting('app.current_tenant_id'); 这样,在 Spring Boot 中新增数据时,你甚至不需要手动为实体类设置 ownerName,数据库会自动根据当前的会话变量将其填入 。


六、 总结

控制维度 传统业务层控制 PostgreSQL RLS 控制
实现位置 后端代码(MyBatis/JPA/Sequelize等) 数据库引擎层
漏写风险 高(代码一改,极易漏掉 WHERE) 极低(全局生效,一劳永逸)
性能损耗 无额外损耗 如果 Policy 复杂,可能有轻微重写损耗
适用场景 复杂多变、与外部系统频繁交互的权限系统 纯净的数据多租户隔离、合规性审计系统

PostgreSQL 的 RLS 为数据安全提供了一道坚固的底线防御 。在设计多租户、Saas 平台或财务医疗等敏感系统时,将 RLS 结合 Spring Boot 拦截器代理作为防漏写、防越权的"兜底"方案,是现代化架构中非常值得推崇的实践 。

相关推荐
苍何2 小时前
用 WorkBuddy / Codex + Obsidian 搭建自生长的个人知识库实战
后端
堕落年代2 小时前
Ollama CPU 推理大提示词优化实测报告(细致化数据版)
java·后端·spring
橘色的喵3 小时前
ARM-Linux 嵌入式库:内存池与信号量的无锁化改造
后端
SamDeepThinking3 小时前
写代码,到底应该参考优秀作品,还是坚持自己的想法?
后端
cfm_29143 小时前
基于OAuth2.0实现微服务SSO单点登录
后端·spring·微服务·架构
橘色的喵3 小时前
MergeCell 合并机制:双平台事件流去重
后端
予怀9603 小时前
ClickHouse踩坑:一个sum()引发的"数字不一致"悬案
后端
橘色的喵3 小时前
ReclaimBatcher 批量回收:RT-Thread 单核与 Linux SMP 通用
后端