跳转到主要内容

23 固若金汤:认证、授权与数据安全

数据库安全不是在系统外面再围一堵墙,而是让每一次越过边界都留下可验证的 答案:

谁在连接
  -> 从哪里、通过哪条加密路径
      -> 以哪个 login 通过认证
          -> 当前使用哪个 effective role
              -> 对哪个对象有什么权限
                  -> 哪些行可见、哪些新值可写
                      -> 谁能变更这些规则
                          -> 事件能否被安全地调查和撤销

只检查其中一层会产生危险的“半安全”:

SCRAM 成功              != 网络中的服务器身份正确
TLS 已加密              != 客户端验证了证书名称
HBA 匹配                != 角色有对象权限
GRANT SELECT            != 能看见全部行
RLS 生效                != table owner / superuser 也受约束
密码已经修改            != 旧连接已经断开
PostgreSQL role 已创建   != PgBouncer 已交付该身份
日志很多                != 有完整、受保护、可检索的审计链

本章先建威胁模型,再沿认证、授权、行级安全、密钥和审计一路向内。最后把 第 22 章的 transaction pool 纳入模型:同一个 PostgreSQL backend 会先后 服务不同客户端,因此 session 级安全上下文不仅会“丢失”,还可能泄漏给下一 个租户。

本章目标

完成本章后,你应当能够:

  1. 用资产、主体、入口、信任跨越和失败后果编写数据库威胁模型;
  2. 区分终端用户、应用 login、effective role、object owner 与 break-glass;
  3. pg_hba_file_rules 解释 first-match,而不是凭配置片段猜认证结果;
  4. 区分认证方法、密码存储、传输加密和服务器身份校验;
  5. 使用 SCRAM,理解 channel binding、密码轮换与客户端兼容边界;
  6. 说明 sslmode=requireverify-caverify-full 分别证明什么;
  7. pg_stat_ssl、证书 SAN、HBA 和真实连接共同验收 TLS;
  8. 把 LOGIN、group、owner、migrate、runtime、readonly 角色拆开;
  9. 正确使用 PostgreSQL 16+ membership 的 ADMININHERITSET
  10. 设计 schema/table/sequence/function/default privilege 的最小权限;
  11. 识别 PUBLIC、owner、search_pathSECURITY DEFINER 和预定义高权 角色造成的越权路径;
  12. 为共享表设计 RLS 的 USINGWITH CHECK
  13. 解释 default-deny、permissive OR、restrictive AND 与完整表操作边界;
  14. 使用 FORCE ROW LEVEL SECURITY 约束 owner,并明确 superuser/ BYPASSRLS 仍会绕过;
  15. 通过事务级 SET LOCAL ROLE 和 tenant context 支持 transaction pool;
  16. 复现 session SET 跨客户端泄漏,并证明事务局部状态在提交后消失;
  17. 设计生成、分发、双版本轮换、撤销和应急回收的凭据生命周期;
  18. 区分普通运行日志、对象/会话审计、平台审计和合规证据;
  19. 在 Pigsty 中声明用户、HBA 和接入层,再从渲染产物与运行事实反查;
  20. 对一个环境给出“通过、带例外通过、待整改或拒绝”的诚实安全结论。

前置与后续

前置:

后续:

  • 第 24 章把安全例外、owner、轮换、SOP 和审批纳入治理;
  • 第 25 章把连接、认证失败、角色变更与审计事件接入可观测系统;
  • 第 29、30 章处理复制/迁移/升级中的身份与双版本兼容;
  • 第 31 章把泄露、越权、凭据失陷与取证放进事件响应;
  • 第 32–35 章会再次约束备份、恢复、故障操作和抢救身份。

学习路径

资产与主体
  -> 信任边界和攻击路径
      -> HBA/认证/TLS
          -> login 与 effective role
              -> 对象所有权和最小权限
                  -> RLS 行边界
                      -> transaction-pool 上下文
                          -> secret 生命周期
                              -> 日志、审计和脱敏
                                  -> Pigsty 声明/渲染/运行差异
                                      -> 双租户对抗性验收

顺序很重要。若先写一条 RLS policy、最后才问“tenant id 从哪里来”,就可能 把用户自己提交的 tenant id 原样写入 GUC,得到一套语法正确却可随意越权的 系统。

六层安全证明

要证明的问题 PostgreSQL / Pigsty 证据
暴露面 哪些端口和网络能到达 listener、防火墙/安全组、HAProxy、HBA
传输 对端是谁、链路是否加密 sslmode、CA/SAN、pg_stat_ssl、PgBouncer TLS
认证 login 是谁、凭据是否有效 HBA first-match、SCRAM/cert/外部身份、认证日志
授权 current role 能做什么 role graph、ACL、owner、default privilege
数据 哪些行可见、哪些新值可写 RLS flag、policy、正负测试、FORCE RLS
治理 谁能变更、撤销、调查 inventory、审批、secret manager、audit/retention

这六层不能互相代替。例如 PostgreSQL hostssl 只要求连接使用 TLS;客户端若 选择不校验证书名称,仍可能把密码发给错误的服务器。反过来,verify-full 只能验证连接到证书所代表的服务器,不能证明这个 login 应当读取某个租户。

角色分层

本章采用一个可复用的角色图:

login identity
  ├─ SET TRUE, INHERIT FALSE -> runtime NOLOGIN
  └─ SET TRUE, INHERIT FALSE -> readonly NOLOGIN

migration identity
  -> migrate NOLOGIN
      -> SET TRUE, INHERIT FALSE -> owner NOLOGIN

break-glass
  -> 独立控制;不属于应用正常路径

职责:

角色 LOGIN 主要权限 明确不应拥有
application login 只允许切换到批准的 runtime role owner、DDL、ADMIN OPTION
runtime USAGE + 必要 DML schema CREATE、TRUNCATE、BYPASSRLS
readonly USAGE + SELECT 写入、迁移
migrate 可切换到 owner 日常服务流量、凭据
owner 拥有应用对象和 policy 日常 LOGIN
break-glass 独立 紧急高权动作 无审批、无时限、无审计

PostgreSQL 16 起,membership 自身有 ADMININHERITSET 选项。 本章使用:

GRANT pg36_ch23_runtime TO test
WITH ADMIN FALSE, INHERIT FALSE, SET TRUE;

因此 test 登录后不会隐式得到 runtime 权限,但能在批准的事务中 SET LOCAL ROLE;它也不能把 runtime 身份再授予别人。

租户事务合同

共享表 RLS 的最小请求序列:

BEGIN;
SET LOCAL ROLE pg36_ch23_runtime;
SELECT set_config('app.tenant_id', $1, true);

-- 所有业务 SQL;$1 必须来自已认证、已授权的应用身份映射

COMMIT;

第三个参数 true 表示 transaction-local。提交或回滚之后,角色和 tenant context 都不应继续生效。

表同时使用:

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

policy 分开描述读写:

SELECT            USING
INSERT            WITH CHECK
UPDATE            USING + WITH CHECK
owner             USING + WITH CHECK,且 FORCE RLS

USING 回答“旧行能否进入操作”;WITH CHECK 回答“新行版本能否存在”。 只写其中一边,常会允许把一行从本租户改到另一个租户,或者插入不可见数据。

正式实验

target          pg36-l2-vagrant/pg-test
Pigsty          v4.5.0
PostgreSQL      18.6
PgBouncer       1.25.2, transaction mode
database        test
fixture         schema pg36_ch23, two tenants, four synthetic rows
topology        pg-test-1 primary, pg-test-2/3 streaming replicas
timeline        11 before and after

角色与对象:

five synthetic roles             all NOLOGIN after drill
superuser/CREATEDB/CREATEROLE     false
REPLICATION/BYPASSRLS             false
runtime table ACL                 SELECT, INSERT, UPDATE
readonly table ACL                SELECT
schema CREATE for runtime         false
RLS / FORCE RLS                   true / true
policies                           5
tenant row counts                  2 + 2

RLS 观测:

runtime tenant A                  exactly 2 A rows
runtime tenant B                  exactly 2 B rows
missing context                   0 rows
malformed context                 SQLSTATE 22P02
cross-tenant INSERT               SQLSTATE 42501
cross-tenant UPDATE               SQLSTATE 42501
runtime disable RLS               SQLSTATE 42501
runtime CREATE/TRUNCATE           SQLSTATE 42501
readonly INSERT                   SQLSTATE 42501
row_security=off                  SQLSTATE 42501, not a bypass
raw sandbox login                 SQLSTATE 42501
owner without context             0 rows under FORCE RLS
owner with tenant A               2 A rows
superuser break-glass             all 4 rows

连接池反例临时把入口 PgBouncer 从:

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

客户端 A 用 session 级 set_config(..., false) 设置 tenant A;关闭后,客户端 B 在同一个 backend、没有设置 tenant 的情况下仍读到 tenant A 的两行。这是 有意注入的失败,不是支持方式。

刷新 pool 后,四个事务依次在同一个 backend 上得到:

tenant A local context        A 的 2 行
missing context               0 行
tenant B local context        B 的 2 行
missing context               0 行

这证明隔离来自事务边界,而不是恰好换了 backend。随后四个 pool 参数精确 复位,并对三个节点执行 RECONNECT test 清理注入的 session 状态。

TLS 与认证观测:

PostgreSQL ssl                          on
minimum protocol                       TLSv1.2
direct sslmode=require                  TLSv1.3 / AES-256-GCM
direct verify-full                      成功
wrong certificate name                 拒绝
verify-full + channel_binding=require   成功
direct sslmode=disable                  成功,生产缺口
PgBouncer client TLS                    disable
pooled sslmode=disable                  成功
pooled sslmode=require                  拒绝,生产缺口
HBA parser errors                       0
server private-key mode                 0600
certificate SAN                         覆盖各节点 DNS 与 IP
CRL file/directory                      未配置

凭据轮换使用一个不进入 PgBouncer userlist 的 direct-only synthetic role:

secret v1 new connection               成功
pool connection                        失败;身份面未声明
change to secret v2
secret v1 new connection               失败
secret v2 new connection               成功
already-authenticated v1 session       仍可用
ALTER ROLE ... NOLOGIN
new connection                         失败
already-authenticated session          仍可用
final PASSWORD NULL + NOLOGIN          已验证

密码值、SCRAM verifier、raw userlist 和 private key 都没有进入证据。

生产结论

本章 formal sandbox 结论是:

identity/role separation                通过
object ACL                              通过
two-tenant FORCE RLS                    通过
transaction-local pool context          通过
credential lifecycle semantics          通过
public cert and key-mode checks          通过
topology/pool restoration                通过

direct business TLS enforcement          未通过
PgBouncer client TLS                     未通过
CRL/revocation drill                     未完成
client CA distribution/rotation          未完成
pgAudit                                  未安装/未加载
log bind-parameter policy                待整改
production approval                      pending

这不是“Pigsty 不安全”的概括,而是对这一份 dev/test inventory 和运行状态的 精确判断。Pigsty 默认面向可信内网的开发、测试和演示;生产必须依据自己的 威胁模型收紧密码、网络、HBA、证书、审计与 secret 管理。

本章例外

在前四章下卷例外之外,本章保留:

EX23-TRUSTED-INTRANET-NO-TLS
  普通业务 HBA 使用 host + SCRAM;内网明文 TCP 可成功。

EX23-PGBOUNCER-CLIENT-TLS-DISABLED
  池化客户端入口没有 TLS;不能通过生产传输门禁。

EX23-NO-CRL-OR-ROTATION-DRILL
  证书命名正确,但没有执行 CA/cert/CRL 双版本轮换与撤销。

EX23-NO-PGAUDIT
  shared_preload_libraries 没有 pgAudit,扩展也未安装。

EX23-FULL-NONERROR-BIND-PARAMETERS
  log_parameter_max_length=-1;与慢 SQL 日志组合时可能记录完整 bind 值。

EX23-SYNTHETIC-TWO-TENANT
  只有四行合成数据、一个 schema;不能推出复杂产品的 policy 正确。

EX23-MULTIPLEXED-TEST-LOGIN
  为复用已有 PgBouncer 声明,formal lab 用 test 切换 runtime/readonly;
  生产应为 workload 配置独立 login 与 credential。

本章目录

23.1 威胁模型与信任边界

23.2 认证与连接准入

23.3 角色与最小权限

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

23.5 密钥、审计与敏感信息

23.6 Pigsty 安全基线

23.7 实战:隔离两个租户

实验入口

task.sh all 只重验既有证据,不登录应用身份、不改 role/password/pool/HBA/ certificate,也不删除 fixture。drill:securityreset:fixture 是两条 完全分离、精确守卫的路径。

参考资料


上一章:四通八达:服务接入、连接池与路由 · 返回下卷导读 · 下一章:纲举目张:SLO、SOP 与组织治理 · 查看全书目录 · 查看索引中心

23.1 威胁模型与信任边界

安全设计的起点不是“打开 TLS”或“创建一个只读用户”,而是回答:

保护什么
防谁做什么
跨过哪条边界
造成什么后果
由哪一层阻止、发现、限制和恢复

没有威胁模型,最小权限就没有“最小”的参照;审计也不知道该记录什么。

本章不要求先写一份几十页的合规文档。对一个数据库服务,先把下面六列填满 就足以发现大部分架构空洞:

资产 主体 入口 不允许的动作 首要控制 验收证据
租户订单 API runtime pooled primary 跨租户读写 ACL + RLS 正负 SQL
模式定义 migration pipeline direct primary 未审批 DDL owner 分离 role graph + log
备份/WAL backup agent repository 未授权读取/删除 专用身份 + 存储策略 restore/audit
凭据 application/deployer secret channel 泄露、长期有效 轮换/撤销 双版本演练
运行日志 operator/SIEM log pipeline 敏感值扩散 脱敏 + ACL config + sample

23.1.1 用户、应用、运维、平台与第三方

一个请求里有不止一个“用户”

典型 API 请求至少包含五种身份:

human end user
  -> application service identity
      -> PostgreSQL session_user
          -> PostgreSQL current_user
              -> business tenant / subject

它们不可互换。

session_user 是连接时通过 PostgreSQL/PgBouncer 认证的 login。current_user 是当前做权限检查的 effective role;SET ROLE 后二者可以不同。终端用户往往 根本没有数据库 login,其身份由应用认证系统维护。tenant 又可能是组织、项目、 账户或数据域,并不一定等于人或数据库角色。

本章实验刻意记录:

SELECT session_user, current_user;

在 runtime 事务中应类似:

session_user = test
current_user = pg36_ch23_runtime

这能证明数据库执行权限被收窄,却不能证明终端用户是谁。后者需要应用把 request id、actor id、授权结果与数据库 transaction 关联到受保护的审计链。

终端用户

终端用户可以被信任去:

  • 提交业务输入;
  • 持有自己的认证因子;
  • 发起自己被授权的动作。

不能被信任去:

  • 声明“我属于 tenant B”后直接控制数据库上下文;
  • 选择 effective database role;
  • 决定查询是否绕过 RLS;
  • 控制审计字段、来源 IP 或 application_name 的安全含义。

因此:

HTTP header X-Tenant-ID
  -> 只能作为一个待校验输入
  -> 应用根据已认证 actor 和授权关系求出 authorized tenant
  -> 再用 bind parameter 写入 transaction-local database context

若直接做:

SET app.tenant_id = request.headers["X-Tenant-ID"]

RLS 只是把越权选择高效地执行了一遍。

应用与批处理

“应用”也不是一个主体。至少拆成:

workload 需要 不需要
API runtime 短事务、必要 DML owner、DDL、TRUNCATE
async worker 特定队列对应的 DML 全库后台权限
read API SELECT 写入
report/ETL 受控只读、资源预算 主写高权
CDC replication/slot 的精确能力 SUPERUSER
migration object owner 或受控 DDL 常驻 serving credential

若它们共用一个 login:

  • 一处泄露扩大到所有能力;
  • 无法按 workload 撤销;
  • 日志难以归因;
  • 连接预算和 timeout 无法分开;
  • 临时授予会悄悄变成永久默认。

本章角色模型先按“能力”拆 NOLOGIN role,再让独立 login 以明确 membership 获得其中一项。实验为了复用已交付的 PgBouncer test 身份,在同一个沙箱 login 上挂 runtime 和 readonly;这是有标签的实验例外,不是生产模板。

运维人员

运维需要的不是“平时就是超级用户”,而是两条路径:

routine operator
  read catalogs / metrics / logs
  run approved bounded procedures
  no arbitrary data access by default

break-glass operator
  time-bound elevation
  ticket + reason + peer/after-the-fact review
  short credential lifetime
  complete action evidence
  explicit revoke

PostgreSQL superuser 可以绕过对象 ACL 和 RLS,访问敏感 catalog,执行服务器 文件/程序相关能力;它是信任根,不是普通管理员的方便模式。

还要区分 OS root、PostgreSQL superuser 和平台控制面:

OS root                    可以读数据目录和进程内存
PostgreSQL superuser       可以绕过数据库权限
Pigsty/Ansible controller  可以改 inventory 并重渲染大量节点
secret administrator       可以改变认证材料
backup administrator       可能读出全量历史数据

把五者授给同一个长期账号,会让数据库内最精细的 GRANT 失去意义。

平台自动化

平台被信任去:

  • 根据受评审声明创建 role/database/HBA/service;
  • 在限定主机和阶段收敛配置;
  • 输出变更记录;
  • 检查 drift;
  • 回收明确属于平台管理的对象。

平台不应被默认信任去:

  • 猜测现有手工对象能否覆盖;
  • 在 production 看到差异就无条件“强制收敛”;
  • 把 secret 展开到日志、diff 或工单;
  • 用一个全局账号服务所有 workload;
  • 把“playbook 成功”当成应用授权语义通过。

声明是 desired state,运行 catalog/HBA/连接实验才是 actual state。二者都要 保留,差异本身就是安全事件或变更线索。

第三方、扩展与外部系统

第三方包括:

  • PostgreSQL extension;
  • 备份/归档存储;
  • APM、日志、SIEM;
  • BI/ETL/CDC;
  • cloud/KMS/secret manager;
  • 外包运维和供应商 support bundle。

每个集成至少回答:

它获得什么数据和 metadata
credential 存在哪里、有效多久
是否能进一步委托
失败时是否 fail open
日志/备份保存在哪里
删除与撤销如何传播
供应链版本如何验证

安装 trusted extension 不等于“其维护者、发行包、依赖和升级以后都可信”。 将数据库日志送往 SaaS 也不自动满足数据驻留和删除要求。第三方边界必须进入 数据流图,而不是写在采购附件里。

主体—能力矩阵

一个评审可从这个矩阵开始:

主体 connect data DML DDL/owner secret backup audit admin
API runtime 必要子集 只读自身
read workload SELECT 只读自身
migration 窗口内 验证所需 受控 短期 产生日志
operator 受控 默认无 SOP 子集 默认无 检查 只读
break-glass 临时 临时 临时 受审批 临时 不得删改
backup agent 专用 只读自身 写仓库
audit collector 专用 只读自身 写不可变目标

不是“所有权限”,每一格还要落到 endpoint、role、ACL、network 和证据。

23.1.2 网络、凭据、SQL、备份和日志攻击面

用数据流而不是组件清单建模

“我们有 PostgreSQL、PgBouncer 和防火墙”不是威胁模型。先画流:

client
  -> DNS / VIP / load balancer
      -> HAProxy
          -> PgBouncer
              -> PostgreSQL primary / replica
                  -> WAL archive / backup repository
                  -> logs / metrics / traces

对每条箭头问:

  1. 谁发起;
  2. 如何认证对端;
  3. 是否加密;
  4. 是否可以重放;
  5. metadata 会泄露什么;
  6. 失败时转向哪里;
  7. 谁能修改路由或信任根;
  8. 证据由谁保存。

第 22 章已经说明代理和池化会改变 session 与故障语义。本章再加一项:它们也 是独立认证面。PostgreSQL role 新建成功,不代表 PgBouncer 的 auth_fileauth_query 或 HBA 已经接受它。

网络攻击面

网络层包括的不只是公开 5432

  • PostgreSQL、PgBouncer、HAProxy 服务端口;
  • Patroni REST API;
  • etcd/DCS;
  • SSH/Ansible;
  • exporter、Grafana、日志和备份端点;
  • DNS、VIP、cloud load balancer;
  • 同机 Unix socket;
  • 容器/overlay 网络和跨区链路。

常见失败:

0.0.0.0 listen + broad security group
intranet CIDR 被当成永久可信主体
TLS 可用但客户端允许降级
证书验证了 CA,却没有验证 hostname
管理面与业务面共用网络和 credential
监控接口可读 SQL 文本、role、database 和拓扑
DCS/API 被暴露后可以影响选主

listen_addresses、主机防火墙、安全组、HBA、代理 ACL 和应用身份是串联控制。 任一层收紧都能缩小暴露面,但不能宣称另一层不再需要。

凭据攻击面

凭据不仅是 PostgreSQL password:

database password / SCRAM verifier
client private key
server private key / CA private key
SSH key / sudo authority
Patroni / etcd / backup credentials
cloud access token
application secret-manager token
session cookie / OAuth token

需要同时保护:

  • 生成时的随机性;
  • 存储位置和文件权限;
  • 注入过程;
  • 进程环境、命令行、core dump;
  • CI 日志、shell history、debug output;
  • 备份和旧版本;
  • 轮换期间的双版本窗口;
  • 撤销后的既有 session。

SCRAM verifier 不是明文,但仍是敏感认证材料。raw PgBouncer userlist、完整 inventory 和 CA private key 不应进入普通 evidence bundle。

SQL 攻击面

SQL 注入只是其中一类:

路径 例子 控制
值注入 拼接用户输入 bind parameter
标识符注入 动态表/schema 名 allowlist + identifier API
search path 同名恶意函数/操作符 受控 path + qualified name
definer 提权 PUBLIC EXECUTE secure path + revoke/grant
owner 提权 runtime 拥有表 owner/login 分离
role 链 ADMIN/SET 过宽 membership options + graph test
RLS 绕过 owner/superuser/BYPASSRLS FORCE + 独立 break-glass
policy 错误 USING 正确、WITH CHECK 缺失 正负 DML 测试
DoS 极端查询、锁、临时文件 timeout + resource governance

安全测试必须包含“有效但不该允许的 SQL”。语法错误只证明 parser 工作,不 证明授权边界正确。

备份和 WAL 攻击面

数据库表做了 RLS,不代表备份按租户隔离。物理备份和 WAL 通常包含整个 cluster 的历史状态:

  • 已删除或更新前的数据可能仍在;
  • credential/catalog 也会进入;
  • repository 管理员可能读到所有租户;
  • retention 超过业务删除期限;
  • object storage versioning 会延长实际寿命;
  • restore 到隔离区后会出现新的明文副本;
  • support bundle 可能携带配置、日志和样本数据。

因此备份安全至少包括:

repository identity
encryption at rest/in transit
key separation
immutable/retention policy
delete/legal-hold semantics
restore sandbox access
evidence cleanup

第 21 章验证的是恢复能力;本章补上谁能读取、删除和恢复。

日志与可观测攻击面

日志既是证据,也是数据外泄渠道。可能出现:

  • SQL literal;
  • extended protocol bind value;
  • error context;
  • connection string;
  • tenant/user/email/order id;
  • DDL 中的 password 或 secret;
  • backup path 和内部地址;
  • application_name 中的用户输入。

监控也会泄露:

  • pg_stat_activity.query
  • query sample;
  • role/database/schema 名;
  • replication/topology;
  • dashboard screenshot;
  • alert payload。

安全目标不是“少记录”,而是:

记录足以调查的 actor/action/resource/outcome/time/correlation
不记录不必要的 secret 和敏感 payload
限制谁能读、改、删
确保时间、完整性、保留和检索可用

可用性也是安全属性

认证和授权控制也能造成拒绝服务:

  • 外部 IdP 不可用导致所有新连接失败;
  • CRL/OCSP 依赖超时;
  • 密码轮换不同步导致连接风暴;
  • HBA 错序锁死管理员;
  • audit 全量记录填满磁盘;
  • RLS policy 中的昂贵子查询放大每次访问;
  • brute-force 占满认证和连接槽。

威胁模型要写 fail-open/fail-closed 和应急路径。不能为了“高可用”悄悄回退 到弱认证,也不能为了“安全”在没有管理恢复入口时一次性切断所有访问。

从攻击路径生成测试

把抽象威胁变成实验:

wrong server name
  -> verify-full connection must fail

client disables TLS
  -> production endpoint must fail;本章 sandbox 反而成功,所以 gate pending

runtime attempts ALTER TABLE
  -> SQLSTATE 42501

tenant A writes tenant B row
  -> WITH CHECK rejects

session tenant context survives pool reuse
  -> reproduce, then replace with transaction-local context

old password after rotation
  -> new connection fails;existing connection remains and must be drained

new PostgreSQL role through pool
  -> absent from pool auth surface, connection fails

每个测试都要记录目标、路径、预期 SQLSTATE/事实、清理和解释边界。

23.1.3 数据分级、租户边界与应急权限

分级决定控制,而不是标签颜色

一个实用分级至少回答:

维度 问题
confidentiality 泄露给谁会造成什么
integrity 被改错/伪造的后果
availability 最长可中断多久
residency 可以存放在哪些区域/供应商
retention 保存多久、何时必须删除
audit 哪些访问和变更必须可追溯
recovery 恢复副本需要什么同等级控制

同一行可以混合不同级别:公开商品名、内部成本、个人地址和支付 token 不应因 都在 orders 表里就采用同一日志/访问策略。

数据库实现可以组合:

  • schema/table/column privilege;
  • view 或 security-invoker API;
  • RLS;
  • application-level field policy;
  • tokenization/encryption;
  • 独立 database/cluster/account;
  • 备份与日志分级。

不要把“加密列”写成万能答案。密钥与数据库若由同一长期高权主体控制,主要 价值可能只是介质或下游暴露面收缩,而不是防数据库管理员。

租户边界的四种常见形态

形态 优点 主要代价/风险
shared table + tenant key/RLS 密度高、统一迁移 policy/上下文错误影响面大
schema per tenant 对象和迁移边界更清晰 对象爆炸、search_path/运维复杂
database per tenant catalog/连接/备份边界更强 连接、升级、监控规模增加
cluster/account per tenant 故障/管理员/资源隔离最强 成本和平台复杂度最高

选择不是“RLS 安全不安全”,而是:

tenant 数量和规模
监管/密钥/驻留要求
故障与 noisy-neighbor 边界
备份/恢复粒度
迁移频率
operator 信任模型
成本

RLS 适合共享表的数据库内 defense-in-depth。它不隔离 shared buffer、CPU、 WAL、backup、superuser,也不自动提供每租户 PITR。

RLS 之外的隐蔽通道

即使行不可见,仍可能通过以下方式推断:

  • unique/foreign-key 冲突;
  • sequence/identity 变化;
  • timing、lock wait、row count;
  • error message;
  • query plan/statistics;
  • aggregate 或 rate limit;
  • log/metric label;
  • object name。

PostgreSQL 的 referential integrity 检查会绕过 RLS 以维护完整性。不要向低权 用户返回“该 email 已被另一个租户使用”之类能够确认全局存在性的细节,除非 这是明确业务合同。

数据边界必须贯穿派生物

租户边界要追到:

primary row
  -> indexes / materialized views / search index
  -> logical replication / CDC
  -> cache
  -> analytics warehouse
  -> backup / PITR restore
  -> logs / traces / support evidence

源表 RLS 不会自动复制到这些系统。每个 consumer 要重新定义 identity、filter、 retention 和删除传播。

应急权限是一套协议

break-glass 至少包含:

触发条件       正常路径不可用且存在明确风险
批准者         谁能批准,单人还是双人
身份           独立账号,禁止共享
时限           自动过期
范围           cluster/database/action/source
证据           ticket/reason/session/action/outcome
约束           禁止删审计、禁止无关数据浏览
退出           revoke/terminate/rotate
复盘           为什么需要、正常能力缺什么

仅把 superuser password 放进保险箱不够。取出后谁知道、已有 session 如何回收、 PgBouncer 是否仍接受、使用了哪些命令、何时换新,都必须可执行。

应急时的优先级

凭据疑似泄露时,一个保守序列:

1. 限制暴露面和新认证
2. 保存时间线、连接、日志与配置证据
3. 判断是 login、role、host、CA 还是控制面失陷
4. 创建/验证替代凭据和管理路径
5. 双版本切换合法客户端
6. NOLOGIN / HBA reject / revoke
7. 终止仍有风险的既有 session
8. 轮换上下游与 PgBouncer
9. 验证旧凭据失败
10. 查找未授权动作并恢复

直接 ALTER ROLE ... PASSWORD 只影响后续认证,不会杀死已认证 session。本章 实验明确证明 password change 和 NOLOGIN 后旧连接仍能执行 SELECT 1

什么时候升级为安全事件

至少这些情况不应作为普通工单悄悄修复:

  • 未知主体获得高权 membership;
  • production 出现 trust/意外 broad HBA;
  • private key、password、SCRAM verifier 进入日志或仓库;
  • RLS/ACL drift 造成跨租户可见;
  • audit pipeline 被停用或删改;
  • backup/restore 落入未批准位置;
  • CA/secret manager/Ansible controller 身份失陷;
  • operator 使用 break-glass 但无批准或证据。

第 31 章会展开事件指挥。本章先保证检测项和回收动作在平时可练。

最小威胁模型模板

service: pg36_shop
asset:
  - tenant orders
  - credentials
  - backups and logs
subjects:
  - api runtime
  - migration pipeline
  - operator
trust_crossings:
  - client -> pooled endpoint
  - login -> runtime role
  - runtime role -> tenant row
abuse_cases:
  - wrong tenant context
  - stolen password
  - session state reused by another client
  - unapproved DDL
controls:
  - verify-full + SCRAM
  - SET LOCAL ROLE
  - FORCE RLS
  - separate owner
  - rotation and session drain
evidence:
  - HBA/TLS/role/policy projections
  - positive and negative transactions
  - immutable audit correlation
owner: data-platform
reviewers: application-owner, security
production_exceptions: []

模板的价值不在 YAML,而在于让每个控制都对应威胁、owner 和可重放证据。

本节检查表

[ ] 列出数据、凭据、备份、日志和控制面资产
[ ] 区分 end user、service identity、session_user、current_user、tenant
[ ] 每个 workload 有独立能力与撤销边界
[ ] OS、database、platform、secret、backup 管理权没有无意合并
[ ] 画出 client 到 database、backup、logs 的每条信任跨越
[ ] 网络位置不被当作充分身份
[ ] SQL、role、owner、RLS 的攻击路径都有负向测试
[ ] 备份、WAL、日志和 support evidence 纳入数据分级
[ ] 租户隔离选择覆盖 backup/recovery/noisy-neighbor
[ ] break-glass 有触发、时限、证据、撤销和复盘
[ ] 凭据失陷流程会处理既有 session 与 PgBouncer
[ ] production exception 有 owner、期限和补偿控制

参考资料


返回本章目录 · 下一节:认证与连接准入 · 查看全书目录 · 查看索引中心

23.2 认证与连接准入

客户端拿到数据库连接之前,至少要连续通过五道关:

network reachability
  -> listener / proxy entry
      -> TLS negotiation and peer verification
          -> HBA first matching record
              -> authentication method
                  -> database CONNECT privilege

这五道关回答的问题不同。防火墙放行不等于 HBA 放行,HBA 选中 scram-sha-256 不等于密码正确,认证成功也不等于角色拥有 CONNECT,更不等于它可以读取业务表。安全评审必须逐层给出证据,不能用 “我连上了”概括全部连接准入。

23.2.1 pg_hba.conf 的匹配顺序与证据

HBA 是有序规则,不是规则集合

PostgreSQL 对一条新连接从上到下检查 HBA:

  1. 找到第一条在连接类型、数据库、用户和来源地址上都匹配的记录;
  2. 使用这条记录指定的方法认证;
  3. 认证失败就拒绝,不会继续尝试后面的记录;
  4. 没有任何记录匹配也会拒绝。

因此下面两段配置语义完全不同:

# A:先拒绝高风险网段,后允许业务网段
host    appdb    +app_login    10.20.30.0/24    reject
hostssl appdb    +app_login    10.20.0.0/16     scram-sha-256
# B:宽规则已经接住连接,后面的 reject 永远不会命中
hostssl appdb    +app_login    10.20.0.0/16     scram-sha-256
host    appdb    +app_login    10.20.30.0/24    reject

HBA 不是防火墙 ACL 的“最具体规则优先”,也没有失败后回退。官方文档明确 规定了 first-match 语义;任何生成器、模板或平台都不能改变这一点。 参见 PostgreSQL:客户端认证配置文件

一条记录匹配哪些维度

常见 record type:

类型 传输条件 典型用途
local Unix-domain socket 节点本地管理或 peer 认证
host TCP,TLS 与非 TLS 都可 仅当两种传输都明确允许
hostssl TCP 且已经建立 TLS 业务和远程管理的常见下限
hostnossl TCP 且未使用 TLS 显式拒绝或受控兼容例外
hostgssenc TCP 且使用 GSS 加密 采用 GSSAPI 的环境

匹配列还包括:

database -> user -> client address -> authentication method/options

几个容易误判的细节:

  • all 很宽,不表示“最末默认规则”;
  • sameusersamerole 等数据库关键字有特定语义;
  • user 列中的 +role_name 匹配该角色的直接或间接成员,而不是匹配字符串 前缀;
  • database 列匹配的是客户端请求的数据库;
  • hostname 规则需要正反向名称解析,延迟和失败模式不同于 CIDR;
  • replication 连接有专门的 database 关键字和权限要求;
  • hostssl 只说明客户端到该 PostgreSQL listener 的这段链路用了 TLS。

如果入口是 PgBouncer,客户端首先连接的是 PgBouncer。此时:

client -> PgBouncer HBA/auth/TLS
PgBouncer -> PostgreSQL HBA/auth/TLS or local socket

这是两条独立的准入链。PostgreSQL 的 pg_hba.conf 不会替 PgBouncer 过滤客户端来源;PgBouncer 的 HBA 也不会自动约束绕过代理、直连 PostgreSQL 的流量。

pg_hba_file_rules 是解析证据

不要只读取模板文件。PostgreSQL 提供 pg_hba_file_rules,可把当前 HBA 文件解析为行:

SELECT
    rule_number,
    file_name,
    line_number,
    type,
    database,
    user_name,
    address,
    netmask,
    auth_method,
    options,
    error
FROM pg_hba_file_rules
ORDER BY rule_number NULLS LAST, file_name, line_number;

它能证明:

  • PostgreSQL 从哪些文件和行解析出规则;
  • include 后的最终顺序;
  • 方法、地址、选项是否符合预期;
  • 是否存在语法或解析错误。

它不能单独证明:

  • 某条规则可从目标网段实际到达;
  • 防火墙、安全组、HAProxy 或 PgBouncer 是否放行;
  • DNS 名称匹配是否如预期;
  • 密码、证书或外部身份提供方是否可用;
  • 连接最后究竟命中了哪条规则。

因此验收还要从允许与禁止的真实来源分别连接,并把时间、目标地址、目标 数据库、login、TLS 属性和结果关联起来。生产系统不应为了测试负例而从未知 公网来源扫描数据库;应使用预先批准的测试节点。

HBA 不负责对象授权

下面的连接可能通过 HBA 和 SCRAM,却仍被数据库拒绝:

REVOKE CONNECT ON DATABASE appdb FROM app_login;

反过来,CONNECT 只是进入数据库:

GRANT CONNECT ON DATABASE appdb TO app_login;

它没有授予:

  • schema 的 USAGE
  • table 的 SELECT
  • sequence 的 USAGE
  • function 的 EXECUTE
  • 切换到某个业务角色的 membership。

这也是为什么 HBA 不能被称为“权限配置”。它选择认证方法并执行连接准入, 对象授权要在 23.3 单独证明。

安全变更顺序

HBA 变更采用“声明—解析—负例—正例—回滚”:

1. 从 inventory / policy source 生成候选配置
2. 检查宽规则、shadowed rule、host/hostssl 与来源 CIDR
3. 在节点上进行语法/解析检查
4. 保留当前管理连接与独立 break-glass 路径
5. reload,不把 reload 误写成 restart
6. 从允许来源做正例,从禁止来源做负例
7. 核对 pg_hba_file_rules 和认证日志
8. 失败则恢复上一份已验证配置并 reload

在 Pigsty 中,应同时检查 PostgreSQL 与 PgBouncer 的 HBA 声明和渲染产物。 23.6 会把这条流程映射到 pg_hba_rulespgb_hba_rules 及默认规则。

23.2.2 SCRAM、证书与外部身份

先区分四件事

“数据库密码安全”常把四个问题混在一起:

问题 典型机制
服务器保存什么 SCRAM verifier、外部身份映射、客户端证书映射
线上如何证明身份 SCRAM exchange、certificate、GSS/SSPI、LDAP、OAuth
链路是否加密 TLS 或 GSS encryption
客户端是否找对服务器 CA chain + hostname/IP identity verification

只把 password_encryption 设为 scram-sha-256,不会自动启用 TLS;只使用 TLS 也不会自动把数据库中旧的 MD5 verifier 变成 SCRAM。

SCRAM 的角色

PostgreSQL 使用 SCRAM-SHA-256 时,服务器保存的是 salted verifier,而不是 可直接用于登录的明文密码。新密码应在:

SHOW password_encryption;

返回 scram-sha-256 的受控环境中设置。不要通过命令行参数、shell history、 CI 日志或 Git 文件传递明文:

bad:  psql postgresql://user:plain-password@host/db
bad:  ALTER ROLE user PASSWORD 'plain-password';  # copied into ticket/log
good: secret manager -> short-lived private file/fd/env contract -> client

环境变量也不是天然的 secret manager:它可能被子进程继承、被诊断工具采集, 或留在流水线元数据中。重点是限制创建、读取、传递和销毁它的主体与时间。

PostgreSQL 18 已将 MD5 密码支持标记为弃用。迁移时可以先把 HBA 目标方法改为 SCRAM-compatible 路径,再逐个重置用户密码生成 SCRAM verifier,并验证所有 驱动。官方迁移说明见 PostgreSQL:密码认证

channel binding

SCRAM channel binding 把认证交换绑定到当前 TLS channel,降低凭据交换被代理 到另一条 TLS 会话的风险。支持它的 libpq 客户端可以要求:

sslmode=verify-full
channel_binding=require

这里两个选项不可互相替代:

verify-full          验证证书链和目标名称
channel_binding      把 SCRAM 认证绑定到已建立的 TLS channel

部署前必须确认驱动版本、TLS 库和中间代理是否支持。不能因为服务端支持 SCRAM 就假设所有客户端都支持 SCRAM-SHA-256-PLUS

本章沙箱的直连实验证明 verify-full + channel_binding=require 可以成功; 这是一条兼容性证据,不代表 PgBouncer 客户端入口也自动具备同样属性。

客户端证书

cert 认证由 TLS 客户端证书证明身份,通常还需用 map= 把证书主体映射到 PostgreSQL role。它适合:

  • 节点间或服务间受管身份;
  • 有成熟 CA、签发、吊销和轮换系统的环境;
  • 不希望长期共享密码的管理链路。

它并不自动适合每个终端用户。必须解决:

  • private key 存放与文件权限;
  • 客户端证书分发;
  • SAN/subject 与 role 的映射;
  • 有效期和轮换重叠期;
  • 离职、设备丢失与 CRL/OCSP;
  • 代理终止 TLS 后如何继续传递可信身份。

如果 PgBouncer 终止客户端 TLS,PostgreSQL 后端看到的是 PgBouncer 的连接, 不能凭空看到原始客户端证书。身份终止点必须在架构图和审计模型中明确。

外部身份不是“无密码”捷径

PostgreSQL 还可以接入 LDAP、GSS/SSPI、PAM、RADIUS、OAuth 等方法,具体可用 范围取决于版本和构建。它们把一部分认证判断交给外部系统,但数据库仍需定义:

external principal -> PostgreSQL login role -> effective business role

评审时要问:

  • 外部主体如何唯一映射,是否会因重名或大小写碰撞映射错误;
  • 身份提供方不可用时是 fail-closed 还是出现旁路;
  • token/ticket 的 audience、issuer、有效期和撤销如何验证;
  • 数据库本地 break-glass 是否独立保管;
  • PgBouncer 是否支持该认证方法,还是需要 auth_query/代理集成;
  • 外部组变化多久才能反映到数据库 session;
  • 已建立 session 在外部身份撤销后何时终止。

身份联邦减少的是一类凭据管理,不会消除 role graph、对象 ACL、RLS 和审计。

PgBouncer 的身份交付面

PgBouncer 需要知道如何验证 client login,并以何种 server identity 连接 PostgreSQL。常见入口包括:

auth_file       PgBouncer 本地认证材料
auth_query      从受控数据库函数/视图获取认证材料
auth_user       执行 auth_query 的受限身份

这意味着“PostgreSQL 中创建了 LOGIN role”并不必然让 PgBouncer 接受该用户。 本章轮换探针刻意创建了一个未进入池认证面的临时 login:

direct PostgreSQL new authentication     succeeds
PgBouncer authentication                 fails

这个负例证明两套身份面必须分别验收。不要为了让探针通过而临时把高权用户 加入 PgBouncer userlist。

23.2.3 TLS 验证、吊销与密钥轮换

sslmode 分别证明什么

libpq 的主要模式可理解为:

sslmode 加密 验证 CA 验证目标名称 适用判断
disable 只用于明确受控的非 TLS 路径
allow 不保证 不保证 不保证 兼容优先,不是生产安全基线
prefer 不保证 不保证 不保证 默认兼容行为,不是证明
require 通常不证明名称 防窃听,不充分防冒充
verify-ca 对端由受信 CA 签发
verify-full 生产客户端通常应达到

require 能加密,但若不核对服务器名称,客户端可能把密码交给持有另一张受信 证书或被错误路由的服务器。生产应用应优先使用 verify-full。libpq 的精确 行为和 root certificate 兼容细节见 PostgreSQL:SSL 支持

名称验证依赖 SAN 和连接名

verify-full 校验的是连接参数中的 host 与证书身份。证书应在 Subject Alternative Name 中声明实际使用的 DNS name 或 IP:

application DSN host=pg-primary.example.com
certificate SAN DNS:pg-primary.example.com

若客户端使用 VIP、HAProxy 名称或 Kubernetes service name,证书必须覆盖这个 稳定入口;只给后端节点名签证书并不能验证入口名称。

本章沙箱节点证书包含 localhost、集群名、节点名、loopback 与节点 IP 的 DNS/IP SAN。正式实验从证书解析 SAN,再执行三种连接:

sslmode=require       success, TLS 1.3
sslmode=verify-full   success with matching name
verify-full wrong name rejected

第三条负例和前两条正例同等重要。没有负例,无法排除客户端根本没有执行名称 校验。

pg_stat_ssl,但不要只看它

服务器端可把 session 与 TLS 属性关联:

SELECT
    a.pid,
    a.usename,
    a.application_name,
    a.client_addr,
    s.ssl,
    s.version,
    s.cipher,
    s.bits,
    s.client_dn,
    s.issuer_dn
FROM pg_stat_activity AS a
JOIN pg_stat_ssl AS s USING (pid)
WHERE a.pid = pg_backend_pid();

正式直连观察到:

ssl       true
version   TLSv1.3
cipher    TLS_AES_256_GCM_SHA384
bits      256

pg_stat_ssl.ssl=true 只证明 PostgreSQL 看到的这一跳使用 TLS。若拓扑是:

client ==TLS==> PgBouncer --Unix socket--> PostgreSQL

PostgreSQL 看到的后端连接自然是 ssl=false,不能据此断言客户端链路未加密。 反之,后端 TLS 为 true 也不能证明 client-to-proxy 使用 TLS。两跳要分别测。

CA、CRL 与 private key

服务端最少要治理:

  • server certificate;
  • server private key;
  • trusted client CA(若验证客户端证书);
  • CRL 或其他撤销机制;
  • 文件 owner、mode 和可读取主体;
  • reload/restart 语义;
  • 到期时间、提前轮换窗口和告警。

PostgreSQL 要求 private key 权限受到严格限制。本章节点上的服务端 key 为 0600。这只是静态权限证据,还需确认备份、配置管理缓存、工单附件和监控 采集器没有复制密钥。

沙箱的 CRL file/dir 未配置,因此不能声称已经具备客户端证书撤销闭环。证书 有效期很长也不等于安全或不安全;必须根据签发自动化、暴露面和撤销能力制定 生命周期,而不是只看 expiry date。

双版本轮换,而不是瞬间替换

CA、服务器证书和客户端证书/密码轮换都应保留重叠窗口:

prepare
  -> distribute trust for old + new
      -> deploy new identity material
          -> reload/reconnect
              -> prove new works
                  -> prove fleet migrated
                      -> revoke old
                          -> prove old fails

对于 CA:

client trust store: old CA + new CA
server certificate: switch old-signed -> new-signed
fleet evidence: all active clients trust and use new chain
client trust store: remove old CA

对于密码:

create new secret version
change database verifier
roll clients to new version
terminate/drain old sessions when policy requires
destroy old secret version

PostgreSQL role 只有一个当前 password verifier,没有天然的“双密码同时有效”。 应用侧重叠通常要借助两个 login、连接池分批切换,或把数据库密码变更与快速 客户端 rollout 精确协调。

凭据撤销不等于 session 撤销

本章做了一个容易被忽略的实验:

1. password v1 建立连接                         success
2. 改为 password v2
3. 使用 v1 建立新连接                           rejected
4. 使用 v2 建立新连接                           success
5. 原来用 v1 建立的 session 继续查询             success
6. ALTER ROLE ... NOLOGIN
7. 新认证                                       rejected
8. 已建立 session 继续查询                       success
9. 最终 PASSWORD NULL,NOLOGIN                  verified

结论是:

credential revocation != session revocation

若处置的是泄露或人员离职,还要:

  • 在应用池和代理层停止新借用;
  • 找出目标 role/session;
  • 评估事务影响后终止连接;
  • rotate downstream secret;
  • 检查复制、备份、日志和导出物;
  • 保存不含秘密的取证证据。

本章沙箱的诚实结论

形式化验收同时发现:

项目 运行事实 结论
PostgreSQL TLS 开启,最低 TLS 1.2 基础能力存在
直连 verify-full 成功,错误名称失败 名称校验可用
SCRAM channel binding 直连成功 该客户端路径兼容
业务 HBA 内网存在 host 规则 非 TLS 直连仍可成功
PgBouncer client TLS 禁用 客户端到池入口不加密
PgBouncer backend 本地 Unix socket 该跳不使用 TLS,符合本地链路事实
CRL 未配置 吊销闭环缺失

所以沙箱适合验证机制,不满足本章定义的生产安全门槛。正确结论不是因为发现 缺口就隐藏实验,而是输出:

mechanism proof        PASS
production security    PENDING remediation

下一节在已经确定 login 身份之后,继续回答 effective role 与对象权限问题。


上一节:威胁模型与信任边界 · 返回本章目录 · 下一节:角色与最小权限 · 查看全书目录 · 查看索引中心

23.3 角色与最小权限

认证确认的是 login identity,授权判断的却是当前 effective role。把应用密码 直接挂在对象 owner 身上,看起来省去了一层 SET ROLE,实际上把“能够连接” 和“能够改变安全边界”绑在了同一个身份上。

本节的目标是建立一条清晰的权限链:

human/workload identity
  -> LOGIN role
      -> explicitly SET approved NOLOGIN role
          -> object ACL
              -> row policy

每一条边都要有理由,每一个高权角色都应尽量不可登录。

23.3.1 login、group、owner 与 runtime role

PostgreSQL 只有 role 这一种主体

PostgreSQL 的 user 和 group 都建立在 role 上:

CREATE ROLE app_login LOGIN;
CREATE ROLE app_runtime NOLOGIN;

CREATE USER 只是默认带 LOGIN 的语法别名。所谓 group role 通常只是 NOLOGIN role,用 membership 聚合权限。

关键属性包括:

LOGIN
SUPERUSER
CREATEDB
CREATEROLE
REPLICATION
BYPASSRLS
CONNECTION LIMIT
VALID UNTIL

正常应用身份通常全部关闭高权属性:

CREATE ROLE app_login
  LOGIN
  NOSUPERUSER NOCREATEDB NOCREATEROLE
  NOINHERIT NOREPLICATION NOBYPASSRLS;

VALID UNTIL 只约束密码认证的有效期,不会让既有 session 自动断开,也不 约束所有外部认证方式。它是凭据控制的一部分,不是完整账户生命周期。

四类角色不要合并

本章使用:

类型 LOGIN 用途 为什么拆开
workload login 认证应用实例 可单独轮换、禁用和归因
runtime 正常 DML 不拥有对象,不做 DDL
readonly 受控查询 与写路径独立授权、撤销
migrate 否或临时身份切入 发布窗口 不进入日常流量
owner 拥有 schema、table、policy 隔离隐式 owner 权力
break-glass 独立 限时应急 不属于应用 role graph

推荐图:

app_login
  ├─ SET TRUE / INHERIT FALSE -> app_runtime
  └─ SET TRUE / INHERIT FALSE -> app_readonly

release identity
  -> app_migrate
      -> SET TRUE / INHERIT FALSE -> app_owner

不推荐:

app_login LOGIN
  -> owns schema
  -> owns tables
  -> can ALTER/DROP policies
  -> credential copied to every app instance

owner 的能力来自所有权,不完全来自 ACL。撤销 table 上的 ALL 不能撤销 owner 的 ALTERDROP、授权和 policy 管理能力。要收回这些能力,必须改变 owner 或改变运行身份。

session_usercurrent_user

连接建立后:

SELECT session_user, current_user, current_role;

初始通常相同。执行:

SET ROLE app_runtime;

之后:

session_user   仍是完成认证的 login
current_user   变为权限检查使用的 effective role
current_role   与 current_user 对应

审计时应保留两者。只记录 current_user=app_runtime 会丢失是哪个 workload login 使用了该能力;只记录 login 又可能误判 SQL 实际以何权限执行。

membership 的三个开关

PostgreSQL 16 起,一条 role membership 有三个独立选项:

GRANT app_runtime TO app_login
WITH ADMIN FALSE, INHERIT FALSE, SET TRUE;

语义:

选项 问题 本章默认
ADMIN member 能否继续授予/撤销该 membership FALSE
INHERIT member 是否自动使用目标角色权限 FALSE
SET member 能否 SET ROLE 到目标角色 TRUE

这种组合要求应用显式进入受控事务:

BEGIN;
SET LOCAL ROLE app_runtime;
-- business statements
COMMIT;

如果 INHERIT TRUE,login 在没有 SET ROLE 时就可能使用 runtime ACL,破坏 “没有声明上下文就失败”的设计。如果 SET FALSE,即便是 member 也不能切换 到该 role。完整语义见 PostgreSQL:角色成员关系SET ROLE

检查 membership 不应只看成员名称:

SELECT
    parent.rolname AS granted_role,
    member.rolname AS member_role,
    m.admin_option,
    m.inherit_option,
    m.set_option
FROM pg_auth_members AS m
JOIN pg_roles AS parent ON parent.oid = m.roleid
JOIN pg_roles AS member ON member.oid = m.member
ORDER BY 1, 2;

还要检查 role 自身属性:

SELECT
    rolname, rolcanlogin, rolsuper, rolcreatedb, rolcreaterole,
    rolreplication, rolbypassrls, rolconnlimit, rolvaliduntil
FROM pg_roles
WHERE rolname LIKE 'app_%'
ORDER BY rolname;

SET LOCAL ROLE 的边界

SET LOCAL 只在事务中有局部效果:

BEGIN;
SET LOCAL ROLE app_runtime;
SELECT current_user;
COMMIT;
SELECT current_user;  -- 回到原 login

它特别适合 transaction pooling,因为角色状态在事务结束时回收。应用不能把 切换角色与业务 SQL 分在两个独立事务里:

transaction A: SET LOCAL ROLE app_runtime; COMMIT
transaction B: business query

第二个事务可能落在不同 backend,且局部角色早已消失。23.4 会把 role 与 tenant context 放进同一个事务合同。

23.3.2 schema、table、sequence、function 权限

权限是一组相互独立的门

一条:

SELECT id FROM app.account;

至少受这些条件影响:

database CONNECT
schema USAGE
table SELECT
column privilege, if table privilege is absent
RLS policy
role membership / ownership / bypass attributes

因此“给了表权限却仍报 permission denied”并不奇怪。应从外到内定位,而不是 直接 GRANT ALL

database 与 schema

database 常见权限:

CONNECT
CREATE
TEMPORARY

schema 常见权限:

USAGE   可以按名称访问 schema 中已获授权对象
CREATE  可以在 schema 中创建对象

runtime 通常只需:

GRANT CONNECT ON DATABASE appdb TO app_runtime;
GRANT USAGE ON SCHEMA app TO app_runtime;
REVOKE CREATE ON SCHEMA app FROM app_runtime;

USAGE 不会自动授予表权限,CREATE 却是一条重要越权路径:若可写 schema 出现在高权函数的 search_path 前部,攻击者可能创建同名函数、operator 或 对象劫持解析。

安全基线通常包括:

REVOKE CREATE ON SCHEMA public FROM PUBLIC;

但执行前要盘点依赖。已有应用可能把 public 当作共享可写工作区,直接撤销 会暴露历史设计问题。先发现、迁移,再收紧。

table 与 column

table 权限主要有:

SELECT INSERT UPDATE DELETE
TRUNCATE REFERENCES TRIGGER
MAINTAIN

不要把 TRUNCATE 当成普通 DELETE。它绕过逐行语义,不触发 ON DELETE trigger,且不受 RLS policy 逐行过滤。runtime 通常不应拥有它。

REFERENCES 允许创建引用约束,TRIGGER 允许在表上创建 trigger,二者也 不属于日常 DML。最小 runtime grant 示例:

GRANT SELECT, INSERT, UPDATE ON TABLE app.account TO app_runtime;
REVOKE DELETE, TRUNCATE, REFERENCES, TRIGGER
ON TABLE app.account FROM app_runtime;

如果只授权部分列:

GRANT SELECT (id, display_name) ON app.account TO reporting_role;

要同时检查 view、function、COPY、returning expression 和新增列的暴露方式。 列级 grant 不是数据脱敏系统。

sequence 不随 table 自动授权

使用 identity/serial 的 INSERT 可能还要访问 sequence:

GRANT USAGE, SELECT ON SEQUENCE app.account_id_seq TO app_runtime;

table 上的权限不会自动扩展到 sequence。常见症状是:

INSERT permission okay
nextval(...) -> permission denied for sequence

USAGE 允许 currval/nextvalSELECT 涉及 currvalUPDATE 可影响 setval。runtime 通常不应随意 setval

function 默认可执行

新 function/procedure 通常会把 EXECUTE 授给 PUBLIC,除非创建者通过 默认权限改变。对安全敏感函数,应在同一事务内创建和撤销:

BEGIN;

CREATE FUNCTION app.rotate_secret(...)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, app_private
AS $function$
...
$function$;

REVOKE ALL ON FUNCTION app.rotate_secret(...) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION app.rotate_secret(...) TO security_operator;

COMMIT;

不要让函数在“已创建但仍对 PUBLIC 开放”的窗口被调用。

SECURITY INVOKER 使用调用者权限,是默认和首选。SECURITY DEFINER 使用 函数 owner 权限,必须:

  • owner 不可登录且不是不必要的 superuser;
  • 固定安全 search_path,把 pg_catalog 和受控 schema 放入;
  • 避免引用可被调用者替换的对象;
  • 撤销 PUBLIC EXECUTE
  • 验证参数、tenant identity 和动态 SQL;
  • 对返回错误与日志进行脱敏;
  • 定期审计 owner 和函数定义。

官方安全写法见 PostgreSQL:CREATE FUNCTION

PUBLIC 是隐式全体角色

每个角色都隐式属于 PUBLIC。审计 ACL 时不能只搜索显式 app_runtime

effective privilege =
  PUBLIC
  + direct grant
  + inherited membership
  + owner rights
  + special attributes / predefined roles

这解释了为什么“ACL 里没有这个用户”不等于没有权限。

用权限函数做行为验收

ACL 文本适合审计来源,has_*_privilege 适合回答结果:

SELECT
    has_database_privilege('app_runtime', 'appdb', 'CONNECT') AS db_connect,
    has_schema_privilege('app_runtime', 'app', 'USAGE') AS schema_usage,
    has_schema_privilege('app_runtime', 'app', 'CREATE') AS schema_create,
    has_table_privilege('app_runtime', 'app.account', 'SELECT') AS can_select,
    has_table_privilege('app_runtime', 'app.account', 'TRUNCATE') AS can_truncate;

二者都不能替代实际负例。例如 has_table_privilege(..., 'SELECT')=true 不会 告诉你 RLS 最终能看到哪些行。

23.3.3 默认权限、所有权迁移与越权路径

default privilege 只影响未来对象

下面语句不是给现有表授权:

ALTER DEFAULT PRIVILEGES
FOR ROLE app_owner
IN SCHEMA app
GRANT SELECT, INSERT, UPDATE ON TABLES TO app_runtime;

它表示:

以后由 app_owner 在 app schema 创建的 table
  -> 自动给 app_runtime 指定权限

三个限定都很重要:

  1. future objects,不追溯现有对象;
  2. creating role 是 app_owner
  3. schema scope 是 app

如果迁移工具实际以 release_login 创建对象,而没有先 SET LOCAL ROLE app_owner,owner 和 default privilege 都可能偏离设计。 角色 membership 的权限不会自动替创建者的默认权限生效。官方细节见 PostgreSQL:ALTER DEFAULT PRIVILEGES

一个完整初始化事务通常同时处理当前和未来对象:

BEGIN;
SET LOCAL ROLE app_owner;

REVOKE CREATE ON SCHEMA public FROM PUBLIC;

GRANT USAGE ON SCHEMA app TO app_runtime, app_readonly;
GRANT SELECT, INSERT, UPDATE
  ON ALL TABLES IN SCHEMA app TO app_runtime;
GRANT SELECT
  ON ALL TABLES IN SCHEMA app TO app_readonly;
GRANT USAGE, SELECT
  ON ALL SEQUENCES IN SCHEMA app TO app_runtime;

ALTER DEFAULT PRIVILEGES IN SCHEMA app
  GRANT SELECT, INSERT, UPDATE ON TABLES TO app_runtime;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
  GRANT SELECT ON TABLES TO app_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
  GRANT USAGE, SELECT ON SEQUENCES TO app_runtime;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
  REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;

COMMIT;

实际语句需按应用操作矩阵裁剪,不能机械复制。

所有权迁移是安全迁移

把一个旧 login 改为 NOLOGIN 之前,要盘点它拥有的对象:

SELECT
    n.nspname,
    c.relname,
    c.relkind
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
JOIN pg_roles AS r ON r.oid = c.relowner
WHERE r.rolname = 'legacy_app'
ORDER BY 1, 2;

还要覆盖:

database, schema
table, sequence, view, materialized view
function, procedure
type, domain
publication/subscription
large object
default privileges
extension-owned dependencies

REASSIGN OWNED BY legacy_app TO app_owner 只作用于当前 database 中的对象, 其他数据库要分别执行。DROP OWNED 会撤销 grant、并可能删除对象,是破坏性 动作,不能拿来“顺手清理”生产账号。

推荐迁移:

inventory all databases
  -> create NOLOGIN owner
      -> transfer ownership in a reviewed change
          -> recreate/verify default privileges
              -> run positive and negative tests
                  -> stop old workload
                      -> NOLOGIN + PASSWORD NULL
                          -> terminate old sessions if required

常见越权路径

最小权限评审至少检查:

路径 风险
SUPERUSER / BYPASSRLS 绕过大多数数据库内控制
CREATEROLE / membership ADMIN 扩展角色图
owner login 日常凭据可改变对象和 policy
INHERIT TRUE 未显式进入业务角色也能使用其 ACL
writable search_path schema 对象名称劫持
SECURITY DEFINER + PUBLIC EXECUTE 以 owner 权限执行攻击输入
table owner without FORCE RLS owner 默认绕过 RLS
pg_read_all_data / pg_write_all_data 跨 schema 广泛读写
pg_read_server_files 读取数据库服务器可见文件
pg_write_server_files 写入服务器文件
pg_execute_server_program 执行服务器程序
extension install/control 引入高权代码
untrusted procedural language 数据库进程内执行不受信代码

预定义角色是方便的能力包,不是低风险标签。它们随版本演进,升级评审必须 重新阅读目标版本的 预定义角色说明

本章权限矩阵

正式实验收敛到:

行为 raw login runtime readonly owner break-glass
schema USAGE owner
schema CREATE
table SELECT owner
INSERT/UPDATE owner
DELETE/TRUNCATE owner 能力
管理 RLS policy
绕过 RLS FORCE 后否

五个 synthetic role 在演练结束时全部:

NOLOGIN
NOSUPERUSER
NOCREATEDB
NOCREATEROLE
NOREPLICATION
NOBYPASSRLS

负例实际得到:

raw login SELECT table       SQLSTATE 42501
runtime CREATE               SQLSTATE 42501
runtime TRUNCATE             SQLSTATE 42501
readonly INSERT              SQLSTATE 42501

这比一张手工填写的权限表更强,因为它同时证明“应该成功的能成功”和“不该 成功的确实失败”。下一节再把 table ACL 与 RLS 行边界组合起来。


上一节:认证与连接准入 · 返回本章目录 · 下一节:行级安全与连接池上下文 · 查看全书目录 · 查看索引中心

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

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

WHERE tenant_id = $tenant

RLS(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 tests

23.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 ROW trigger 可先修改新行,再执行 WITH CHECK
  • policy 不适用于 TRUNCATEREFERENCES 这类整表操作。

因此 runtime 还必须在 ACL 层失去 TRUNCATE。完整 command matrix 见 PostgreSQL:CREATE POLICY

多个 policy 怎样组合

policy 默认为 PERMISSIVE,多个适用 policy 用 OR

Ppermit=P1P2Pn 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 rows

FORCE 不是 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_invokersecurity_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 featuresPgBouncer 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;

四条不可拆:

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

SET LOCAL ROLEset_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 中的 runtimereadonly

长事务与租户上下文

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 cardinality

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


上一节:角色与最小权限 · 返回本章目录 · 下一节:密钥、审计与敏感信息 · 查看全书目录 · 查看索引中心

23.5 密钥、审计与敏感信息

数据库安全材料不只是一串密码:

password and SCRAM verifier
TLS private key and certificate
CA private key, trust bundle and CRL
OAuth client secret / token
LDAP bind credential
backup encryption key
replication credential
PgBouncer authentication material
break-glass credential

其中任何一项若进入 Git、命令行历史、日志、监控标签或实验产物,后续的 权限设计都可能失效。另一方面,为了“绝不记录敏感信息”而关闭所有日志,也 会让越权事件无法发现和调查。

本节处理的是这组张力:秘密必须最小暴露,安全行为必须留下足够而受保护的 证据。

23.5.1 凭据生成、存放、轮换和撤销

先建立 secret inventory

每类 secret 都要有 owner 和生命周期:

字段 要回答的问题
identity 它代表哪个人、服务或组件
scope 能访问哪些入口、数据库和角色
source 谁生成,熵和算法是否合格
storage secret manager、HSM、受限文件还是其他载体
delivery 哪个 workload 如何获得
readers 哪些人、服务账户和进程可读
lifetime 创建、启用、到期和最大使用时长
rotation 是否支持重叠版本,多久轮换
revocation 如何阻止新认证
session eviction 如何处理既有连接
downstream 是否复制到代理、CI、备份或灾备
evidence 如何证明已完成且不导出秘密

如果连“有几份副本”都不知道,就无法声称秘密已经撤销。

生成

机器凭据应由密码学安全随机源生成,避免:

human memorable password
service-name + environment + year
one shared password for all replicas/apps
copy production password to staging

长度和字符集要兼容客户端、URI、配置格式与 secret manager;不要为了规避 转义问题而把熵降得过低。优先通过结构化参数或独立字段传递,避免把密码拼进 连接 URI。

证书与 key 的生成还要固定:

key algorithm and size
signature algorithm
SAN identities
extended key usage
issuer and path length
validity and renewal window
private-key exportability

CA private key 与数据库 server key 不应由同一批日常运维主体任意读取。

存放和交付

首选工作流:

secret manager / HSM
  -> authenticated workload
      -> short-lived retrieval
          -> memory or private runtime file
              -> database driver

如果使用文件:

  • 明确 owner/group;
  • 通常使用 0600 或经过评审的 0640
  • 目录同样不可遍历;
  • 不写入镜像层、共享 volume 或备份;
  • 不把内容输出到 diagnostics;
  • 用完安全删除临时副本,并考虑文件系统/快照语义。

.pgpass 要求严格权限,它适合受控本地客户端,不是企业 secret manager。 环境变量适合某些运行时注入,但必须处理进程继承、crash dump、support bundle 和调度平台元数据风险。

最危险的交付路径通常很方便:

psql "postgresql://app:plaintext@db/app"

URI 可能进入 process list、shell history、trace、错误和工单。即使工具会 隐藏部分内容,也不应把安全性押在每个中间层都正确脱敏。

数据库密码变更

交互式 psql 可使用:

\password app_login

它在客户端提示密码并发送 verifier,避免明文出现在命令历史和 server log。 官方说明见 psql \password。直接执行:

ALTER ROLE app_login PASSWORD 'plaintext';

可能让明文进入 client history 或 server log;PostgreSQL 官方也明确警告 这一点,参见 ALTER ROLE

自动化系统应通过 secret-safe API、受控 stdin/fd 或专门管理函数完成,且 验证流水线不会回显命令与异常。

轮换是状态机

密码轮换不能只有“ALTER 成功”:

S0 old active
  -> S1 new generated and stored
      -> S2 database accepts new
          -> S3 workloads use new
              -> S4 old new-auth rejected
                  -> S5 old sessions drained/terminated
                      -> S6 old copies destroyed

每个状态需要证据与回滚条件。若 PostgreSQL role 只能保存一个当前 verifier, S2 与 S3 之间的兼容窗口可以采用:

  • 蓝绿两个 login role;
  • 应用小批量快速 rollout;
  • 代理/身份系统支持的双版本机制;
  • 计划内短暂重连窗口。

不要假装一个 role 可以同时接受两个普通 PostgreSQL 密码。

撤销新认证与终止旧会话是两件事

本章的受控临时 login 实测:

password v1 new auth                  success
change to password v2
password v1 new auth                  rejected
password v2 new auth                  success
session opened with v1                still usable
ALTER ROLE ... NOLOGIN
all new auth                          rejected
existing session                      still usable
final NOLOGIN + PASSWORD NULL         verified

因此应急撤销至少有两条动作:

ALTER ROLE compromised_login NOLOGIN PASSWORD NULL;

以及在识别范围并评估事务影响后:

SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE usename = 'compromised_login'
  AND pid <> pg_backend_pid();

第二条是有破坏性的:会中断事务,产生 commit outcome uncertainty,并可能 触发重连风暴。必须先阻止代理和应用继续获取旧 secret,再终止既有 session。

代理层也要轮换

PgBouncer 的 auth_fileauth_queryauth_user 和 server login 都可能 持有或派生认证材料。轮换时要明确:

client -> PgBouncer identity
PgBouncer -> PostgreSQL identity

哪一段在变。更新 PostgreSQL verifier 后:

  • PgBouncer 是否缓存旧认证材料;
  • 是否需要 RELOAD
  • 是否需要 RECONNECT server pools;
  • 已有 client connection 是否继续使用;
  • 所有 PgBouncer 节点是否完成;
  • 直连和池化入口是否给出一致的允许/拒绝结果。

本章临时 role 直连新认证成功,但池入口拒绝,因为它没有进入 PgBouncer 认证面。这是预期的最小暴露,不是轮换失败。

证书和 CA 轮换

证书轮换同样要区分:

trust distribution
certificate/key deployment
service reload
new connection negotiation
old connection lifetime
old trust removal
revocation publication

PostgreSQL reload 新 server certificate 不会让所有现有 TLS session 自动 重新握手。PgBouncer 两侧又各有 TLS 状态。验收必须建立新连接并核对证书 fingerprint/issuer/SAN,而不是只看文件 mtime。

CA rollover 先扩充 trust bundle,再切 server/client certificate,最后等 fleet 全部迁移后删除旧 CA。倒序操作会把尚未更新的客户端全部拒绝。

应急凭据

break-glass credential 应:

  • 与日常 workload secret 分开;
  • 双人或受审批取用;
  • 短时激活;
  • 不进入普通自动化;
  • 使用后立即轮换;
  • 关联 incident/change id;
  • 从数据库外部收集不可篡改证据;
  • 定期演练“能取出、能使用、能回收”。

一个从未测试、到事故时才发现过期的应急密码,不是恢复能力。

secret-free 证据

证明轮换无需保存 secret:

role name
credential version id / hash reference
change timestamp
new-auth accepted boolean
old-auth rejected boolean
existing-session observation
NOLOGIN/password-present boolean
operator/change id
artifact hash

本章证据显式拒绝:

plaintext passwords
SCRAM verifiers
raw PgBouncer userlist
server/CA private keys
connection strings containing credentials

它检查“secret signature 不存在”,同时对证据文件使用 0600。文件权限不能 替代内容最小化;二者都要做。

23.5.2 审计目标、日志范围与访问控制

从调查问题反推记录

先定义要回答的问题:

who authenticated, and by which original identity
which login and effective role executed
from which source and entrypoint
which database/object/action
when it began and ended
whether it succeeded
which rows/records were affected, at safe granularity
which policy/config/role changed
which approved request/incident authorized it
whether evidence could have been altered

“记录所有 SQL”并不能自动回答这些问题,反而可能产生无法检索的海量敏感 数据。

四类证据互相补充

来源 擅长回答 局限
PostgreSQL standard log 连接、错误、慢 SQL、DDL、运行事件 不是完整对象审计
pgAudit 结构化 session/object audit classes 有容量成本,superuser 不可可靠自审
Pigsty/config repository 谁声明、评审、发布了配置 不证明运行实例已收敛
host/proxy/secret manager/IdP 网络入口、secret 读取、外部身份 不知道 SQL 对象语义

还应关联:

application audit       end-user and business action
database audit          login/effective role and SQL object
platform audit          configuration/deployment/operator
security audit          secret access and identity events

共享应用 login 下,数据库看不到每个终端用户,应用必须提供受信 actor/request 关联。不要把用户可自行填写的 application_name 当成强身份。

standard log

PostgreSQL 18 可分别记录 connection receipt、authentication、 authorization、setup duration,以及 disconnect。还可记录:

  • error statement;
  • DDL/DML/all statement classes;
  • duration threshold 或 sampling;
  • lock/recovery/autovacuum/checkpoint;
  • SQLSTATE、session id、transaction id、query id;
  • application/database/user/client address。

log_line_prefix 至少要支持跨行关联,例如:

timestamp session-id pid user database application client SQLSTATE

具体字段按日志格式和数据分类选择。若使用 JSON/CSV,仍要确保 collector、 rotation、磁盘满和转发失败被监控。PostgreSQL logging collector 为避免丢 消息可能在落后时阻塞 backend;syslog 则可能选择丢消息。这也是容量设计, 不是简单开关。

pgAudit 能补什么

pgAudit 将行为分为 READWRITEFUNCTIONROLEDDLMISC 等审计类,并能提供更适合对象审计的记录。部署要点:

matching pgAudit branch/version for PostgreSQL major
shared_preload_libraries includes pgaudit
restart completed
CREATE EXTENSION pgaudit before setting pgaudit.log
selected classes/object audit configured
volume and sensitive-data test passed

只安装 package 或只执行 CREATE EXTENSION 都不等于审计已经运行。官方项目 说明见 pgAudit

不要默认 pgaudit.log=all

  • 高频 SELECT 会产生巨大日志;
  • bind parameter 可能含 PII/secret;
  • 日志 IO/转发可能成为负载瓶颈;
  • 噪声可能淹没 role/DDL 等关键事件;
  • retention 成本和访问面迅速扩大。

应从审计目标选择 session classes 或 object audit,并对新表纳入策略做持续 检查。

superuser 不能可靠地审计自己

pgAudit 官方明确指出,不能可靠审计 superuser。superuser 能改变设置、停用 扩展、修改日志路径或干预本机数据。解决思路不是再加一条数据库内 trigger, 而是:

restrict direct superuser
  -> named operator identity
      -> approved, time-bounded escalation
          -> external control-plane/host audit
              -> remote immutable-ish log copy
                  -> post-use review

“不可变”要具体:谁拥有 bucket retention policy、谁能删除 collector、日志 在源端滞留多久、断网时如何缓冲、时间如何同步,都要写进控制设计。

角色和授权变更

高价值事件:

CREATE / ALTER / DROP ROLE
GRANT / REVOKE role membership
GRANT / REVOKE object privilege
ALTER ... OWNER
ALTER DEFAULT PRIVILEGES
ENABLE / DISABLE / FORCE RLS
CREATE / ALTER / DROP POLICY
SECURITY DEFINER function changes
HBA / TLS / proxy authentication changes
secret reads and rotations
break-glass use

仅记录成功 DDL 不够。还需:

  • 失败尝试;
  • 变更前后投影或配置 diff;
  • 审批 id;
  • 执行主体;
  • 节点收敛状态;
  • 正负验收;
  • 回滚结果。

日志本身是敏感数据

日志可能包含:

user and client IP
database/schema/table names
SQL text and literal values
bind parameters
error context
tenant/request identifiers
certificate distinguished names
security policy and topology clues

因此要实施:

  • 最小读权限,读日志本身也审计;
  • 传输和静态加密;
  • retention 与合法删除;
  • 环境/租户隔离;
  • 索引系统访问控制;
  • 防止下载到个人设备;
  • incident legal hold;
  • collector health 与 ingestion gap 告警。

本章环境结论

沙箱运行事实:

pgAudit installed/preloaded       no
standard log_statement            ddl
log_min_duration_statement        100 ms

它能支持实验和部分运维诊断,不能被描述为完整生产审计。生产 gate 因此保留 pending,不是把“有日志”写成“满足审计要求”。

23.5.3 参数、SQL 文本与日志脱敏

参数绑定解决注入,不保证不落日志

应用正确使用:

SELECT *
FROM app.account
WHERE email = $1;

能避免把 $1 当 SQL 语法解释,但 extended query protocol 的 Bind value 仍可能被 statement/duration/audit/error logging 记录。安全评审必须把:

SQL construction safety
log data exposure

当成两个问题。

PostgreSQL 的参数日志开关

PostgreSQL 18:

log_parameter_max_length
  -1   非错误 statement log 可记录完整 bind 参数
   0   禁止在这类日志记录 bind 参数
  >0   每个参数截断到指定字节

log_parameter_max_length_on_error
   0   error message 不附 bind 参数
  -1   可附完整参数
  >0   截断

精确行为见 PostgreSQL:错误报告与日志

本章沙箱:

log_parameter_max_length             -1
log_parameter_max_length_on_error      0

含义是:

  • error path 默认不附 bind values;
  • 非 error 的 statement/duration logging 路径可能保留完整 bind values。

因此它被标记为生产差距。不能只因为 error 参数为 0 就得出“参数不进日志”。

截断不是脱敏

把每个参数截断到 64 bytes 仍可能完整暴露:

password
API token prefix
email
phone
credit-card number
small JSON secret

0 能阻止特定 PostgreSQL log path 记录 bind 参数,但:

  • SQL literal 仍在 statement text;
  • application log 可能记录参数;
  • pgAudit/extension 行为要单独验证;
  • error message 可能引用业务值;
  • trigger/function 自己可能 RAISE LOG
  • proxy/APM/driver trace 可能复制 SQL。

脱敏必须是端到端数据流评审。

不要把秘密写进 SQL literal

高风险语句:

ALTER ROLE app PASSWORD 'secret';
INSERT INTO integration_config(api_token) VALUES ('secret');
SELECT call_remote_service('secret');

即便业务表有 RLS,这些 literal 也可能进入 statement、DDL、audit、客户端 历史或 trace。对于 credential provisioning,使用专门的 secret-safe 通道; 对于业务 secret,使用参数绑定并缩小数据库日志范围。

在源头分类字段

建议把请求数据分成:

类别 示例 日志策略
public operational version、region、status 可结构化记录
internal identifier request id、tenant surrogate id 最小化、受控保留
personal/confidential email、address、business data 默认不记 value
credential/cryptographic password、token、private key 永不记录
regulated/highly sensitive payment/health/government id 专门政策与审计

数据库团队不能只靠列名猜分类。应用 schema、数据目录和日志 policy 要共享 同一份分类元数据。

SQL fingerprint 与 value 分离

性能分析通常不需要参数值。优先保留:

normalized query / query id
duration
rows
wait/error class
database/user/application
safe request correlation id
plan/statistics reference

而不是:

full statement + all bind values for every request

第 25、26 章会用 pg_stat_statements、query id 和计划证据分析性能;这些 方法能显著减少为了可观测而复制业务值的必要。

pipeline 脱敏是第二道防线

collector 侧可以:

  • 删除已知敏感字段;
  • token/password pattern 检测;
  • 限制异常样本和 payload;
  • 对 identifier 做受控 pseudonymization;
  • 阻止包含 private key/verifier 的事件;
  • 记录 redaction rule version。

但 regex 不能成为唯一控制。SQL 语法、编码、嵌套 JSON、base64 和未知字段 会绕过它。首要措施仍是在源端不产出秘密。

脱敏测试

上线前使用纯 synthetic canary:

unique fake password marker
unique fake token marker
unique fake PII marker

执行经过批准的测试请求,然后检查:

PostgreSQL local logs
PgBouncer / HAProxy logs
central log index
APM traces
application logs
CI artifacts
support bundles
chapter/evidence output

期望是 credential marker 零命中,其他 marker 只出现在预先批准的位置。不要 用真实 secret 做日志泄漏测试。

审计与隐私的验收

最终不是“多记”或“少记”,而是:

security event has enough attributable evidence
AND
credential/business values are absent unless explicitly required
AND
evidence readers and retention are controlled
AND
ingestion gaps and tampering attempts are observable

这是安全日志与普通调试日志的根本区别。


上一节:行级安全与连接池上下文 · 返回本章目录 · 下一节:Pigsty 安全基线 · 查看全书目录 · 查看索引中心

23.6 Pigsty 安全基线

Pigsty 能把角色、HBA、服务入口、证书和日志参数声明化,但“声明化”不等于 “使用默认值即可满足任何生产 threat model”。官方安全说明明确指出,默认 配置面向可信内网中的开发、测试和演示;生产环境要按自身威胁模型配置凭据、 网络边界、认证、证书、备份和审计。

本节建立四层对账:

policy intent
  -> Pigsty inventory
      -> rendered files and service configuration
          -> PostgreSQL/PgBouncer runtime facts
              -> end-to-end positive and negative observations

只有最下面一层能证明客户端实际经历了什么;只有最上面一层能解释为什么这样 设计。

23.6.1 角色、HBA、证书与服务入口声明

角色声明

Pigsty 用:

pg_default_roles    环境级共享角色
pg_users            集群级业务角色/用户

用户按数组顺序创建,因此被引用的 group role 应先定义。下面是结构示例, 不是可直接投产的 secret 文件:

pg_users:
  - name: app_owner
    login: false
    superuser: false
    createdb: false
    createrole: false
    inherit: false
    replication: false
    bypassrls: false
    comment: "NOLOGIN owner for app objects"

  - name: app_runtime
    login: false
    inherit: false
    comment: "NOLOGIN normal DML capability"

  - name: app_readonly
    login: false
    inherit: false
    comment: "NOLOGIN read-only capability"

  - name: app_login
    login: true
    password: "<SCRAM-VERIFIER-FROM-PRIVATE-SECRET-PIPELINE>"
    superuser: false
    createdb: false
    createrole: false
    inherit: false
    replication: false
    bypassrls: false
    connlimit: 100
    roles:
      - {name: app_runtime, admin: false, inherit: false, set: true}
      - {name: app_readonly, admin: false, inherit: false, set: true}
    pgbouncer: true
    pool_mode: transaction
    pool_connlimit: 80
    comment: "application authentication identity"

示例标记必须由私密交付机制替换,不能把真实明文或 verifier 提交到本书、Git、 工单或聊天记录。Pigsty 支持 plaintext 或 SCRAM verifier,但官方也把 plaintext inventory 标为不推荐。角色字段与 membership object 格式见 Pigsty:User/Role

这里还要注意:

  • roles 管理是 additive;未声明的历史 membership 不会自动消失;
  • 要撤销旧 membership,应显式使用 state: absent
  • PostgreSQL 16+ 才支持 membership 的 set/inherit 细分;
  • pgbouncer: true 会把 login 纳入 PgBouncer 认证面;
  • NOLOGIN owner/runtime/readonly 不应加入池用户列表;
  • connlimit 与 PgBouncer pool limit 是不同层的限制。

已存在集群修改角色:

bin/pgsql-user <cluster> <username>

它是声明式、可重复入口。删除角色的 state: absent 会涉及断开连接、转移 ownership 和 DROP ROLE,属于破坏性变更;必须先 dry-run/依赖审查和审批, 不能把“脚本支持安全删除”理解为无需变更控制。官方管理流程见 Pigsty:用户管理

membership 与对象 ACL 分开

Pigsty pg_users 可声明 cluster role 和 membership;schema/table/function ACL、owner、default privilege 与 RLS policy 仍应由经过版本控制的 migration 完成:

Pigsty inventory
  -> identity attributes, membership, pool admission

application migration
  -> schema/table owner, ACL, default privileges, RLS policies

不要在多个无序启动脚本里同时管理同一份 grant。平台和应用必须明确各自 owner 和收敛时点。

PostgreSQL 与 PgBouncer HBA

Pigsty 有四组参数:

参数 范围 作用
pg_default_hba_rules global PostgreSQL 环境默认规则
pg_hba_rules global/cluster/instance PostgreSQL 增量规则
pgb_default_hba_rules global PgBouncer 环境默认规则
pgb_hba_rules global/cluster/instance PgBouncer 增量规则

规则会按 order 排序,数值越小越靠前。未显式指定通常进入 1000+ 区域; 如果前面已有宽泛默认 allow,它可能永远匹配不到。

生产应用入口的概念示例:

pg_hba_rules:
  - title: "app direct path from approved application subnet"
    user: app_login
    db: appdb
    addr: "10.20.10.0/24"
    auth: ssl
    order: 50

  - title: "reject app direct path from all other sources"
    user: app_login
    db: appdb
    addr: world
    auth: deny
    order: 51

pgb_hba_rules:
  - title: "app pooled path from approved application subnet"
    user: app_login
    db: appdb
    addr: "10.20.10.0/24"
    auth: ssl
    order: 50

  - title: "reject app pooled path from all other sources"
    user: app_login
    db: appdb
    addr: world
    auth: deny
    order: 51

auth: ssl 的当前 Pigsty alias 会渲染为 hostssl ... scram-sha-256;生产变更 仍应检查目标版本的渲染结果,不能永久依赖书中的 alias 解释。HBA 字段、 alias、role filter 和 order 规则见 Pigsty:HBA Rules

若应用只允许经 pool/service 入口访问,可进一步在网络与 PostgreSQL HBA 中拒绝应用子网直连 5432,只允许本机 PgBouncer 或指定代理身份访问后端。 这能避免客户端绕过 PgBouncer 的连接限制、认证面与事务池合同。

intra 只是地址别名

Pigsty 的 intra/intranet 通常展开为 RFC 1918:

10.0.0.0/8
172.16.0.0/12
192.168.0.0/16

这些地址不是天然可信。企业办公网、VPN、容器网、其他租户 VPC 和开发环境都 可能落在其中。生产规则应尽量使用准确的应用 subnet/security group,而不是 把整个 RFC 1918 视为一个 trust zone。

刷新而非手工改文件

修改 inventory 后:

bin/pgsql-hba <cluster>

会重新渲染并 reload PostgreSQL/PgBouncer 相关 HBA。不要直接编辑:

/pg/data/pg_hba.conf
/etc/pgbouncer/pgb_hba.conf

下次 playbook 会覆盖手工改动,而且 inventory 与运行事实从此分叉。紧急手工 变更若无法避免,也必须同步回声明源、记录例外并尽快恢复收敛。

证书声明和使用

Pigsty 默认基础设施 CA 会为 PostgreSQL、Patroni、etcd、MinIO、Nginx 等 内部服务签发证书。生产评审要区分:

CA exists
server has certificate
listener supports TLS
HBA requires TLS
client trusts the intended CA
client verifies the intended DNS/IP name
client certificate, if required, is mapped and revocable

这六件事不能合并成“开启 SSL”。

证书 SAN 应覆盖客户端实际使用的:

  • HAProxy/VIP DNS name;
  • PostgreSQL direct service name;
  • 节点名/IP(若允许直连);
  • planned disaster-recovery endpoint。

不要让应用用 hostaddr 绕过期望的 DNS name verification,除非同时提供可 校验的 host 语义并完成测试。

CA private key 所在目录是根信任资产。需要离线/受限备份、读取审计和恢复演练。 证书签发与客户端安装流程见 Pigsty:CA and Certificates

两段 TLS 分别声明

若应用经 PgBouncer:

client ==TLS policy A==> PgBouncer
PgBouncer ==TLS/socket policy B==> PostgreSQL

Pigsty 的 PostgreSQL server TLS 默认开启,不表示 HBA 默认要求 TLS; PgBouncer client TLS 默认也不是开启状态。当前官方安全说明明确列出:

PostgreSQL TLS supported
default intranet HBA may not require it
PgBouncer TLS controlled separately by pgbouncer_sslmode
Patroni REST TLS controlled separately

生产要逐入口决定:

链路 加密 对端验证 允许来源 认证主体
app → HAProxy/PgBouncer required service name app subnet app login
PgBouncer → PostgreSQL TLS 或受控 local socket node/service proxy nodes server login
admin → PostgreSQL required admin endpoint bastion/infra named admin
replica → primary required or isolated equivalent node identity cluster only replication role

service entrypoint

第 22 章区分了 direct PostgreSQL、PgBouncer、HAProxy primary/replica/offline 等入口。安全基线要为每个入口建立:

purpose
listener address/port
source allowlist
HBA/authentication
TLS name and CA
allowed roles/databases
pool mode
rate/connection limit
audit label
owner

未使用入口应关闭或从网络上不可达。保留一个“以后可能调试”的公网 5432 会变成长期旁路。

23.6.2 管理面、监控面和数据库面的网络边界

先画流量矩阵

至少区分:

平面 典型组件 主要主体 失陷后风险
管理面 meta/Ansible、SSH、sudo、secret store 平台管理员/自动化 改写全部配置与密钥
控制面 Patroni REST、etcd cluster agent 错误选主、拓扑控制
数据面 HAProxy、PgBouncer、PostgreSQL 应用/分析/迁移 数据读写与租户越权
监控面 exporter、Prometheus、Grafana、日志 monitoring identities 查询/拓扑/日志泄露
备份面 pgBackRest repo、WAL/archive backup identities 全量数据泄露或恢复破坏

“都是内网服务”不是边界设计。每条 flow 应记录:

source identity/network
destination service/address/port
direction
TLS/mTLS
authentication
authorization scope
availability dependency
log/audit
owner and review date

管理面

管理节点通常能:

  • 通过 SSH 到所有节点;
  • sudo/root;
  • 读取 inventory 或 vault integration;
  • 运行 Ansible/playbook;
  • 访问 CA material;
  • 变更防火墙、HBA 和 service。

因此它不是普通“运维跳板”。应:

named human identity + MFA
short-lived SSH certificate/key
no shared permanent root password
separate automation service identity
command/change audit
restricted egress and inbound sources
workstation and bastion hardening
backup/recovery for control repository

把数据库 superuser 密码从 inventory 移走,却允许所有工程师无审计地 sudo -iu postgres,并没有实现职责分离。

控制面

Patroni REST 和 etcd 决定 cluster topology。它们不应暴露给业务 subnet。 控制面规则通常只允许:

cluster members
approved management/monitoring nodes

health check 入口与管理 API 要区分。HAProxy 为角色判断访问 Patroni health endpoint,不表示应用客户端也应访问完整 Patroni API。

控制面 TLS、认证和 ACL 必须按目标 Pigsty 版本验证。不能因为 etcd 使用 TLS, 就推断 Patroni REST 也已经使用 TLS。

数据面

建议把应用路径收敛为:

application subnet
  -> primary/replica service VIP/DNS
      -> HAProxy
          -> local/cluster PgBouncer
              -> PostgreSQL

然后按实际需要开放 direct path:

management/migration subnet -> PostgreSQL direct
replication nodes            -> PostgreSQL replication
backup/monitor               -> dedicated minimum roles

网络控制至少三层:

cloud security group / network ACL
host firewall
listener + HBA

HBA 不是防 DDoS 的边界:连接已经到达 PostgreSQL 才会检查。安全组/防火墙也 不理解 database/user。二者互补。

Pigsty node_firewall_mode=zone 的默认可信 intranet 必须与真实边界对账。 官方建议也明确提醒 RFC 1918 范围可能过宽,生产 demo 配置通常应移除无必要 的 5432 暴露。参见 Pigsty:Security Recommendations

监控面

monitor role 看起来只读,但往往能访问:

  • pg_stat_activity 查询文本;
  • replication/topology;
  • database/object names;
  • slow-query samples;
  • connection source/user;
  • logs and dashboards。

因此:

  • monitor 不应使用 superuser;
  • exporter query 必须受版本控制;
  • dashboard 与 Prometheus API 要认证;
  • label 不能含 password/token/高基数 PII;
  • 跨环境 metrics/logs 要隔离;
  • support snapshot 要脱敏;
  • 监控不可用不能使数据库认证 fail-open。

备份面

backup repository 持有跨 RLS、跨租户的完整数据与 WAL。网络上只允许备份 主体和恢复路径;读取、删除、retention 改动应分权。数据库 RLS/HBA 无法保护 一份已被复制到对象存储的备份。

恢复环境同样要隔离:把生产备份恢复到宽松开发网,常比攻击生产数据库更容易 造成泄露。第 21、32、33 章会继续处理备份与恢复的控制。

egress 也要控制

数据库节点出站能力可能被:

  • extension/FDW;
  • COPY PROGRAM 高权能力;
  • untrusted language;
  • backup/archive command;
  • 运维脚本;
  • 被攻陷进程

用于外带数据。仅做 inbound firewall 不够。生产需要明确允许的软件源、 backup endpoint、DNS/NTP/monitoring 等 egress,并监控异常。

23.6.3 从配置渲染到运行事实的差异检查

四份状态

对同一项控制记录:

desired       reviewed inventory/policy
rendered      node-local file/service config
runtime       catalogs, settings, sockets, process state
observed      actual client connection or rejected action

示例:

控制 desired rendered runtime observed
app TLS auth: ssl hostssl pg_hba_file_rules disable fails, verify-full succeeds
role NOINHERIT generated SQL pg_roles raw login SELECT fails
membership SET true grant statement pg_auth_members SET LOCAL succeeds
RLS migration table/policy DDL pg_class/pg_policies cross-tenant writes fail
pool identity pgbouncer: true userlist presence SHOW USERS projection pool auth succeeds

任何一列缺失都不能宣告闭环。

inventory 评审

先检查:

  • 是否把 secret 直接写入 Git;
  • role 高权 flag;
  • inherit 和 membership admin/set/inherit
  • pgbouncer 暴露范围;
  • HBA order 与宽规则;
  • host/hostssl
  • world/intra alias 展开;
  • primary/replica/offline role filter;
  • listener/service port;
  • PostgreSQL/PgBouncer/Patroni TLS;
  • logging/audit;
  • firewall intranet。

inventory diff 应显示结构,不应把 password/verifier 展开到 PR。

渲染检查

变更前先生成/审查候选,应用后检查节点:

/pg/data/pg_hba.conf
/etc/pgbouncer/pgb_hba.conf
PgBouncer main/user options, secret-redacted projection only
PostgreSQL include/config source
HAProxy listener/backend projection
certificate public metadata
host firewall projection

不要把 /etc/pgbouncer/userlist.txt 原文放进证据;它可能含 verifier。

HBA 应验证:

SELECT *
FROM pg_hba_file_rules
ORDER BY rule_number;

并确认 error IS NULL、顺序与 inventory 相符。

runtime 检查

角色:

SELECT
    rolname, rolcanlogin, rolsuper, rolcreatedb, rolcreaterole,
    rolreplication, rolbypassrls, rolconnlimit, rolvaliduntil
FROM pg_roles
ORDER BY rolname;

membership:

SELECT
    granted.rolname AS granted_role,
    member.rolname AS member,
    m.admin_option,
    m.inherit_option,
    m.set_option
FROM pg_auth_members AS m
JOIN pg_roles AS granted ON granted.oid = m.roleid
JOIN pg_roles AS member ON member.oid = m.member;

对象与 RLS:

owners
schema/table/function/sequence ACL
default ACL
relrowsecurity / relforcerowsecurity
pg_policies
view/function security attributes

TLS:

SHOW ssl / ssl_min_protocol_version
certificate SAN, issuer, validity, fingerprint
private key owner/mode, never contents
pg_stat_ssl on real sessions
verify-full positive and wrong-name negative

日志:

log_statement
duration/sample thresholds
parameter logging limits
connection/disconnection settings
shared_preload_libraries
pg_extension
collector/forwarder health

observed 检查

必须从与真实 workload 相同的网络和 driver 路径执行:

allowed source + right login + right DB     succeeds
wrong source                               rejected
wrong password                             rejected
sslmode=disable where TLS required          rejected
verify-full matching name                  succeeds
verify-full wrong name                     rejected
pool login                                independently succeeds
direct bypass where prohibited             rejected
raw login object access                    rejected
approved SET LOCAL role                    succeeds
cross-tenant action                        rejected

只在 database node 上用 Unix socket psql,无法验收远程网络、proxy HBA、 TLS name 或客户端 CA distribution。

本章沙箱差异

在 Pigsty v4.5.0 nonproduction sandbox 中,正式捕获发现:

控制 期望/能力 运行事实 判定
PostgreSQL TLS server 可加密 on,最低 TLS 1.2 能力通过
cert identity SAN + verify-full 正例成功,错名失败 通过
direct channel binding SCRAM over TLS 成功 通过
business direct HBA 生产应强制 TLS +dbrole_readonly 内网为 host 待整改
PgBouncer client TLS 生产应按 threat model 启用 disabled 待整改
pool → PostgreSQL 受控链路 local Unix socket 事实符合设计
CRL 撤销路径 file/dir unset 待整改
pgAudit 目标要求对象审计 absent/not preloaded 待整改
bind logging secret-minimized 非错误参数上限 -1 待整改

它诚实地同时证明:

TLS mechanism works
AND
non-TLS direct business connection is still admitted

第一条不能抵消第二条。

分阶段整改

安全变更也会造成可用性风险,应分阶段:

Phase 1 inventory
  client versions, source networks, DNS names, CA trust, pool entrypoints

Phase 2 prepare
  issue correct SAN certs
  distribute CA
  make clients use verify-full
  establish metrics and rollback

Phase 3 enforce client-to-pool TLS
  enable PgBouncer TLS
  positive/negative tests
  roll clients

Phase 4 enforce PostgreSQL HBA
  add high-priority hostssl allow
  retain controlled exception if necessary
  prove non-TLS rejection

Phase 5 audit and secret minimization
  install matching pgAudit if required
  select classes/objects
  reduce bind parameter logging
  validate volume and redaction

Phase 6 remove exceptions
  expire compatibility HBA/CA/password
  terminate old sessions
  update runbook and evidence

一次性把所有 host 改成 hostssl,如果客户端尚未安装 CA 或仍用 IP 不匹配 证书,会把安全整改变成全站故障。

例外不是口头备注

暂时保留非 TLS/旧客户端时,例外记录至少包含:

exact source/destination/role/database
business reason
threat and data classification
compensating network control
owner and approver
created/expires
remediation milestone
monitoring
tested rollback

无到期日的例外就是新默认。

生产判定

最终报告只允许:

判定 含义
pass 所有必须项有运行与负例证据
pass-with-exception 明确、批准、限时、受补偿控制的差距
pending 机制可用,但必需控制尚未实施/证明
reject 存在不可接受暴露或证据冲突

本章沙箱是 pending。它完全适合教学实验,却不能被包装成生产审批。这种区分 正是安全工程成熟度的一部分。Pigsty 当前生产注意事项见 Pigsty:Security Considerations


上一节:密钥、审计与敏感信息 · 返回本章目录 · 下一节:实战:隔离两个租户 · 查看全书目录 · 查看索引中心

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

实验明确不做:

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: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 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                   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 版本。

权限矩阵

正式投影:

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: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 与组织治理 · 查看全书目录 · 查看索引中心