角色继承(INHERIT / NOINHERIT)
上一页埋了一个伏笔:成员角色获得组角色的权限有两条路——自动继承(INHERIT)与主动切换(SET ROLE)。这一页专门把这两条路的语义、边界与组合讲清楚。继承是角色体系里最容易被误解的部分:「我是组角色的成员,为什么用不了它的权限?」「为什么 SET ROLE 之后反而少了东西?」「SUPERUSER 能不能通过加入超级用户组获得?」——答案都在这里。
从 PostgreSQL 16 开始,继承的管理方式发生了一次重要演进:INHERIT 从一个纯粹的角色级属性,变成了每条成员关系各自携带的选项(inherit_option / set_option / admin_option)。这既是本页的重点,也是理解 PG16 之后 GRANT 语法变化的前提。
前置知识
建议先阅读:GRANT / REVOKE 权限管理
基础概念
两条使用权限的路径
设 app_readwrite 组角色拥有对 orders 表的写权限,alice 是它的成员。alice 什么时候能写 orders?取决于成员关系的 INHERIT 与 SET 选项:
| 路径 | 条件 | 行为 |
|---|---|---|
| 继承(USAGE) | 成员关系带 INHERIT TRUE | 组角色的对象权限自动可用,语句里直接写 INSERT INTO orders ... 就能通过权限检查 |
| 切换(SET) | 成员关系带 SET TRUE | 会话执行 SET ROLE app_readwrite 后「变成」组角色,使用它的权限;RESET ROLE 回到登录角色 |
两条路径可以同时开启(默认就是如此),也可以只开其中一条。只开 INHERIT 关掉 SET:成员自动用权限,但永远不能切换身份;只开 SET 关掉 INHERIT:成员平时无权,必须显式 SET ROLE 才能使用组权限——这正是「职责分离」场景的基石。
把两条路径在运维视角下的差异摊开对比:
| 维度 | 继承(INHERIT TRUE) | 切换(SET TRUE,通常配 INHERIT FALSE) |
|---|---|---|
| 权限检查身份 | current_user 仍是登录角色,检查时并入组权限 | SET ROLE 之后 current_user 变为组角色 |
| 使用前提 | 无动作,语句直接通过 | 必须显式执行 SET ROLE |
| 观测性 | 与普通授权无异,难以区分「谁用了组权限」 | SET ROLE 动作可被日志、pg_stat_activity 捕捉 |
| 新建对象属主 | 登录角色本人 | 组角色(除非切回来) |
组上的 ALTER ROLE ... SET 参数 | 不生效 | 同样不生效(SET ROLE 不应用角色级参数) |
| 回收权限后的影响 | 下一次语句即失败 | 已 SET ROLE 的会话在下次权限检查时失败 |
最后两行值得展开:组角色身上的会话默认参数(如 ALTER ROLE app_readwrite SET statement_timeout = '5s')只在以该角色直接登录时应用——官方文档明确:SET ROLE 不会处理目标角色的 ALTER ROLE 设置,这只发生在登录时。因此想给一组用户统一注入会话参数,把参数挂在组角色上、再让大家 SET ROLE 过去是无效配置;可行的做法是把参数直接设在每个成员的登录角色上,或者让应用连接池直接以组角色账号建立连接(此时角色级参数随登录生效)。这也是「人 = 登录角色、组 = 权限容器」模型下最容易踩的配置盲区。
成员关系三选项(PG16 起)
PG16 起,每条成员关系携带三个独立开关,存放在 pg_auth_members 中:
| 选项 | 含义 | 新建成员关系时的默认值 |
|---|---|---|
ADMIN | 成员可以把该角色的成员关系授予/回收给他人 | FALSE(WITH ADMIN OPTION 等价 TRUE) |
INHERIT | 成员是否自动继承该角色的权限 | 跟随成员角色自身的 INHERIT/NOINHERIT 属性 |
SET | 成员能否 SET ROLE 切换到该角色 | TRUE |
在 PG16 之前,INHERIT 是角色级属性:它一次性控制该角色所有成员关系的运行时检查。PG16 起改为逐条成员关系控制,角色级 INHERIT 属性退化为「新成员关系的默认值」。老版本升级到 16+ 后,既有成员关系会按旧语义自动换算,无需手工迁移,但新写的授权脚本应当显式指定选项以避免歧义。
SQL 标准视角
官方文档特别指出:SQL 标准里「用户不自动继承、角色自动继承」。PostgreSQL 默认给所有角色 INHERIT 是历史兼容性选择。想贴近标准语义,可以约定「人 = NOINHERIT 角色,组 = INHERIT 角色」——不少严格安全团队采用这种风格。
语法与用法
角色级属性:INHERIT / NOINHERIT
-- 创建时声明(默认 INHERIT)
CREATE ROLE alice LOGIN INHERIT;
CREATE ROLE bob LOGIN NOINHERIT;
-- 随时修改;只影响之后新建的成员关系默认值,不改变既有成员关系
ALTER ROLE bob INHERIT;
ALTER ROLE bob NOINHERIT;PG16 前后的语义差异
PG15 及更早:ALTER ROLE ... INHERIT/NOINHERIT 立即改变该角色全部成员关系的运行时行为。PG16 起:它只决定「以后以 GRANT 把该角色加进组时的默认 inherit_option」,既有的每条成员关系要用 GRANT ... WITH INHERIT TRUE/FALSE 重设。
成员关系级:GRANT 的三个选项
GRANT group_role TO member_role
[ WITH { ADMIN | INHERIT | SET } { OPTION | TRUE | FALSE } ];
-- 典型组合
GRANT app_readwrite TO bob; -- 全默认:可继承、可切换、不可转授
GRANT app_readwrite TO carol WITH INHERIT FALSE, SET TRUE; -- 只能 SET ROLE 使用
GRANT app_admin TO dave WITH ADMIN TRUE; -- 可转授成员关系
GRANT app_readonly TO eve WITH SET FALSE; -- 只继承、不可切换修改既有成员关系的选项:对同一条关系重新执行 GRANT 并写上新值,未提及的选项保持原样。回收成员关系仍是 REVOKE group FROM member;只回收管理权用 REVOKE ADMIN OPTION FOR group FROM member。
SET ROLE / RESET ROLE
SET [ SESSION | LOCAL ] ROLE role_name;
RESET ROLE; -- 等价于 SET ROLE NONE,回到 session_user详细说明
INHERIT 语义:成员权限自动可用
成员关系带 INHERIT TRUE 时,组角色的对象权限直接并入成员的有效权限,无需任何动作。这条规则有两个精确的边界:
- 只对对象权限成立。组角色身上的角色属性(
SUPERUSER、CREATEDB、CREATEROLE、REPLICATION、BYPASSRLS、LOGIN)不通过继承传播,详见下文。 - 逐跳生效。多级嵌套时,权限沿成员关系链传递,但任何一跳缺
INHERIT,链条就在那里断掉,更上层的权限不再自动可用。
NOINHERIT:权限要用 SET ROLE 拿
成员关系带 INHERIT FALSE(且 SET TRUE)时,成员平时完全用不了组权限,必须显式切换:
SET ROLE app_readwrite; -- 此刻起,会话以 app_readwrite 的身份做权限检查
INSERT INTO orders ...; -- 组权限生效
RESET ROLE; -- 回到登录角色,组权限消失这个模式的价值在于默认安全:应用连接、批处理脚本平时跑在「几乎无权」的登录角色上,只有在明确需要执行某类操作时才切换。一旦被注入,攻击者拿到的会话也拿不到组权限,除非注入的 SQL 里恰好包含正确的 SET ROLE。
继承链会中途「断」:一个完整的例子
官方文档用下面这个例子精确演示了逐跳判定,这里完整复现(名字与注释本地化):
CREATE ROLE joe LOGIN; -- 普通用户
CREATE ROLE admin NOLOGIN; -- 一级组
CREATE ROLE wheel NOLOGIN; -- 二级组(高权)
CREATE ROLE island NOLOGIN; -- 独立岛(只继承、不可切换)
GRANT admin TO joe WITH INHERIT TRUE;
GRANT wheel TO admin WITH INHERIT FALSE; -- 链条在 admin → wheel 断开
GRANT island TO joe WITH INHERIT TRUE, SET FALSE;连接为 joe 之后,权限状态如下:
- 立即可用:joe 自己的权限 +
admin的权限 +island的权限(两条成员关系都可继承)。 - 不可用:
wheel的权限——joe 虽然经由 admin 间接成为 wheel 的成员,但中间那一跳(admin → wheel)是INHERIT FALSE,继承链在此中断。 SET ROLE admin;之后:会话只拥有admin的权限,不再拥有 joe 自己的与 island 的权限——SET ROLE是「替换身份」而不是「叠加身份」。SET ROLE wheel;之后:只拥有wheel的权限。注意这里不需要先SET ROLE admin:只要从登录角色到目标角色的成员关系链上每一跳都是SET TRUE,就可以一步直达。- 永远不能
SET ROLE island;:这条成员关系SET FALSE,joe 只能通过继承使用 island 的权限。 - 恢复原状:
SET ROLE joe;/SET ROLE NONE;/RESET ROLE;三者等价。
「SET ROLE 一步直达」的两面性
直达规则让运维很方便,但也意味着:只要登录角色到高危角色的链路全是 SET TRUE,任何获得该会话控制权的人都能一步切到高危角色。对高危组角色,考虑在链路的某一跳显式 SET FALSE,强制经由受控入口(比如只开放给运维工具的专用登录角色)切换。
SET ROLE 与 SESSION AUTHORIZATION 的区别
两者都会改变 current_user(权限检查依据),但层次完全不同:
| 维度 | SET ROLE | SET SESSION AUTHORIZATION |
|---|---|---|
改变 current_user | 是 | 是 |
改变 session_user | 否 | 是(连同最初会话身份一起替换) |
| 前置条件 | 必须是目标角色的成员(且 SET 选项链为真) | 必须是超级用户,无需任何成员关系 |
| 可逆性 | RESET ROLE 回到 session_user | RESET SESSION AUTHORIZATION 回到最初身份 |
| 典型用途 | 使用组成员身份、临时提权 | 运维场景下冒充任意用户验证权限、恢复流程 |
用一个对照实验说明:
-- 以 alice 登录
SELECT session_user, current_user; -- alice | alice
SET ROLE app_readwrite;
SELECT session_user, current_user; -- alice | app_readwrite
RESET ROLE;
-- 超级用户会话里:
SET SESSION AUTHORIZATION alice;
SELECT session_user, current_user; -- alice | alice (两者都变了)另一个会改变 current_user 的场景是 SECURITY DEFINER 函数的执行期——它以函数属主身份运行,属于函数安全主题,这里只需知道它也走「替换 current_user」的机制。
一次完整的身份旅程:会话演示
把本页的关键机制串成一段可直接执行的会话记录,跑一遍胜过读十遍:
-- 以 alice(NOINHERIT,app_readwrite 的成员且 SET TRUE)登录
SELECT session_user, current_user, current_role;
-- session_user | current_user | current_role
-- --------------+--------------+--------------
-- alice | alice | alice
SHOW is_superuser;
-- is_superuser
-- --------------
-- off
SELECT pg_has_role('app_readwrite', 'MEMBER'); -- t:成员身份
SELECT pg_has_role('app_readwrite', 'USAGE'); -- f:NOINHERIT,权限未并入
SET LOCAL ROLE app_readwrite; -- 事务级切换(本事务结束后自动还原)
SELECT session_user, current_user;
-- session_user | current_user
-- --------------+--------------
-- alice | app_readwrite
INSERT INTO app.orders(id) VALUES (1); -- 组权限生效
COMMIT; -- 事务结束,身份自动还原为 alice
SET ROLE app_readwrite; -- 会话级切换
CREATE TABLE app.tmp_notes(t text); -- 新对象属主是 app_readwrite,不是 alice
RESET ROLE; -- 显式还原三个观察点:session_user 在整个会话里始终是 alice(这是 SET ROLE 与 SET SESSION AUTHORIZATION 的分水岭);SET LOCAL ROLE 让「切换 → 干活 → 还原」原子化地绑定在事务上;切换期间建的对象归组角色——想「对象归组」就别急着 RESET,想避免就要在切换前规划好属主。
排错手册:「应该有的权限却用不了」
按命中率从高到低逐层排查,每层给出对应的确认手段:
-- 第 1 步:确认现在到底是谁(几乎一半的问题出在身份误判)
SELECT session_user, current_user;
-- 第 2 步:成员关系存在吗?三态分别是什么?
SELECT pg_has_role(current_user, 'app_readwrite', 'MEMBER'); -- f → 根本不是成员
SELECT pg_has_role(current_user, 'app_readwrite', 'USAGE'); -- f → 是成员但没继承
SELECT pg_has_role(current_user, 'app_readwrite', 'SET'); -- f → 是成员但切换不了
-- 第 3 步:沿成员关系链逐跳找断点(多级组必查)
WITH RECURSIVE chain AS (
SELECT roleid, member, grantor, inherit_option, set_option, 1 AS depth
FROM pg_auth_members
WHERE member = current_user::regrole::oid
UNION ALL
SELECT am.roleid, am.member, am.grantor, am.inherit_option, am.set_option, c.depth + 1
FROM pg_auth_members am
JOIN chain c ON am.member = c.roleid
)
SELECT * FROM chain ORDER BY depth;
-- 第 4 步:权限本身真的授给组了吗?(别忘了并集的其他路径)
SELECT has_table_privilege('app_readwrite', 'app.orders', 'INSERT');
SELECT has_schema_privilege('app_readwrite', 'app', 'USAGE');
SELECT has_database_privilege(current_user, current_database(), 'CONNECT');
-- 第 5 步:是不是列级权限在作怪?
SELECT has_column_privilege(current_user, 'app.orders', 'amount', 'SELECT');| 症状 | 最可能的原因 | 一击定位 |
|---|---|---|
成员但 USAGE = f | 成员关系 INHERIT FALSE,或登录角色在 PG15- 是 NOINHERIT 且升级后未重设 | 查 pg_auth_members.inherit_option |
SET ROLE 报权限拒绝 | 链上某跳 SET FALSE | 用第 3 步的递归查询看每跳 set_option |
直接写表失败、SET ROLE 后成功 | 继承关闭但切换可用 | 第 2 步的三态组合即可判定 |
| 组里明明有权限,新会话还是不行 | 连接池残留了 SET ROLE 到别的身份 | 重连后先查 current_user |
permission denied for schema | 只授了表没授模式 USAGE | 第 4 步的 has_schema_privilege |
角色属性不通过继承传播
这是必须单独强调的规则:LOGIN、SUPERUSER、CREATEDB、CREATEROLE、REPLICATION、BYPASSRLS 永远不随成员关系继承,无论成员关系的 INHERIT 选项如何。即使 alice 是 admin 组的成员,而 admin 拥有 CREATEDB,alice 直接执行 CREATE DATABASE 也会被拒;她必须 SET ROLE admin,以 admin 的身份执行(前提是成员关系 SET TRUE)。
CREATE ROLE admin NOLOGIN CREATEDB CREATEROLE;
GRANT admin TO alice;
-- alice 的会话:
CREATE DATABASE tmp_db; -- ERROR: permission denied to create database
SET ROLE admin;
CREATE DATABASE tmp_db; -- 成功(current_user 已是 admin)
RESET ROLE;从设计角度这是件好事:把高特权属性挂在 NOLOGIN 组角色上,人只能通过显式切换使用它,切换动作本身可被日志与 pg_stat_activity 观测到,比直接给登录角色发属性可审计得多。
WITH ADMIN OPTION 与 WITH GRANT OPTION 不被继承
与角色属性同理,两个「转授权」也不随成员关系链传播:
ADMIN选项:alice 拿到app_admin的 ADMIN 选项,能转授app_admin的成员关系;但这不妨碍另一个事实——她的 ADMIN 资格只针对这一层,不会因为她也间接是某个更上层组的成员而自动获得那个组的 ADMIN。GRANT OPTION:对象权限的转授权资格属于「某角色对某对象」的授权记录,不是可继承的身份属性。组角色持有WITH GRANT OPTION时,成员要通过继承使用它需要权限本身被继承,但官方建议这类「谁能转授」的场景干脆用SET ROLE到持有者身份操作,语义最清晰。
pg_auth_members:成员关系的完整视图
所有成员关系与三选项都记录在集群级目录 pg_auth_members 中(公开可读):
| 列 | 类型 | 说明 |
|---|---|---|
oid | oid | 行标识 |
roleid | oid(引用 pg_authid.oid) | 「组」一方的角色 OID |
member | oid(引用 pg_authid.oid) | 「成员」一方的角色 OID |
grantor | oid(引用 pg_authid.oid) | 授予这条成员关系的角色 |
admin_option | bool | 成员能否转授 roleid 的成员关系 |
inherit_option | bool | 成员是否自动继承 roleid 的权限(PG16 新增) |
set_option | bool | 成员能否 SET ROLE 到 roleid(PG16 新增) |
-- 以名字呈现的成员关系全表
SELECT g.rolname AS group_role,
m.rolname AS member,
gr.rolname AS grantor,
am.admin_option,
am.inherit_option,
am.set_option
FROM pg_auth_members am
JOIN pg_roles g ON g.oid = am.roleid
JOIN pg_roles m ON m.oid = am.member
JOIN pg_roles gr ON gr.oid = am.grantor
ORDER BY 1, 2;版本兼容
inherit_option 与 set_option 是 PG16 新增列。在 PG15 及更早版本的实例上运行包含这两列的查询会直接报错;需要兼容多版本的工具请用 SELECT * 或先探测列存在性。
继承与 pg_roles.rolinherit
角色级的 INHERIT/NOINHERIT 属性可以从 pg_roles 视图读到(列名 rolinherit,布尔)。要区分清楚它的角色:
SELECT rolname, rolinherit, rolcanlogin, rolsuper, rolcreaterole, rolcreatedb
FROM pg_roles
WHERE rolname NOT LIKE 'pg\_%'
ORDER BY rolcanlogin, rolname;- PG15 及更早:
rolinherit直接决定该角色所有成员关系的运行时行为,是真正的「运行时开关」。 - PG16 及以后:
rolinherit只决定新建立的成员关系的默认inherit_option,运行时真正起作用的是pg_auth_members.inherit_option。审计时若只看rolinherit就下结论,在 16+ 的实例上会得出错误判断。
一个实用的迁移巡检口径:把「rolinherit = false 的登录角色」与「其成员关系的 inherit_option = true」两个集合交叉比对,就能发现「角色声明了不继承、但某条关系显式开了继承」的漂移点——升级到 PG16 后这类漂移是允许的,但往往不是本意。
与 SECURITY DEFINER 的交互
SECURITY DEFINER 函数以函数属主的身份执行,这为继承体系提供了第三条「临时获得权限」的通道,也带来独特的风险组合:
- 属主设为组角色:把敏感函数的属主设为
NOLOGIN组角色,成员即便对表毫无权限,也能通过调用函数完成受控操作。相比SET ROLE,这条通道连「切换」动作都不需要,但也因此更难在会话层面审计。 - 不受成员关系选项约束:任何能执行该函数的角色(注意
EXECUTE默认授予PUBLIC)都会以属主身份运行函数体,无论它在pg_auth_members里是什么选项组合。函数是绕过成员关系设计的后门,必须纳入权限审计范围。 - 特洛伊木马警告:官方文档明确提醒——若某角色的权限被继承但不应被
SET ROLE使用,就不要让它拥有任何 SQL 对象。原因是:能继承其权限的成员虽不能切换成它,却可以操纵它拥有的对象(例如重定义它拥有的函数)来获得对该角色的完全访问。 - 配合
SET search_path加固:SECURITY DEFINER函数定义时应显式SET search_path,防止通过 search_path 劫持来调用非预期对象——这与本页主题的交集在于:函数内部的权限检查同样以属主身份进行,任何劫持都会被放大成属主级权限。
-- 典型的受控写入口:调用者对表零权限,函数属主持有写权
CREATE ROLE app_writer NOLOGIN;
GRANT INSERT, UPDATE ON app.ledger TO app_writer;
CREATE FUNCTION app.record_entry(p_amount numeric)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER -- 以 app_writer(属主)身份执行
SET search_path = app, pg_temp -- 固定搜索路径
AS $$
BEGIN
INSERT INTO app.ledger(amount) VALUES (p_amount);
END;
$$;
ALTER FUNCTION app.record_entry(numeric) OWNER TO app_writer;
REVOKE EXECUTE ON FUNCTION app.record_entry(numeric) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION app.record_entry(numeric) TO app_clerk; -- 仅业务组可调用PG16 的 CREATEROLE 收紧与 createrole_self_grant
PG16 对 CREATEROLE 的整顿与继承语义直接相关,方向有三点(概述,细节以官方文档为准):
- 管理权变为显式授予:
CREATEROLE角色不再能修改任意角色,只能管理被授予了 ADMIN 选项的角色。老脚本里「CREATEROLE 用户改任意角色」的用法会开始报权限错误。 - 建角色即建关系:CREATEROLE 角色创建新角色时,会自动建立「新角色 → 创建者」的成员关系,让创建者可管理、可切换、可继承——创建者不再拥有对无主角色的裸特权。
createrole_self_grant参数(默认空):设置如'set, inherit'后,CREATEROLE 角色创建的每个新角色都会自动把这两个选项授回创建者,方便自动化流水线里保持旧行为。
修改继承设置的影响面
把所有变化点整理成一张「什么改了、影响谁」的表:
| 变更 | 影响范围 | 立即生效? |
|---|---|---|
ALTER ROLE u INHERIT / NOINHERIT(PG16+) | 仅影响今后把 u 加入组时的默认 inherit_option | 是(对既有关系无影响) |
ALTER ROLE u INHERIT / NOINHERIT(PG15-) | u 的全部成员关系的运行时行为 | 是 |
GRANT g TO u WITH INHERIT FALSE(重设既有关系) | 仅这一条成员关系 | 是 |
GRANT g TO u WITH SET FALSE | u 能否 SET ROLE g | 是 |
REVOKE g FROM u | u 失去 g 的继承与切换能力 | 是(存量会话中已切换的 SET ROLE 会话在下次权限检查时表现为无权) |
版本迁移清单(PG15 → PG16+)
围绕继承语义的升级,按顺序核对以下事项:
- 盘点存量成员关系:升级前用
pg_auth_members导出全部成员关系;PG16 起ALTER ROLE ... NOINHERIT不再改变既有关系的运行时行为,升级后先核对inherit_option的自动换算结果是否符合预期。 - 改写授权脚本:新脚本一律显式写
WITH INHERIT TRUE/FALSE, SET TRUE/FALSE,不依赖「角色属性推断默认值」的旧习惯。 - 演练 CREATEROLE 收紧:依赖
CREATEROLE的自动化(建号、改属性、授成员)在 16+ 会报权限错误,需改为「管理员显式GRANT ... WITH ADMIN OPTION」或配置createrole_self_grant。 - 兼容性探测:监控与审计脚本引用
inherit_option/set_option前先探测列存在(PG16 之前无此列)。 - 重审「NOINHERIT 角色」语义:PG15 时代用
ALTER ROLE u NOINHERIT一键关掉全部继承的团队,升级后要逐条关系重设,否则成员会突然开始继承。 - 检查工具链:备份/恢复链路(如导出成员关系再导入)在两版本间的目录结构差异需要适配。
常见反模式
| 反模式 | 后果 | 纠正 |
|---|---|---|
| 给登录角色直接挂满权限,用完再逐条回收 | 权限残留、审计困难 | 权限进组,人挂组,走继承或切换 |
| 「SUPERUSER 做成组再拉人」 | 无效——属性不继承,成员什么也得不到 | 高特权操作走受控的专用登录角色 |
只看 rolinherit 判断 16+ 实例的继承行为 | 结论错误——运行时看的是成员关系选项 | 审计查询以 pg_auth_members.inherit_option 为准 |
| 多级组不写选项、依赖默认值 | PG16 前后默认语义不同,环境间漂移 | 每跳显式写全三个选项 |
SET ROLE 后不 RESET ROLE | 后续语句以组身份执行,新建对象归组 | 成对书写 / 事务内用 SET LOCAL ROLE |
用 NOINHERIT 当「防提权锁」 | 有 SET TRUE 依然能提权 | 要阻断就 SET FALSE,并设受控入口 |
实战示例
示例一:NOINHERIT 登录角色 + SET ROLE 的职责分离
场景:应用连接角色平时只拥有「连接 + 查询」的能力,写操作必须显式切换到写组——把「能不能写」的决策点显式化。
psql -U postgres -d appdb <<'SQL'
-- 组角色
CREATE ROLE app_readonly NOLOGIN;
CREATE ROLE app_readwrite NOLOGIN;
GRANT USAGE ON SCHEMA app TO app_readonly, app_readwrite;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_readonly, app_readwrite;
GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_readwrite;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_readwrite;
-- 登录角色:NOINHERIT + 成员只挂只读组可切换写组
CREATE ROLE worker LOGIN PASSWORD 'W0rker_P@ss' NOINHERIT;
GRANT app_readonly TO worker WITH INHERIT TRUE, SET TRUE;
GRANT app_readwrite TO worker WITH INHERIT FALSE, SET TRUE;
SQLpsql "dbname=appdb user=worker password=W0rker_P@ss"-- 平时:只有只读权限
SELECT has_table_privilege(current_user, 'app.orders', 'INSERT');
-- has_table_privilege
-- ---------------------
-- f
SELECT * FROM app.orders LIMIT 1; -- 成功(只读继承)
INSERT INTO app.orders VALUES (1); -- 失败:permission denied for table orders
-- 需要写时显式切换
SET ROLE app_readwrite;
INSERT INTO app.orders VALUES (1); -- 成功
RESET ROLE;示例二:多级组的继承链验证
psql -U postgres -d appdb <<'SQL'
CREATE ROLE grp_base NOLOGIN; -- 基础组
CREATE ROLE grp_power NOLOGIN; -- 高权组
GRANT grp_base TO grp_power WITH INHERIT TRUE; -- 高权组继承基础组
CREATE ROLE u_inherit LOGIN NOINHERIT;
CREATE ROLE u_setonly LOGIN NOINHERIT;
GRANT grp_base TO u_inherit WITH INHERIT TRUE;
GRANT grp_power TO u_inherit WITH INHERIT TRUE; -- 两跳都可继承
GRANT grp_base TO u_setonly WITH INHERIT FALSE, SET TRUE;
GRANT grp_power TO u_setonly WITH INHERIT FALSE, SET TRUE;
SQL-- u_inherit 登录:两级组权限全部自动可用
SET ROLE u_inherit;
SELECT pg_has_role('grp_base', 'MEMBER'), -- t
pg_has_role('grp_base', 'USAGE'), -- t(继承可用)
pg_has_role('grp_power', 'USAGE'); -- t
RESET ROLE;
-- u_setonly 登录:都只是 MEMBER,无 USAGE,必须 SET ROLE
SET ROLE u_setonly;
SELECT pg_has_role('grp_base', 'MEMBER'), -- t
pg_has_role('grp_base', 'USAGE'), -- f(未继承)
pg_has_role('grp_base', 'SET'); -- t(可切换)
RESET ROLE;示例三:审计成员关系的三个开关
-- 找出「能切换到高危组」的成员
SELECT g.rolname AS group_role, m.rolname AS member
FROM pg_auth_members am
JOIN pg_roles g ON g.oid = am.roleid
JOIN pg_roles m ON m.oid = am.member
WHERE am.set_option
AND g.rolname IN ('app_admin', 'grp_power')
ORDER BY 1, 2;
-- 找出 ADMIN 选项的持有者(转授权风险点)
SELECT g.rolname, m.rolname, am.grantor::regrole
FROM pg_auth_members am
JOIN pg_roles g ON g.oid = am.roleid
JOIN pg_roles m ON m.oid = am.member
WHERE am.admin_option;示例四:受控提权入口——用 SET FALSE 设关卡
场景:app_admin 是高危管理组,要求只有运维专用登录角色 dba_oncall 能切换进去;业务人员的登录角色即使通过别的路径成为 app_admin 的间接成员,也不能切换。
psql -U postgres -d appdb <<'SQL'
CREATE ROLE app_admin NOLOGIN; -- 高危组:持有对象属主与管理权限
CREATE ROLE dba_oncall LOGIN PASSWORD 'Dba0nc@ll'; -- 运维值班专用入口
-- 业务成员:只是间接成员,INHERIT FALSE 且 SET FALSE——拿不到也切不进
CREATE ROLE clerk LOGIN PASSWORD 'Cl3rk_P@ss';
GRANT app_admin TO clerk WITH INHERIT FALSE, SET FALSE;
-- 运维入口:显式放行 SET TRUE
GRANT app_admin TO dba_oncall WITH SET TRUE;
SQL-- clerk 的会话:
SELECT pg_has_role('app_admin', 'MEMBER'); -- t(成员身份成立)
SELECT pg_has_role('app_admin', 'USAGE'); -- f(不继承)
SET ROLE app_admin;ERROR: permission denied to set role to "app_admin"-- dba_oncall 的会话:
SET ROLE app_admin; -- 成功:受控入口
RESET ROLE;设计要点:关卡设在「成员关系」上而不是靠流程约束——SET FALSE 是数据库层面的硬边界,不依赖任何人遵守操作手册。间接成员关系(例如未来把 clerk 挂进某个中间组)也继承不了切换能力,因为 SET 与 INHERIT 一样是逐跳判定的。
示例五:CI 中的三态权限断言脚本
权限漂移最好的防线是把它变成测试。下面的脚本对目标环境断言 pg_has_role() 的三个返回值,任何一条不符即失败(配合 CI 流水线在发布前拦截):
#!/usr/bin/env bash
# assert-roles.sh —— 断言成员关系三选项,环境漂移即报错
# 用法:./assert-roles.sh <connstr>
set -euo pipefail
CONN="${1:?usage: assert-roles.sh <connstr>}"
run_assert() {
psql "$CONN" -XAt -v ON_ERROR_STOP=1 -c "$1"
}
fail=0
# 1. clerk 必须是 app_admin 的成员,但不继承、不可切换
out=$(run_assert "SELECT pg_has_role('clerk','app_admin','MEMBER')::int
|| pg_has_role('clerk','app_admin','USAGE')::int * 10
|| pg_has_role('clerk','app_admin','SET')::int * 100")
[ "$out" = "1" ] || { echo "FAIL: clerk/app_admin 三态=${out},期望 1(仅 MEMBER)"; fail=1; }
# 2. dba_oncall 必须能切换 app_admin
out=$(run_assert "SELECT pg_has_role('dba_oncall','app_admin','SET')::int")
[ "$out" = "1" ] || { echo "FAIL: dba_oncall 无法切换 app_admin"; fail=1; }
# 3. 任何成员关系的 inherit_option 必须与设计矩阵一致(抽查写组)
out=$(run_assert "SELECT count(*) FROM pg_auth_members am
JOIN pg_roles g ON g.oid = am.roleid
WHERE g.rolname = 'app_readwrite' AND NOT am.inherit_option")
[ "$out" = "0" ] || { echo "FAIL: app_readwrite 存在 ${out} 条意外关闭继承的成员关系"; fail=1; }
[ "$fail" = "0" ] && echo "OK: 角色成员关系与设计矩阵一致" || exit 1这个模式的威力在于:任何人手工 GRANT 了一个「计划外」的成员关系或改动了选项,下一次 CI 就会变红。断言矩阵建议与权限设计文档放在同一仓库、同一评审流程里。
注意事项
SET ROLE 是替换不是叠加。 切换到组角色后,登录角色自身的权限不再参与权限检查;很多人以为「SET ROLE 后 = 自己的权限 + 组的权限」,实际是「只有组的权限」。需要叠加的场景应把权限都放进组里,让组嵌套表达层次。
继承链逐跳判定,一跳断则全断。 多级组里某一级 INHERIT FALSE,其上层的权限就不会自动可用;排错时沿 pg_auth_members 逐跳检查 inherit_option,不要只看首尾两层。
角色属性与转授权资格永不继承。 SUPERUSER、CREATEDB、CREATEROLE、REPLICATION、BYPASSRLS、LOGIN、ADMIN、GRANT OPTION 都必须通过 SET ROLE 使用(且需要 SET TRUE 链)。「把 SUPERUSER 做成组再拉人」不会让任何成员变成超级用户。
PG16 前后 INHERIT 语义不同。 老脚本迁移到 16+ 后,角色级 ALTER ROLE ... NOINHERIT 不再影响既有成员关系;逐条关系要显式 WITH INHERIT ... 重设。跨版本部署的 IaC 模板必须做分支处理。
pg_auth_members 的新列是 PG16 起才有。 兼容多版本的工具不要直接引用 inherit_option / set_option,或先查 pg_attribute 确认列存在。
NOINHERIT 不是加密锁。 只要 SET TRUE,任何获得会话控制权的主体都能执行 SET ROLE 拿到组权限。它的价值是「让提权成为显式动作、便于日志识别」,不是阻止提权。真正要阻断时用 SET FALSE。
RESET ROLE 才能回到登录身份。 SET ROLE 不会自动还原;长事务或长脚本里切换后忘记 RESET ROLE,后续语句会以组角色身份执行,创建的对象属主也会变成组角色——这可能是期望(对象归组),也可能是事故。
SET ROLE LOCAL 是事务级的自愈方案。 SET LOCAL ROLE group_role 只在当前事务内生效,事务结束(无论提交或回滚)自动还原为登录身份;发布脚本与迁移工具里用它,比手工配对 SET ROLE / RESET ROLE 更不容易漏。
CREATE ROLE 的 IN ROLE / ROLE / ADMIN 子句也能建初始成员关系。 但它们无法逐项指定 INHERIT / SET 两个开关(ADMIN 子句等价于附带管理权),建议自动化脚本统一用 GRANT 建成员关系,选项一目了然。
SECURITY DEFINER 函数是成员关系设计之外的通道。 即使某角色对组权限「既不继承也切换不进」,只要它有权限执行一个以高权角色为属主的 SECURITY DEFINER 函数,就能以属主身份做事。权限审计要把这类函数一并纳入,并检查它们的 EXECUTE 授予范围。
最佳实践
- 人用
NOINHERIT,组用权限,切换显式化:登录角色默认无权(除连接),需要什么能力就SET ROLE什么组,让每次提权在日志里可见、可审计。 - 组角色一律
NOLOGIN且不挂角色属性:高特权属性(CREATEDB/CREATEROLE)挂在NOLOGIN组上,通过SET ROLE使用,比直接发给人安全且可审计。 - 多级组的每一跳都显式写选项:
GRANT ... WITH INHERIT TRUE, SET TRUE写全,不依赖默认值,避免 PG16 前后默认语义差异造成的漂移。 - 高危组在链路里放一个
SET FALSE关卡:确保高危能力只能从指定入口(如运维专用登录角色、受控工具)切换进入。 - 用
pg_has_role()三态断言做巡检:MEMBER/USAGE/SET三个返回值分别回答「是成员吗」「权限自动可用吗」「能切换吗」,比肉眼读表可靠。 - 审计脚本兼容 PG16:引用
inherit_option/set_option前先探测列存在,或按版本维护两套查询。 - 升级 PG16 前演练 CREATEROLE 收紧:所有依赖 CREATEROLE 的自动化(建号、改属性)在测试环境过一遍,补齐显式的
GRANT ... WITH ADMIN OPTION。 - 在脚本里成对使用
SET ROLE/RESET ROLE:用事务包裹或 try/finally 语义保证还原;发布流程中把「切换 → 执行 → 还原」写成模板。 - 需要「临时变成某身份建对象」时优先组角色:
SET ROLE后创建的对象属主是组角色而非个人,天然避免「个人离职导致对象无主」的清理难题。 - 不要用
SET SESSION AUTHORIZATION做业务功能:它是超级用户级的身份替换工具,只用于运维验证与恢复场景;业务里的身份切换应由应用层的多角色设计(成员关系 +SET ROLE)承担。
下一节
继续学习:组角色与会话角色