23.4 行级安全与连接池上下文
共享表多租户系统最常见的事故不是 SQL 不会写,而是某一条 SQL 忘了写:
WHERE tenant_id = $tenantRLS(Row-Level Security)把这个条件从每条业务 SQL 下沉为表级策略。但这
只是第一步。若 $tenant 来自用户可篡改的请求字段,或者作为 session 状态
残留在 transaction pool 的 backend 上,policy 本身完全正确,系统仍会越权。
安全链必须完整:
authenticated end-user/workload
-> authorized tenant mapping
-> transaction-local database context
-> effective role
-> table ACL
-> RLS USING / WITH CHECK
-> positive + negative + reuse tests23.4.1 RLS policy、owner bypass 与强制 RLS
RLS 是 ACL 之后的行过滤
RLS 不替代普通权限。一次查询要先有 schema/table 权限,再由 policy 决定 哪些行可见:
table SELECT denied -> permission denied
table SELECT allowed
+ RLS policy true -> row visible
+ RLS policy false -> row silently absent启用:
ALTER TABLE app.account ENABLE ROW LEVEL SECURITY;如果没有适用于当前 command/role 的 policy,PostgreSQL 使用 default-deny:
0 visible rows / no modifiable rows这是一项很有价值的 fail-closed 属性。但如果 RLS 根本没有启用,已经创建的 policy 不会生效。验收要同时检查:
SELECT
n.nspname,
c.relname,
c.relrowsecurity,
c.relforcerowsecurity
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.oid = 'app.account'::regclass;以及:
SELECT
policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'account'
ORDER BY policyname;USING 看旧行,WITH CHECK 看新行
四类 DML 的核心语义:
| command | 旧行可见/可操作 | 新行可写入 |
|---|---|---|
SELECT | USING | 不适用 |
INSERT | 不适用 | WITH CHECK |
UPDATE | USING | WITH CHECK |
DELETE | USING | 不适用 |
多租户 UPDATE 必须约束两边:
CREATE POLICY account_runtime_update
ON app.account
FOR UPDATE
TO app_runtime
USING (
tenant_id = app.current_tenant()
)
WITH CHECK (
tenant_id = app.current_tenant()
);只写 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:
[ P_{\text{permit}} = P_1 \lor P_2 \lor \cdots \lor P_n ]
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”是在扩大可见集合。比如:
CREATE POLICY tenant_rows
ON app.account
FOR SELECT TO app_runtime
USING (tenant_id = app.current_tenant());
CREATE POLICY support_all_rows
ON app.account
FOR SELECT TO app_runtime
USING (true);第二条会让 app_runtime 看见全部行,不是对第一条的补充限制。策略评审必须
查看某 command/role 的完整 policy 集合,而不是逐条认为“看起来都合理”。
owner、superuser 与 BYPASSRLS
默认情况下:
ordinary role subject to applicable RLS
table owner normally bypasses RLS
SUPERUSER always bypasses RLS
role with BYPASSRLS always bypasses RLS要让 owner 也受 policy 约束:
ALTER TABLE app.account FORCE ROW LEVEL SECURITY;本章把 owner 设为 NOLOGIN,仍启用 FORCE:
CREATE POLICY account_owner_all
ON app.account
FOR ALL TO app_owner
USING (tenant_id = app.current_tenant())
WITH CHECK (tenant_id = app.current_tenant());
ALTER TABLE app.account ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.account FORCE ROW LEVEL SECURITY;正式测试证明:
owner, no tenant context 0 rows
owner, tenant A context exactly 2 A rows
superuser break-glass all 4 rowsFORCE 不是 superuser containment。若 threat model 包含数据库管理员恶意行为, 要依赖组织职责分离、主机/平台控制、不可变审计和密钥治理,不能只依赖 RLS。
row_security=off 不是旁路
这个设置常被误读:
SET row_security = off;它不会给普通角色绕过 RLS;当查询结果本应被 policy 过滤时,它会报错。这样
备份工具可以避免悄悄导出不完整数据。本章 runtime 负例得到
SQLSTATE 42501,正好证明它不是绕过按钮。
约束与 policy 的隐蔽信道
primary key、unique 和 foreign key 等 referential integrity 检查会绕过 RLS 以维护一致性。攻击者可能从错误差异推断“不可见行是否存在”:
insert guessed email
-> unique violation may reveal an invisible matching row多租户唯一性设计应明确范围:
UNIQUE (tenant_id, external_key) -- tenant-local uniqueness
UNIQUE (email) -- intentionally global uniqueness如果业务要求全局唯一但不能暴露存在性,应用错误映射、接口语义、重试和审计 都要一起设计,RLS 本身不能消除这个 channel。
policy 表达式若查询其他表,还可能出现并发 snapshot/race 和权限问题。优先 让 policy 只依赖当前行与稳定、简单的事务上下文;复杂授权图要专门做并发 安全评审。官方 RLS 文档详细说明了 referential-integrity 与并发风险: PostgreSQL:行安全策略。
view 可能改变 RLS 主体
普通 view 默认按 view owner 的权限访问底层 relation,底层 RLS 也默认使用 view owner 的 policy。PostgreSQL 支持:
CREATE VIEW app.account_visible
WITH (security_invoker = true)
AS
SELECT ... FROM app.account;此时底层权限与 RLS 使用调用者身份。不能因为 base table 有 RLS,就假设所有
view 路径都等同于直接访问;应检查 view owner、security_invoker、
security_barrier、函数安全属性和 grant。参见
PostgreSQL:CREATE VIEW。
23.4.2 租户身份通过事务参数传递
tenant id 必须来自授权结果
下面的 API 是危险的:
{
"tenant_id": "user-supplied-value",
"operation": "list_accounts"
}如果应用不经验证就执行:
SELECT set_config('app.tenant_id', $1, true);RLS 只会忠实地允许 $1 对应租户。正确来源应是:
verified token / mTLS / session
-> immutable principal id
-> server-side authorization mapping
-> authorized tenant id
-> database transaction context请求 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:
CREATE FUNCTION app.current_tenant()
RETURNS uuid
LANGUAGE sql
STABLE
PARALLEL SAFE
SET search_path = pg_catalog
RETURN NULLIF(
pg_catalog.current_setting('app.tenant_id', true),
''
)::uuid;设计意图:
missing_ok=true:缺少设置时返回NULL;NULLIF(..., ''):显式 reset/空值也转成NULL;- cast to
uuid:非法格式直接失败; STABLE:一条 statement 中按稳定表达式处理;- 固定
search_path:不从可写 schema 解析对象。
policy:
tenant_id = app.current_tenant()当 context 缺失:
tenant_id = NULL -> UNKNOWN -> row rejected正式观察:
missing context accepted query, 0 rows, current tenant NULL
malformed context SQLSTATE 22P02这叫 fail-closed。它仍不是鉴权器,因为能够执行 set_config 的 session 可以
尝试设置任意值。可信度来自调用它之前的身份授权流程。
policy helper 的安全属性
helper 能保持 SECURITY INVOKER 就不要使用 SECURITY DEFINER。如果必须从
授权表查询:
- 使用不可登录、最小权限 owner;
- 固定
search_path; - schema-qualified 所有对象;
- 收紧
PUBLIC EXECUTE; - 避免动态 SQL;
- 处理并发快照和授权撤销延迟;
- 对高频查询评估性能;
- 为输入与返回值建立负例。
把一个复杂的 definer function 塞进每行 policy,可能同时引入越权路径和 严重性能成本。
context 还应携带什么
根据审计需求,同一事务还可设置:
application_name
request/correlation id
actor id
authorization decision id
tenant id不要把 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 transaction begins
-> assign one PostgreSQL server connection
-> execute transaction
-> COMMIT / ROLLBACK
-> return server connection to pool
-> next client may receive it如果 client A 执行 session-level:
SELECT set_config('app.tenant_id', 'tenant-a', false);第三个参数 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;
SET LOCAL ROLE app_runtime;
SELECT set_config(
'app.tenant_id',
$1, -- server-authorized tenant id
true -- transaction-local
);
-- all business statements for this request
COMMIT;四条不可拆:
- 必须显式
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 中:
authenticate request
-> authorize tenant
-> borrow logical client
-> BEGIN
-> SET LOCAL ROLE
-> set tenant context
-> execute callback/unit of work
-> COMMIT or ROLLBACK
-> release client需要验证框架是否会:
- 因 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 版本与配置。安全原则不变:
plan/cache reuse may be allowed
authorization context must be established per transaction不要把“同名 prepared statement 可复用”误解为角色或 tenant context 也可 跨事务复用。权限、RLS 与当前 GUC 必须在执行时接受测试。
23.4.4 验证复用连接不会泄漏上一个租户状态
让复用成为确定事件
随机并发测试可能碰巧用了不同 backend,从而给出假阴性。本章在确认沙箱无
活动业务 client 后,临时把 test 池从:
default_pool_size 50
reserve_pool_size 30
reserve_pool_timeout 1
query_wait_timeout 120改为:
default_pool_size 1
reserve_pool_size 0
reserve_pool_timeout 1
query_wait_timeout 15这样先后两个 client 会确定复用同一 server connection。实验在 finally
中精确恢复四项原值,并对三节点执行受控 RECONNECT test。这类临时调整只能
用于明确的 nonproduction 空闲池;不能在生产流量中强行把 pool size 改成 1。
先证明漏洞存在
反例:
client A
session set tenant A
BEGIN
SET LOCAL ROLE runtime
SELECT
COMMIT
disconnect
client B
does not set tenant
BEGIN
SET LOCAL ROLE runtime
SELECT
COMMIT实测:
client A backend pid 72521
client B backend pid 72521
same backend true
client B effective tenant tenant A
client B visible rows 2 rows of tenant A这不是理论警告,而是一条跨逻辑客户端的数据泄漏。它也说明“client disconnect 时清理状态”的假设在 transaction pool 中为什么错误。
再证明合同成立
清理实验 backend 后,用 transaction-local 合同依次执行:
tenant A request
missing-context request
tenant B request
missing-context request四次都复用了 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。证据至少包括:
row count
minimum tenant id
maximum tenant id
backend pid
effective tenant context
session_user / current_user敏感字段不应为了证明隔离而导出。本章证据只投影 synthetic tenant/account id 与 display name,并明确:
secret_note_exported = false上线门槛
RLS 上线前必须同时满足:
[ ] tenant source is server-authorized
[ ] login cannot inherit object grants without intended role
[ ] table ACL and RLS policies both reviewed
[ ] ENABLE + FORCE flags match design
[ ] views/functions/partitions alternate paths reviewed
[ ] transaction-local initialization is centralized
[ ] missing/malformed/cross-tenant tests pass
[ ] same-backend reuse test passes
[ ] rollback/timeout/retry paths pass
[ ] break-glass path is separate and audited
[ ] policy performance is measured on production-like cardinalityRLS 是强大的纵深防御,但只有当身份来源与连接生命周期同样严格时,才会成为 真正的租户边界。
上一节:角色与最小权限 · 返回本章目录 · 下一节:密钥、审计与敏感信息 · 查看全书目录 · 查看索引中心