23.7 实战:隔离两个租户
本节把前六节压成一条可重放的安全证明:
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 后指向生产。
实验合同:
23.7.1 建立应用角色、迁移角色和只读角色
环境与范围
正式环境:
target pg36-l2-vagrant/pg-test
Pigsty v4.4.0
PostgreSQL 18.4
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实验明确不做:
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 的含义不是“执行所有实验”,而是:
validate existing drill evidence
+ adversarial review
+ no database/pool/topology mutation这种命名刻意防止 CI 或读者误触有状态演练。
两份私密输入
完整 drill 需要:
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:
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:securitytarget、authority、confirm 任一不完全相同,inventory 缺失/权限不安全,或 output 非空,runner 都拒绝。执行前还会跑完整第 19 章 preflight;结束后再跑 postflight。
角色图
fixture 使用已有 login 与五个 synthetic role:
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 都要求:
NOSUPERUSER
NOCREATEDB
NOCREATEROLE
NOREPLICATION
NOBYPASSRLSsetup 在复用同名 role/schema 前,检查 owner、comment、属性与完整 fixture shape。若发现同名非实验对象,不会接管。
schema 与 synthetic data
对象:
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:
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。
权限
先撤销 PUBLIC、raw test login 和 Pigsty 宽 group role 对实验 schema/
table/function 的权限,再授予:
runtime
schema USAGE
table SELECT, INSERT, UPDATE
function EXECUTE current_tenant()
readonly
schema USAGE
table SELECT
function EXECUTE current_tenant()明确不授予:
schema CREATE
DELETE
TRUNCATE
REFERENCES
TRIGGER
ALTER/DISABLE RLSowner 拥有对象但不可登录,migrate 能显式切为 owner 而不继承/转授 owner。
policy
helper:
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:
account_runtime_select
account_readonly_select
account_runtime_insert
account_runtime_update
account_owner_all其中 UPDATE 和 owner 明写 USING 与 WITH CHECK,最后:
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
应用事务
runner 模拟应用:
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:
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:
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 42501cross-tenant UPDATE 使用 A context,尝试把 A 行的 tenant_id 改为 B:
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:
tenant_id = NULL -> UNKNOWN -> policy rejects row对读路径,这能 fail-closed。malformed value 则因 UUID cast 报错。应用层仍应 把 missing context 视为 bug 并告警,不能因为数据库返回空集合就安静吞掉。
同时确认 transport 事实
RLS 测试经过 pool,但沙箱 PgBouncer client TLS 是 disabled。runner 没有把 这条路径包装成生产安全链,而是分别测试:
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 注入会话状态泄漏与越权访问并修复
临时单 backend pool
为让复用确定发生,runner 先确认 test pool 无活动 client,再保存:
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然后在三节点对 test pool 执行 RECONNECT。无论后续成功或失败,finally
都会恢复四项 exact baseline,再次 reconnect 并比较 SHOW CONFIG。
泄漏注入
client A 在事务外执行 session setting:
SELECT set_config(
'app.tenant_id',
'11111111-1111-4111-8111-111111111111',
false
);然后 A 和一个新 client B 都只在事务内设置 runtime role,不再设置 tenant。
结果:
client A backend pid 72521
client B backend pid 72521
same backend true
B context tenant A
B visible rows 2 tenant-A rowsB 从未声明 tenant A,却继承 A 的 session state。这条反例是 validator 的必须 项;如果没有复现,实验不会用“可能没复用”蒙混通过。
修复验证
清理 server connection 后,四个逻辑 client 依次执行完整事务合同:
A
missing
B
missing全部复用 PID 72578:
A context A, 2 A rows
missing context NULL, 0 rows
B context B, 2 B rows
missing context NULL, 0 rows因此修复证据同时包含:
same backend reused
AND
previous transaction state absent若只验证两个 backend PID 不同,不能证明 transaction-local 合同。
密码轮换注入
pg36_ch23_rotate 只用于 direct PostgreSQL 认证,不进入 PgBouncer 声明面。
runner 用随机、只存在内存/私密进程输入中的 v1/v2:
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这同时证明:
- PostgreSQL 与 PgBouncer authentication surface 不相同;
- password rotation 控制新认证;
NOLOGIN不终止既有 session;- 演练结束没有留下可登录的 rotation role 或 verifier。
失败时的恢复边界
脚本可以安全自动恢复的只有已知、可比较状态:
PgBouncer four runtime settings
rotation role -> NOLOGIN PASSWORD NULL它不自动:
drop fixture
change HBA/firewall/certificates
force topology
hide failed evidence若拓扑不再唯一稳定或 pool restore compare 失败,报告失败并保留证据,交由 operator 检查;不猜测性修复。
23.7.4 输出权限矩阵、轮换证据与应急回收步骤
evidence tree
完整私密证据:
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。
manifest
manifest 关联:
run id and timestamps
exact target/release
source SHA-256
evidence SHA-256
declared mutations
restoration state
known production gaps
production gatevalidator 会重新计算 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 后:
export PG36_EVIDENCE_DIR=/absolute/private/path/to/ch23-run
static/labs/ch23/task.sh all应给出:
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 要求 validator 拒绝
20 类篡改,包括:
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:
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应急时不能只运行:
ALTER ROLE ... NOLOGIN;本章已经证明既有 session 仍能查询。
destructive reset
正常 drill 不调用 reset。只有明确要删除 synthetic fixture 时:
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:fixturereset 会先检查:
exact schema owner/comment
exact five role comments/attributes
no active synthetic-role sessions
known memberships然后只删除:
schema pg36_ch23
five pg36_ch23_* roles
memberships introduced by this lab它保留已有 test login 和全部非 fixture 对象。该命令是破坏性操作;本书正式
验收没有执行它,fixture 留作复查。
生产门槛
实验正式通过:
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:
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 还不是生产安全批准。
本章完成定义
读者应能独立解释并证明:
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
- PostgreSQL 18:Database Roles
- PostgreSQL 18:Row Security Policies
- PostgreSQL 18:SSL Support
- PgBouncer:Features
- Pigsty:Security Considerations
上一节:Pigsty 安全基线 · 返回本章目录 · 下一章:纲举目张:SLO、SOP 与组织治理 · 查看全书目录 · 查看索引中心