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。
策略绕过风险:审计必须覆盖的四个方向
第一,表所有者与 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 快照与基线。对于多租户系统,建议把“策略定义”纳入版本管理,任何变更都经过评审后再上线。
SET ROLE 或 SET SESSION AUTHORIZATION 在应用连接中临时切换身份来“测试”RLS,这会改变会话角色并可能掩盖真实连接角色下的策略行为。验证应在与生产一致的连接角色下进行。最后,把验证任务固化为可重复执行的检查脚本,是 RLS 审计从“一次性排查”走向“持续合规”的关键。每次策略变更后,重新执行跨租户读写用例与绕过路径排查,确认返回行数、报错信息和角色属性都符合预期,才能认为多租户隔离仍然成立。