在多租户或带权限隔离的业务系统中,JOIN操作常常是数据泄露的隐形通道。很多团队在单表上配置了行级安全策略,却忽略了一个事实:当两张表做关联查询时,如果其中一张表的策略没有正确施加,或者JOIN条件本身允许跨权限边界匹配,就会导致本不该看到的数据被带出。理解这类泄露的产生机制,是写出安全SQL的前提。

一、JOIN为何会绕过行级安全策略
行级安全(Row-Level Security,简称RLS)是主流关系型数据库提供的对表行进行访问控制的能力。以PostgreSQL为例,开启RLS后,数据库会在查询基表时自动追加一条由策略函数生成的隐藏条件。例如,只让员工看自己部门的记录,策略可能是current_setting('app.dept_id') = dept_id。但当这张表与另一张表JOIN时,如果另一张表没有同等策略,或者JOIN路径通过视图暴露了底层表,隐藏条件就可能被优化器消除。
一个典型场景是:表A有RLS,表B没有;写了一个SELECT * FROM A JOIN B USING (id)。如果B中某行关联到了A里本不属于当前部门的记录,而查询计划先扫描了B再哈希连接A,部分数据库可能不会把A的策略完全下推,造成泄露。此外,使用SECURITY DEFINER视图时,视图拥有者的权限会覆盖调用者,RLS也可能被意外关闭。
1.1 用执行计划确认策略是否生效
排查的第一步是看真实执行的SQL是否带了策略谓词。在PostgreSQL中,可以开启auto_explain或手动运行EXPLAIN。如果发现Seq Scan或Index Scan的Filter里没有你的部门条件,说明策略没起作用。
-- 开启详细执行计划
SET app.dept_id = 'D10';
EXPLAIN (VERBOSE, COSTS OFF)
SELECT a.col, b.col
FROM orders a
JOIN customers b ON a.cust_id = b.id;
-- 若输出中orders表的Filter不包含 dept_id = current_setting('app.dept_id')
-- 则表明JOIN场景下RLS未正确附加
上面的检查能快速定位是不是优化器把策略弄丢了。有些旧版本在嵌套连接或某些JOIN顺序下存在RLS下推缺陷,升级或改写查询才能解决。同时也要注意,如果表被声明为FORCE ROW LEVEL SECURITY,即使表所有者也会受约束,否则所有者默认豁免。
二、通过JOIN限制与策略函数堵住漏洞
最根本的做法是让参与JOIN的每一张表都具备一致的行级安全策略,并且在策略函数中显式校验关联字段。例如,订单表通过cust_id关联客户表,而客户表带有租户ID,那么订单表的策略函数不仅要查自己的租户,还要确认cust_id对应的客户属于同一租户。
2.1 编写跨表校验的策略函数
下面以PostgreSQL为例,展示一个安全的策略函数写法。它在判断订单可见性时,顺带验证客户表的租户归属,从而避免通过JOIN客户表越权访问。
-- 创建租户校验函数
CREATE OR REPLACE FUNCTION can_see_order(order_row orders)
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
SELECT EXISTS (
SELECT 1
FROM customers c
WHERE c.id = order_row.cust_id
AND c.tenant_id = current_setting('app.tenant_id')::int
);
$$;
-- 绑定策略到orders表
CREATE POLICY tenant_isolation ON orders
FOR ALL
TO app_user
USING (can_see_order(orders));
这种写法的好处是,无论订单表是单独查询还是作为JOIN的内表,只要策略生效,越权客户对应的订单就不会进入结果集。需要注意,函数必须是STABLE或IMMUTABLE以提升性能,并且不要在函数内部做复杂写操作。
2.2 限制JOIN本身的条件
除了RLS,还可以在应用层或视图层限制JOIN。例如,只允许通过特定视图查询,视图里把租户条件写死在ON子句中,普通用户无法直接访问基表。
CREATE VIEW safe_orders AS
SELECT o.id, o.amount, c.name
FROM orders o
JOIN customers c
ON o.cust_id = c.id
AND c.tenant_id = current_setting('app.tenant_id')::int;
-- 收回基表权限,只授权视图
REVOKE ALL ON orders FROM app_user;
GRANT SELECT ON safe_orders TO app_user;
这种方式把JOIN限制固化在数据库对象里,即使用户写错SQL也无法跨租户关联。缺点是视图不够灵活,复杂报表可能需要多个视图。实际项目中常把RLS与受限视图结合使用,形成双层防护。
三、排查流程与常见误区
当怀疑JOIN导致泄露时,建议按以下顺序排查:先确认所有相关表都开启了RLS且策略绑定到当前角色;再检查是否有BYPASSRLS权限的角色参与;然后用EXPLAIN看计划中的Filter;最后在测试环境用低权限账号跑真实JOIN语句,比对行数。
3.1 容易忽略的误区
一个常见误区是认为表所有者自动受RLS保护。实际上,默认情况下表所有者不受RLS限制,必须使用ALTER TABLE ... FORCE ROW LEVEL SECURITY。另一个误区是信任视图的SECURITY INVOKER默认行为,却忘了视图底层JOIN的表对调用者可能是全表可读。
| 误区 | 后果 | 修正方式 |
|---|---|---|
| 所有者默认豁免RLS | 管理员查询泄露全部数据 | 使用FORCE RLS |
| 视图绕过策略 | 通过视图JOIN读出越权行 | 视图内写死租户条件并收权 |
| 只给单表加策略 | JOIN无策略表时泄露 | 关联表同步加策略或函数校验 |
只要把上述环节逐一落实,JOIN带来的数据泄露风险就能被有效控制。核心原则只有一条:任何可能被关联的表,都不能脱离当前会话的权限上下文。
四、小结与实操建议
SQL中JOIN导致的数据泄露,本质是对行级安全策略作用范围理解不足。排查时从执行计划中的Filter入手,确认策略谓词是否出现;修复时优先采用带跨表校验的RLS函数,并配合受限视图收敛权限。上线前务必用低权限账号做JOIN渗透测试,观察返回行数是否符合预期。把安全边界写在数据库层,比依赖应用代码更可靠。