KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 权限、schema 与租户隔离 — keel 龙骨
## 现场:应用连的是超级用户
现场:应用连的是超级用户
一个典型的快速起步配置:
database:
url: postgresql://postgres:password@db:5432/appdb
用超级用户连库,开发阶段什么都能跑,迁移不用授权,建表不用申请。问题在第一次安全审计时暴露:数据库里没有一层边界。
具体一点,这个配置意味着什么:
- 一个 SQL 注入漏洞可以从应用直接
DROP TABLE; - 一个写错的迁移脚本可以删掉别的库;
- 应用连的账号能看到
pg_shadow(所有角色的密码哈希)。
这一章讲的是把这些边界补回来——以及补的过程中会遇到的两个坑:search_path 和序列权限。
先猜一下:给一个角色 GRANT SELECT, INSERT ON 表 之后,它能不能 INSERT 一条 id 是 bigserial 的记录?
直觉模型:边界要在最里面一层
应用层的权限校验(谁能调用哪个接口)是最外面一层。数据库的权限是最里面一层。
外层会被绕过——多一条写入路径、一个运维脚本、一次数据修复。所以「最里面那层」的价值不是替代外层,而是当外层失效时,损失有个上界。
工程上的做法是给应用一个只够干活的账号:能读写的表就那几个、能执行的语句就那几类、能访问的 schema 只有一个。
角色与授权
PG 的权限模型里,ROLE 和 USER 是同一个东西——CREATE USER 就是带 LOGIN 的 CREATE ROLE。授权用 GRANT,粒度可以到表、列、序列、函数。
建一个只读写的角色:
CREATE TABLE t_doc(id bigserial PRIMARY KEY, tenant_id int NOT NULL, title text NOT NULL, body text);
CREATE ROLE app_rw LOGIN PASSWORD 'lab';
GRANT CONNECT ON DATABASE labpgfund TO app_rw;
GRANT USAGE ON SCHEMA public TO app_rw;
GRANT SELECT, INSERT ON t_doc TO app_rw;
现在查这个角色拿到了什么:
SELECT grantee, privilege_type FROM information_schema.role_table_grants
WHERE table_name = 't_doc' AND grantee = 'app_rw' ORDER BY privilege_type;
grantee | privilege_type
---------+----------------
app_rw | INSERT
app_rw | SELECT
只有两条。注意这里没有 UPDATE、没有 DELETE——如果这个角色只负责写入和读取,这两个权限就不该给。
验证一下越权确实被拦:
SET ROLE app_rw;
DELETE FROM t_doc WHERE id = 1;
ERROR: permission denied for table t_doc
报错很直白,没有「部分成功」这种中间态。这是数据库权限相对应用层校验的优势:它的判定是原子的、无条件的。
序列权限是单独的
现在看那个容易踩的坑。t_doc.id 是 bigserial,也就是 bigint + 一个默认值取自序列。给角色 INSERT 权限之后再插入:
CREATE TABLE t_seq_demo(id bigserial PRIMARY KEY, v text);
GRANT SELECT, INSERT ON t_seq_demo TO app_rw;
SET ROLE app_rw;
INSERT INTO t_seq_demo(v) VALUES ('x');
ERROR: permission denied for sequence t_seq_demo_id_seq
表权限和序列权限是分开的。INSERT 时数据库要去 nextval() 那个序列,而序列是独立的对象,需要单独授权:
GRANT USAGE ON SEQUENCE t_seq_demo_id_seq TO app_rw;
这个坑的表现是「权限明明给了却还是插不进去」,而且报错指向的是一个你可能根本不知道存在的对象(t_seq_demo_id_seq)。GRANT ALL ON ALL SEQUENCES IN SCHEMA public 可以一次性给全,但那已经偏离最小权限了;按表授序列权限更符合这一章的初衷。
顺带一个工程上的建议:把权限用角色组来组织,而不是给每个应用账号单独授权。做法是建一个不带 LOGIN 的角色(比如 app_readwrite)承载权限,应用账号 GRANT app_readwrite TO 应用账号。这样增删表时只改一处,也不会因为漏授权导致上线后才发现。PG 16 起还支持 GRANT ... WITH INHERIT 这类角色继承选项,可以把「默认不继承」作为更严格的选择。
search_path:同名表的静默劫持
search_path 决定了「不带 schema 前缀的表名去哪里找」。默认值是 "$user", public——先找与当前用户名同名的 schema,再找 public。
这个机制带来的问题可以用一个实验看清。先在一个 reporting schema 里建一张同名表:
CREATE SCHEMA IF NOT EXISTS reporting;
CREATE TABLE IF NOT EXISTS reporting.t_doc(id int, tenant_id int, title text, body text);
INSERT INTO reporting.t_doc VALUES (999, 0, 'FAKE', 'FAKE');
然后切换 search_path,跑同一条 SQL:
-- 默认 search_path:
search_path
-----------------
"$user", public
-- 同一个 SQL,在 search_path 前缀 reporting 后指向了另一张表:
id | title
-----+-------
999 | FAKE
SET
id | title
----+-------------
1 | acme 合同
2 | globex 合同
SELECT id, title FROM t_doc 这句 SQL 一个字没改,返回的数据完全换了。这就是 search_path 的威力:它决定了你的表名解析到哪张表。
风险有三个层次:
- 迁移或者修复脚本里临时
SET search_path之后忘了RESET,后面的语句跑到别的 schema 上; - 应用账号与某个 schema 同名(
"$user"那一项生效),导致该 schema 里的表被优先命中; publicschema 默认对所有人有CREATE权限(PG 15 之前),任何人都能在public里创建同名对象来劫持解析。
防御方式是把解析路径写死。应用账号的 search_path 应该显式设成它需要的那个 schema,并且不含 public:
ALTER ROLE app_rw SET search_path = app_schema;
更彻底的防御是在创建对象时就不依赖解析:建表写全 schema.table,查询也写全。这一条在以「多 schema 隔离租户」为架构的系统里尤其重要。
RLS:把隔离下沉到数据库
行级安全(Row-Level Security)做的是「同一张表,不同身份只能看到属于自己的行」。
开启并建策略:
ALTER TABLE t_doc ENABLE ROW LEVEL SECURITY;
CREATE POLICY p_tenant ON t_doc
USING (tenant_id = current_setting('app.tenant_id', true)::int);
GRANT SELECT ON t_doc TO app_rw;
这里的 current_setting('app.tenant_id', true) 读的是一个自定义会话变量(第二个参数 true 表示「不存在时返回 NULL 而不是报错」)。应用在拿到连接后、执行业务查询前,把它设成当前租户:
SET ROLE app_rw;
SET app.tenant_id = '1';
SELECT tenant_id, title FROM t_doc;
tenant_id | title
-----------+-----------
1 | acme 合同
换一个租户:
SET app.tenant_id = '2';
SELECT tenant_id, title FROM t_doc;
tenant_id | title
-----------+-------------
2 | globex 合同
同一张表、同一条 SQL,只看得到自己那个租户的行。而如果忘了设置这个变量:
ERROR: invalid input syntax for type integer: ""
这个报错值得留意。current_setting('app.tenant_id', true) 在变量不存在时返回 NULL(或者空串,取决于上下文),::int 转换失败。这是好事——它 fail-closed:忘了设置就报错,而不是返回全部数据。如果你把策略写成 USING (tenant_id = current_setting('app.tenant_id', true)::int OR current_setting('app.tenant_id', true) IS NULL),那就变成了「忘了设置就返回全部」,是一个静默的越权。
RLS 的关键约束:
- 表的所有者默认绕过 RLS(除非加
FORCE ROW LEVEL SECURITY)。所以它只在「应用账号不是表所有者」时真正生效——这正好和最小权限的目标一致; - RLS 的策略按命令分(
FOR SELECT/INSERT/UPDATE/DELETE),不写就是ALL。增删改也要有对应的策略,否则默认拒绝; - 每行都要求值,策略里的函数调用会对每一行求值。复杂策略(子查询、函数)会影响性能,必要时用
STABLE函数或者缓存; SET app.tenant_id是会话级的。连接池复用连接时,上一个请求设的租户会留给下一个请求。必须在每次取到连接时重设(或者用SET LOCAL在事务内设置,事务结束自动失效)。
最后一条是 RLS 在生产里最常见的失效方式:策略写对了、测试也过了,但连接池复用的间隙里租户变量没清零,导致 A 租户的请求读到了 B 租户的数据。SET LOCAL 是最省心的规避方式——它只在当前事务里有效。
多租户的三种隔离形态
RLS 只是其中一种。三种形态各有适用范围:
| 形态 | 结构 | 隔离强度 | 迁移成本 | 适用 |
|---|---|---|---|---|
同库同表 + tenant_id 列 |
一张表,靠 WHERE 或 RLS 过滤 |
最弱(靠过滤正确性) | 最低 | 租户多、数据量小、需要跨租户统计 |
| 同库多 schema | 每个租户一套表 | 中(靠 search_path 正确性) |
中(每租户要跑迁移) | 租户数十个、数据要物理分开、又要共用一个实例 |
| 独立库/实例 | 每租户一个库 | 最强 | 最高 | 大客户、合规要求、数据不可混放 |
值得注意的是:第一种形态的隔离强度取决于每一处查询是否都带了 tenant_id 条件。这让它成为最容易出越权事故的形态,也是 RLS 存在的理由——把「记得带条件」这件事从人转移到数据库。
第二种形态的坑在 search_path(见上一节)。它引入了「每个租户的表结构可能不一致」的风险:某个租户的迁移失败了,它的 schema 落后一个版本,问题只在这个租户身上出现。
选择依据通常是合规要求和租户规模。如果客户会问「我的数据是不是和别人存在一起」,第一种形态无论技术做得多好都不好答。
权限决策表
| 需求 | 做法 | 注意 |
|---|---|---|
| 应用只用该用的权限 | 专用角色 + 按表 GRANT |
绝不用超级用户连应用 |
| 权限要好维护 | 用不带 LOGIN 的角色承载权限 |
应用账号继承它,改一处生效 |
| 表有自增列 | 单独 GRANT USAGE ON SEQUENCE |
表权限不覆盖序列权限 |
| 避免表名解析被劫持 | 角色级 SET search_path,不含 public |
关键 SQL 写全 schema.table |
| 同表多租户隔离 | RLS + 会话变量 | 用 SET LOCAL 避免连接池串租户 |
| 每行都要过滤但怕慢 | 策略里用 STABLE 函数 |
策略对每行求值,注意代价 |
生产边界
本课实验在单实例、单库上做,SET ROLE 模拟的是权限判定。生产环境的差别主要在「谁在什么时候设的变量」这一层:连接池(PgBouncer、应用内置池)会让 SET 的会话级效果跨请求残留,这是本地实验看不出来的。
替换到生产环境时要改的地方:
- 连接池模式。PgBouncer 的
transaction模式不支持会话级SET(连接会在事务间被复用给别的客户端),此时租户变量必须用SET LOCAL。 publicschema 的权限。PG 15 起publicschema 默认不再允许所有人CREATE,但已有集群升级后这个权限可能还在。检查方式是查pg_namespace.nspacl,不确定就显式REVOKE CREATE ON SCHEMA public FROM PUBLIC。- 表所有者与 RLS。如果你的迁移工具用表所有者身份运行、应用用另一个账号,RLS 对应用生效;如果两者是同一个账号,RLS 会被绕过(这就是需要
FORCE ROW LEVEL SECURITY的场景)。
上线后该盯的指标:pg_stat_activity 里连接使用的 role(确认没有超级用户连接)、pg_stat_user_tables 上的 RLS 策略命中开销(通过 pg_stat_statements 的 total_exec_time 变化观察)、以及应用日志里 permission denied 的出现频次——生产上出现 permission denied 通常意味着迁移没跑完或者授权脚本漏了。
动手
- 建一个只能
SELECT的角色,用它查一张表、再尝试UPDATE。判断标准:查询成功,UPDATE报permission denied。 - 给一个带
bigserial的表只授INSERT(不授序列),插入一次。判断标准:报错里出现<表名>_<列名>_seq,你能说出该补哪一句GRANT。 - 复现
search_path劫持。判断标准:同一条 SQL 在两个search_path下返回不同的数据,你能说出为什么。 - 建一个 RLS 策略,用两个不同的
app.tenant_id值查同一张表。判断标准:两次返回的行不重叠;然后把变量清掉再查,判断标准:报错而不是返回全部。
自测
- 为什么给表
INSERT权限不够,还需要序列权限?序列在权限模型里是什么对象? search_path的默认值"$user", public里,"$user"那一项在什么情况下会带来安全问题?- RLS 策略写成
USING (tenant_id = current_setting('app.tenant_id', true)::int),当变量未设置时会报错。这个报错是好事还是坏事?如果改成返回全部数据会怎样? - 表的所有者默认绕过 RLS。这对「迁移工具用所有者、应用用普通账号」的部署方式意味着什么?
- 连接池的
transaction模式下,为什么SET app.tenant_id可能串租户?SET LOCAL为什么能避免?
↓ 下一步:06 章 · 选型与边界