4. 行级安全策略审计

行级安全(Row Level Security,RLS)是 PostgreSQL 在表级权限之上叠加的一层“行过滤”机制。它不改变表结构,也不替代 GRANT,而是在查询规划与执行阶段,把策略表达式翻译成附加的过滤条件与写入校验条件。审计 RLS 的核心,不是看策略是否存在,而是看策略是否真正生效、是否被绕过、是否与业务租户边界一致。

策略定义与生效前提

RLS 的开关在表上,策略挂在表上。一个表必须先启用 RLS,策略才会参与执行:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  FOR ALL
  TO app_user
  USING (tenant_id = current_setting('app.tenant_id')::int)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

这里有两个关键点。第一,ENABLE ROW LEVEL SECURITY 只对普通用户生效,表所有者默认不受策略约束;FORCE ROW LEVEL SECURITY 才会让表所有者也被策略约束。第二,策略的 TO 子句决定适用角色,FOR ALL 表示同时覆盖 SELECT、INSERT、UPDATE、DELETE。审计时若发现表已启用 RLS 但没有匹配当前角色的策略,默认结果是“拒绝”,而不是“放行”。

USING 与 WITH CHECK 的分工

USING 表达式用于“读”和“定位已有行”:SELECT、UPDATE 的 WHERE 部分、DELETE 的可见行判断,都依赖它。它回答的是“哪些行对当前角色可见、可被操作”。WITH CHECK 表达式用于“写”:INSERT 的新行、UPDATE 后的新行,必须满足该表达式,否则语句报错。二者可以相同,也可以不同。

一个常见的审计误区是只写 USING 不写 WITH CHECK。在 FOR ALL 或 FOR INSERT/UPDATE 策略中,如果省略 WITH CHECK,PostgreSQL 会复用 USING 表达式作为写入校验。这看起来方便,但语义上要清楚:读可见不等于写合法。例如允许用户看到本租户所有订单,但只允许写入自己创建的订单,就需要把 USING 写成 tenant_id = ...,而 WITH CHECK 写成 tenant_id = ... AND created_by = current_user。

RLS 策略执行路径:USING 过滤可见行,WITH CHECK 校验写入行 应用会话 app.tenant_id = 7 SQL 语句 SELECT / UPDATE / DELETE INSERT / UPDATE 新行 USING 表达式 tenant_id = 7 决定行是否可见 SELECT / UPDATE / DELETE WITH CHECK 表达式 tenant_id = 7 AND created_by = current_user 决定新行是否可写入 可见行集合 仅 tenant_id = 7 其他租户行被过滤 写入结果 满足则提交 不满足则报错 new row violates RLS 常见策略绕过与失效风险 表所有者未 FORCE owner 绕过全部策略 BYPASSRLS 角色 超级用户/复制角色 SECURITY DEFINER 函数内以定义者身份执行 策略 TO 角色不匹配 默认拒绝而非放行

策略绕过风险:审计必须覆盖的四个方向

第一,表所有者与 FORCE。如果表由业务账号创建,而该账号同时是应用连接账号,那么未加 FORCE 时,它作为 owner 可以无视 RLS。审计语句应检查 pg_class.relrowsecurity 与 relforcerowsecurity 两个字段是否同时为真。

第二,BYPASSRLS 属性。拥有该属性的角色(以及超级用户)会跳过所有 RLS 策略。审计时要查 pg_roles.rolbypassrls,确认应用角色没有该属性,并确认没有把该属性授予中间件账号。

第三,SECURITY DEFINER 函数。函数以定义者身份执行,若定义者是表 owner 或 BYPASSRLS 角色,函数内部查询就会绕过调用者的 RLS。审计时要检查函数定义者的角色属性,并确认函数是否被授予了 PUBLIC 执行权限。

第四,策略的 TO 子句与角色继承。策略只对列出的角色生效,但角色成员关系会继承。若 app_user 是某个被策略覆盖角色的成员,策略同样生效;反之,若应用实际连接的是另一个未被策略覆盖的角色,则默认拒绝。审计时应把 pg_policies 与 pg_auth_members 联合检查。

多租户场景下的审计与验证任务

多租户 RLS 的审计目标可以归纳为三句话:租户之间不可见、不可写、不可借道。验证时不要只依赖人工阅读策略,而应构造可重复执行的验证任务。

任务一:确认策略覆盖。查询 pg_policies,核对每张多租户表的 polname、polcmd、polroles、polqual、polwithcheck,确认 SELECT、INSERT、UPDATE、DELETE 四类操作都有对应策略,且 WITH CHECK 不为空。

SELECT schemaname, tablename, policyname, cmd,
       roles, qual, with_check
FROM pg_policies
WHERE tablename IN ('orders','order_items','customers')
ORDER BY tablename, cmd;

任务二:跨租户读写验证。以租户 7 的会话执行查询,确认返回行全部满足 tenant_id = 7;再尝试插入 tenant_id = 8 的行,预期报错 new row violates row-level security policy;再尝试把已有行的 tenant_id 从 7 改为 8,预期同样被 WITH CHECK 拒绝。

SET app.tenant_id = '7';
SELECT count(*) FROM orders;                    -- 应只统计租户 7
INSERT INTO orders(tenant_id, amount) VALUES (8, 100);  -- 预期失败
UPDATE orders SET tenant_id = 8 WHERE id = 101;         -- 预期失败

任务三:绕过路径排查。分别以表 owner、BYPASSRLS 角色、SECURITY DEFINER 函数调用者身份执行同一查询,比较返回行数。若 owner 或 BYPASSRLS 角色返回了跨租户数据,说明存在绕过路径,需要收紧角色属性或改用 FORCE。

任务四:策略变更留痕。RLS 策略的创建、修改、删除都应纳入审计日志。可以通过事件触发器记录 CREATE POLICY、ALTER POLICY、DROP POLICY 的 DDL,或在合规审计中定期比对 pg_policies 快照与基线。对于多租户系统,建议把“策略定义”纳入版本管理,任何变更都经过评审后再上线。

审计 RLS 时,先确认“谁在连接、以什么角色连接、该角色是否受策略约束”,再去看策略表达式本身。很多看似正确的策略,失效原因并不在表达式,而在角色属性或函数定义者身份。
不要用 SET ROLE 或 SET SESSION AUTHORIZATION 在应用连接中临时切换身份来“测试”RLS,这会改变会话角色并可能掩盖真实连接角色下的策略行为。验证应在与生产一致的连接角色下进行。

最后,把验证任务固化为可重复执行的检查脚本,是 RLS 审计从“一次性排查”走向“持续合规”的关键。每次策略变更后,重新执行跨租户读写用例与绕过路径排查,确认返回行数、报错信息和角色属性都符合预期,才能认为多租户隔离仍然成立。