Skip to content

RLS 实战场景与方案 ​

前几节把 RLS 的语法、USING / WITH CHECK 的差别、以及绕过机制都讲清了。但真正落地时,难点从来不是"怎么写一条策略",而是"这套策略挂到我的业务模型上,能不能跑得动、有没有漏洞、改需求时好不好维护"。

本节用七个贴近生产的场景把这些零件组装起来:多租户 SaaS、终端用户自助访问、组织层级可见性、软删除收口、权限组动态可见性、审计日志、报表角色。每个场景都给出可直接改造使用的表结构、策略、验证方式,并指出该场景特有的坑。

前置知识

阅读本节前,建议先了解:RLS 绕过机制

基础概念 ​

七类典型场景与对应的策略形态 ​

场景隔离维度策略表达式的主要依据典型命令覆盖
多租户 SaaS租户 ID会话变量里的 tenant_idALL
终端用户自助访问用户身份current_user / 应用传入的 user_idSELECT / UPDATE / DELETE
组织层级可见性部门路径子查询比对部门祖先链SELECT
软删除收口行状态deleted_at IS NULL全部读写
权限组动态可见性规则表子查询关联权限组表SELECT / UPDATE
审计日志只可追加WITH CHECK (true) + 不开 UPDATE / DELETESELECT / INSERT
报表角色只读全量独立角色 + 放宽的 USINGSELECT

这七类不一定互斥:一个真实的 SaaS 系统通常同时叠加"多租户隔离 + 软删除收口 + 报表角色"三层。

RLS 在应用架构中的位置 ​

理解 RLS 的位置,才能判断它该替换掉代码里的哪些逻辑:

text
   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 通常只用于库内的细分角色。

设计前的四个必答问题 ​

  1. 隔离标识从哪来? 是会话级(同一连接内固定),还是请求级(每个请求可能不同租户)?这决定你能否用 SET,还是必须每次 SET LOCAL。
  2. 写路径要不要校验? 只写 USING 会让越权写入静默通过——写路径必须有 WITH CHECK。
  3. 有没有跨租户的合法操作? 平台管理员、运营后台、跨租户报表需要单独的绕过身份,不要用"给普通角色放宽策略"来实现。
  4. 权限变更频率如何? 策略里引用子查询(如权限组表)会带来一致性风险与性能开销,高频变更时要特别设计。

语法与用法 ​

多租户隔离的标准骨架 ​

最通用的一套写法:会话变量承载租户身份,策略读取变量做比对。

sql
-- 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——但显式写出来更安全也更易读,尤其在后续要单独调整某一侧时。

会话变量的设置方式 ​

sql
-- 连接建立后,整条连接固定一个租户(适合长连接)
SET app.tenant_id = '42';

-- 事务级设置:事务结束自动失效(推荐,配合连接池)
BEGIN;
SET LOCAL app.tenant_id = '42';
SELECT * FROM orders;
COMMIT;

SET LOCAL 是连接池场景下的正确选择:连接归还池中时变量自动清空,避免下一个请求误用上一个租户的身份。

每用户自助访问(按行归属) ​

sql
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);

只读报表角色(全量可见,但不可写) ​

sql
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 叠加) ​

sql
-- 与租户策略是 AND 关系:两个条件必须同时满足
CREATE POLICY p_not_deleted ON orders
    AS RESTRICTIVE
    TO app_user
    USING (deleted_at IS NULL);

RESTRICTIVE 策略用 AND 组合,天然适合做"全局约束"(软删除、归档、状态白名单),和多个 PERMISSIVE 业务策略叠加时不会互相干扰。

详细说明 ​

场景一:多租户 SaaS —— 共享表的行级隔离 ​

这是 RLS 最有价值、也最容易踩坑的场景。完整落地需要三层配合:表结构带租户列 → 策略强制过滤 → 索引支撑性能。

sql
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 是别人的记录。

sql
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 只能按行控制,不能按列控制。

sql
-- 只允许自助修改昵称
GRANT UPDATE (nickname) ON user_profiles TO app_user;

场景三:组织层级与上下级可见性 ​

部门树、多级代理这类需求,可见性不是"等于"关系,而是"在我的子树里"。常见做法是给每个节点存一个物化路径,用前缀匹配表达"子树"。

sql
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 策略把它变成数据库强制:

sql
-- 全局约束:已删除的行对普通业务角色不可见
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)的动态可见性 ​

当可见性规则本身存在表里(而不是硬编码在策略里)时,策略表达式就必须查这张规则表。这是最灵活也最危险的一种设计。

sql
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 锁——适合规则表更新不频繁的场景;
  • 更新规则表提交后,等待所有并发事务结束,再做依赖新权限的数据变更。

对绝大多数业务,最省事的选择是第三种,或者干脆把"权限等级"在应用侧读出来、通过会话变量传入,避免策略里出现子查询。

场景六:审计日志的"只可追加"模型 ​

审计表的需求通常是:谁都能写、特定角色能读、任何人(包括应用账号)都不能改或删。

sql
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 的宽松策略,而不是去给已有业务角色放宽。

sql
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) 让报表角色能看到所有租户的数据。如果报表本身也需要按租户切分,就应该为报表角色也加上租户条件:

sql
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 设置租户变量在池化场景下是数据泄漏事故:

sql
-- 危险:SET 的作用域是整个会话/连接
SET app.tenant_id = '42';

连接归还池中时这个值仍然存在。如果下一个请求忘了重设,就会以租户 42 的身份操作数据。正确做法是把它绑定在事务上:

sql
BEGIN;
SET LOCAL app.tenant_id = '42';   -- 事务提交/回滚后自动失效
-- ... 业务 SQL
COMMIT;

在 PHP 中对应地写成显式的 beginTransaction() / commit(),或者用 set_config 的三参数形式:

sql
-- 第三个参数 true 表示"事务级"(等价于 SET LOCAL)
SELECT set_config('app.tenant_id', '42', true);

需要提醒的是:PgBouncer 的 transaction 池化模式下,同一事务内的所有语句才会落到同一连接,所以事务边界必须包住"设置变量 + 全部业务 SQL",不能只包住变量设置。

与 PHP / PDO 及 ORM 的集成要点 ​

RLS 对应用代码的侵入很小,但有三处必须配合:

php
// 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 的输出可读、可按前缀批量删除:

sql
-- 记录某个租户可见哪些策略
SELECT schemaname, tablename, policyname, cmd, permissive, roles
FROM pg_policies
WHERE policyname LIKE 'p_%'
ORDER BY tablename, policyname;

把"权限需求"写成可回归的测试用例。RLS 最危险的地方是"看起来生效了"——一定用 SET ROLE + SET LOCAL 模拟真实身份跑一遍:

sql
-- 测试:以租户 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;
text
 count
-------
     0
(1 row)

如果这条返回了非 0,说明策略有漏洞。把这类断言写进 CI,是 RLS 上生产的最低门槛。

实战示例 ​

示例一:多租户订单系统端到端搭建与验证 ​

从建表到验证,走完一个完整闭环。

sql
-- ============ 准备:角色与表 ============
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 的身份读取:

sql
BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '42', true);

SELECT id, tenant_id, amount FROM orders ORDER BY id;
text
 id | tenant_id | amount
----+-----------+--------
  1 |        42 | 100.00
  2 |        42 | 200.00
(2 rows)

租户 43 的那条 999.00 被策略静默过滤掉了。再验证写路径的 WITH CHECK:

sql
-- 试图写入别的租户的数据
INSERT INTO orders (tenant_id, user_id, amount) VALUES (43, 1, 1.00);
text
ERROR:  new row violates row-level security policy for table "orders"

写入被拦下(SQLSTATE 42501),提交事务即可看到效果:

sql
COMMIT;

这个闭环说明了一件事:读保护靠 USING(静默过滤),写保护靠 WITH CHECK(报错),两者缺一不可。

示例二:报表角色与只读访问 ​

在示例一的基础上,加一个只能读、且只看本租户的报表角色,并验证它无论如何改不动数据。

sql
-- ============ 报表角色 ============
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);
sql
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;
text
 tenant_id | count |  total
-----------+-------+---------
        43 |     1 |  999.00
(1 row)

报表角色按 report_tenant_id 切分,看到了租户 43 的汇总。现在验证它写不动数据——它本来就没有写权限,这里故意模拟一次配置事故:

sql
-- 以属主身份误授写权限,模拟一次配置事故
GRANT UPDATE ON orders TO rpt_user;
sql
UPDATE orders SET amount = 0;
text
UPDATE 0

注意这里是 UPDATE 0,不是报错。rpt_user 只有 FOR SELECT 策略,UPDATE 命令没有任何策略覆盖它,而没有策略即默认拒绝——RLS 会注入一个恒假条件,把所有待更新行过滤掉。数据一行都没改动,但语句本身"成功"返回。

这正是 RLS 容易被误判的地方:写路径的默认拒绝是静默的(UPDATE 0 / DELETE 0),而 WITH CHECK 校验失败才会报错。所以监控"写操作影响了 0 行"比捕获异常更重要。若确实需要"越权写入直接报错",那就得显式提供一条永远不满足的 WITH CHECK 策略,或依赖上层应用的权限检查。

sql
ROLLBACK;

注意事项 ​

读被过滤是静默的,别指望异常。USING 表达式返回假时,行会被悄悄剔除,不报错。所以"查询结果比预期少"这类 bug 只能靠测试发现,不能靠 try/catch。

sql
-- 不会报错,只是返回 0 行
SELECT * FROM orders WHERE tenant_id = 43;   -- 当前身份是租户 42

只写 USING 是越权写入的根源。ALL 或 UPDATE 策略如果省略 WITH CHECK,USING 会被同时用于新行校验;但 INSERT 策略根本不允许有 USING,必须写 WITH CHECK。别让写路径缺失校验。

sql
-- 错误!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 设置租户身份。池化连接下会串租户。

sql
-- 不推荐:会话级,连接复用时会泄漏
SET app.tenant_id = '42';
-- 推荐:事务级
SET LOCAL app.tenant_id = '42';

子查询策略要防竞态。策略里查权限组表,在 READ COMMITTED 下可能读到旧快照,导致权限降级后仍能看到新数据。低频变更用锁或延迟;高频变更把判断前移到应用侧。

表属主默认绕过,务必 FORCE。策略写了却不生效,绝大多数情况是漏了 FORCE ROW LEVEL SECURITY。

sql
-- 检查是否漏了
SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'orders';
-- relforcerowsecurity = f 就是漏洞

策略表达式不得含聚合与窗口函数。需要聚合结果时先算好存到表里,或在应用侧算完用会话变量传入。

sql
-- 错误!
CREATE POLICY p_bad ON orders TO app_user
    USING (tenant_id = max(tenant_id));  -- aggregate functions are not allowed in policy expressions

COPY FROM 在 RLS 表上不可用。批量导入数据到受 RLS 保护的表时,需要改用 INSERT,或让导入以特权身份进行。

跨租户要用专门的特权身份,不要放宽业务策略。给 app_user 加一条 USING (true) 是最常见的"临时方案",且几乎没人会记得删掉它。

策略要有命名前缀。pg_policies 里没有前缀的策略无法批量管理,改需求时容易漏删。

别用 FOR ALL 处理审计表。FOR ALL 覆盖 INSERT / UPDATE / DELETE,会让"只可追加"的审计表变成可改的。

最佳实践 ​

租户列永远放复合索引最左。RLS 注入的 tenant_id = ? 需要一个可用的索引前缀,否则大表会退化成全表扫描。

sql
CREATE INDEX idx_orders_tenant_user ON orders (tenant_id, user_id);

用会话变量而不是 current_user 承载租户身份。一个数据库用户往往要服务多个租户,把租户写进角色名会导致角色数量爆炸。current_setting('app.tenant_id') 才是可扩展的做法。

给会话变量读取加兜底,避免变量未设置时报错中断业务:

sql
-- 未设置时返回 NULL,策略自然不通过(安全默认)
NULLIF(current_setting('app.tenant_id', true), '')::int

软删除、归档、状态白名单统一用 RESTRICTIVE。它们语义上是 AND,用 PERMISSIVE 会变成 OR,等于放宽。

报表与运维角色单独建,不复用业务角色。职责分离,也便于把它们的策略单独审计。

所有多租户表一次性打开 ENABLE + FORCE,并写进建表模板(migration 里成对出现):

sql
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——两种都要断言。

sql
-- 断言跨租户写会报错(期望 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 文件:

sql
SELECT policyname, cmd, permissive, roles, qual, with_check
FROM pg_policies WHERE tablename = 'orders';

连接池用 transaction 模式并保证事务包住"设变量 + 业务 SQL"。会话模式的池化会让 SET LOCAL 语义混乱。

为绕过通道建立清单与审计。谁有 BYPASSRLS、哪些表属主未 FORCE、哪些视图用了 security_invoker,定期复核一遍:

sql
-- 所有具备 BYPASSRLS 的角色
SELECT rolname FROM pg_roles WHERE rolbypassrls;

下一节

继续学习:逻辑备份 pg_dump

参考链接 ​