Skip to content

第 13 章 权限与安全

学习目标:能向架构师解释「PG 的 ROLE 体系为什么把用户和组合并成一个东西」;能独立设计「最小权限」的生产账号矩阵;能用 行级安全 RLS 实现一个干净的多租户 SaaS;能看懂 pg_hba.conf 5 列的含义并知道生产应该用 scram-sha-256;能在面试中接住「列级权限 / RLS / pg_hba 优先级 / 默认权限」这一连串组合拳。


13.0 导读:为什么把「权限」单列一章?

很多读者用 PG 都是直接拿 postgres 这个超级用户连,写代码、跑业务、改表结构,全干。等到有一天:

  • 测试同学误删了一张生产表,发现整个团队都用同一个账号;
  • 多租户系统里,租户 A 通过应用 Bug 看到了租户 B 的数据;
  • 安全审计要求所有连接必须 SSL,否则不许进数据库;
  • 监管要求「只读分析师不许看到用户手机号列」。

这些事情,靠 ORM 在应用层做防御都治标不治本。真正能挡住它们的,是数据库内核的权限模型。PG 在权限这一块的能力,比 MySQL 强出一个时代——不仅有传统的「对象/动作」二维 GRANT,还有自 9.5 起的 行级安全 RLS(Row Level Security),让数据库自己来当「门卫」,应用程序写错也漏不出去。

本章会让你彻底理解:

  1. ROLE 体系:用户、组、组的组,全是一个东西;
  2. GRANT/REVOKE 矩阵:13 种对象 × 12 种权限的组合;
  3. 默认权限 ALTER DEFAULT PRIVILEGES:让以后创建的表也自动授权;
  4. 行级安全 RLS:多租户隔离、按部门隔离、按数据所有者隔离;
  5. pg_hba.conf 五列法则:谁能从哪台机器连过来、用什么方式认证;
  6. SSL/TLS 强制加密sslmode 四档、自签证书与生产证书;
  7. 密码策略scram-sha-256VALID UNTIL、密码强度;
  8. 审计pgaudit 扩展、event trigger 自建审计;
  9. 生产最佳实践清单:把以上技术拼成一套可落地的安全规范。

13.1 ROLE 体系:一切都是「角色」

13.1.1 生活类比:通行证 = 工牌 = 门卡

想象一栋写字楼:

  • 每个员工有一张 工牌(身份);
  • 工牌可以开门(LOGIN 属性);
  • 几个员工可以共属一个 部门门卡组GROUP);
  • 门卡组也是「一张卡」,只不过它没法刷电梯下班,只能挂在员工卡上做权限继承;
  • HR 的卡可以开「全公司所有门」(SUPERUSER)。

PostgreSQL 9.0 之后,做了一个非常聪明的统一:员工卡和部门门卡,本质都是「卡」。它把这个东西叫作 ROLE(角色)

📌 与 MySQL 的区别

MySQL 5.x 把「用户」('alice'@'%',用户名 + 主机名是主键)和「角色」(MySQL 8.0 才加的,且独立于用户)分得很死。PG 9.0 起就是「ROLE 一统天下」:可以登录的 ROLE 就是「用户」,不能登录的 ROLE 就是「组/角色」。一个 ROLE 既可以是用户,也可以是别人的组,组也能套娃成另一个组。

13.1.2 创建 ROLE 的全套语法

sql
-- 创建一个能登录的「用户」
CREATE ROLE alice LOGIN PASSWORD 'Alice@2025';

-- 等价的语法糖:CREATE USER 自带 LOGIN
CREATE USER bob PASSWORD 'Bob@2025';

-- 创建一个「组角色」(不能登录,只用于挂权限)
CREATE ROLE devs;        -- 等价 NOLOGIN

-- 把 alice、bob 加进 devs 组
GRANT devs TO alice;
GRANT devs TO bob;

-- 移除
REVOKE devs FROM bob;

-- 改密码、改属性
ALTER ROLE alice WITH PASSWORD 'NewPass@2025';
ALTER ROLE alice WITH CONNECTION LIMIT 5;        -- 同时只能开 5 条连接
ALTER ROLE alice VALID UNTIL '2026-01-01';        -- 密码到期时间
ALTER ROLE alice WITH NOLOGIN;                    -- 临时禁用登录

跑一下 \du(psql 元命令):

text
learn_pg=# \du
                        List of roles
 Role name |                         Attributes
-----------+----------------------------------------------------------
 alice     | Cannot login                +                            -- 因为前一句 NOLOGIN
 bob       |
 devs      | Cannot login
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS

13.1.3 ROLE 的「属性」八件套

属性(attribute)是 ROLE 自己的能力,不通过 GRANT 授予,必须在 CREATE/ALTER 里指定:

属性缺省含义生产建议
LOGIN / NOLOGINNOLOGIN能否作为登录身份连接 PG用户 = LOGIN;组 = NOLOGIN
SUPERUSER / NOSUPERUSERNOSUPERUSER是否拥有超级权限(绕过所有检查)业务账号绝对不要给
CREATEDB / NOCREATEDBNOCREATEDB能否 CREATE DATABASE仅 DBA 给
CREATEROLE / NOCREATEROLENOCREATEROLE能否创建/管理其它非 SUPERUSER 角色团队 leader 可酌情给
REPLICATION / NOREPLICATIONNOREPLICATION能否做物理/逻辑复制连接只给复制账号
BYPASSRLS / NOBYPASSRLSNOBYPASSRLS是否绕过行级安全只给做数据修复的临时账号
INHERIT / NOINHERITINHERIT是否自动继承所属组角色的权限默认即可,下面会重点讲
CONNECTION LIMIT n-1 不限同时连接上限应用账号建议设

⚠️ INHERIT 是最容易踩坑的属性。下一节单独细讲。

13.1.4 INHERIT vs SET ROLE:两种「使用组权限」的姿势

PG 的组继承有两种模式:

模式 1:INHERIT(默认)—— 自动继承

sql
CREATE ROLE devs;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO devs;
CREATE ROLE alice LOGIN PASSWORD 'x' INHERIT;   -- 默认就是 INHERIT
GRANT devs TO alice;
-- alice 连上后,立即可以 SELECT,自动拥有 devs 的所有权限

模式 2:NOINHERIT —— 必须 SET ROLE 切身份

sql
CREATE ROLE bob LOGIN PASSWORD 'x' NOINHERIT;
GRANT devs TO bob;
-- bob 连上后默认不带 devs 权限,必须主动切:
SET ROLE devs;       -- 现在以 devs 身份操作
RESET ROLE;          -- 切回 bob

怎么选?

  • INHERIT:日常开发首选,方便。
  • NOINHERIT安全敏感系统用,可以防止「不小心以 devs 身份做了不该做的操作」,类似 Linux 的 sudo:默认是普通用户,要做特权动作必须显式 sudo

13.1.5 一图看懂 ROLE 关系网

text
                ┌─────────────┐
                │  postgres   │  SUPERUSER
                └─────────────┘

        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
   ┌────────┐    ┌────────┐    ┌──────────┐
   │  dba   │    │ app_rw │    │ app_ro   │ ← 都是 NOLOGIN 的「组角色」
   └────────┘    └────────┘    └──────────┘
                     │              │
                ┌────┴────┐         │
                ▼         ▼         ▼
            ┌──────┐ ┌──────┐ ┌──────────┐
            │alice │ │bob   │ │analyst_a │ ← 都是 LOGIN 的「用户角色」
            └──────┘ └──────┘ └──────────┘

13.2 GRANT / REVOKE:权限矩阵

13.2.1 PG 把「权限」拆成两个维度

  • 对象类型:DATABASE / SCHEMA / TABLE / SEQUENCE / FUNCTION / TYPE / LANGUAGE / FOREIGN SERVER / ...
  • 动作(权限名):SELECT / INSERT / UPDATE / DELETE / TRUNCATE / REFERENCES / TRIGGER / USAGE / EXECUTE / CONNECT / CREATE / TEMP

不是所有动作都适用所有对象。下面这张「对象 × 权限」矩阵是 DBA 的内功心法:

对象 \ 权限SELECTINSERTUPDATEDELETETRUNCATEREFERENCESTRIGGERUSAGEEXECUTECONNECTCREATETEMP
DATABASE
SCHEMA
TABLE
SEQUENCE
FUNCTION
TYPE/DOMAIN
LANGUAGE

记住三个高频的「容易忘」点:

  1. 要用 SCHEMA 里的表,必须先 GRANT USAGE ON SCHEMA xx(光给表 SELECT 不够)。
  2. SERIAL 自增列实际是个 SEQUENCE,给写权限时记得 GRANT USAGE ON SEQUENCE,否则 INSERT 会因为 nextval() 拿不到序列报错。
  3. DATABASE 的 CONNECT 权限:默认所有人都能连任意库(PUBLIC),生产建议 REVOKE CONNECT ON DATABASE learn_pg FROM PUBLIC 然后白名单授权。

13.2.2 GRANT 全语法速查

sql
-- 给 alice 单表 SELECT
GRANT SELECT ON TABLE orders TO alice;

-- 一次性给一个 schema 下所有现有表
GRANT SELECT ON ALL TABLES IN SCHEMA public TO alice;

-- 列级权限:仅允许查 id, name 两列
GRANT SELECT (id, name) ON users TO analyst;

-- 同时给读 + 写
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_rw;

-- 给序列(SERIAL/IDENTITY 列依赖序列)
GRANT USAGE, SELECT ON SEQUENCE orders_id_seq TO app_rw;

-- 给函数执行权限
GRANT EXECUTE ON FUNCTION calc_tax(numeric) TO app_rw;

-- 给一组角色(一次给一组人)
GRANT SELECT ON orders TO devs;

-- 带 WITH GRANT OPTION:alice 还能继续把这个权限授予别人
GRANT SELECT ON orders TO alice WITH GRANT OPTION;

-- 收回
REVOKE SELECT ON orders FROM alice;

-- 级联收回(连同 alice 转授给别人的也一起收)
REVOKE SELECT ON orders FROM alice CASCADE;

实操一段 psql:

text
learn_pg=# \z orders
                                         Access privileges
 Schema |  Name  | Type  |        Access privileges        | Column privileges | Policies
--------+--------+-------+---------------------------------+-------------------+----------
 public | orders | table | postgres=arwdDxt/postgres      +|                   |
        |        |       | alice=r/postgres               +|                   |
        |        |       | app_rw=arwd/postgres            |                   |

arwdDxtRX 缩写解读(这是面试题):

字母全称中文
aINSERT
rSELECT (read)
wUPDATE (write)
dDELETE
DTRUNCATE清空
xREFERENCES外键引用
tTRIGGER触发器
XEXECUTE函数执行
UUSAGE使用 schema/序列
CCREATE建对象
TTEMP临时表
cCONNECT连库

grantee=权限/grantor 表示「grantee 拥有这些权限,权限是 grantor 授予的」。空 grantee 表示 PUBLIC。

13.2.3 所有权 vs 权限:所有者隐含「全部权限」

对象的 owner 隐含拥有该对象的所有权限,不需要 GRANT。但要注意:

  • \z 里 owner 自己的权限默认不显示(除非有人 REVOKE 然后重新 GRANT 过)。
  • owner 还有「修改对象结构」的能力(DROP / ALTER),这不属于 GRANT 体系。
  • 想转移所有权:ALTER TABLE orders OWNER TO devs;
  • 想让组成员「集体拥有」一个对象:让对象的 owner 设为组角色(比如 devs),组里的 alice/bob 通过 INHERIT 都能改这张表。

⚠️ 生产坑:很多团队让 alice 直接建表,结果 alice 离职后,这张表只有 alice 和 superuser 能 ALTER,其他同事改不了。正确姿势:让 alice SET ROLE devs; 后再建表,对象 owner 就是 devs,全组都能管。


13.3 默认权限 ALTER DEFAULT PRIVILEGES:给「未来的对象」也授权

13.3.1 痛点

sql
-- DBA 给 ro 组授权了所有现有表的 SELECT
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ro;

-- 一周后,开发新建了一张表
CREATE TABLE new_orders (...);

-- ro 用户想 SELECT new_orders?
SELECT * FROM new_orders;
-- ERROR: permission denied for table new_orders

GRANT ON ALL TABLES 只授权当时存在的表,对将来新建的表无效。一种笨方法是每次建表都手动 GRANT,但显然不优雅。

13.3.2 解法:ALTER DEFAULT PRIVILEGES

sql
-- 含义:以后由 alice 在 public schema 下创建的所有 TABLE,
--       自动给 ro 组 SELECT 权限
ALTER DEFAULT PRIVILEGES
    FOR ROLE alice
    IN SCHEMA public
    GRANT SELECT ON TABLES TO ro;

记住四个 限定范围 关键字:

关键字含义不指定时
FOR ROLE限定「由谁创建」的对象默认是当前用户
IN SCHEMA限定哪个 schema 下默认所有 schema
ON TABLES限定对象类型必须显式指定(TABLES/SEQUENCES/FUNCTIONS/TYPES/SCHEMAS)
TO role授予给谁必填

13.3.3 一份「只读账号 ro」的标准化授权脚本

sql
-- 1. 创建只读组
CREATE ROLE ro NOLOGIN;
CREATE USER analyst PASSWORD 'x' IN ROLE ro;

-- 2. 允许连库
GRANT CONNECT ON DATABASE learn_pg TO ro;

-- 3. 允许使用 schema
GRANT USAGE ON SCHEMA public TO ro;

-- 4. 已有对象立即授权
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ro;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO ro;

-- 5. ★ 未来新建对象自动授权 ★
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON TABLES TO ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON SEQUENCES TO ro;

注意ALTER DEFAULT PRIVILEGES 是「按对象创建者」记录的。如果 alice 和 bob 都建表,得各自跑一遍(或省略 FOR ROLE 让规则属于「当前角色」自己)。最稳妥是 统一让一个组角色(如 app_owner)拥有所有业务表

sql
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public
    GRANT SELECT ON TABLES TO ro;

📌 与 MySQL 的区别:MySQL 没有「未来对象」自动授权机制,新建库/表后必须手动 GRANT。一些 DBA 用脚本巡检 + 自动 GRANT 来弥补,PG 在数据库内核层面就解决了。


13.4 行级安全 RLS(Row Level Security)

RLS 是 PG 9.5 引入的功能,允许在「表里每一行」上挂一个过滤条件。对于多租户 SaaS、按部门隔离、按数据所有者隔离的场景,是 杀手级功能

13.4.1 生活类比:图书馆的「专属阅览室」

想象一个图书馆,所有书都在同一个大书架上(同一张表),但你只有「儿童区」的借书卡(你的 ROLE)。每次你走到书架前,门卫先看你卡上的颜色(current_user),自动只让你看到儿童区的书。其他大人想看儿童区的书也行,但他们看不到「教师专属书架」。这个门卫就是 RLS Policy,不是你写在应用层的 WHERE owner_id = $1,而是数据库自己挂在表上的过滤条件,应用怎么写 SQL 都绕不开

13.4.2 一个完整的多租户 SaaS 例子

假设我们做一个 多公司协作 SaaS,所有公司的工单都在同一张表 ch13_tickets,靠 tenant_id 区分。要求:

  • 每家公司只能看到/改自己的数据;
  • 应用通过 SET LOCAL app.tenant_id = '<id>' 在事务里告诉数据库「我现在代表谁」;
  • 哪怕程序员手抖写了 SELECT * FROM ch13_tickets,也只会返回当前租户的数据。

Step 1:建表(注意 tenant_id 列)

sql
CREATE TABLE ch13_tickets (
    id          BIGSERIAL PRIMARY KEY,
    tenant_id   INT NOT NULL,
    title       TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'open',
    created_at  TIMESTAMPTZ DEFAULT now()
);

Step 2:开启 RLS

sql
ALTER TABLE ch13_tickets ENABLE ROW LEVEL SECURITY;

⚠️ 开启之后 没有任何 policy 的话,普通用户读这张表会一行都看不到(owner 和 superuser 例外)。所以必须紧接着写 policy。

Step 3:写 policy

sql
-- 读策略:只能看到自己 tenant 的行
CREATE POLICY ch13_tenant_iso_select ON ch13_tickets
    FOR SELECT
    USING (tenant_id = current_setting('app.tenant_id')::int);

-- 写策略:只能给自己 tenant 写入
CREATE POLICY ch13_tenant_iso_insert ON ch13_tickets
    FOR INSERT
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

-- 改/删策略:只能改/删自己的
CREATE POLICY ch13_tenant_iso_update ON ch13_tickets
    FOR UPDATE
    USING       (tenant_id = current_setting('app.tenant_id')::int)
    WITH CHECK  (tenant_id = current_setting('app.tenant_id')::int);

CREATE POLICY ch13_tenant_iso_delete ON ch13_tickets
    FOR DELETE
    USING (tenant_id = current_setting('app.tenant_id')::int);

USINGWITH CHECK 的区别(面试常考):

子句时机含义
USING读取 / 更新 / 删除时这一行 是否对当前用户可见
WITH CHECK插入 / 更新后这一行 是否被允许写入

简单记:USING 是「门卫看进来的」,WITH CHECK 是「门卫看出去的」。

Step 4:应用如何使用

python
# 每次 web 请求,进事务后立即设置租户上下文
cur.execute("BEGIN")
cur.execute("SET LOCAL app.tenant_id = %s", (current_tenant_id,))
# 后续所有 SQL,无论怎么写,都只能看到本租户数据
cur.execute("SELECT * FROM ch13_tickets")    # 自动加了 tenant_id = N 的过滤

SET LOCAL 而不是 SET,含义是「只在当前事务内生效」,事务一结束自动失效,避免连接复用时上一个租户的设置漏给下一个请求。

13.4.3 RLS 流程图

13.4.4 RLS 的进阶用法

(1) FOR ALL —— 一条策略覆盖所有动作

sql
CREATE POLICY ch13_tenant_all ON ch13_tickets
    FOR ALL
    USING (tenant_id = current_setting('app.tenant_id')::int)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

(2) TO role —— 仅对特定角色生效

sql
CREATE POLICY ch13_admin_full_access ON ch13_tickets
    FOR ALL
    TO admin           -- 仅对 admin 角色应用此策略
    USING (true)        -- 看所有行
    WITH CHECK (true);

(3) PERMISSIVE vs RESTRICTIVE

  • 默认是 PERMISSIVE多个 policy 之间是 OR 关系,任何一个匹配即放行;
  • RESTRICTIVE和其它 policy 是 AND 关系,所有 RESTRICTIVE 都要满足,常用于「黑名单」。
sql
CREATE POLICY ch13_block_archived ON ch13_tickets
    AS RESTRICTIVE
    FOR ALL
    USING (status <> 'archived');
-- 哪怕之前的 PERMISSIVE 让你看到 archived 行,
-- 这条 RESTRICTIVE 也会把它拦下来

(4) 强制 owner 也走 RLS:FORCE ROW LEVEL SECURITY

默认 owner 和 superuser 都绕过 RLS。如果连 owner 也想被 RLS 限制(防止程序用 owner 账号连):

sql
ALTER TABLE ch13_tickets FORCE ROW LEVEL SECURITY;

(5) BYPASSRLS 角色

sql
ALTER ROLE backup_user BYPASSRLS;
-- backup_user 即使不是 superuser,也能绕过 RLS 看全表(用于 pg_dump)

13.4.5 RLS 的性能开销

RLS 本质是重写 SQL,把 policy 当成 WHERE 条件追加。所以:

  • 一定要在 policy 引用的列上建索引(如 tenant_id);
  • 复杂 policy(带子查询)可能阻碍优化器,必要时用函数 STABLE 标记;
  • 大量 policy 链式叠加会增加 plan 复杂度,单表 policy 别超过 5~6 条。

📌 与 MySQL 的区别:MySQL 没有原生 RLS,多租户场景必须靠应用层 WHERE 条件,或用 VIEW + DEFINER 凑出类似效果,但都不如 PG RLS 干净彻底。一旦应用代码哪天忘了加 WHERE,MySQL 就出数据隔离事故;PG 的 RLS 会在内核层兜底。


13.5 认证与连接:pg_hba.conf 五列法则

13.5.1 它是干嘛的?

pg_hba.conf 全称 Host-Based Authentication,决定「谁、从哪台机器、连哪个库、用什么身份、用什么方式认证」。它是 PG 的「门禁规则表」,比 GRANT 更靠前——你密码再对,pg_hba 不放行也连不进来。

文件位置:PGDATA/pg_hba.conf(容器里通常 /var/lib/postgresql/data/pg_hba.conf),改完要 pg_ctl reload 或在数据库内 SELECT pg_reload_conf(); 才生效。

13.5.2 五列结构

text
# TYPE   DATABASE        USER            ADDRESS            METHOD
local    all             postgres                            peer
host     all             all             127.0.0.1/32        scram-sha-256
host     all             all             ::1/128             scram-sha-256
host     learn_pg        alice           10.0.0.0/8          scram-sha-256
hostssl  all             all             0.0.0.0/0           scram-sha-256
hostnossl all            all             0.0.0.0/0           reject

五列含义

#取值示例含义
1TYPElocal / host / hostssl / hostnossl本地 socket / TCP / 仅 SSL / 仅非 SSL
2DATABASEall / learn_pg / db1,db2 / replication适用哪个库
3USERall / alice / +devs适用哪个用户/组(前缀 + 表示组成员)
4ADDRESS127.0.0.1/32 / 10.0.0.0/8 / samenet来源 IP 段
5METHODtrust / reject / md5 / scram-sha-256 / cert / peer / ident / ldap / pam / ...认证方式

匹配规则(极其重要)

  • 从上往下逐行匹配;
  • 第一条匹配的规则就是最终结果,后面的不再看;
  • 如果 method 是 reject,立刻拒绝;
  • 如果 method 是 trust任何密码都能进(仅本地调试用);
  • 如果一行都不匹配,默认拒绝

13.5.3 常用 METHOD 一览

METHOD安全性适用场景说明
trust⚠️ 极低仅本机 docker 容器内调试不验密码,谁来都让进
reject显式拒绝某些来源立即拒绝
md5老版本兼容MD5 哈希,已有彩虹表破解风险,不推荐
scram-sha-256生产推荐PG 10+ 默认推荐,需要客户端支持
password仅 SSL 内传明文几乎不用,明文密码
cert双向 TLS、零密码客户端必须有受信证书
peer本地 unix socket用 OS 用户名作为 PG 用户名
identTCP 但内网可信通过 ident 协议查对方 OS 用户
ldap大公司统一登录接公司 AD/LDAP
pamLinux PAM 体系集成 OS 认证模块

13.5.4 「为什么我配了密码就是连不上?」常见诊断

text
# 错误信息: psql: error: connection to server ... failed:
#          FATAL: no pg_hba.conf entry for host "1.2.3.4", user "alice"

→ pg_hba 没匹配到,加一行 host 规则。

text
# FATAL: password authentication failed for user "alice"

→ pg_hba 规则匹配了,但密码不对;或者用了 md5 但密码是 scram-sha-256 加密的(升级时常见,重设密码即可)。

text
# FATAL: SSL required

→ pg_hba 配了 hostssl,但客户端用的非 SSL 连接,加 sslmode=require

13.5.5 PG 14+ 的默认推荐:scram-sha-256

bash
# postgresql.conf
password_encryption = scram-sha-256

# pg_hba.conf
host all all 0.0.0.0/0 scram-sha-256

升级注意:老用户的密码哈希仍是 md5,需要让他们重新设置一次密码:

sql
ALTER ROLE alice PASSWORD 'NewSecret@2025';
-- 重设后会用当前 password_encryption 的算法(scram-sha-256)存储

13.6 SSL/TLS:传输加密

13.6.1 服务端启用

ini
# postgresql.conf
ssl = on
ssl_cert_file = '/etc/postgres/server.crt'
ssl_key_file  = '/etc/postgres/server.key'
ssl_ca_file   = '/etc/postgres/root.crt'   -- 可选:双向认证用

证书可以用 OpenSSL 自签(测试),生产用内部 CA 或公网 CA。

13.6.2 客户端 sslmode 四档

sslmode含义中间人风险
disable不用 SSL
allow服务端要求才用
prefer(默认)优先 SSL,不行就降级明文⚠️ 仍可能被降级攻击
require必须 SSL,但不验证证书中间人可伪装
verify-caSSL + 校验证书是 CA 颁发主机名仍可能被伪造
verify-fullSSL + 校验 CA + 校验主机名✅ 最安全
python
# psycopg v3 连接字符串
conn = psycopg.connect(
    "host=db.prod.example.com port=5432 dbname=learn_pg "
    "user=app sslmode=verify-full sslrootcert=/etc/ssl/ca.crt"
)

📌 生产强烈建议 verify-fullrequire 只能挡被动嗅探,挡不住主动 MITM。


13.7 密码策略与口令安全

13.7.1 加密算法

sql
SHOW password_encryption;
-- scram-sha-256

绝对不要用 md5 了。如果你的库还是 md5,迁移步骤:

sql
SET password_encryption = 'scram-sha-256';
ALTER ROLE alice PASSWORD 'newsecret';   -- 触发用新算法重新加密

13.7.2 过期时间

sql
ALTER ROLE alice VALID UNTIL '2026-01-01 00:00:00';
-- 到期后无法登录,必须 DBA 续期或改密码

13.7.3 强密码:扩展 passwordcheck

sql
-- 在 postgresql.conf 加载
shared_preload_libraries = 'passwordcheck'
-- 重启后生效,会拒绝过短/含用户名/全数字的弱密码

13.7.4 连接数限制

sql
ALTER ROLE alice CONNECTION LIMIT 5;     -- 防止某账号占满连接池

13.8 审计:知道谁在做什么

13.8.1 自带的 log 级审计(轻量)

ini
# postgresql.conf
log_statement = 'ddl'              -- 仅 DDL,或 'mod' / 'all'
log_connections = on
log_disconnections = on
log_line_prefix = '%m [%p] %u@%d '  -- 时间、PID、用户、库

13.8.2 重武器:pgaudit 扩展

sql
CREATE EXTENSION pgaudit;
-- 在 postgresql.conf
-- shared_preload_libraries = 'pgaudit'
-- pgaudit.log = 'write,ddl,role'

pgauditlog_statement 强在:

  • 区分 DDL / WRITE / READ / ROLE / FUNCTION 等类别;
  • 能针对单个用户/单张表开启;
  • 支持「对象级审计」:指定 audit role + GRANT 给它要审计的对象。

13.8.3 自建审计:用触发器

参见第 12 章,可以为业务关键表挂 AFTER 触发器把变更写入 audit 表。


13.9 生产最佳实践清单

把前面的内容拼成一个 可落地的安全规范

text
[网络层]
  ├── 数据库不暴露公网,VPC 内访问
  ├── pg_hba 仅放行业务 IP 段
  └── 强制 ssl=on,应用 sslmode=verify-full

[账号层]
  ├── 一应用一账号(app_rw / app_ro)
  ├── 业务账号绝不用 SUPERUSER
  ├── DBA 操作账号独立,开 MFA
  └── 复制账号独立、且 NOSUPERUSER

[权限层]
  ├── 按 schema 拆业务(payment / user / order)
  ├── REVOKE CONNECT FROM PUBLIC,白名单授权
  ├── REVOKE ALL ON SCHEMA public FROM PUBLIC
  ├── 用组角色管权限,用户挂在组下
  └── ALTER DEFAULT PRIVILEGES 兜底未来对象

[数据层]
  ├── 多租户开 RLS,FORCE ROW LEVEL SECURITY
  ├── 敏感列考虑列级 GRANT 或 pgcrypto 加密
  └── 大表 owner 设为组角色,避免离职断层

[认证层]
  ├── password_encryption = scram-sha-256
  ├── 高敏感账号 VALID UNTIL 定期到期
  └── 加载 passwordcheck,禁弱密码

[审计层]
  ├── log_connections / log_disconnections = on
  ├── log_statement = 'ddl' (生产少 'all',量大)
  └── 关键表用 pgaudit 或触发器写 audit log

13.10 与 MySQL 的对比速查

主题MySQLPostgreSQL
用户与组用户 = 用户名+主机;角色 8.0+ 才支持,独立体系ROLE 一统,用户和组都是 ROLE
密码哈希caching_sha2_password(8.0 默认)scram-sha-256(PG 14+ 推荐)
主机白名单'user'@'host' 写在用户身份里集中在 pg_hba.conf
列级权限支持支持,且更细:SELECT (col)
行级安全无原生,靠 VIEW/应用层RLS Policy 内核级
默认权限无「未来对象自动授权」ALTER DEFAULT PRIVILEGES
SSL类似 sslmode,叫 --ssl-mode=VERIFY_IDENTITYsslmode=verify-full
审计MySQL Enterprise Audit / 第三方 pluginpgaudit 扩展
角色继承SET ROLE 切换INHERIT 自动 / NOINHERIT 显式 SET ROLE

13.11 小结

  • ROLE = 用户 ∪ 组,用 LOGIN 区分两者;属性中 SUPERUSER / CREATEDB / BYPASSRLS / INHERIT 必须熟记。
  • GRANT 是「对象 × 动作」二维矩阵,列级权限和 SCHEMA USAGE 是新手最容易漏的。
  • ALTER DEFAULT PRIVILEGES 解决「未来对象」自动授权,是只读账号方案的关键。
  • RLS 是 PG 多租户系统的杀手锏,USING 控读,WITH CHECK 控写,FORCE ROW LEVEL SECURITY 让 owner 也守规矩。
  • pg_hba.conf 五列法则、自上而下匹配、生产用 scram-sha-256
  • sslmode=verify-full + 内部 CA 是传输加密的标配。
  • 审计:先开 log_* 三件套,关键表上 pgaudit


🎮 配套演示

用浏览器打开 ./13_security/demo.html,跟着可视化动画再走一遍本章核心概念。

配套代码在 ./13_security/code/,每个脚本都可以独立 python xxx.py 运行,先跑 init.sql 准备数据。


13.12 面试高频题

Q1. PG 的 ROLE / USER / GROUP 是什么关系?(⭐⭐)

考察点:基础概念,PG 9.0+ 的「角色统一」设计。

答案

PostgreSQL 9.0 之后,用户和组在内核里被统一成 ROLE。一个 ROLE 既可以是「能登录的用户」,也可以是「不能登录的组」,区分点是 LOGIN 属性

  • CREATE ROLE alice LOGIN PASSWORD 'x'; 等价于「用户」;
  • CREATE ROLE devs;(不带 LOGIN,默认 NOLOGIN)等价于「组」;
  • CREATE USER 只是 CREATE ROLE ... LOGIN 的语法糖,背后存的都是 pg_authid 这一张表。

这个统一设计带来 三大好处

  1. 嵌套自由:组可以再属于另一个组(GRANT devs TO seniors;),形成多级权限继承;
  2. 「以组身份建对象」:用户先 SET ROLE devs; 再建表,对象 owner 就是 devs,全组都能管,离职无影响;
  3. INHERIT 控制权限继承方式:默认自动继承所属组权限;改成 NOINHERIT 后,必须 SET ROLE 显式切换,相当于内置的 sudo,更安全。

与 MySQL 对比:MySQL 5.x 完全没有角色概念,直到 8.0 才加上,且角色和用户是两套体系(角色不能登录、用户独立存在),不像 PG 这样优雅统一。

易错点:以为 CREATE GROUP 还有用——这是 PG 9.0 之前的语法,已经废弃但语法上仍兼容,等价于 CREATE ROLE ... NOLOGIN,不要在新代码中使用。


Q2. pg_hba.conf 里 trust / md5 / scram-sha-256 / cert 的区别?(⭐⭐⭐)

考察点:认证方式、安全推荐、生产规范。

答案

pg_hba.conf 的 METHOD 列决定客户端连接时如何被认证:

方式工作原理安全性适用
trust不认证,匹配到的连接直接放行⚠️ 极低仅 docker 容器内或单机调试
md5客户端用 MD5 哈希密码(带盐 = 用户名)老版本兼容;MD5 已有破解工具
scram-sha-256SCRAM 协议,挑战-应答 + SHA256,密码不在网络上传输PG 10+ 生产推荐
cert双向 TLS:客户端必须出示由服务端信任的 CA 颁发的证书,无密码零密码方案、机器对机器

为什么 PG 14+ 推荐 scram-sha-256

  1. SCRAM 是 IETF RFC 5802 标准协议,不依赖单向哈希;
  2. 即使中间人截获连接全过程,也无法重放或还原密码;
  3. 服务端只存 SCRAM 验证元数据(盐 + 迭代次数 + StoredKey),DBA 也看不到原密码;
  4. 自带 channel binding(与 SSL 通道绑定),防降级。

升级踩坑password_encryption = scram-sha-256 改完后,老用户的密码哈希仍是 md5,必须 ALTER ROLE alice PASSWORD 'newpwd'; 重设一次,新哈希才会以 SCRAM 方式存储;否则把 pg_hba 改成 scram 反而会让老用户登不上。

生产组合建议hostssl all all 0.0.0.0/0 scram-sha-256 + password_encryption = scram-sha-256 + passwordcheck 扩展强制弱密码拦截。


Q3. 行级安全 RLS 怎么用?多租户场景怎么落地?(⭐⭐⭐⭐)

考察点:RLS 全套语法、应用集成、性能与坑。

答案

三步走

  1. 建表时含租户字段,并 ALTER TABLE ch13_tickets ENABLE ROW LEVEL SECURITY;
  2. 创建 POLICY,用 current_setting() 读应用注入的租户 ID:
sql
CREATE POLICY ch13_tenant_iso ON ch13_tickets
    FOR ALL
    USING       (tenant_id = current_setting('app.tenant_id')::int)
    WITH CHECK  (tenant_id = current_setting('app.tenant_id')::int);
  1. 应用每次进事务后注入
python
cur.execute("BEGIN")
cur.execute("SET LOCAL app.tenant_id = %s", (tenant_id,))
# 后续所有 SQL 都自动带上 tenant_id 过滤

关键细节

  • USING vs WITH CHECK:USING 决定能否「看到」一行(用于 SELECT/UPDATE/DELETE 时的可见性判断);WITH CHECK 决定能否「写入」一行(INSERT/UPDATE 后新值必须满足)。常见做法是两者都写同一条件,防止把数据「改成」另一个租户。
  • owner 默认绕过 RLS!如果应用直接用表的 owner 账号连,RLS 形同虚设。生产必须 ALTER TABLE ch13_tickets FORCE ROW LEVEL SECURITY; 让 owner 也受约束,并让应用用单独的 app_rw 账号。
  • PERMISSIVE vs RESTRICTIVE:多 PERMISSIVE 之间是 OR;RESTRICTIVE 与所有 policy 是 AND,常用于黑名单(如「不许看 archived 状态」)。
  • 性能:policy 中引用的列必须建索引(如 tenant_id),否则每次都全表扫描;policy 表达式应尽量简单,复杂逻辑用 STABLE 函数包装让优化器复用。
  • SET LOCAL 而非 SET:连接复用场景下,SET LOCAL 在 COMMIT/ROLLBACK 时自动失效,避免「下一个请求继承上个租户身份」的严重事故。
  • BYPASSRLS 角色:备份脚本(pg_dump)需要看全表,给备份账号 ALTER ROLE backup BYPASSRLS;,比授超管安全。

与 MySQL 对比:MySQL 没有原生 RLS,多租户隔离要么靠应用层 WHERE(漏一次就出事),要么用 VIEW + 不暴露底表,但维护成本高得多。PG RLS 是数据库层兜底,写错 SQL 也漏不出去,是多租户 SaaS 的事实标准。


Q4. 权限粒度可以细到什么程度?列级权限怎么用?(⭐⭐⭐)

考察点:列级 GRANT、合规场景、与 RLS 的搭配。

答案

PG 的权限粒度可以细到列级

sql
-- 给 analyst 角色:仅可查 users 表的 id, name 列;不能查 phone, id_card
GRANT SELECT (id, name) ON users TO analyst;

-- 也可以分开授不同列的不同动作
GRANT UPDATE (status) ON orders TO ops;   -- 只能改 status,不能改金额

典型应用场景

  1. 隐私合规:手机号、身份证等敏感列,给分析师只授非敏感列的 SELECT;
  2. 状态机控制:运营只能改订单状态,不能改金额;
  3. 审计配合:业务列开 SELECT,审计列(created_by/created_at)只允许 INSERT 不允许 UPDATE。

底层注意

  • 没有显式列权限时,等价于「整张表的权限都没有」;
  • 只查未授权的列会报 permission denied for column phone
  • SELECT * 会被拦截,因为 * 包含未授权列;建议应用代码显式列名;
  • 列权限和 RLS正交叠加:RLS 决定能看到哪些行,列权限决定每行能看到哪些列。两者结合可以实现「分析师只看自家公司的数据,且看不到手机号」。

进阶:对于真正敏感的字段(比如银行卡号),列权限只能限制 GRANT 体系,不能防 superuser 与 owner。要彻底防泄露应叠加 pgcrypto 加密存储(pgp_sym_encrypt),密钥放 KMS 或 HSM,应用解密时再读。

与 MySQL 对比:MySQL 也支持列级权限(GRANT SELECT (col) ON tbl),但实际生产中用得少;PG 因为有 RLS 配合,列级权限的实用性更高。


Q5. 怎么让一个只读账号「真·只读」?避开 SECURITY DEFINER 函数风险。(⭐⭐⭐)

考察点:默认权限、PUBLIC 收权、SECURITY DEFINER 陷阱。

答案

新手做的「只读账号」往往不真只读,常见漏洞包括:

  1. PUBLIC 默认权限没收回,新建表被新账号读到;
  2. SECURITY DEFINER 函数让只读账号借壳改写数据;
  3. 未来新建对象 没自动 GRANT,但旧表能改也是只读账号未必只读。

完整脚本

sql
-- ① 先把 public schema 的默认权限收紧
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM PUBLIC;
REVOKE ALL ON DATABASE learn_pg FROM PUBLIC;

-- ② 建只读组与用户
CREATE ROLE ro NOLOGIN;
CREATE USER analyst PASSWORD 'x' IN ROLE ro;

-- ③ 精确授权
GRANT CONNECT ON DATABASE learn_pg TO ro;
GRANT USAGE ON SCHEMA public TO ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ro;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO ro;

-- ④ 兜底:未来对象也自动只读
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON TABLES TO ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON SEQUENCES TO ro;

SECURITY DEFINER 风险

PG 函数有两种执行身份:

  • SECURITY INVOKER(默认):以调用者身份跑,权限受调用者限制;
  • SECURITY DEFINER:以函数所有者身份跑,类似 setuid

如果一个 owner 是超级用户的函数被 GRANT EXECUTE 给 ro,那么 ro 调用它就能借壳干超级用户的事。防御

sql
-- 收回 PUBLIC 对所有 SECURITY DEFINER 函数的执行权限
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC;
-- 函数定义里设置 search_path,避免被 schema 劫持
ALTER FUNCTION risky() SET search_path = pg_catalog, public;

最后用一个 验证步骤 自测:以 ro 身份连上后,跑这些应该全 ERROR:

sql
INSERT INTO users VALUES (...);   -- ✗ permission denied
UPDATE orders SET status='x';      -- ✗ permission denied
CREATE TABLE foo(id int);          -- ✗ permission denied for schema public

Q6. 如何审计 PG 的 DDL / DML 操作?(⭐⭐⭐)

考察点:自带日志、pgaudit 扩展、event trigger 自建审计。

答案

PG 提供 三档审计,按需选型:

(1) 内置 log(轻量、零依赖)

ini
log_connections = on
log_disconnections = on
log_statement = 'ddl'   -- 仅 DDL;可选 'mod'(变更)/ 'all'
log_line_prefix = '%m [%p] %u@%d app=%a '

优点:无需扩展、性能影响小。缺点:粒度粗,「all」会把日志写爆,且日志在 OS 文件里,离线分析麻烦。

(2) pgaudit 扩展(推荐生产用)

ini
shared_preload_libraries = 'pgaudit'
pgaudit.log = 'write,ddl,role'
pgaudit.log_relation = on

特点:

  • 类别精细:READ / WRITE / DDL / ROLE / FUNCTION / MISC;
  • 支持 对象级审计pgaudit.role + 给该 role GRANT 要审计的对象);
  • 输出到 PG 日志,可对接 ELK/Loki。

(3) Event Trigger + 自建审计表(DDL 审计专用)

sql
CREATE TABLE ddl_audit (
    occurred_at TIMESTAMPTZ DEFAULT now(),
    username    TEXT, command_tag TEXT, object_id   TEXT, query TEXT
);

CREATE OR REPLACE FUNCTION audit_ddl() RETURNS event_trigger AS $$
DECLARE r record;
BEGIN
  FOR r IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP
    INSERT INTO ddl_audit(username, command_tag, object_id, query)
    VALUES (current_user, r.command_tag, r.object_identity, current_query());
  END LOOP;
END $$ LANGUAGE plpgsql;

CREATE EVENT TRIGGER trg_ddl_audit ON ddl_command_end
    EXECUTE FUNCTION audit_ddl();

之后任何 DDL(CREATE/ALTER/DROP)都自动落库,可以查询,可以告警。

(4) 表级 DML 审计:用第 12 章的触发器,AFTER INSERT/UPDATE/DELETE 写入审计表,记录 OLD / NEW、操作人、IP。

对比建议

  • 合规场景(金融/医疗):pgaudit + 关键表触发器双保险;
  • 普通业务:内置 log + Event Trigger 即可;
  • 不要把所有 DML 都写审计表,否则写入翻倍。

Q7. PUBLIC 默认权限有哪些坑?为什么生产要先「裸奔收权」?(⭐⭐⭐)

考察点:PUBLIC 角色、默认权限收紧、安全基线。

答案

PUBLIC 是 PG 内置的「所有角色的隐式父组」,相当于「任何人」。新装的 PG 默认给了 PUBLIC 这些权限:

对象PUBLIC 默认权限风险
所有 DATABASECONNECT, TEMP任何能登录的账号都能连任意库、建临时表
pg_catalog schemaUSAGE系统目录可读(必要)
public schema(PG 14 之前)CREATE, USAGE任何账号都能在 public 下建表,污染严重
函数EXECUTE(非 SECURITY DEFINER)调用 SECURITY DEFINER 函数潜在越权

🎉 PG 15 起,public schema 的 CREATE 权限默认 REVOKE 了,但很多老库升级上来仍是松的,必须人工收权。

生产「安全基线」三连

sql
-- 1. 任何账号默认不能连这个库
REVOKE CONNECT ON DATABASE learn_pg FROM PUBLIC;

-- 2. 任何账号默认不能在 public schema 建对象
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

-- 3. 任何账号默认不能调用所有函数
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;

收权完之后,再按业务白名单逐个 GRANT 给 app_rwapp_ro 等角色。

易错点

  • 容易忘的是 pg_catalog 的 USAGE 不能收,否则连 \d 都跑不了;
  • pg_dump 时如果某些对象本身被 PUBLIC GRANT 了,导出会带这些 GRANT,恢复到新库时把不严格的权限带进去——所以 dump 后要校对 ACL;
  • 改 PUBLIC 权限后,所有现有账号立即受影响,要在维护窗口操作。

🔗 延伸阅读


本章完。下一章 14_backup_recovery.md:备份与恢复,物理 vs 逻辑、PITR 时光机、生产备份策略。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

python
"""
第 13 章 · 演示 1:ROLE 创建、组继承、GRANT/REVOKE 全流程
=========================================================

依赖:
    pip install "psycopg[binary]>=3.1"

前置:
    数据库 learn_pg 已存在,且当前用户是 superuser(postgres)。
    建议先 psql -f ../init.sql 跑过初始化,本脚本不依赖 init.sql 中的 ch13_tickets 表,
    会创建/销毁自己专属的演示对象。

运行:
    python 01_role_grant.py
"""

from __future__ import annotations

import psycopg

DSN = "host=127.0.0.1 port=5432 dbname=learn_pg user=postgres"


def line(title: str) -> None:
    print("\n" + "=" * 60)
    print(title)
    print("=" * 60)


def show_roles(conn: psycopg.Connection, names: list[str]) -> None:
    """打印若干 ROLE 的属性。"""
    sql = """
        SELECT rolname,
               rolsuper, rolcreatedb, rolcreaterole,
               rolcanlogin, rolinherit, rolbypassrls,
               rolconnlimit, rolvaliduntil
        FROM pg_roles
        WHERE rolname = ANY(%s)
        ORDER BY rolname;
    """
    with conn.cursor() as cur:
        cur.execute(sql, (names,))
        rows = cur.fetchall()
    for r in rows:
        print(f"  {r[0]:<10} super={r[1]} createdb={r[2]} createrole={r[3]} "
              f"login={r[4]} inherit={r[5]} bypassrls={r[6]} conn_limit={r[7]}")


def cleanup(conn: psycopg.Connection) -> None:
    """删掉演示用的对象(幂等)。"""
    with conn.cursor() as cur:
        cur.execute("DROP TABLE IF EXISTS demo_payroll CASCADE;")
        for role in ("hr_alice", "hr_bob", "engineer_carol", "hr_team"):
            cur.execute(f"DROP OWNED BY {role} CASCADE;") if role_exists(cur, role) else None
            cur.execute(f"DROP ROLE IF EXISTS {role};")
    conn.commit()


def role_exists(cur: psycopg.Cursor, name: str) -> bool:
    cur.execute("SELECT 1 FROM pg_roles WHERE rolname = %s", (name,))
    return cur.fetchone() is not None


def step1_create_roles(conn: psycopg.Connection) -> None:
    line("Step 1:创建组角色 + 用户角色")
    with conn.cursor() as cur:
        cur.execute("CREATE ROLE hr_team NOLOGIN;")                              # 组
        cur.execute("CREATE ROLE hr_alice LOGIN PASSWORD 'Hr_Alice@2025' INHERIT;")
        cur.execute("CREATE ROLE hr_bob   LOGIN PASSWORD 'Hr_Bob@2025'   NOINHERIT;")
        cur.execute("CREATE ROLE engineer_carol LOGIN PASSWORD 'Eng_Carol@2025';")
        cur.execute("GRANT hr_team TO hr_alice, hr_bob;")
    conn.commit()
    show_roles(conn, ["hr_alice", "hr_bob", "engineer_carol", "hr_team"])


def step2_create_table_and_grant(conn: psycopg.Connection) -> None:
    line("Step 2:创建一张 demo_payroll 表,把 SELECT/UPDATE 授权给 hr_team")
    with conn.cursor() as cur:
        cur.execute("""
            CREATE TABLE demo_payroll(
                id SERIAL PRIMARY KEY,
                emp_name TEXT,
                salary  NUMERIC(10,2)
            );
        """)
        cur.execute("INSERT INTO demo_payroll(emp_name, salary) VALUES "
                    "('Alice', 18000), ('Bob', 22000), ('Carol', 35000);")
        cur.execute("GRANT SELECT, UPDATE ON demo_payroll TO hr_team;")
        # SERIAL 列依赖 sequence
        cur.execute("GRANT USAGE, SELECT, UPDATE ON SEQUENCE demo_payroll_id_seq TO hr_team;")
    conn.commit()
    print("  表已建好,权限授给 hr_team 组。")


def step3_inherit_vs_noinherit() -> None:
    line("Step 3:演示 INHERIT vs NOINHERIT 的差别")
    # hr_alice INHERIT,连上来直接能 SELECT
    with psycopg.connect("host=127.0.0.1 port=5432 dbname=learn_pg "
                         "user=hr_alice password=Hr_Alice@2025") as alice:
        with alice.cursor() as cur:
            cur.execute("SELECT count(*) FROM demo_payroll;")
            print(f"  hr_alice (INHERIT) SELECT 成功,行数 = {cur.fetchone()[0]}")

    # hr_bob NOINHERIT,连上来默认不带 hr_team 权限,必须 SET ROLE
    with psycopg.connect("host=127.0.0.1 port=5432 dbname=learn_pg "
                         "user=hr_bob password=Hr_Bob@2025") as bob:
        with bob.cursor() as cur:
            try:
                cur.execute("SELECT count(*) FROM demo_payroll;")
                print(f"  hr_bob (NOINHERIT) 直接 SELECT = {cur.fetchone()[0]}(不应该看到这行)")
            except psycopg.errors.InsufficientPrivilege as e:
                print(f"  hr_bob (NOINHERIT) 直接 SELECT → 被拒绝:{type(e).__name__}")
            bob.rollback()
            cur.execute("SET ROLE hr_team;")
            cur.execute("SELECT count(*) FROM demo_payroll;")
            print(f"  hr_bob 在 SET ROLE hr_team 后 SELECT 成功,行数 = {cur.fetchone()[0]}")


def step4_engineer_denied() -> None:
    line("Step 4:未授权账号 engineer_carol 应该被拒绝")
    with psycopg.connect("host=127.0.0.1 port=5432 dbname=learn_pg "
                         "user=engineer_carol password=Eng_Carol@2025") as eng:
        with eng.cursor() as cur:
            try:
                cur.execute("SELECT count(*) FROM demo_payroll;")
            except psycopg.errors.InsufficientPrivilege:
                print("  engineer_carol SELECT demo_payroll → 被拒绝 ✅")


def step5_show_acl(conn: psycopg.Connection) -> None:
    line("Step 5:查看 ACL(access privileges)")
    with conn.cursor() as cur:
        cur.execute("""
            SELECT relname, relacl
            FROM pg_class
            WHERE relname = 'demo_payroll';
        """)
        row = cur.fetchone()
        print(f"  表 {row[0]} 的 ACL = {row[1]}")
        print("  解读:每条形如 'grantee=权限/grantor',权限缩写见文档 13.2.2 节")


def main() -> None:
    with psycopg.connect(DSN) as conn:
        cleanup(conn)
        try:
            step1_create_roles(conn)
            step2_create_table_and_grant(conn)
            step3_inherit_vs_noinherit()
            step4_engineer_denied()
            step5_show_acl(conn)
        finally:
            line("收尾:清理演示对象")
            cleanup(conn)
            print("  已清理。")


if __name__ == "__main__":
    main()
python
"""
第 13 章 · 演示 2:ALTER DEFAULT PRIVILEGES
============================================

场景:DBA 给 ro 角色授权了「未来所有由 dba_user 创建的表」的 SELECT;
      然后 dba_user 新建一张表,验证 ro 不需要再 GRANT 就能读。

依赖:
    pip install "psycopg[binary]>=3.1"
    需先 psql -f ../init.sql 跑过初始化(init.sql 已设置好 dba_user / analyst
    与默认权限规则)。

运行:
    python 02_default_privileges.py
"""

from __future__ import annotations

import psycopg

ADMIN_DSN = "host=127.0.0.1 port=5432 dbname=learn_pg user=postgres"
DBA_DSN   = "host=127.0.0.1 port=5432 dbname=learn_pg user=dba_user  password=Dba@2025"
RO_DSN    = "host=127.0.0.1 port=5432 dbname=learn_pg user=analyst   password=Analyst@2025"


def line(t: str) -> None:
    print("\n" + "=" * 60 + "\n" + t + "\n" + "=" * 60)


def show_default_privs() -> None:
    """展示当前 ALTER DEFAULT PRIVILEGES 规则。"""
    sql = """
        SELECT pg_get_userbyid(defaclrole)        AS for_role,
               nspname,
               defaclobjtype,                      -- r=table, S=sequence, f=function...
               defaclacl
          FROM pg_default_acl d
          JOIN pg_namespace n ON n.oid = d.defaclnamespace
         ORDER BY for_role, nspname;
    """
    with psycopg.connect(ADMIN_DSN) as c, c.cursor() as cur:
        cur.execute(sql)
        rows = cur.fetchall()
    print("  当前 pg_default_acl 中的规则:")
    for r in rows:
        kind = {'r': '表', 'S': '序列', 'f': '函数', 'T': '类型'}.get(r[2], r[2])
        print(f"    FOR ROLE {r[0]:<10} IN SCHEMA {r[1]:<10} ON {kind}: {r[3]}")


def main() -> None:
    line("Step 1:查看当前默认权限规则(init.sql 已设置)")
    show_default_privs()

    # ★ 关键:以 dba_user 身份新建一张表
    line("Step 2:dba_user 在 public 下新建一张表 future_table")
    with psycopg.connect(DBA_DSN) as dba, dba.cursor() as cur:
        cur.execute("DROP TABLE IF EXISTS future_table;")
        cur.execute("""
            CREATE TABLE future_table(
                id   BIGSERIAL PRIMARY KEY,
                memo TEXT
            );
        """)
        cur.execute("INSERT INTO future_table(memo) VALUES ('hello'), ('world');")
        dba.commit()
        # 立刻看一下这张表的 ACL,应该已经包含 analyst=r/dba_user
        cur.execute("""
            SELECT relname, relacl
              FROM pg_class
             WHERE relname = 'future_table';
        """)
        row = cur.fetchone()
        print(f"  新表 {row[0]} 的 ACL = {row[1]}")

    # 用 analyst 连过去,应该可以直接 SELECT,不需要再 GRANT
    line("Step 3:analyst 直接 SELECT future_table(验证默认权限生效)")
    with psycopg.connect(RO_DSN) as ro, ro.cursor() as cur:
        try:
            cur.execute("SELECT count(*) FROM future_table;")
            print(f"  ✅ analyst SELECT 成功,行数 = {cur.fetchone()[0]}")
        except psycopg.errors.InsufficientPrivilege as e:
            print(f"  ❌ analyst SELECT 失败:{e}")

    # 进一步:尝试 INSERT,应失败(默认权限只 GRANT SELECT)
    line("Step 4:analyst 尝试 INSERT,应被拒绝")
    with psycopg.connect(RO_DSN) as ro, ro.cursor() as cur:
        try:
            cur.execute("INSERT INTO future_table(memo) VALUES ('hack');")
            print("  ❌ analyst INSERT 居然成功了,规则有问题!")
        except psycopg.errors.InsufficientPrivilege:
            print("  ✅ analyst INSERT 被拒绝(默认权限只授了 SELECT)")
        ro.rollback()

    # 收尾
    line("收尾:删除 future_table")
    with psycopg.connect(DBA_DSN) as dba, dba.cursor() as cur:
        cur.execute("DROP TABLE IF EXISTS future_table;")
        dba.commit()
        print("  已清理。")


if __name__ == "__main__":
    main()
python
"""
第 13 章 · 演示 3:行级安全 RLS · 多租户隔离
==============================================

场景:ch13_tickets 表有 tenant_id 字段,开启 RLS。
      用 tenant_a / tenant_b 两个账号分别连进去,验证:
      1) 各自只看到自己 tenant 的数据;
      2) 不能跨 tenant 写入;
      3) RESTRICTIVE 策略屏蔽 archived;
      4) dba_user (BYPASSRLS) 看到全部。

依赖:
    pip install "psycopg[binary]>=3.1"
    必须先 psql -f ../init.sql 跑过初始化。

运行:
    python 03_rls_multi_tenant.py
"""

from __future__ import annotations

import psycopg

DSN_BASE = "host=127.0.0.1 port=5432 dbname=learn_pg "

USERS = {
    "tenant_a": ("TenantA@2025", 1),    # (密码, 租户号)
    "tenant_b": ("TenantB@2025", 2),
    "dba_user": ("Dba@2025",     None), # BYPASSRLS,无需 setting
}


def line(t: str) -> None:
    print("\n" + "=" * 60 + "\n" + t + "\n" + "=" * 60)


def query_as(user: str, password: str, tenant: int | None) -> None:
    """以指定用户身份查询 ch13_tickets,可选注入 app.tenant_id。"""
    dsn = f"{DSN_BASE}user={user} password={password}"
    print(f"\n--- 以 {user} 身份连接 (tenant={tenant}) ---")
    with psycopg.connect(dsn) as conn, conn.cursor() as cur:
        cur.execute("BEGIN")
        if tenant is not None:
            cur.execute("SET LOCAL app.tenant_id = %s", (str(tenant),))
        cur.execute("SELECT id, tenant_id, title, status FROM ch13_tickets ORDER BY id;")
        rows = cur.fetchall()
        for r in rows:
            print(f"    id={r[0]:<3} tenant={r[1]} status={r[2]:<8} title={r[3]}")
        if not rows:
            print("    (空)")
        conn.rollback()


def try_cross_tenant_insert() -> None:
    """tenant_a 试图写入 tenant_id=2 的行,应被 WITH CHECK 拦截。"""
    line("Step 2:tenant_a 试图越权插入 tenant_id=2 的行")
    dsn = f"{DSN_BASE}user=tenant_a password=TenantA@2025"
    with psycopg.connect(dsn) as conn, conn.cursor() as cur:
        cur.execute("BEGIN")
        cur.execute("SET LOCAL app.tenant_id = '1'")   # 自报身份是租户1
        try:
            cur.execute(
                "INSERT INTO ch13_tickets(tenant_id, title) VALUES (2, '我是 A 试图伪装成 B')"
            )
            print("  ❌ 写入成功了,RLS 失效!请检查 init.sql。")
        except psycopg.errors.InsufficientPrivilege as e:
            # 注:触发 WITH CHECK 失败时报的是 InsufficientPrivilege 或 CheckViolation
            print(f"  ✅ 被 WITH CHECK 拦截:{type(e).__name__}: {str(e).splitlines()[0]}")
        except Exception as e:
            print(f"  ✅ 被拦截:{type(e).__name__}: {str(e).splitlines()[0]}")
        conn.rollback()


def show_archived_filter() -> None:
    """RESTRICTIVE 策略:ch13_no_archived 应该让 archived 行隐身(即使 owner 也不行)。"""
    line("Step 3:验证 RESTRICTIVE 策略 ch13_no_archived(archived 行不可见)")
    dsn = f"{DSN_BASE}user=tenant_a password=TenantA@2025"
    with psycopg.connect(dsn) as conn, conn.cursor() as cur:
        cur.execute("BEGIN")
        cur.execute("SET LOCAL app.tenant_id = '1'")
        cur.execute("SELECT status, count(*) FROM ch13_tickets GROUP BY status ORDER BY status;")
        for r in cur.fetchall():
            print(f"    status={r[0]:<8} count={r[1]}")
        print("  → 应该看不到 archived 状态(init.sql 写入时确实有一行 archived)")
        conn.rollback()


def superuser_view() -> None:
    line("Step 4:用 superuser (postgres) 连接,确认数据库里实际数据全貌")
    dsn = f"{DSN_BASE}user=postgres"
    with psycopg.connect(dsn) as conn, conn.cursor() as cur:
        # 由于 init.sql 用了 FORCE ROW LEVEL SECURITY,但 superuser 默认 BYPASSRLS
        cur.execute("SELECT tenant_id, status, count(*) FROM ch13_tickets "
                    "GROUP BY tenant_id, status ORDER BY tenant_id, status;")
        for r in cur.fetchall():
            print(f"    tenant={r[0]} status={r[1]:<8} count={r[2]}")


def main() -> None:
    line("Step 1:以两个租户身份分别 SELECT,应该只看到自己的行")
    query_as("tenant_a", "TenantA@2025", 1)
    query_as("tenant_b", "TenantB@2025", 2)

    try_cross_tenant_insert()
    show_archived_filter()
    superuser_view()

    line("Step 5:dba_user (BYPASSRLS) 不需要设置 app.tenant_id 也能看全表")
    query_as("dba_user", "Dba@2025", None)


if __name__ == "__main__":
    main()
python
"""
第 13 章 · 演示 4:pg_hba.conf 各 METHOD 差异(说明 + 自检)
==============================================================

本脚本不会修改 pg_hba.conf(那需要 root + reload 服务),而是:
  1) 解析当前数据库的 pg_hba_file_rules 视图,把规则可读化打印;
  2) 解析 pg_settings 中关键参数(password_encryption / ssl 等);
  3) 对若干 METHOD 给出实操建议与示范的 pg_hba 行;
  4) 简单实验:尝试用 sslmode=disable / require / verify-ca 连接,看哪些被拒。

依赖:
    pip install "psycopg[binary]>=3.1"

运行:
    python 04_pg_hba_demo.py
"""

from __future__ import annotations

import psycopg

DSN = "host=127.0.0.1 port=5432 dbname=learn_pg user=postgres"

EXAMPLES = [
    ("trust",
     "local   all     postgres                       trust",
     "本地 socket 不验密码,仅适合 docker 容器内、CI 临时调试。生产严禁。"),
    ("md5",
     "host    all     all      0.0.0.0/0             md5",
     "MD5 哈希存储,PG 14+ 不再推荐;存在彩虹表/碰撞风险。"),
    ("scram-sha-256",
     "host    all     all      0.0.0.0/0             scram-sha-256",
     "PG 14+ 默认推荐,挑战-应答协议,密码不上网。"),
    ("cert",
     "hostssl all     all      0.0.0.0/0             cert clientcert=verify-full",
     "客户端必须出示受信 CA 颁发的证书,可零密码登录,机器对机器最安全。"),
    ("peer",
     "local   all     all                            peer map=devmap",
     "用 OS 用户名作为 PG 用户名,仅本地 unix socket 有效。"),
    ("ident",
     "host    all     all      10.0.0.0/8            ident map=devmap",
     "通过 ident 协议询问对方 OS 用户,需要客户端机器跑 identd,几乎已废弃。"),
    ("ldap",
     "host    all     all      0.0.0.0/0             ldap ldapserver=ldap.corp ldapprefix=\"cn=\" ldapsuffix=\",ou=People,dc=corp\"",
     "对接公司 AD/LDAP,统一身份。"),
    ("reject",
     "host    all     all      192.168.99.0/24       reject",
     "显式拒绝,匹配后立即 ERROR,不再向下匹配。"),
]


def line(t: str) -> None:
    print("\n" + "=" * 60 + "\n" + t + "\n" + "=" * 60)


def show_current_rules(conn: psycopg.Connection) -> None:
    """从 pg_hba_file_rules 视图读取当前生效的规则。"""
    sql = """
        SELECT line_number, type, database, user_name,
               address, netmask, auth_method, options, error
          FROM pg_hba_file_rules
         ORDER BY line_number;
    """
    with conn.cursor() as cur:
        cur.execute(sql)
        rows = cur.fetchall()

    print(f"  pg_hba.conf 当前共 {len(rows)} 条规则(按文件顺序匹配):")
    print(f"  {'#':<4}{'TYPE':<8}{'DB':<10}{'USER':<10}{'ADDR':<22}{'METHOD':<18}ERROR")
    for r in rows:
        addr = r[4] or '-'
        print(f"  {r[0]:<4}{r[1]:<8}{','.join(r[2]):<10}{','.join(r[3]):<10}"
              f"{addr:<22}{r[6]:<18}{r[8] or ''}")


def show_security_settings(conn: psycopg.Connection) -> None:
    keys = ['password_encryption', 'ssl', 'ssl_cert_file',
            'ssl_key_file', 'authentication_timeout',
            'log_connections', 'log_disconnections']
    sql = "SELECT name, setting FROM pg_settings WHERE name = ANY(%s)"
    with conn.cursor() as cur:
        cur.execute(sql, (keys,))
        rows = cur.fetchall()
    print(f"  {'参数':<28}值")
    for k, v in rows:
        print(f"  {k:<28}{v}")


def explain_methods() -> None:
    print(f"  {'METHOD':<16}示例规则")
    print(f"  {'-'*16}{'-'*60}")
    for name, example, desc in EXAMPLES:
        print(f"  {name:<16}{example}")
        print(f"  {'':<16}{desc}\n")


def try_sslmodes() -> None:
    """尝试不同 sslmode 连接,观察成功/失败。仅做现象展示。"""
    print("  注意:本地 PG 若未开 ssl,require/verify-* 会失败;这是预期。")
    for mode in ("disable", "prefer", "require", "verify-ca", "verify-full"):
        dsn = f"host=127.0.0.1 port=5432 dbname=learn_pg user=postgres sslmode={mode}"
        try:
            with psycopg.connect(dsn, connect_timeout=3) as c, c.cursor() as cur:
                cur.execute("SELECT current_setting('ssl')")
                ssl_on = cur.fetchone()[0]
                # 是否真用 SSL:psycopg v3 暴露 connection.info.ssl_in_use
                in_use = c.info.ssl_in_use if hasattr(c.info, "ssl_in_use") else "?"
                print(f"  sslmode={mode:<12} → ✅ 连上,ssl_in_use={in_use} (server ssl={ssl_on})")
        except Exception as e:
            print(f"  sslmode={mode:<12} → ❌ {type(e).__name__}: {str(e).splitlines()[0]}")


def main() -> None:
    with psycopg.connect(DSN) as conn:
        line("Step 1:当前 pg_hba.conf 规则一览")
        show_current_rules(conn)

        line("Step 2:与认证/SSL 相关的 GUC 参数")
        show_security_settings(conn)

    line("Step 3:常见 METHOD 速查 + 示例")
    explain_methods()

    line("Step 4:实测不同 sslmode 的连接行为")
    try_sslmodes()

    line("生产建议总结")
    print("""
  ① 公网/办公网入口的规则一律用 hostssl + scram-sha-256;
  ② 本地 socket 用 peer + map 文件,禁止 trust;
  ③ 复制账号单独开 type=replication,IP 限定为对端从库;
  ④ password_encryption 设 scram-sha-256;老用户必须重设一次密码;
  ⑤ 修改 pg_hba.conf 后只需 reload,不必 restart:
       SELECT pg_reload_conf();
""")


if __name__ == "__main__":
    main()
markdown
# 第 13 章 配套代码 · 权限与安全

## 准备工作

1.`psql -h 127.0.0.1 -U postgres -d learn_pg -f ../init.sql` 初始化测试表与角色
2. 安装依赖:`pip install "psycopg[binary]>=3.1"`
3. (可选)通过环境变量覆盖默认连接:`export PG_DSN="host=... port=... dbname=... user=..."`
4. 本章脚本默认以 `postgres` 超级用户连接。运行 03 时还会以 `tenant_a / tenant_b / dba_user` 三个角色登录,密码已写在 `init.sql`

## 脚本一览(推荐运行顺序)

| 脚本 | 一句话说明 | 关键 PG 特性 |
|------|------------|--------------|
| `01_role_grant.py` | 演示 ROLE 创建、组继承、`GRANT/REVOKE` 全流程,最后清理 | `CREATE ROLE``INHERIT``SET ROLE` |
| `02_default_privileges.py` | 验证 `ALTER DEFAULT PRIVILEGES` 让未来对象也自动授权 | `ALTER DEFAULT PRIVILEGES`、PUBLIC 收权 |
| `03_rls_multi_tenant.py` | 多租户 RLS 隔离,跨租户写入被 `WITH CHECK` 拦截 | RLS、`USING / WITH CHECK``FORCE RLS``BYPASSRLS` |
| `04_pg_hba_demo.py` | 解析当前连接的认证元信息,列举 `pg_hba.conf` 优先级与常见报错 | `pg_hba.conf``scram-sha-256``pg_hba_file_rules` |

## 预期输出

`03_rls_multi_tenant.py` 关键片段(命中 RLS 后只能看到自己 tenant 的工单):

============================================================ Step 1:以两个租户身份分别 SELECT,应该只看到自己的行

--- 以 tenant_a 身份连接 (tenant=1) --- id=1 tenant=1 status=open title=租户A-工单001 id=2 tenant=1 status=closed title=租户A-工单002

--- 以 tenant_b 身份连接 (tenant=2) --- id=4 tenant=2 status=open title=租户B-工单001 ...


注意:`init.sql` 写入了一行 `archived` 工单,但 `ch13_no_archived` 的 RESTRICTIVE 策略会让它对所有人不可见。

## 常见报错与依赖

- `connection refused` → PG 未启动,或 `pg_hba.conf` 没放行本机
- `relation "ch13_tickets" does not exist` → 未运行 `init.sql`
- `password authentication failed for user "tenant_a"` → 改过密码后忘记同步脚本里的密码常量
- `permission denied for table ch13_tickets` → 当前角色不在 `devs` 组里,回到 `init.sql` 检查 `GRANT devs TO ...`
- `04_pg_hba_demo.py` 仅做只读检查,不会改 `pg_hba.conf`;如需修改请直接编辑 `$PGDATA/pg_hba.conf` 后 `SELECT pg_reload_conf();`

01_role_grant.py ↗ · 02_default_privileges.py ↗ · 03_rls_multi_tenant.py ↗ · 04_pg_hba_demo.py ↗ · README.md ↗