内容提要
PostgreSQL 17 中,行级安全策略读取其保护的同一张表时会报“infinite recursion detected in policy”。基于百万行数据的实测显示,六种修复方案中,会话变量存租户最快(0.09 毫秒),STABLE 函数返回数组配合 = any 次之,VOLATILE 函数逐行调用慢至 6.6 秒。建议使用 BYPASSRLS 角色、限定 FOR SELECT 并撤销公开执行权限。
延伸解读
递归错误的触发条件与常见误区
文章指出,当行级安全策略需要读取其保护的表时,PostgreSQL 17 会抛出“infinite recursion detected in policy”。这种错误在策略创建时不会出现,且仅当策略应用于读取操作(如 FOR SELECT 或 FOR ALL)时才会触发。开发中容易忽略的是,以表所有者或超级用户身份执行相同查询不会报错,因此必须使用应用角色进行测试才能提前发现。
不同修复方案的性能差异与选择
基于百万行发票表的实测显示,六种修复方案性能悬殊:会话变量存租户最快(0.09 毫秒),STABLE 函数返回数组配合 = any 次之(0.18 毫秒),而 VOLATILE 函数逐行调用慢至 6.6 秒。选择方案时需权衡:若每次请求只涉及一个租户,会话变量简单高效;若需支持多租户查询,应使用 STABLE SECURITY DEFINER 函数并以数组形式调用,避免逐行计算。
安全配置与权限管理要点
修复递归时需注意安全边界:SECURITY DEFINER 函数应属一个具有 BYPASSRLS 且非表所有者的角色,否则在 FORCE ROW LEVEL SECURITY 下会引发栈溢出。同时,策略应限定为 FOR SELECT,写操作单独定义策略,防止“查看成员”意外变成“增删改成员”。此外,必须撤销 PUBLIC 对函数的执行权限,仅授权给应用角色,避免权限泄露。
Q&A
PostgreSQL 报错 infinite recursion detected in policy for relation 是什么意思?
这是 SQLSTATE 42P17 错误,表示 PostgreSQL 在重写查询时发现,要应用某张表的行级安全策略,必须读取该表本身,而读取时又要应用同样的策略,形成无限递归。错误在查询执行前就会抛出,且只由适用于读取的策略(FOR SELECT 或 FOR ALL)触发。
为什么行级安全策略会检测到无限递归?
当策略的 USING 表达式里包含对同一张表的子查询时,就会递归。例如多租户系统中,memberships 表的策略写为 tenant_id in (select tenant_id from memberships where user_id = me),要判断你能看哪些成员,就得先读 memberships 表,而读表又要应用同一策略,于是无限递归。
修复 infinite recursion detected in policy 有哪些方案?哪种最快?
文章实测了六种方案:1. SECURITY DEFINER 函数返回集合,用 in (select …);2. 同一函数用 = any (array(select …));3. 返回数组的 STABLE 函数;4. 返回数组的 VOLATILE 函数;5. 表所有者拥有的视图;6. 会话变量存租户。最快的是方案 6,计数 801 张发票仅 0.09 毫秒;方案 2 和 3 次之,分别为 0.15 和 0.18 毫秒;方案 4 最慢,达 6.6 秒。
为什么用 SECURITY DEFINER 函数修复后查询仍然很慢?
因为消除递归和让规划器使用索引是两回事。如果策略写成 tenant_id in (select my_tenants()),规划器会对 invoices 做顺序扫描,逐行与子计划比较,导致小用户 801 张发票计数耗时 74.5 毫秒。若改为 tenant_id = any (array(select my_tenants())),子查询变成 InitPlan 只执行一次,结果转为数组,规划器就能用 Index Only Scan,耗时降至 0.15 毫秒。
使用 SECURITY DEFINER 函数修复递归时,为什么开启 FORCE ROW LEVEL SECURITY 会失败?
因为函数以所有者身份运行,而 FORCE ROW LEVEL SECURITY 会让表所有者同样受策略约束。函数调用策略,策略又调用函数,形成循环,最终报 stack depth limit exceeded。解决办法是让函数由一个拥有 BYPASSRLS 且非应用登录角色的独立角色拥有,这样即使开启 FORCE 也能正常工作。
修复递归时如何避免权限泄露和安全风险?
文章建议:策略应限定 FOR SELECT,写操作单独建策略;撤销 PUBLIC 对函数的 EXECUTE 权限,只授权给应用角色;函数设置 search_path = '' 并模式限定表名;使用 BYPASSRLS 角色拥有函数,且该角色不能是应用登录角色。否则可能出现越权插入、删除或读取所有租户数据等风险。