23.4 行级安全与连接池上下文
共享表多租户系统最常见的事故不是 SQL 不会写,而是某一条 SQL 忘了写:
RLS(Row-Level Security)把这个条件从每条业务 SQL 下沉为表级策略。但这
只是第一步。若 $tenant 来自用户可篡改的请求字段,或者作为 session 状态
残留在 transaction pool 的 backend 上,policy 本身完全正确,系统仍会越权。
安全链必须完整:
23.4.1 RLS policy、owner bypass 与强制 RLS
RLS 是 ACL 之后的行过滤
RLS 不替代普通权限。一次查询要先有 schema/table 权限,再由 policy 决定 哪些行可见:
启用:
如果没有适用于当前 command/role 的 policy,PostgreSQL 使用 default-deny:
这是一项很有价值的 fail-closed 属性。但如果 RLS 根本没有启用,已经创建的 policy 不会生效。验收要同时检查:
以及:
USING 看旧行,WITH CHECK 看新行
四类 DML 的核心语义:
| command | 旧行可见/可操作 | 新行可写入 |
|---|---|---|
SELECT |
USING |
不适用 |
INSERT |
不适用 | WITH CHECK |
UPDATE |
USING |
WITH CHECK |
DELETE |
USING |
不适用 |
多租户 UPDATE 必须约束两边:
只写 USING 容易忽略“修改后的行能否移到另一个租户”;只考虑
WITH CHECK 又没有明确旧行选择边界。PostgreSQL 对某些 policy 会在省略
WITH CHECK 时复用 USING,但安全代码应把双边意图写清楚。
还要覆盖复杂命令:
UPDATE ... RETURNING同时涉及 SELECT/UPDATE policy;INSERT ... ON CONFLICT会触发 SELECT、INSERT,走 update path 时还会 触发 UPDATE policy;MERGE按实际 action 应用相关 policy;BEFORE ROWtrigger 可先修改新行,再执行WITH CHECK;- policy 不适用于
TRUNCATE和REFERENCES这类整表操作。
因此 runtime 还必须在 ACL 层失去 TRUNCATE。完整 command matrix 见
PostgreSQL:CREATE POLICY。
多个 policy 怎样组合
policy 默认为 PERMISSIVE,多个适用 policy 用 OR:
RESTRICTIVE policy 用 AND,并与至少一个 permissive policy 组合:
[ P_{\text{effective}}
(P_1 \lor \cdots \lor P_n) \land R_1 \land \cdots \land R_m ]
这意味着“再加一条 permissive policy”是在扩大可见集合。比如:
第二条会让 app_runtime 看见全部行,不是对第一条的补充限制。策略评审必须
查看某 command/role 的完整 policy 集合,而不是逐条认为“看起来都合理”。
owner、superuser 与 BYPASSRLS
默认情况下:
要让 owner 也受 policy 约束:
本章把 owner 设为 NOLOGIN,仍启用 FORCE:
正式测试证明:
FORCE 不是 superuser containment。若 threat model 包含数据库管理员恶意行为, 要依赖组织职责分离、主机/平台控制、不可变审计和密钥治理,不能只依赖 RLS。
row_security=off 不是旁路
这个设置常被误读:
它不会给普通角色绕过 RLS;当查询结果本应被 policy 过滤时,它会报错。这样
备份工具可以避免悄悄导出不完整数据。本章 runtime 负例得到
SQLSTATE 42501,正好证明它不是绕过按钮。
约束与 policy 的隐蔽信道
primary key、unique 和 foreign key 等 referential integrity 检查会绕过 RLS 以维护一致性。攻击者可能从错误差异推断“不可见行是否存在”:
多租户唯一性设计应明确范围:
如果业务要求全局唯一但不能暴露存在性,应用错误映射、接口语义、重试和审计 都要一起设计,RLS 本身不能消除这个 channel。
policy 表达式若查询其他表,还可能出现并发 snapshot/race 和权限问题。优先 让 policy 只依赖当前行与稳定、简单的事务上下文;复杂授权图要专门做并发 安全评审。官方 RLS 文档详细说明了 referential-integrity 与并发风险: PostgreSQL:行安全策略。
view 可能改变 RLS 主体
普通 view 默认按 view owner 的权限访问底层 relation,底层 RLS 也默认使用 view owner 的 policy。PostgreSQL 支持:
此时底层权限与 RLS 使用调用者身份。不能因为 base table 有 RLS,就假设所有
view 路径都等同于直接访问;应检查 view owner、security_invoker、
security_barrier、函数安全属性和 grant。参见
PostgreSQL:CREATE VIEW。
23.4.2 租户身份通过事务参数传递
tenant id 必须来自授权结果
下面的 API 是危险的:
如果应用不经验证就执行:
RLS 只会忠实地允许 $1 对应租户。正确来源应是:
请求 body、URL path 或 header 中的 tenant id 可以作为“用户想访问谁”,但 必须与服务端授权集合比对,不能成为信任根。
还要明确 RLS 的 threat boundary:
| 风险 | 共享 login + 可设置 tenant GUC 是否能防 |
|---|---|
开发者漏写 tenant WHERE |
能 |
| ORM 某条查询未注入 scope | 能 |
| 普通 readonly 报表误查全表 | 能 |
| 外部用户篡改请求 tenant,应用正确鉴权 | 能 |
| 应用进程被攻陷,可任意设置 GUC | 不能 |
| 数据库 login 凭据泄露,可选择任意 tenant | 不能 |
| superuser / BYPASSRLS 恶意访问 | 不能 |
如果必须防住被攻陷的单个 tenant workload,就应使用每租户 login/role、独立 数据库或 schema,或者让数据库从不可伪造的连接身份映射 tenant,而不是让 共享 login 自报 tenant id。
自定义 GUC 是载体,不是鉴权器
本章 helper:
设计意图:
missing_ok=true:缺少设置时返回NULL;NULLIF(..., ''):显式 reset/空值也转成NULL;- cast to
uuid:非法格式直接失败; STABLE:一条 statement 中按稳定表达式处理;- 固定
search_path:不从可写 schema 解析对象。
policy:
当 context 缺失:
正式观察:
这叫 fail-closed。它仍不是鉴权器,因为能够执行 set_config 的 session 可以
尝试设置任意值。可信度来自调用它之前的身份授权流程。
policy helper 的安全属性
helper 能保持 SECURITY INVOKER 就不要使用 SECURITY DEFINER。如果必须从
授权表查询:
- 使用不可登录、最小权限 owner;
- 固定
search_path; - schema-qualified 所有对象;
- 收紧
PUBLIC EXECUTE; - 避免动态 SQL;
- 处理并发快照和授权撤销延迟;
- 对高频查询评估性能;
- 为输入与返回值建立负例。
把一个复杂的 definer function 塞进每行 policy,可能同时引入越权路径和 严重性能成本。
context 还应携带什么
根据审计需求,同一事务还可设置:
不要把 access token、password、完整个人信息或业务秘密放进 GUC。它们可能 出现在:
pg_stat_activity;- error context;
- statement/config logs;
- diagnostics;
- monitoring snapshots。
context value 应短小、不可变、可关联,并有明确的数据分类。
23.4.3 transaction pooling 下使用 SET LOCAL
为什么 session SET 会泄漏
PgBouncer transaction pooling 的基本语义:
如果 client A 执行 session-level:
第三个参数 false 让值在 backend session 中持续。client A 断开不等于
PostgreSQL backend 断开;client B 复用它时可能继承 tenant A。
不能把 server_reset_query = DISCARD ALL 当成当然成立的保护。PgBouncer 在
transaction pooling 下并不默认依赖每次事务后的 session reset,且所有入口、
版本和配置必须分别证明。支持矩阵见
PgBouncer features 与
PgBouncer configuration。
完整事务合同
支持的请求序列:
四条不可拆:
- 必须显式
BEGIN; - effective role 与 tenant context 在同一事务设置;
- 所有依赖它们的业务 SQL 在同一事务;
- 任一错误都
ROLLBACK,不能把 aborted transaction 放回应用池。
SET LOCAL ROLE 和 set_config(..., true) 都在 commit/rollback 后结束。即使
下一个请求复用同一个 backend,也会回到 login 身份和无 tenant context。
应用框架的实现位置
不要让每个 repository method 自己记住设置上下文。应在统一 transaction boundary 中:
需要验证框架是否会:
- 因 autocommit 把每条语句拆成独立事务;
- 在 transaction callback 之前执行隐式查询;
- retry 时更换连接但漏掉初始化;
- nested transaction/savepoint 时改变上下文;
- 把 readonly 与 runtime role 混用;
- 在异步任务/streaming cursor 生命周期中提前提交;
- 把
SET LOCAL参数当成 SQL identifier 拼接; - 发生 timeout/cancel 后未 rollback。
tenant value 必须参数绑定;role 名称不能直接来自用户输入。本章只允许固定
allowlist 中的 runtime 或 readonly。
长事务与租户上下文
transaction-local 并不意味着请求可以无限长。长事务会:
- 长时间占用 pool server connection;
- 延迟授权撤销生效到下一事务;
- 放大 idle-in-transaction 风险;
- 持有 snapshot/lock,影响 vacuum;
- 让一次错误上下文影响更多工作。
应为业务事务设置 deadline,拆分批处理,并让授权变化的 SLA 与最长事务时间 一致。
prepared statement 与缓存
transaction pool 支持哪些 prepared statement、temporary object、advisory lock 和 session feature,取决于 PgBouncer 版本与配置。安全原则不变:
不要把“同名 prepared statement 可复用”误解为角色或 tenant context 也可 跨事务复用。权限、RLS 与当前 GUC 必须在执行时接受测试。
23.4.4 验证复用连接不会泄漏上一个租户状态
让复用成为确定事件
随机并发测试可能碰巧用了不同 backend,从而给出假阴性。本章在确认沙箱无
活动业务 client 后,临时把 test 池从:
改为:
这样先后两个 client 会确定复用同一 server connection。实验在 finally
中精确恢复四项原值,并对三节点执行受控 RECONNECT test。这类临时调整只能
用于明确的 nonproduction 空闲池;不能在生产流量中强行把 pool size 改成 1。
先证明漏洞存在
反例:
实测:
这不是理论警告,而是一条跨逻辑客户端的数据泄漏。它也说明“client disconnect 时清理状态”的假设在 transaction pool 中为什么错误。
再证明合同成立
清理实验 backend 后,用 transaction-local 合同依次执行:
四次都复用了 PID 72578,结果:
| 请求 | effective tenant | 可见行 |
|---|---|---|
| A | tenant A | 2 条 A |
| missing after A | NULL |
0 |
| B | tenant B | 2 条 B |
| missing after B | NULL |
0 |
这里最强的证据不是 A/B 正例,而是两个 missing-context 负例:它们证明前一个 事务的 tenant 状态没有留在同一 backend。
完整负例矩阵
共享表至少要自动测试:
| case | 预期 |
|---|---|
| tenant A SELECT | 只返回 A |
| tenant B SELECT | 只返回 B |
| context missing | 0 rows |
| malformed context | 格式错误 |
| A INSERT B row | policy violation |
| A UPDATE row into B | policy violation |
| readonly INSERT | permission denied |
| raw login without effective role | permission denied |
runtime TRUNCATE |
permission denied |
| runtime disable RLS | permission denied |
| owner without context under FORCE | 0 rows |
row_security=off as runtime |
error, not bypass |
| superuser break-glass | all rows, separately audited |
| same backend after commit | no previous tenant |
| same backend after rollback/error | no previous tenant |
本章已验证其中核心 12 类并由 validator 校验 SQLSTATE;生产实现还应加入应用 驱动层的 rollback、timeout、cancel、retry 和并发测试。
不要只断言行数
若两个租户恰好都有两行,错误地返回 B 也会满足 count(*)=2。证据至少包括:
敏感字段不应为了证明隔离而导出。本章证据只投影 synthetic tenant/account id 与 display name,并明确:
上线门槛
RLS 上线前必须同时满足:
RLS 是强大的纵深防御,但只有当身份来源与连接生命周期同样严格时,才会成为 真正的租户边界。
上一节:角色与最小权限 · 返回本章目录 · 下一节:密钥、审计与敏感信息 · 查看全书目录 · 查看索引中心