# 行级安全与连接池上下文

LLMS 索引： [llms.txt](/llms.txt)

---

共享表多租户系统最常见的事故不是 SQL 不会写，而是某一条 SQL 忘了写：

```sql
WHERE tenant_id = $tenant
```

RLS（Row-Level Security）把这个条件从每条业务 SQL 下沉为表级策略。但这
只是第一步。若 `$tenant` 来自用户可篡改的请求字段，或者作为 session 状态
残留在 transaction pool 的 backend 上，policy 本身完全正确，系统仍会越权。

安全链必须完整：

```text
authenticated end-user/workload
  -> authorized tenant mapping
      -> transaction-local database context
          -> effective role
              -> table ACL
                  -> RLS USING / WITH CHECK
                      -> positive + negative + reuse tests
```

## 23.4.1 RLS policy、owner bypass 与强制 RLS {#item-23-4-1}

### RLS 是 ACL 之后的行过滤

RLS 不替代普通权限。一次查询要先有 schema/table 权限，再由 policy 决定
哪些行可见：

```text
table SELECT denied      -> permission denied
table SELECT allowed
  + RLS policy true      -> row visible
  + RLS policy false     -> row silently absent
```

启用：

```sql
ALTER TABLE app.account ENABLE ROW LEVEL SECURITY;
```

如果没有适用于当前 command/role 的 policy，PostgreSQL 使用 default-deny：

```text
0 visible rows / no modifiable rows
```

这是一项很有价值的 fail-closed 属性。但如果 RLS 根本没有启用，已经创建的
policy 不会生效。验收要同时检查：

```sql
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;
```

以及：

```sql
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 必须约束两边：

```sql
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 ROW` trigger 可先修改新行，再执行 `WITH CHECK`；
- policy 不适用于 `TRUNCATE` 和 `REFERENCES` 这类整表操作。

因此 runtime 还必须在 ACL 层失去 `TRUNCATE`。完整 command matrix 见
[PostgreSQL：CREATE POLICY][create-policy]。

[create-policy]: https://www.postgresql.org/docs/18/sql-createpolicy.html

### 多个 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”是在扩大可见集合。比如：

```sql
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`

默认情况下：

```text
ordinary role                  subject to applicable RLS
table owner                    normally bypasses RLS
SUPERUSER                      always bypasses RLS
role with BYPASSRLS            always bypasses RLS
```

要让 owner 也受 policy 约束：

```sql
ALTER TABLE app.account FORCE ROW LEVEL SECURITY;
```

本章把 owner 设为 `NOLOGIN`，仍启用 FORCE：

```sql
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;
```

正式测试证明：

```text
owner, no tenant context       0 rows
owner, tenant A context        exactly 2 A rows
superuser break-glass          all 4 rows
```

FORCE 不是 superuser containment。若 threat model 包含数据库管理员恶意行为，
要依赖组织职责分离、主机/平台控制、不可变审计和密钥治理，不能只依赖 RLS。

### `row_security=off` 不是旁路

这个设置常被误读：

```sql
SET row_security = off;
```

它不会给普通角色绕过 RLS；当查询结果本应被 policy 过滤时，它会报错。这样
备份工具可以避免悄悄导出不完整数据。本章 runtime 负例得到
`SQLSTATE 42501`，正好证明它不是绕过按钮。

### 约束与 policy 的隐蔽信道

primary key、unique 和 foreign key 等 referential integrity 检查会绕过 RLS
以维护一致性。攻击者可能从错误差异推断“不可见行是否存在”：

```text
insert guessed email
  -> unique violation       may reveal an invisible matching row
```

多租户唯一性设计应明确范围：

```sql
UNIQUE (tenant_id, external_key)   -- tenant-local uniqueness
UNIQUE (email)                     -- intentionally global uniqueness
```

如果业务要求全局唯一但不能暴露存在性，应用错误映射、接口语义、重试和审计
都要一起设计，RLS 本身不能消除这个 channel。

policy 表达式若查询其他表，还可能出现并发 snapshot/race 和权限问题。优先
让 policy 只依赖当前行与稳定、简单的事务上下文；复杂授权图要专门做并发
安全评审。官方 RLS 文档详细说明了 referential-integrity 与并发风险：
[PostgreSQL：行安全策略][rls-doc]。

[rls-doc]: https://www.postgresql.org/docs/18/ddl-rowsecurity.html

### view 可能改变 RLS 主体

普通 view 默认按 view owner 的权限访问底层 relation，底层 RLS 也默认使用
view owner 的 policy。PostgreSQL 支持：

```sql
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][create-view]。

[create-view]: https://www.postgresql.org/docs/18/sql-createview.html

## 23.4.2 租户身份通过事务参数传递 {#item-23-4-2}

### tenant id 必须来自授权结果

下面的 API 是危险的：

```json
{
  "tenant_id": "user-supplied-value",
  "operation": "list_accounts"
}
```

如果应用不经验证就执行：

```sql
SELECT set_config('app.tenant_id', $1, true);
```

RLS 只会忠实地允许 `$1` 对应租户。正确来源应是：

```text
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：

```sql
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：

```sql
tenant_id = app.current_tenant()
```

当 context 缺失：

```text
tenant_id = NULL -> UNKNOWN -> row rejected
```

正式观察：

```text
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 还应携带什么

根据审计需求，同一事务还可设置：

```text
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` {#item-23-4-3}

### 为什么 session `SET` 会泄漏

PgBouncer transaction pooling 的基本语义：

```text
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：

```sql
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][pgb-features] 与
[PgBouncer configuration][pgb-config]。

[pgb-features]: https://www.pgbouncer.org/features.html
[pgb-config]: https://www.pgbouncer.org/config.html

### 完整事务合同

支持的请求序列：

```sql
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;
```

四条不可拆：

1. 必须显式 `BEGIN`；
2. effective role 与 tenant context 在同一事务设置；
3. 所有依赖它们的业务 SQL 在同一事务；
4. 任一错误都 `ROLLBACK`，不能把 aborted transaction 放回应用池。

`SET LOCAL ROLE` 和 `set_config(..., true)` 都在 commit/rollback 后结束。即使
下一个请求复用同一个 backend，也会回到 login 身份和无 tenant context。

### 应用框架的实现位置

不要让每个 repository method 自己记住设置上下文。应在统一 transaction
boundary 中：

```text
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 版本与配置。安全原则不变：

```text
plan/cache reuse may be allowed
authorization context must be established per transaction
```

不要把“同名 prepared statement 可复用”误解为角色或 tenant context 也可
跨事务复用。权限、RLS 与当前 GUC 必须在执行时接受测试。

## 23.4.4 验证复用连接不会泄漏上一个租户状态 {#item-23-4-4}

### 让复用成为确定事件

随机并发测试可能碰巧用了不同 backend，从而给出假阴性。本章在确认沙箱无
活动业务 client 后，临时把 `test` 池从：

```text
default_pool_size       50
reserve_pool_size       30
reserve_pool_timeout    1
query_wait_timeout      120
```

改为：

```text
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。

### 先证明漏洞存在

反例：

```text
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
```

实测：

```text
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 合同依次执行：

```text
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`。证据至少包括：

```text
row count
minimum tenant id
maximum tenant id
backend pid
effective tenant context
session_user / current_user
```

敏感字段不应为了证明隔离而导出。本章证据只投影 synthetic tenant/account id
与 display name，并明确：

```text
secret_note_exported = false
```

### 上线门槛

RLS 上线前必须同时满足：

```text
[ ] 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 cardinality
```

RLS 是强大的纵深防御，但只有当身份来源与连接生命周期同样严格时，才会成为
真正的租户边界。

---

[上一节：角色与最小权限](../03/) · [返回本章目录](../) · [下一节：密钥、审计与敏感信息](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
