PostgreSQL 行级权限为何串租户:连接池身份、策略旁路与隔离验收

数据库已经启用行级安全,应用仍可能因为会话身份残留、策略组合、视图或高权限账号而返回其他租户的数据。本文以 PostgreSQL 18 的只读订单查询为边界,给出可信身份绑定、事务内上下文与角色隔离两种实现路径,通过可复算样本和确定性失败用例验收连接复用,并说明任意 SQL 与应用缓存的额外风险。

把 tenant_id 写进查询条件,不等于完成租户隔离;开启行级安全,也不等于任意数据库身份和入口都会受限。可靠方案需要把已认证身份、实际执行角色、策略表达式和连接生命周期连成一条可验证的链路。本篇以 PostgreSQL 18、企业服务端只读订单查询为范围,提供设计与预期结果,未在真实生产系统实测,也不表示某个产品默认采用这些机制。

先固定威胁边界,再选择身份承载方式

行级安全 RLS 是数据库按策略决定哪些记录可见或可修改的机制。PostgreSQL 18 文档说明,启用后没有适用策略时默认拒绝,但超级用户、BYPASSRLS 角色以及通常情况下的表所有者存在绕过路径。[1] 因而验收的第一份输入不是一条查询,而是角色清单:谁登录、谁拥有表、谁执行迁移、谁能够切换身份,以及日常应用到底使用哪个角色。

本文的基础场景是假设可信后端验证用户登录,再从受控授权映射确定租户。浏览器传入的租户编号仅能作为请求对象,不能直接成为授权依据。后端必须核对该用户在当前业务中确有访问权,拒绝未知或失效映射。模型生成的文字、SQL 片段和前端隐藏字段都属于不可信输入,不能用它们自行声明“我属于哪个租户”。

必须区分防止开发遗漏与抵抗任意 SQL 执行。共享角色加事务内租户变量,可以减少受控查询忘写过滤条件的问题;如果攻击者或模型能任意执行 set_config、SET ROLE、调用高权限函数,变量便不是不可伪造的凭证。此时不能宣称仅凭 RLS 表达式已经建立强隔离,应改用更窄的执行接口、独立身份或独立数据库,并限制可执行对象。

输入契约包含用户映射版本、租户标识类型、数据库主版本、连接池模式、应用角色、可访问表与视图、查询入口以及缓存规则。输出契约则包含允许的记录编号集合、聚合值和明确的拒绝状态。无授权与合法租户没有订单是不同结果,业务层应区分;不能把权限故障包装成收入为零,让管理者作出错误判断。

用五条订单建立独立的预期结果

以下全部是构造样本,金额单位为元,不含税,未考虑退货或跨币种。订单表 orders 的字段为 tenant_id、order_id、amount;租户 A 有 A01 一百元、A02 二百元,租户 B 有 B01 四百元、B02 五百元,另有一条归属缺失的 X01 七百元。正式表应拒绝缺失归属,X01 放入隔离区用于验证导入失败,不进入正常可查询表。

因此普通 A 身份应看到编号集合 A01、A02,记录数二,总额三百;普通 B 身份看到 B01、B02,记录数二,总额九百。无有效租户的请求应在后端入口拒绝,数据库策略作为第二道边界不得放出行。隔离记录 X01 不计入任何租户,不能默认归给第一个租户或全局共享范围。独立检查表事先写好,避免用待验收查询自己生成标准答案。

主键也要表达身份范围。若 order_id 仅在租户内部唯一,应采用 tenant_id 与 order_id 的组合唯一性;关联订单明细时也使用两列,而不是只靠订单编号。可增加一个分支:A 和 B 都有编号 O01,但金额不同,预期各自只见本租户版本。这个分支检查身份和对象键是否一起流转,不要求把真实客户数据复制到测试环境。

应用查询可以请求 SELECT order_id, amount FROM app.orders ORDER BY order_id;这里没有用户提供的租户条件,预期仍只能返回当前授权范围。这是故意构造的防遗漏测试,并非鼓励所有 SQL 都省略过滤。正式查询可保留显式范围以改善可读性和计划选择,但不得让显式条件成为唯一安全边界,更不能让查询扩大数据库已限定的范围。

共享角色方案:把租户上下文限制在完整事务内

第一种实现使用不拥有表、没有 BYPASSRLS 和超级用户权限的应用角色,由可信后端传递租户上下文。数据库策略可以把行中的 tenant_id 与事务上下文比较。示意表达式为 tenant_id = NULLIF(current_setting('app.tenant_id', true), '')。本例两侧均采用 text;缺失或空串转换为 NULL,比较不为真,因此不放行。格式不合法应先在后端拒绝,不能通过宽松转换映射到其他身份。

current_setting 的 missing_ok 参数为 true 时,未定义的设置可返回 NULL;set_config 的第三个参数决定是否只在当前事务有效。[2] 这些函数提供状态读写,不验证租户真实性。本文表达式只是机制示例,部署前还需按真实数据类型、角色及迁移脚本验证,不能复制进现有库后就宣告隔离完成。

完整调用顺序是:获取连接,显式 BEGIN;由已认证映射得到租户值;使用参数化 SELECT set_config('app.tenant_id', $1, true) 设置事务局部状态;在同一事务和同一后端连接执行获准查询;收集结果;COMMIT;出现异常则 ROLLBACK,再按连接池规则归还连接。没有有效上下文时不执行业务查询,设置失败后也不得继续使用旧状态。

PostgreSQL 的 SET LOCAL 在事务结束后失效,普通 SET 则可能在提交后继续留在会话中;回滚到更早保存点也会撤销之后的局部设置。[3] 所以不要在自动提交模式中先设置变量,再单独执行查询,那是两个不同事务。也不要在设好上下文后回滚到之前的保存点,却假定身份仍然存在。重试完整事务时重新认证或核对有效授权并重新绑定。

PgBouncer 的事务池只在事务期间分配后端连接,语句池不允许多语句事务;其功能表明确警告不要依赖跨事务的会话 SET/RESET 状态。[4] 本文需要同一显式事务内的局部设置与读取,实际驱动、代理和池化组合仍须测试。不能通过“连接池通常会清理”证明安全,也不能把某个池的启动参数跟踪能力推广为所有自定义变量都会被隔离。

这条路径的可信计算边界包括能够写入该变量的后端。任意 SQL 即使外观看起来是 SELECT,也可能调用带副作用的函数,因此“只允许 SELECT 开头”不够。查询接口应采用批准的模板或经过严格限制的语法树与函数清单,并拒绝多语句、任意函数调用和角色切换。若无法建立这种执行边界,应选择下一节更强的身份隔离,而不是继续堆字符串过滤规则。

独立角色或数据库方案:把伪造上下文的风险移出共享会话

第二种实现让租户对应受限数据库登录角色,并通过受控映射关联 tenant_id 与 current_user。每个角色只得到必要对象的读取权限,不获得其他租户角色的切换权限,也不拥有策略表或业务表。连接池按真实数据库身份分开管理。这样,攻击者仅修改自定义参数不能改写数据库身份,但凭证保管、角色成员关系与高权限函数仍是需要验收的边界。

不能用一个可 SET ROLE 到所有租户的超级应用账号冒充独立身份方案,再允许任意 SQL 经过该账号。应画出认证链路:外部用户到可信授权映射,再到对应受限连接。映射表不可由普通租户修改,池的键也不能只包含数据库地址而漏掉用户身份。新增租户时,角色、授权、池配置与删除流程必须作为同一次可追踪交付。

共享角色方案适合后端可信、执行入口窄、租户规模较大且需要统一管理的场景,成本主要是事务包装、入口审计和回归测试。独立角色方案增加角色及连接管理,却让数据库自身参与识别边界;若存在更强的运维、故障或合同隔离需求,还可比较独立数据库。数据库分开会增加迁移、监控与跨租户汇总成本,不应仅因角色多就直接跳到最重方案。

用本构造样本比较实施工作:共享路径只需一个执行角色,但所有订单读取入口都必须经过可靠包装;角色路径至少要为 A、B 准备独立身份与池,并验证无法相互切换。两者都应得到三百与九百的相同业务结果。选择依据是攻击者能控制什么、维护人员能持续管理什么,而不是哪条配置更短。本文不提供跨场景通用的租户数量阈值。

策略组合、视图和函数会改变实际访问身份

先检查策略是否真正生效,而非只有定义。对测试表启用 RLS,应用角色给予必要 SELECT 权限,并使其不拥有该表。FORCE ROW LEVEL SECURITY 可以让所有者通常也受策略约束,但不能限制超级用户或 BYPASSRLS。[1] 迁移角色与应用角色分离比让日常服务借用所有者方便得多,也更容易审计。策略缺失导致合法查询失败时,修策略,不要临时授予绕过权限。

PostgreSQL 的宽松策略默认按 OR 组合,限制性策略按 AND 收窄,且需要至少一条宽松策略允许访问。[5] 因此新增一条看似临时的 USING (true) 宽松策略,可能扩大现有租户范围。评审应看该角色在该操作上的最终组合,而不是单独阅读某条“正确的租户策略”。把有效角色成员关系一起展开,检查继承得到的授权。

只读示例不需要写权限。如果业务后来新增写入,要独立设计 USING 与 WITH CHECK:前者控制已有行的可见或可操作范围,后者检查新增或修改后的行。[5] 可构造将 A01 的 tenant_id 改成 B 的请求,预期不允许完成;也应测试直接插入 B 记录。不能把读取验收通过自动扩展成写入安全证明。TRUNCATE 等整表操作更不能交给普通应用。

视图不是天然的安全壳。PostgreSQL 18 默认会按视图所有者处理底层权限和相关行策略;security_invoker 视图可改按调用者检查,并要求调用者具有相应底层权限。[6] 因此应使用应用真实角色同时查询表和对外视图,比较记录集合。视图里 CURRENT_USER 的返回与底层权限检查也不能混为一谈,必须看实际定义及执行规则。

SECURITY DEFINER 函数按函数所有者权限执行。官方要求注意安全的 search_path 以及默认向 PUBLIC 开放的函数执行权限。[7] 日常读取接口不需要此能力时不要引入;确需使用时限定对象名、调用权限和输入,并单独检查是否绕过本应存在的租户边界。RLS 保护的是具体数据库访问路径,不会自动保护另一个高权限函数已经拿到的全量结果。

按确定性事件顺序验收连接复用与失败恢复

用例一是缺失身份。通过真实应用角色、不设置事务上下文发起受控查询,预期后端拒绝,直接数据库防线不返回业务行;再设置合法 A,预期两行三百元。保留身份映射、事务边界与结果编号证据。若缺失变成全表可见,检查默认分支和策略组合;修正后同时重跑无身份与合法身份,避免以拒绝所有请求伪装安全。

用例二是复用同一后端。把测试池缩到一个可控连接,让 A 完成查询并提交,再让 B 获得该后端执行查询。预期 A 只见 A01、A02,B 只见 B01、B02;随后无身份请求仍不能见任何租户数据。记录后端连接标识以证明发生了复用。若 B 看见 A,检查会话 SET、自动提交、事务包装和池键,修复后再做 A、B、无身份的连续序列。

用例三是异常退出。A 在设置上下文后制造一个可控查询错误,确认事务回滚,然后让 B 复用连接。预期 B 仍得到两行九百元,没有 A 的记录。若连接处于失败事务状态,应先清理或销毁而非直接交给下一请求。再模拟连接中断与客户端取消,检查归还路径;不能只测试正常 COMMIT,因为串用常发生在异常分支。

用例四是上下文伪造。让 A 的请求体或生成查询尝试声明 B,预期在可信映射或受限执行入口被拒绝。对于共享变量方案,还应在专门测试中证明拥有任意 SQL 能力的角色确实可能改变变量,并把它登记为不适用边界;不能删除这条反例后宣称抵抗数据库任意代码。若需求要求抵抗该能力,改身份方案并复验角色切换、函数调用和对象访问。

用例五是对象旁路。为测试准备表直读、实际业务视图和允许调用的函数三条路径,分别比较 A、B 的编号集合;再在隔离环境检查使用所有者身份会发生什么,验证部署检查能发现错误账号。若视图或函数放出额外行,修正执行身份或收紧入口后,所有路径一起复验,不只修正页面最终显示。

用例六是保存点与重试。在设置上下文之前建立保存点,设置 A 后回滚到该点,再尝试继续查询,预期包装层停止或重新绑定已核实身份,不沿用错误假设。完整事务重试也必须重新设置上下文。检查重试计数、授权版本与实际结果;不能把第一次请求的租户值放入全局可变对象,随后被另一并发请求覆盖。

数据库通过之后,还要检查缓存、汇总与性能

应用缓存位于数据库之外。若缓存键只有问题文本“本月订单总额”,A 查询得到三百元后,B 可能直接命中三百元,根本没有触发 RLS。缓存范围至少需要绑定可信租户、授权范围或版本及业务数据版本,并在返回时遵守实际授权规则。构造先 A 后 B 的相同问题测试,核对是否真的访问了数据库或从正确缓存空间读取,不能只看 SQL 日志里没有越权查询。

后台任务也需要显式身份。报表订阅、导出和异步分析不能借用当前交互线程的临时变量,更不能默认用迁移账号。任务保存受控租户标识与批准用途,执行时重新核对权限并走同一隔离入口。长任务期间发生撤权的时间边界,可继续阅读 RAG 撤权与在途请求的一致性方法;数据库单次查询隔离不等于已经解决结果发出时的全部授权问题。

聚合查询应在授权范围内计算,但合法汇总仍可能通过差值暴露其他范围。如果允许 A 看全集团一千二百元,又知道自身三百元,就能推得 B 九百元。是否允许这种披露属于单独的数据策略,不能靠不返回 B 的明细证明已解决。验收时把允许的集团汇总、钻取和组合查询列清,按业务规则限制出口,且不要把安全限制误报成统计缺失。

性能验证使用实际角色、真实政策复杂度与代表性租户规模。可评估包含 tenant_id 的索引、策略查询的额外访问以及冷热数据差异,但索引不是权限机制。比较有权限查询的计划、延迟和扫描量,同时验证结果不变;不要为获得更漂亮的基准而改用绕过角色。数据倾斜时分别测小租户和大租户,平均值不能解释最慢业务路径。

共享策略若依赖额外授权表,会引入访问权限、查询成本和并发变更语义,不能直接把本文只比较当前行的示例推广为任意复杂授权图。跨表映射、分区直读、复制副本和离线导出都应单列范围。业务无法维护这些例外时,先开放经过验证的最小查询集合,再按明确的新增用例扩大,不一次性授予所有表读取权。

把隔离保证变成发布和回归清单

交付清单每行记录入口、真实登录角色、对象及策略版本、池模式、身份来源、事务包装位置、允许与禁止编号集合、异常结果和证据。至少覆盖正常 A、正常 B、无身份、同连接复用、异常回滚、伪造身份、视图函数旁路、缓存命中和后台任务。允许场景必须成功,禁止场景必须不放出行;未测试的入口标记未验收而非默认通过。

上线前对角色属性、表所有者、RLS 开关、策略定义、视图选项和函数权限做可比较的快照。应用新增入口、数据库升级、连接池模式变化、策略迁移和缓存改造都应触发相关回归。测试证据保留虚构订单编号及配置版本即可,不需要把真实敏感正文复制进日志。这个清单是可复用的工程成果,不能用一张权限配置截图代替。

发生串租户迹象时,先停用受影响入口、保全必要的脱敏证据并界定影响范围,再修身份和对象访问路径。回滚只能回到已验证的安全配置;关闭 RLS 让查询恢复正常不属于安全回滚。恢复前重跑发现故障的事件序列、其他租户正向用例和全部共享入口,记录数据库通过与最终用户输出通过两个层次。

本文给出的机制与预期值可用于搭建隔离测试,但真实安全保证仍取决于部署身份、可执行 SQL 范围、应用出口与持续维护。租户隔离的合格交付,是能指出每条请求为什么只得到允许集合、故障时如何保持边界,以及哪些路径尚未被覆盖;它不是把一个策略开关设置成开启后就永久完成的工作。

参考资料

  1. [1] PostgreSQL 18 — Row Security Policies
  2. [2] PostgreSQL 18 — Configuration Settings Functions
  3. [3] PostgreSQL 18 — SET
  4. [4] PgBouncer — Features and pooling mode compatibility
  5. [5] PostgreSQL 18 — CREATE POLICY
  6. [6] PostgreSQL 18 — CREATE VIEW
  7. [7] PostgreSQL 18 — Writing SECURITY DEFINER Functions Safely
返回洞察
鲁ICP备2024109755号-2
可拖动移动。右键、长按或按 Shift+F10 可选择停靠位置。