跳至内容
23.7 实战:隔离两个租户

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风险行为
captureL0只读当前快照
verify / review / allL0只重验既有 evidence
drill:securityL2建 fixture、临时 pool/rotation mutation
reset:fixtureL3删除本章 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:security

target、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
NOBYPASSRLS

setup 在复用同名 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 RLS

owner 拥有对象但不可登录,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 明写 USINGWITH 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 A2 条且全是 A
runtime + tenant B2 条且全是 B
runtime + missing0 条,context NULL
owner + missing, FORCE RLS0 条
owner + tenant A2 条且全是 A
migrate SET ROLE ownersession/current role 链正确
superuser break-glass4 条

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

负例

实际 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                   42501

cross-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 rows

B 从未声明 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

这同时证明:

  1. PostgreSQL 与 PgBouncer authentication surface 不相同;
  2. password rotation 控制新认证;
  3. NOLOGIN 不终止既有 session;
  4. 演练结束没有留下可登录的 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 gate

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

权限矩阵

正式投影:

capabilityraw loginruntimereadonlyownersuperuser
table SELECT ACLimplicit
INSERTimplicit
UPDATEimplicit
DELETEimplicit
TRUNCATEimplicit
schema CREATE
policy management
rows without context无 ACL000 under FORCE4

“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:fixture

reset 会先检查:

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,却无法证明身份来源、复用连接、负例、轮换和日志 边界,本章还没有完成。

参考资料


上一节:Pigsty 安全基线 · 返回本章目录 · 下一章:纲举目张:SLO、SOP 与组织治理 · 查看全书目录 · 查看索引中心

最后更新于