KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05 · 权限、schema 与租户隔离 — keel 龙骨

## 现场:应用连的是超级用户

现场:应用连的是超级用户

一个典型的快速起步配置:

database:
  url: postgresql://postgres:password@db:5432/appdb

用超级用户连库,开发阶段什么都能跑,迁移不用授权,建表不用申请。问题在第一次安全审计时暴露:数据库里没有一层边界。

具体一点,这个配置意味着什么:

这一章讲的是把这些边界补回来——以及补的过程中会遇到的两个坑: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 的威力:它决定了你的表名解析到哪张表。

风险有三个层次:

  1. 迁移或者修复脚本里临时 SET search_path 之后忘了 RESET,后面的语句跑到别的 schema 上;
  2. 应用账号与某个 schema 同名("$user" 那一项生效),导致该 schema 里的表被优先命中;
  3. public schema 默认对所有人有 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 在生产里最常见的失效方式:策略写对了、测试也过了,但连接池复用的间隙里租户变量没清零,导致 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 的会话级效果跨请求残留,这是本地实验看不出来的。

替换到生产环境时要改的地方:

  1. 连接池模式。PgBouncer 的 transaction 模式不支持会话级 SET(连接会在事务间被复用给别的客户端),此时租户变量必须用 SET LOCAL。
  2. public schema 的权限。PG 15 起 public schema 默认不再允许所有人 CREATE,但已有集群升级后这个权限可能还在。检查方式是查 pg_namespace.nspacl,不确定就显式 REVOKE CREATE ON SCHEMA public FROM PUBLIC。
  3. 表所有者与 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 通常意味着迁移没跑完或者授权脚本漏了。

动手

  1. 建一个只能 SELECT 的角色,用它查一张表、再尝试 UPDATE。判断标准:查询成功,UPDATE 报 permission denied。
  2. 给一个带 bigserial 的表只授 INSERT(不授序列),插入一次。判断标准:报错里出现 <表名>_<列名>_seq,你能说出该补哪一句 GRANT。
  3. 复现 search_path 劫持。判断标准:同一条 SQL 在两个 search_path 下返回不同的数据,你能说出为什么。
  4. 建一个 RLS 策略,用两个不同的 app.tenant_id 值查同一张表。判断标准:两次返回的行不重叠;然后把变量清掉再查,判断标准:报错而不是返回全部。

自测

  1. 为什么给表 INSERT 权限不够,还需要序列权限?序列在权限模型里是什么对象?
  2. search_path 的默认值 "$user", public 里,"$user" 那一项在什么情况下会带来安全问题?
  3. RLS 策略写成 USING (tenant_id = current_setting('app.tenant_id', true)::int),当变量未设置时会报错。这个报错是好事还是坏事?如果改成返回全部数据会怎样?
  4. 表的所有者默认绕过 RLS。这对「迁移工具用所有者、应用用普通账号」的部署方式意味着什么?
  5. 连接池的 transaction 模式下,为什么 SET app.tenant_id 可能串租户?SET LOCAL 为什么能避免?

↓ 下一步:06 章 · 选型与边界

进入 keel 阅读