RLS 实战场景与方案
前几节把 RLS 的语法、USING / WITH CHECK 的差别、以及绕过机制都讲清了。但真正落地时,难点从来不是"怎么写一条策略",而是"这套策略挂到我的业务模型上,能不能跑得动、有没有漏洞、改需求时好不好维护"。
本节用七个贴近生产的场景把这些零件组装起来:多租户 SaaS、终端用户自助访问、组织层级可见性、软删除收口、权限组动态可见性、审计日志、报表角色。每个场景都给出可直接改造使用的表结构、策略、验证方式,并指出该场景特有的坑。
前置知识
阅读本节前,建议先了解:RLS 绕过机制
基础概念
七类典型场景与对应的策略形态
| 场景 | 隔离维度 | 策略表达式的主要依据 | 典型命令覆盖 |
|---|---|---|---|
| 多租户 SaaS | 租户 ID | 会话变量里的 tenant_id | ALL |
| 终端用户自助访问 | 用户身份 | current_user / 应用传入的 user_id | SELECT / UPDATE / DELETE |
| 组织层级可见性 | 部门路径 | 子查询比对部门祖先链 | SELECT |
| 软删除收口 | 行状态 | deleted_at IS NULL | 全部读写 |
| 权限组动态可见性 | 规则表 | 子查询关联权限组表 | SELECT / UPDATE |
| 审计日志 | 只可追加 | WITH CHECK (true) + 不开 UPDATE / DELETE | SELECT / INSERT |
| 报表角色 | 只读全量 | 独立角色 + 放宽的 USING | SELECT |
这七类不一定互斥:一个真实的 SaaS 系统通常同时叠加"多租户隔离 + 软删除收口 + 报表角色"三层。
RLS 在应用架构中的位置
理解 RLS 的位置,才能判断它该替换掉代码里的哪些逻辑:
PHP 应用进程
│
│ 1. 从会话/Token 解析出 tenant_id
│ 2. 建立连接后 SET LOCAL app.tenant_id = '42'
│
▼
┌────────────────────────────────────────┐
│ PostgreSQL 后端 │
│ │
│ ┌────────────────────────────────┐ │
│ │ SQL 解析 → 权限检查(GRANT) │ │
│ └───────────────┬────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ RLS 策略注入(逐行求值) │ │
│ │ USING → 决定哪些行可见 │ │
│ │ WITH CHECK → 决定哪些行可写 │ │
│ └───────────────┬────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ 执行器 / 索引扫描 │ │
│ └────────────────────────────────┘ │
└────────────────────────────────────────┘关键点:策略过滤发生在用户查询条件之前(leakproof 函数除外),所以应用里那些手写的 WHERE tenant_id = ? 不能省——它们是给优化器用的索引条件,而 RLS 是最后一道防线。两者是纵深防御关系,不是替代关系。
三种多租户物理模型
选 RLS 之前,先确认你的物理隔离模型选对了:
| 模型 | 结构 | 隔离强度 | 运维成本 | RLS 角色 |
|---|---|---|---|---|
| 共享库 + 共享表 | 所有租户一张表,加 tenant_id 列 | 逻辑隔离 | 最低 | 核心手段 |
| 共享库 + 独立 schema | 每租户一个 schema,同名表 | 逻辑隔离 | 中 | 辅助手段 |
| 独立库 | 每租户一个 database | 物理隔离 | 最高 | 基本不需要 |
RLS 主要服务于第一种模型——它把"每个查询都必须记得加租户过滤"这件事,从应用纪律变成了数据库强制。第二种模型仍建议加 RLS,因为同库内 schema 之间并没有天然隔离;第三种模型下 RLS 通常只用于库内的细分角色。
设计前的四个必答问题
- 隔离标识从哪来? 是会话级(同一连接内固定),还是请求级(每个请求可能不同租户)?这决定你能否用
SET,还是必须每次SET LOCAL。 - 写路径要不要校验? 只写
USING会让越权写入静默通过——写路径必须有WITH CHECK。 - 有没有跨租户的合法操作? 平台管理员、运营后台、跨租户报表需要单独的绕过身份,不要用"给普通角色放宽策略"来实现。
- 权限变更频率如何? 策略里引用子查询(如权限组表)会带来一致性风险与性能开销,高频变更时要特别设计。
语法与用法
多租户隔离的标准骨架
最通用的一套写法:会话变量承载租户身份,策略读取变量做比对。
-- 1. 业务表:每张多租户表都带 tenant_id
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL,
user_id int NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL DEFAULT 'pending',
deleted_at timestamptz
);
CREATE INDEX idx_orders_tenant ON orders (tenant_id);
-- 2. 打开行级安全
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 3. 一条 ALL 策略覆盖读写
CREATE POLICY p_tenant ON orders
TO app_user
USING (tenant_id = current_setting('app.tenant_id')::int)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);USING 管可见性,WITH CHECK 管写入。对 ALL 策略,如果不显式写 WITH CHECK,它会隐式复用 USING——但显式写出来更安全也更易读,尤其在后续要单独调整某一侧时。
会话变量的设置方式
-- 连接建立后,整条连接固定一个租户(适合长连接)
SET app.tenant_id = '42';
-- 事务级设置:事务结束自动失效(推荐,配合连接池)
BEGIN;
SET LOCAL app.tenant_id = '42';
SELECT * FROM orders;
COMMIT;SET LOCAL 是连接池场景下的正确选择:连接归还池中时变量自动清空,避免下一个请求误用上一个租户的身份。
每用户自助访问(按行归属)
CREATE POLICY p_owner ON user_profiles
TO app_user
USING (user_id = current_setting('app.user_id')::int)
WITH CHECK (user_id = current_setting('app.user_id')::int);只读报表角色(全量可见,但不可写)
CREATE ROLE rpt_user NOLOGIN;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO rpt_user;
CREATE POLICY p_report ON orders
FOR SELECT
TO rpt_user
USING (true);注意这条策略只覆盖 SELECT。报表角色没有 INSERT / UPDATE / DELETE 策略,而 RLS 的默认语义是拒绝——所以即使误授了写权限,写操作也会被策略挡下。
软删除收口(RESTRICTIVE 叠加)
-- 与租户策略是 AND 关系:两个条件必须同时满足
CREATE POLICY p_not_deleted ON orders
AS RESTRICTIVE
TO app_user
USING (deleted_at IS NULL);RESTRICTIVE 策略用 AND 组合,天然适合做"全局约束"(软删除、归档、状态白名单),和多个 PERMISSIVE 业务策略叠加时不会互相干扰。
详细说明
场景一:多租户 SaaS —— 共享表的行级隔离
这是 RLS 最有价值、也最容易踩坑的场景。完整落地需要三层配合:表结构带租户列 → 策略强制过滤 → 索引支撑性能。
CREATE TABLE tenants (
id int PRIMARY KEY,
code text NOT NULL,
name text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL REFERENCES tenants,
user_id int NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
-- 复合索引:先租户,后高频查询列
CREATE INDEX idx_orders_tenant_created ON orders (tenant_id, created_at DESC);
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- 表属主也要受限
CREATE POLICY p_tenant ON orders
TO app_user
USING (tenant_id = current_setting('app.tenant_id')::int)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);ALTER TABLE ... FORCE ROW LEVEL SECURITY 在多租户场景里几乎是必写的:很多团队让应用账号同时也是建表账号,如果不 FORCE,策略对应用完全无效——这是"策略写了却没生效"的头号原因。
为什么索引用 (tenant_id, created_at DESC) 而不是单独 (created_at)?因为 RLS 会把 tenant_id = ? 作为过滤条件注入,优化器需要它作为索引前缀才能把扫描范围限制在本租户内。
场景二:终端用户自助访问自己的数据
面向 C 端的功能,用户只能看/改自己的行。这里是 WITH CHECK 必须存在的典型场景:只写 USING 的话,用户可以插入一条 user_id 是别人的记录。
CREATE TABLE user_profiles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL,
user_id int NOT NULL,
nickname text NOT NULL,
phone text,
UNIQUE (user_id)
);
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
-- 读写都用同一条策略:既能看自己的,也只能写自己的
CREATE POLICY p_self ON user_profiles
TO app_user
USING (user_id = current_setting('app.user_id')::int)
WITH CHECK (user_id = current_setting('app.user_id')::int);如果想进一步限制"哪些列允许改",那属于列级权限(GRANT UPDATE (nickname) ON ...)的职责,不是 RLS 的——RLS 只能按行控制,不能按列控制。
-- 只允许自助修改昵称
GRANT UPDATE (nickname) ON user_profiles TO app_user;场景三:组织层级与上下级可见性
部门树、多级代理这类需求,可见性不是"等于"关系,而是"在我的子树里"。常见做法是给每个节点存一个物化路径,用前缀匹配表达"子树"。
CREATE TABLE departments (
id int PRIMARY KEY,
tenant_id int NOT NULL,
name text NOT NULL,
path text NOT NULL -- 例如 '/1/7/33/'
);
CREATE INDEX idx_dept_path ON departments (path text_pattern_ops);
CREATE TABLE documents (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL,
department_id int NOT NULL REFERENCES departments,
title text NOT NULL,
body text
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- 可见本部门及所有下级部门的文档
CREATE POLICY p_subtree ON documents
FOR SELECT
TO app_user
USING (
department_id IN (
SELECT d.id
FROM departments d
JOIN departments me ON d.path LIKE me.path || '%'
WHERE me.id = current_setting('app.dept_id')::int
)
);这里策略表达式里出现了子查询。官方文档专门提醒:策略中引用其他行或表会引入竞态条件(race condition),可能造成信息泄漏。如果被引用的表(这里是 departments)会频繁变更可见性,要么用 SELECT ... FOR SHARE,要么在修改层级后等待并发事务结束再放出新的可见性变更。低频变更的组织架构可以用物化路径;高频变更则建议由应用层在写请求时预计算可见的 department_id 列表。
场景四:软删除与状态过滤统一收口
"所有业务查询都要过滤掉已删除行"是一条很容易在某个新接口里被忘掉的纪律。用一条 RESTRICTIVE 策略把它变成数据库强制:
-- 全局约束:已删除的行对普通业务角色不可见
CREATE POLICY p_active_only ON orders
AS RESTRICTIVE
TO app_user
USING (deleted_at IS NULL);
-- 另一个全局约束:归档终态不允许再修改
CREATE POLICY p_no_archive_write ON orders
AS RESTRICTIVE
TO app_user
WITH CHECK (status <> 'archived');用 RESTRICTIVE 而不是再写一条 PERMISSIVE,是因为组合语义不同:RESTRICTIVE 是 AND,会和其他所有 PERMISSIVE 策略一起收敛结果;如果写成 PERMISSIVE,它就变成 OR,等于放宽了而不是收紧。
这两条策略需要注意覆盖范围:p_active_only 只写 USING,作用于 ALL。按官方规则,ALL 策略未显式给 WITH CHECK 时,USING 会同时用于新行校验——也就是你无法插入/更新出一条 deleted_at 非空的行。这在大多数场景下是想要的,但如果你需要"软删除操作"本身能写入 deleted_at,就得为它单开一条更宽松的策略,或让软删除走特权角色。
场景五:基于权限组(rules table)的动态可见性
当可见性规则本身存在表里(而不是硬编码在策略里)时,策略表达式就必须查这张规则表。这是最灵活也最危险的一种设计。
CREATE TABLE acl_groups (
group_id int PRIMARY KEY,
tenant_id int NOT NULL,
name text NOT NULL,
level int NOT NULL
);
CREATE TABLE acl_members (
group_id int NOT NULL REFERENCES acl_groups,
user_id int NOT NULL,
PRIMARY KEY (group_id, user_id)
);
CREATE TABLE resources (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL,
level int NOT NULL, -- 需要达到的等级才能看
payload text
);
ALTER TABLE resources ENABLE ROW LEVEL SECURITY;
-- 当前用户所属组的最高等级 >= 资源要求的等级
CREATE POLICY p_level ON resources
FOR SELECT
TO app_user
USING (
level <= (
SELECT max(g.level)
FROM acl_members m
JOIN acl_groups g ON g.group_id = m.group_id
WHERE m.user_id = current_setting('app.user_id')::int
)
);官方文档给出过一个与本书场景几乎一致的例子,并演示了竞态条件:管理员在一个事务里先降低某用户的权限等级,再更新被保护的行;如果该用户并发执行 SELECT ... FOR UPDATE,在 READ COMMITTED 隔离级别下,他的策略子查询用的是查询开始时的快照,于是旧的(更高的)等级仍被用来判断,他可能看到本应看不到的新内容。
规避方式(官方给出三种):
- 策略子查询里用
SELECT ... FOR SHARE,强制对规则行加共享锁——代价是需要给用户授予规则表的UPDATE权限(可用另一条策略阻止他真正去写); - 更新规则表时加
ACCESS EXCLUSIVE锁——适合规则表更新不频繁的场景; - 更新规则表提交后,等待所有并发事务结束,再做依赖新权限的数据变更。
对绝大多数业务,最省事的选择是第三种,或者干脆把"权限等级"在应用侧读出来、通过会话变量传入,避免策略里出现子查询。
场景六:审计日志的"只可追加"模型
审计表的需求通常是:谁都能写、特定角色能读、任何人(包括应用账号)都不能改或删。
CREATE TABLE audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL,
actor_id int NOT NULL,
action text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
-- 允许写入:只校验租户归属
CREATE POLICY p_audit_insert ON audit_log
FOR INSERT
TO app_user
WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);
-- 允许读取:只能读本租户
CREATE POLICY p_audit_select ON audit_log
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.tenant_id')::int);故意不创建 UPDATE / DELETE 策略。因为 RLS 默认拒绝,没有对应策略的命令会被直接挡下——这比"在代码里约定不要改审计表"可靠得多。即便某天有人手滑给 app_user 授了 UPDATE 权限,也改不动任何一行。
注意别把 FOR ALL 策略用在这里:一条 ALL 策略会同时覆盖 SELECT / INSERT / UPDATE / DELETE,等于亲手把审计表变成可改的。
场景七:报表与导出角色
报表需要全量数据,但绝不能有写权限。做法是独立角色 + 只覆盖 SELECT 的宽松策略,而不是去给已有业务角色放宽。
CREATE ROLE rpt_user NOLOGIN;
GRANT USAGE ON SCHEMA public TO rpt_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO rpt_user;
-- 只对 SELECT 生效,全量可见
CREATE POLICY p_rpt_orders ON orders
FOR SELECT
TO rpt_user
USING (true);这里有一个容易忽略的点:USING (true) 让报表角色能看到所有租户的数据。如果报表本身也需要按租户切分,就应该为报表角色也加上租户条件:
CREATE POLICY p_rpt_tenant ON orders
FOR SELECT
TO rpt_user
USING (tenant_id = current_setting('app.report_tenant_id')::int);"报表要不要跨租户"是一个业务决策,不是技术决策。如果答案是"要",那么这个角色实质上就是一个受控的绕过通道,应当纳入审计范围(参见RLS 绕过机制中关于 BYPASSRLS 与审计的讨论)。
连接池与事务边界:SET LOCAL 的正确时机
连接池(PgBouncer、PHP-FPM 长连接、ORM 连接池)会把同一条物理连接复用给不同租户的请求。用 SET 设置租户变量在池化场景下是数据泄漏事故:
-- 危险:SET 的作用域是整个会话/连接
SET app.tenant_id = '42';连接归还池中时这个值仍然存在。如果下一个请求忘了重设,就会以租户 42 的身份操作数据。正确做法是把它绑定在事务上:
BEGIN;
SET LOCAL app.tenant_id = '42'; -- 事务提交/回滚后自动失效
-- ... 业务 SQL
COMMIT;在 PHP 中对应地写成显式的 beginTransaction() / commit(),或者用 set_config 的三参数形式:
-- 第三个参数 true 表示"事务级"(等价于 SET LOCAL)
SELECT set_config('app.tenant_id', '42', true);需要提醒的是:PgBouncer 的 transaction 池化模式下,同一事务内的所有语句才会落到同一连接,所以事务边界必须包住"设置变量 + 全部业务 SQL",不能只包住变量设置。
与 PHP / PDO 及 ORM 的集成要点
RLS 对应用代码的侵入很小,但有三处必须配合:
// 1. 每个请求(事务)开始时设置身份
$pdo->beginTransaction();
$stmt = $pdo->prepare("SELECT set_config('app.tenant_id', :tid, true)");
$stmt->execute([':tid' => (string) $tenantId]);
// 2. 业务 SQL 照常写 —— RLS 会自动加过滤条件
$stmt = $pdo->query("SELECT id, amount FROM orders WHERE status = 'pending'");
// 3. 越权写入会抛异常,按 SQLSTATE 区分处理
try {
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
if ($e->getCode() === '42501') {
// 违反 RLS 的 WITH CHECK(权限不足)
} else {
throw $e;
}
}三个要点:
getCode()的类型不稳定:PDO 在不同驱动/设置下可能返回字符串或整数,比较 SQLSTATE 时建议统一转成字符串再比。USING失败不报错:读路径被策略过滤是静默的(返回更少的行),不会抛异常。所以"数据变少了"这类问题不能靠捕获异常发现,得靠测试。- ORM 的
INSERT ... ON CONFLICT DO UPDATE:如果冲突行不满足UPDATE的策略USING,会直接报错而不是静默跳过,写业务代码时要留意这个差异。
策略的集中管理、命名约定与自动测试
策略数量随业务增长,散落在各个 migration 里会变成维护噩梦。建议:
统一命名前缀,让 pg_policies 的输出可读、可按前缀批量删除:
-- 记录某个租户可见哪些策略
SELECT schemaname, tablename, policyname, cmd, permissive, roles
FROM pg_policies
WHERE policyname LIKE 'p_%'
ORDER BY tablename, policyname;把"权限需求"写成可回归的测试用例。RLS 最危险的地方是"看起来生效了"——一定用 SET ROLE + SET LOCAL 模拟真实身份跑一遍:
-- 测试:以租户 42 的身份,不能看到租户 43 的行
BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '42', true);
-- 期望结果:0 行
SELECT count(*) FROM orders WHERE tenant_id = 43;
ROLLBACK; count
-------
0
(1 row)如果这条返回了非 0,说明策略有漏洞。把这类断言写进 CI,是 RLS 上生产的最低门槛。
实战示例
示例一:多租户订单系统端到端搭建与验证
从建表到验证,走完一个完整闭环。
-- ============ 准备:角色与表 ============
CREATE ROLE app_user NOLOGIN;
CREATE TABLE tenants (
id int PRIMARY KEY,
code text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id int NOT NULL REFERENCES tenants,
user_id int NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL DEFAULT 'pending',
deleted_at timestamptz
);
CREATE INDEX idx_orders_tenant_created ON orders (tenant_id);
INSERT INTO tenants VALUES (42, 'ACME'), (43, 'GLOBEX');
INSERT INTO orders (tenant_id, user_id, amount) VALUES
(42, 1, 100.00),
(42, 1, 200.00),
(43, 2, 999.00);
-- ============ 权限与策略 ============
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
GRANT USAGE ON SEQUENCE orders_id_seq TO app_user;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY p_tenant ON orders
TO app_user
USING (tenant_id = current_setting('app.tenant_id')::int)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);以租户 42 的身份读取:
BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '42', true);
SELECT id, tenant_id, amount FROM orders ORDER BY id; id | tenant_id | amount
----+-----------+--------
1 | 42 | 100.00
2 | 42 | 200.00
(2 rows)租户 43 的那条 999.00 被策略静默过滤掉了。再验证写路径的 WITH CHECK:
-- 试图写入别的租户的数据
INSERT INTO orders (tenant_id, user_id, amount) VALUES (43, 1, 1.00);ERROR: new row violates row-level security policy for table "orders"写入被拦下(SQLSTATE 42501),提交事务即可看到效果:
COMMIT;这个闭环说明了一件事:读保护靠 USING(静默过滤),写保护靠 WITH CHECK(报错),两者缺一不可。
示例二:报表角色与只读访问
在示例一的基础上,加一个只能读、且只看本租户的报表角色,并验证它无论如何改不动数据。
-- ============ 报表角色 ============
CREATE ROLE rpt_user NOLOGIN;
GRANT USAGE ON SCHEMA public TO rpt_user;
GRANT SELECT ON orders TO rpt_user;
-- 只对 SELECT 生效
CREATE POLICY p_rpt_tenant ON orders
FOR SELECT
TO rpt_user
USING (tenant_id = current_setting('app.report_tenant_id')::int);BEGIN;
SET LOCAL ROLE rpt_user;
SELECT set_config('app.report_tenant_id', '43', true);
SELECT tenant_id, count(*), sum(amount) AS total
FROM orders
GROUP BY tenant_id;
COMMIT; tenant_id | count | total
-----------+-------+---------
43 | 1 | 999.00
(1 row)报表角色按 report_tenant_id 切分,看到了租户 43 的汇总。现在验证它写不动数据——它本来就没有写权限,这里故意模拟一次配置事故:
-- 以属主身份误授写权限,模拟一次配置事故
GRANT UPDATE ON orders TO rpt_user;UPDATE orders SET amount = 0;UPDATE 0注意这里是 UPDATE 0,不是报错。rpt_user 只有 FOR SELECT 策略,UPDATE 命令没有任何策略覆盖它,而没有策略即默认拒绝——RLS 会注入一个恒假条件,把所有待更新行过滤掉。数据一行都没改动,但语句本身"成功"返回。
这正是 RLS 容易被误判的地方:写路径的默认拒绝是静默的(UPDATE 0 / DELETE 0),而 WITH CHECK 校验失败才会报错。所以监控"写操作影响了 0 行"比捕获异常更重要。若确实需要"越权写入直接报错",那就得显式提供一条永远不满足的 WITH CHECK 策略,或依赖上层应用的权限检查。
ROLLBACK;注意事项
读被过滤是静默的,别指望异常。USING 表达式返回假时,行会被悄悄剔除,不报错。所以"查询结果比预期少"这类 bug 只能靠测试发现,不能靠 try/catch。
-- 不会报错,只是返回 0 行
SELECT * FROM orders WHERE tenant_id = 43; -- 当前身份是租户 42只写 USING 是越权写入的根源。ALL 或 UPDATE 策略如果省略 WITH CHECK,USING 会被同时用于新行校验;但 INSERT 策略根本不允许有 USING,必须写 WITH CHECK。别让写路径缺失校验。
-- 错误!INSERT 策略不能有 USING
CREATE POLICY p_bad ON orders FOR INSERT TO app_user
USING (tenant_id = 1); -- ERROR: "USING" not allowed for INSERT policy不许用 SET 设置租户身份。池化连接下会串租户。
-- 不推荐:会话级,连接复用时会泄漏
SET app.tenant_id = '42';
-- 推荐:事务级
SET LOCAL app.tenant_id = '42';子查询策略要防竞态。策略里查权限组表,在 READ COMMITTED 下可能读到旧快照,导致权限降级后仍能看到新数据。低频变更用锁或延迟;高频变更把判断前移到应用侧。
表属主默认绕过,务必 FORCE。策略写了却不生效,绝大多数情况是漏了 FORCE ROW LEVEL SECURITY。
-- 检查是否漏了
SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'orders';
-- relforcerowsecurity = f 就是漏洞策略表达式不得含聚合与窗口函数。需要聚合结果时先算好存到表里,或在应用侧算完用会话变量传入。
-- 错误!
CREATE POLICY p_bad ON orders TO app_user
USING (tenant_id = max(tenant_id)); -- aggregate functions are not allowed in policy expressionsCOPY FROM 在 RLS 表上不可用。批量导入数据到受 RLS 保护的表时,需要改用 INSERT,或让导入以特权身份进行。
跨租户要用专门的特权身份,不要放宽业务策略。给 app_user 加一条 USING (true) 是最常见的"临时方案",且几乎没人会记得删掉它。
策略要有命名前缀。pg_policies 里没有前缀的策略无法批量管理,改需求时容易漏删。
别用 FOR ALL 处理审计表。FOR ALL 覆盖 INSERT / UPDATE / DELETE,会让"只可追加"的审计表变成可改的。
最佳实践
租户列永远放复合索引最左。RLS 注入的 tenant_id = ? 需要一个可用的索引前缀,否则大表会退化成全表扫描。
CREATE INDEX idx_orders_tenant_user ON orders (tenant_id, user_id);用会话变量而不是 current_user 承载租户身份。一个数据库用户往往要服务多个租户,把租户写进角色名会导致角色数量爆炸。current_setting('app.tenant_id') 才是可扩展的做法。
给会话变量读取加兜底,避免变量未设置时报错中断业务:
-- 未设置时返回 NULL,策略自然不通过(安全默认)
NULLIF(current_setting('app.tenant_id', true), '')::int软删除、归档、状态白名单统一用 RESTRICTIVE。它们语义上是 AND,用 PERMISSIVE 会变成 OR,等于放宽。
报表与运维角色单独建,不复用业务角色。职责分离,也便于把它们的策略单独审计。
所有多租户表一次性打开 ENABLE + FORCE,并写进建表模板(migration 里成对出现):
ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;
ALTER TABLE <t> FORCE ROW LEVEL SECURITY;把 RLS 断言写进 CI。每个受保护的表至少三条用例:本租户能读、跨租户读不到、跨租户 INSERT 报错。注意区分:INSERT 违反 WITH CHECK 会报错(42501),而 UPDATE / DELETE 若被 USING 过滤则是静默的 UPDATE 0 / DELETE 0——两种都要断言。
-- 断言跨租户写会报错(期望 SQLSTATE 42501)
BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '42', true);
INSERT INTO orders (tenant_id, user_id, amount) VALUES (43, 1, 1.00);
ROLLBACK;
-- 断言跨租户改是静默 0 行(期望 UPDATE 0)
BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '42', true);
UPDATE orders SET amount = 0 WHERE tenant_id = 43;
ROLLBACK;优先设计"只依赖当前行"的策略。官方明确说这是最简单、性能最好的形式。需要跨行/跨表判断时,先问能不能改为把结果物化到当前表的一列上。
给策略表达式里用到的函数加索引友好的写法。例如 current_setting(...) 是稳定函数,放在等号右侧可以让左侧的列走索引。
升级或调整策略后,用 pg_policies 复核实际内容,不要只看 migration 文件:
SELECT policyname, cmd, permissive, roles, qual, with_check
FROM pg_policies WHERE tablename = 'orders';连接池用 transaction 模式并保证事务包住"设变量 + 业务 SQL"。会话模式的池化会让 SET LOCAL 语义混乱。
为绕过通道建立清单与审计。谁有 BYPASSRLS、哪些表属主未 FORCE、哪些视图用了 security_invoker,定期复核一遍:
-- 所有具备 BYPASSRLS 的角色
SELECT rolname FROM pg_roles WHERE rolbypassrls;下一节
继续学习:逻辑备份 pg_dump
参考链接
- PostgreSQL 官方文档:Row Security Policies
- PostgreSQL 官方文档:CREATE POLICY
- PostgreSQL 官方文档:ALTER POLICY
- PostgreSQL 官方文档:ALTER TABLE(ENABLE / FORCE ROW LEVEL SECURITY)
- PostgreSQL 官方文档:Client Connection Defaults(row_security)
- PostgreSQL 官方文档:pg_policies
- PostgreSQL 官方文档:CREATE VIEW(security_invoker)
- PostgreSQL 官方文档:System Administration Functions(set_config / current_setting)