# 实战：隔离两个租户

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

---

本节把前六节压成一条可重放的安全证明：

```text
deployment baseline gate
  -> threat and authority boundary
      -> role graph and object ACL
          -> forced RLS for two synthetic tenants
              -> direct TLS and HBA observations
                  -> deterministic pool-state leak
                      -> transaction-local repair
                          -> password rotation/session survival
                              -> exact pool restore
                                  -> postflight topology gate
                                      -> formal validation and adversarial mutations
```

实验面向第 19 章保留的 Pigsty nonproduction sandbox。它会保留 synthetic
schema/roles，临时改变一个 PgBouncer runtime pool，并短暂启用一个轮换探针
login。不能删除 guard 后指向生产。

实验合同：

- [requirements.json](/labs/ch23/requirements.json)
- [威胁模型](/labs/ch23/threat-model.json)
- [角色合同](/labs/ch23/role-contract.json)
- [租户事务合同](/labs/ch23/tenant-contract.json)
- [安全 ADR](/labs/ch23/security-adr.md)
- [实验权限与生产门槛](/labs/ch23/lab-contract.md)
- [拓扑图](/labs/ch23/topology.mmd)

## 23.7.1 建立应用角色、迁移角色和只读角色 {#item-23-7-1}

### 环境与范围

正式环境：

```text
target          pg36-l2-vagrant/pg-test
Pigsty          v4.5.0
PostgreSQL      18.6
PgBouncer       1.25.2
database        test
leader          pg-test-1
replicas        pg-test-2, pg-test-3
timeline        11 before and after
data            four fixed synthetic rows
production      data=false, traffic=false
```

实验明确不做：

```text
production approval
CA/private-key rotation
HBA/firewall mutation
real customer data
malicious root test
topology change
```

### 风险分级

| action | 风险 | 行为 |
|---|---:|---|
| `capture` | L0 | 只读当前快照 |
| `verify` / `review` / `all` | L0 | 只重验既有 evidence |
| `drill:security` | L2 | 建 fixture、临时 pool/rotation mutation |
| `reset:fixture` | L3 | 删除本章 schema 和五个 synthetic role |

`all` 的含义不是“执行所有实验”，而是：

```text
validate existing drill evidence
  + adversarial review
  + no database/pool/topology mutation
```

这种命名刻意防止 CI 或读者误触有状态演练。

### 两份私密输入

完整 drill 需要：

```text
PG36_CH23_CREDENTIAL_INVENTORY
  已部署 sandbox 的既有 test login credential
  private mode 0600

PG36_CH19_INVENTORY
  第 19 章冻结 baseline gate 使用的 inventory
  private mode 0600
```

普通情况下两者可以来自同一份经过评审的 private inventory。书中正式运行把
它们分开，因为本地当前声明已与第 19 章冻结样本发生演进；不能拿新声明冒充
旧部署的 baseline。

runner 只读取既有 `test` login 的 credential。它不：

- 输出 credential；
- hash credential 到报告；
- 修改 `test` 的 password 或 role attributes；
- 将 private inventory 复制进仓库。

### exact mutation guards

使用一个全新的空 evidence directory：

```bash
export PG36_EVIDENCE_DIR=/absolute/private/path/to/new-empty/ch23-run
export PG36_CH23_CREDENTIAL_INVENTORY=/absolute/private/path/to/credential.yml
export PG36_CH19_INVENTORY=/absolute/private/path/to/baseline.yml

export PG36_CH23_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH23_NONPRODUCTION=true
export PG36_CH23_SYNTHETIC_DATA_ONLY=true
export PG36_CH23_PRODUCTION_TRAFFIC=false
export PG36_CH23_CONFIRM=SECURITY_RLS_ROTATION_CH23

static/labs/ch23/task.sh drill:security
```

target、authority、confirm 任一不完全相同，inventory 缺失/权限不安全，或
output 非空，runner 都拒绝。执行前还会跑完整第 19 章 preflight；结束后再跑
postflight。

### 角色图

fixture 使用已有 login 与五个 synthetic role：

```text
test LOGIN, existing sandbox identity
  ├─ ADMIN false, INHERIT false, SET true -> pg36_ch23_runtime NOLOGIN
  └─ ADMIN false, INHERIT false, SET true -> pg36_ch23_readonly NOLOGIN

pg36_ch23_migrate NOLOGIN
  -> ADMIN false, INHERIT false, SET true -> pg36_ch23_owner NOLOGIN

pg36_ch23_rotate
  -> LOGIN only during direct credential probe
  -> final NOLOGIN PASSWORD NULL
```

五个 synthetic role 都要求：

```text
NOSUPERUSER
NOCREATEDB
NOCREATEROLE
NOREPLICATION
NOBYPASSRLS
```

setup 在复用同名 role/schema 前，检查 owner、comment、属性与完整 fixture
shape。若发现同名非实验对象，不会接管。

### schema 与 synthetic data

对象：

```sql
CREATE SCHEMA pg36_ch23
  AUTHORIZATION pg36_ch23_owner;

CREATE TABLE pg36_ch23.account (
    tenant_id       uuid        NOT NULL,
    account_id      uuid        NOT NULL,
    display_name    text        NOT NULL,
    balance_cents   bigint      NOT NULL CHECK (balance_cents >= 0),
    secret_note     text        NOT NULL,
    created_at      timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (tenant_id, account_id)
);
```

固定 fixture：

```text
tenant A  11111111-1111-4111-8111-111111111111  2 rows
tenant B  22222222-2222-4222-8222-222222222222  2 rows
```

脚本验证 exact columns、constraints、row ids、owner 与 comment；同 schema 中
出现第五条或未知 id 就拒绝。所有数据都是 synthetic，evidence 不导出
`secret_note`。

完整 SQL 见 [`setup.sql`](/labs/ch23/setup.sql)。

### 权限

先撤销 `PUBLIC`、raw `test` login 和 Pigsty 宽 group role 对实验 schema/
table/function 的权限，再授予：

```text
runtime
  schema USAGE
  table SELECT, INSERT, UPDATE
  function EXECUTE current_tenant()

readonly
  schema USAGE
  table SELECT
  function EXECUTE current_tenant()
```

明确不授予：

```text
schema CREATE
DELETE
TRUNCATE
REFERENCES
TRIGGER
ALTER/DISABLE RLS
```

owner 拥有对象但不可登录，migrate 能显式切为 owner 而不继承/转授 owner。

### policy

helper：

```sql
CREATE FUNCTION pg36_ch23.current_tenant()
RETURNS uuid
LANGUAGE sql
STABLE
PARALLEL SAFE
SET search_path = pg_catalog
RETURN NULLIF(
    current_setting('app.tenant_id', true),
    ''
)::uuid;
```

五条 policy：

```text
account_runtime_select
account_readonly_select
account_runtime_insert
account_runtime_update
account_owner_all
```

其中 UPDATE 和 owner 明写 `USING` 与 `WITH CHECK`，最后：

```sql
ALTER TABLE pg36_ch23.account ENABLE ROW LEVEL SECURITY;
ALTER TABLE pg36_ch23.account FORCE ROW LEVEL SECURITY;
```

default privileges 同时约束未来由 owner 创建的 table/function，防止下一次
migration 恢复 `PUBLIC` 暴露。

## 23.7.2 通过 PgBouncer 事务池验证 RLS {#item-23-7-2}

### 应用事务

runner 模拟应用：

```python
connection.execute("BEGIN")
connection.execute("SET LOCAL ROLE pg36_ch23_runtime")
connection.execute(
    "SELECT set_config('app.tenant_id', %s, true)",
    (authorized_tenant_id,),
)
rows = connection.execute(
    """
    SELECT tenant_id, account_id, display_name
    FROM pg36_ch23.account
    ORDER BY tenant_id, account_id
    """
).fetchall()
connection.execute("COMMIT")
```

role 来自固定 allowlist，tenant id 使用参数绑定，并被标记为 synthetic
server-authorized mapping。实验不把终端用户输入直接信任为 context。

### 正例

通过 primary pooled entry：

```text
10.10.10.11:5433
database=test
login=test
pool_mode=transaction
```

观测：

| effective role/context | 结果 |
|---|---|
| runtime + tenant A | 2 条且全是 A |
| runtime + tenant B | 2 条且全是 B |
| runtime + missing | 0 条，context `NULL` |
| owner + missing, FORCE RLS | 0 条 |
| owner + tenant A | 2 条且全是 A |
| migrate `SET ROLE owner` | session/current role 链正确 |
| superuser break-glass | 4 条 |

最后一项单独从节点本地受控管理路径观察，不是应用能力。

### 负例

实际 SQLSTATE：

```text
malformed tenant UUID              22P02
cross-tenant INSERT                42501
cross-tenant UPDATE                42501
runtime DISABLE RLS                42501
runtime CREATE in schema           42501
runtime TRUNCATE                   42501
readonly INSERT                    42501
row_security=off as runtime        42501
raw login SELECT                   42501
```

cross-tenant UPDATE 使用 A context，尝试把 A 行的 `tenant_id` 改为 B：

```sql
UPDATE pg36_ch23.account
SET tenant_id = $tenant_b
WHERE tenant_id = $tenant_a
  AND account_id = $known_a_row;
```

它专门证明 `WITH CHECK`，不是只证明不可见行被 `USING` 过滤。

### 为什么缺失 context 返回 0 而不是报错

helper 的 missing value 是 `NULL`：

```text
tenant_id = NULL -> UNKNOWN -> policy rejects row
```

对读路径，这能 fail-closed。malformed value 则因 UUID cast 报错。应用层仍应
把 missing context 视为 bug 并告警，不能因为数据库返回空集合就安静吞掉。

### 同时确认 transport 事实

RLS 测试经过 pool，但沙箱 PgBouncer client TLS 是 disabled。runner 没有把
这条路径包装成生产安全链，而是分别测试：

```text
direct PostgreSQL sslmode=require             success
direct verify-full matching name              success
direct verify-full wrong name                 rejected
direct verify-full + channel_binding=require  success
direct sslmode=disable                        success, known gap
pooled sslmode=disable                        success, known gap
pooled sslmode=require                        rejected, known gap
```

直连协商为 TLS 1.3 / `TLS_AES_256_GCM_SHA384` / 256 bits。证书 public metadata
包含节点对应 DNS/IP SAN，private key 只检查 mode `0600`，从不读取内容。

## 23.7.3 注入会话状态泄漏与越权访问并修复 {#item-23-7-3}

### 临时单 backend pool

为让复用确定发生，runner 先确认 test pool 无活动 client，再保存：

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

然后在三节点对 `test` pool 执行 `RECONNECT`。无论后续成功或失败，`finally`
都会恢复四项 exact baseline，再次 reconnect 并比较 `SHOW CONFIG`。

### 泄漏注入

client A 在事务外执行 session setting：

```sql
SELECT set_config(
    'app.tenant_id',
    '11111111-1111-4111-8111-111111111111',
    false
);
```

然后 A 和一个新 client B 都只在事务内设置 runtime role，不再设置 tenant。

结果：

```text
client A backend pid             72521
client B backend pid             72521
same backend                     true
B context                        tenant A
B visible rows                   2 tenant-A rows
```

B 从未声明 tenant A，却继承 A 的 session state。这条反例是 validator 的必须
项；如果没有复现，实验不会用“可能没复用”蒙混通过。

### 修复验证

清理 server connection 后，四个逻辑 client 依次执行完整事务合同：

```text
A
missing
B
missing
```

全部复用 PID `72578`：

```text
A         context A,    2 A rows
missing   context NULL, 0 rows
B         context B,    2 B rows
missing   context NULL, 0 rows
```

因此修复证据同时包含：

```text
same backend reused
AND
previous transaction state absent
```

若只验证两个 backend PID 不同，不能证明 transaction-local 合同。

### 密码轮换注入

`pg36_ch23_rotate` 只用于 direct PostgreSQL 认证，不进入 PgBouncer 声明面。
runner 用随机、只存在内存/私密进程输入中的 v1/v2：

```text
enable LOGIN with v1
new direct auth v1                       success
pooled auth                              rejected
change verifier to v2
new direct auth v1                       rejected
new direct auth v2                       success
session authenticated with v1            still usable
set NOLOGIN
new direct auth                          rejected
existing session                         still usable
set PASSWORD NULL
final role state                         NOLOGIN/password absent
```

这同时证明：

1. PostgreSQL 与 PgBouncer authentication surface 不相同；
2. password rotation 控制新认证；
3. `NOLOGIN` 不终止既有 session；
4. 演练结束没有留下可登录的 rotation role 或 verifier。

### 失败时的恢复边界

脚本可以安全自动恢复的只有已知、可比较状态：

```text
PgBouncer four runtime settings
rotation role -> NOLOGIN PASSWORD NULL
```

它不自动：

```text
drop fixture
change HBA/firewall/certificates
force topology
hide failed evidence
```

若拓扑不再唯一稳定或 pool restore compare 失败，报告失败并保留证据，交由
operator 检查；不猜测性修复。

## 23.7.4 输出权限矩阵、轮换证据与应急回收步骤 {#item-23-7-4}

### evidence tree

完整私密证据：

```text
ch23-run/
├── preflight-ch19/
├── drill/
│   ├── before.json
│   ├── inventory-projection.json
│   ├── fixture.json
│   ├── tls-tests.json
│   ├── rls-tests.json
│   ├── pool-context.json
│   ├── rotation-tests.json
│   ├── pool-restore.json
│   ├── after.json
│   ├── drill-manifest.json
│   ├── validation-report.json
│   └── negative-report.json
├── postflight-ch19/
└── review.txt
```

所有 drill evidence file 要求 mode `0600`。完整 bundle 含 internal addresses、
role graph、public certificate metadata 和 backend PID，应放 private evidence
store，不提交 Git。仓库只保留 secret-free 聚合
[`security-run.json`](/labs/ch23/security-run.json)。

### manifest

manifest 关联：

```text
run id and timestamps
exact target/release
source SHA-256
evidence SHA-256
declared mutations
restoration state
known production gaps
production gate
```

validator 会重新计算 evidence hash，防止在运行后悄悄替换观测文件。source
hash 让报告对应到具体 runner/contract 版本。

### 权限矩阵

正式投影：

| capability | raw login | runtime | readonly | owner | superuser |
|---|---:|---:|---:|---:|---:|
| table SELECT ACL | 否 | 是 | 是 | implicit | 是 |
| INSERT | 否 | 是 | 否 | implicit | 是 |
| UPDATE | 否 | 是 | 否 | implicit | 是 |
| DELETE | 否 | 否 | 否 | implicit | 是 |
| TRUNCATE | 否 | 否 | 否 | implicit | 是 |
| schema CREATE | 否 | 否 | 否 | 是 | 是 |
| policy management | 否 | 否 | 否 | 是 | 是 |
| rows without context | 无 ACL | 0 | 0 | 0 under FORCE | 4 |

“owner implicit”不表示生产 owner 应日常执行 DML；它不可登录，仅由 migration
在审批窗口显式切换。

### read-only 重验

拿到一个既有完整 bundle 后：

```bash
export PG36_EVIDENCE_DIR=/absolute/private/path/to/ch23-run
static/labs/ch23/task.sh all
```

应给出：

```text
status=review-ok
tenant_rls=pass
pool_session_leak=reproduced
transaction_local_context=pass
credential_rotation=pass
rotation_role_final_state=nologin-password-null
pool_settings=restored
final_leader=pg-test-1
counterexamples=20-rejected
production_ch23_gate=pending
secret_material=absent
mutation=none
```

### 对抗性反例

[`negative-cases.json`](/labs/ch23/negative-cases.json) 要求 validator 拒绝
20 类篡改，包括：

```text
wrong target/topology
production gate falsely passed
synthetic superuser/BYPASSRLS/LOGIN
membership ADMIN OPTION
RLS/FORCE omitted
missing tenant shows all
cross-tenant writes accepted
leak counterexample omitted
session SET claimed supported
pool override not restored
password change claimed to terminate session
rotation role still usable
secret/verifier/private key/raw userlist in evidence
sslmode=require called server authentication
non-TLS/TLS gaps hidden
ordinary logs called complete audit
unguarded destructive reset
```

测试 validator 对坏证据的拒绝能力，才能避免“只要 JSON 字段存在就 pass”的
自证循环。

### 应急回收

生产 credential suspected compromised：

```text
1. freeze secret distribution and identify exact identity/scope
2. block new auth at IdP/proxy/PostgreSQL
3. set role NOLOGIN and revoke password/token/certificate
4. stop app pools from reconnecting with old material
5. enumerate existing sessions across direct and proxy paths
6. assess in-flight transactions and terminate sessions
7. rotate downstream/shared credentials
8. inspect role grants, RLS/DDL, exports, logs and backups
9. validate old auth fails and new controlled auth succeeds
10. preserve secret-free evidence and start incident review
```

应急时不能只运行：

```sql
ALTER ROLE ... NOLOGIN;
```

本章已经证明既有 session 仍能查询。

### destructive reset

正常 drill 不调用 reset。只有明确要删除 synthetic fixture 时：

```bash
export PG36_CH23_TARGET=pg36-l2-vagrant/pg-test
export PG36_CH23_NONPRODUCTION=true
export PG36_CH23_SYNTHETIC_DATA_ONLY=true
export PG36_CH23_RESET_CONFIRM=DROP_CH23_SYNTHETIC_SECURITY_FIXTURE

static/labs/ch23/task.sh reset:fixture
```

reset 会先检查：

```text
exact schema owner/comment
exact five role comments/attributes
no active synthetic-role sessions
known memberships
```

然后只删除：

```text
schema pg36_ch23
five pg36_ch23_* roles
memberships introduced by this lab
```

它保留已有 `test` login 和全部非 fixture 对象。该命令是破坏性操作；本书正式
验收没有执行它，fixture 留作复查。

### 生产门槛

实验正式通过：

```text
role separation                 pass
object minimum privilege        pass
forced two-tenant RLS           pass
transaction-local pool context  pass
password rotation semantics     pass
pool restore                    pass
topology unchanged              pass
secret-free evidence            pass
```

但生产仍为 `pending`：

```text
business direct HBA permits non-TLS
PgBouncer client TLS disabled
CRL absent
client CA distribution/rotation not exercised
pgAudit absent
non-error bind values may be fully logged
```

这些差距与实验通过不矛盾。前者说明机制被正确验证，后者说明当前 sandbox
还不是生产安全批准。

## 本章完成定义

读者应能独立解释并证明：

```text
HBA first-match 为什么必须看最终顺序
SCRAM、TLS 加密与 verify-full 分别证明什么
login、effective role、owner 和 break-glass 为什么分开
membership ADMIN/INHERIT/SET 如何影响权限路径
USING 与 WITH CHECK 如何约束旧行和新行
FORCE RLS 能约束谁、不能约束谁
tenant context 为什么必须来自授权映射
session SET 如何跨 transaction-pool client 泄漏
SET LOCAL 如何与事务所有权边界对齐
密码/NOLOGIN 为什么不终止既有 session
Pigsty 声明、渲染、运行、观测如何对账
为什么 sandbox pass 仍可以是 production pending
```

如果只能创建一条 policy，却无法证明身份来源、复用连接、负例、轮换和日志
边界，本章还没有完成。

## 参考资料

- [PostgreSQL 18：Client Authentication](https://www.postgresql.org/docs/18/client-authentication.html)
- [PostgreSQL 18：Database Roles](https://www.postgresql.org/docs/18/user-manag.html)
- [PostgreSQL 18：Row Security Policies](https://www.postgresql.org/docs/18/ddl-rowsecurity.html)
- [PostgreSQL 18：SSL Support](https://www.postgresql.org/docs/18/ssl-tcp.html)
- [PgBouncer：Features](https://www.pgbouncer.org/features.html)
- [Pigsty：Security Considerations](https://pigsty.io/docs/deploy/security/)

---

[上一节：Pigsty 安全基线](../06/) · [返回本章目录](../) · [下一章：纲举目张：SLO、SOP 与组织治理](/slo-sop-governance/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
