Skip to content

USING 与 WITH CHECK 详解 ​

USING 和 WITH CHECK 是 RLS 策略里最关键、也最容易混淆的两个子句。它们的名字很接近,位置相邻,却分别守卫两个完全不同的方向:

  • USING 守卫"入口"——哪些已有行能被看见、被选中、被操作;
  • WITH CHECK 守卫"出口"——哪些新行能被写进表里。

很多线上权限事故的根因,就是只写了 USING(或只写了 WITH CHECK),让行从另一个方向上"漏"了出去。本节把这两个子句的语义、求值时机、组合方式与典型误用讲透。

前置知识

阅读本节前,建议先了解:创建与管理策略

基础概念 ​

一句话区分 ​

text
        ┌──────────────────────────────────────────────────────┐
        │                                                      │
  已有行 │   USING         ──▶ 这一行我能不能看见 / 能不能选中?   │  新行
        │                                                      │
        │   WITH CHECK    ──▶ 这一行我能不能写进表里?           │
        │                                                      │
        └──────────────────────────────────────────────────────┘
维度USINGWITH CHECK
作用对象表中已存在的行INSERT / UPDATE 产生的新行
失败时的行为静默过滤(行被丢弃,不报错)抛错,整个语句回滚
未定义时相当于 USING (true)对 ALL/UPDATE:复用 USING;对 INSERT:相当于 WITH CHECK (true)
适用命令ALL、SELECT、UPDATE、DELETEALL、INSERT、UPDATE
作用阶段查询/修改前的过滤数据落盘前的校验

失败的两种表现:静默 vs 报错 ​

这是两者最重要的行为差异,也是安全模型的核心:

sql
-- USING 不匹配:行"不存在",查询返回空,不报错
SET app.tenant_id = '7';
SELECT * FROM orders WHERE tenant_id = 8;   -- 返回 0 行
text
 id | tenant_id | amount
----+-----------+--------
(0 rows)
sql
-- WITH CHECK 不匹配:语句直接失败
INSERT INTO orders (tenant_id, amount) VALUES (8, 10.00);
text
ERROR:  new row violates row-level security policy for table "orders"

USING 的"静默"是刻意的:报错本身会泄露"这一行存在"的信息。而 WITH CHECK 必须报错——用户需要知道自己的写操作被拒绝了。

求值时机 ​

WITH CHECK 的求值时机在官方文档中有精确规定:

  • 在 BEFORE 触发器之后、实际数据修改之前求值;
  • 在其他所有约束之前求值。

这带来一个容易忽略的后果:BEFORE ROW 触发器可以修改即将插入的数据,而策略检查看到的是触发器修改后的值。

sql
-- 若 BEFORE 触发器把 tenant_id 改成 0,WITH CHECK 校验的是 0
CREATE POLICY p ON orders WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

USING 与 WITH CHECK 的搭配矩阵 ​

FORUSINGWITH CHECK说明
ALL可选可选未写 WITH CHECK 时用 USING 兼任
SELECT可选禁止只读,没有新行
INSERT禁止可选只写,没有已有行
UPDATE可选可选未写 WITH CHECK 时用 USING 兼任
DELETE可选禁止只删,没有新行

语法与用法 ​

只写 USING 的 ALL 策略 ​

sql
CREATE POLICY p_tenant ON orders FOR ALL TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::int);

这条策略看起来简洁,实际等价于:

sql
CREATE POLICY p_tenant ON orders FOR ALL TO app_user
    USING     (tenant_id = current_setting('app.tenant_id')::int)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

对 ALL 和 UPDATE,未定义 WITH CHECK 时会自动复用 USING 表达式。这让很多场景下只写一个表达式就够了。

显式区分读写条件 ​

当一个表达式无法同时表达"看得见"和"写得进"时,必须分开写:

sql
CREATE POLICY p_own ON documents FOR UPDATE TO app_user
    USING (owner_id = current_setting('app.user_id')::int)
    WITH CHECK (
        owner_id = current_setting('app.user_id')::int
        AND status IN ('draft', 'review')          -- 只允许改草稿 / 待审状态
    );

这里 USING 说"我只能改自己的文档",WITH CHECK 进一步说"改完之后状态必须是 draft 或 review"——即使用户是自己的文档,也不能把它改成 published。

分开的 SELECT 与 INSERT 策略 ​

INSERT 策略不能写 USING,所以写入控制只能靠 WITH CHECK:

sql
-- 可见性控制
CREATE POLICY p_sel ON orders FOR SELECT TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::int);

-- 写入控制
CREATE POLICY p_ins ON orders FOR INSERT TO app_user
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

这两条策略叠加后,app_user 能读本租户、能写本租户,但读到的一行与他写进去的一行不必是同一行——这正是需要区分两个方向的地方。

DELETE 只有 USING ​

sql
CREATE POLICY p_del ON orders FOR DELETE TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::int AND is_deleted = true);

DELETE 不产生新行,所以只有 USING。上面这条表达"只能删除本租户下已软删除的记录",比 SELECT 策略更严格,是典型的"可读不可删"设计。

用 ALTER POLICY 单独调整 ​

sql
-- 只改 WITH CHECK,保留原有 USING
ALTER POLICY p_own ON documents
    WITH CHECK (
        owner_id = current_setting('app.user_id')::int
        AND status IN ('draft')
    );

-- 只改 USING
ALTER POLICY p_own ON documents
    USING (owner_id = current_setting('app.user_id')::int AND is_archived = false);

被省略的子句保持不变,这让"收紧某一条条件"成为低风险操作。

详细说明 ​

USING 决定"能选中",不决定"能看见列" ​

USING 返回 false 或 NULL 的行,对用户而言如同不存在:

sql
CREATE POLICY p ON orders USING (tenant_id = 7);
SELECT count(*) FROM orders;                    -- 只数得出 tenant_id = 7 的
DELETE FROM orders WHERE tenant_id = 8;         -- 影响 0 行
UPDATE orders SET amount = 0 WHERE tenant_id = 8; -- 影响 0 行

注意最后两条:UPDATE / DELETE 不报错,只是影响 0 行。这是很多"数据似乎删不掉但也不报错"的困惑来源。

text
DELETE 0
UPDATE 0

WITH CHECK 的错误码与消息 ​

违反 WITH CHECK 时,PostgreSQL 抛出 insufficient_privilege 类错误:

sql
INSERT INTO orders (tenant_id, amount) VALUES (999, 1);
text
ERROR:  new row violates row-level security policy for table "orders"

对应 SQLSTATE 为 42501(insufficient_privilege)。应用层可以通过捕获这个 SQLSTATE 给出更友好的提示:

php
// PHP PDO 示例:只要用到能读到 SQLSTATE 的接口,都能拿到 42501
try {
    $pdo->prepare('INSERT INTO orders (tenant_id, amount) VALUES (?, ?)')
        ->execute([$tenantId, $amount]);
} catch (PDOException $e) {
    // 42501 表示违反了 RLS 的 WITH CHECK
    if ($e->getCode() === '42501') {
        // 提示用户"无权写入该租户的数据"
    }
    throw $e;
}

UPDATE 会同时检查两个表达式 ​

UPDATE 是唯一同时涉及两个方向的命令,它的检查流程是:

text
UPDATE orders SET amount = 100 WHERE id = 5;
   │
   ├── 1. 用 USING 过滤「能改哪些行」
   │      id = 5 这行若不通过 USING → 被静默跳过(UPDATE 0)
   │
   └── 2. 用 WITH CHECK 校验「改完的新行」
          新行若不通过 → 报错,整个语句回滚

关键差异:第一步失败是静默的,第二步失败是报错的。所以同一个 UPDATE 语句可能对 A 行毫无动静,却因为 B 行报错而整体失败。

sql
-- 假设 tenant_id = 7 的行通过 USING
UPDATE orders SET tenant_id = 8 WHERE id = 5;
text
ERROR:  new row violates row-level security policy for table "orders"

这条语句如果没有 WITH CHECK,会把行"送出"自己的租户——而 USING 只写了 tenant_id = 7 时,WITH CHECK 复用同样的表达式,恰好挡住了这个越界。

表达式不能含聚合与窗口函数 ​

sql
-- 错误!
CREATE POLICY p ON orders WITH CHECK (amount > avg(amount));
text
ERROR:  aggregate functions are not allowed in policy expressions

原因很实际:策略是逐行求值的,聚合需要看到整个集合,语义上不成立。想表达"金额不得超过本租户平均值"这类规则,应改用:

  • 应用层校验;
  • CHECK 约束(若规则不跨行);
  • 触发器(若规则需跨行查询)。
sql
-- 用触发器实现跨行规则(不是 RLS 职责)
CREATE FUNCTION check_amount() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    IF NEW.amount > (SELECT avg(amount) * 10 FROM orders WHERE tenant_id = NEW.tenant_id) THEN
        RAISE EXCEPTION 'amount out of range';
    END IF;
    RETURN NEW;
END $$;

NULL 的处理:NULL 视为不通过 ​

策略表达式返回 false 或 NULL 的效果相同——都不通过:

sql
CREATE POLICY p ON orders USING (note ILIKE '%approved%');

若某行的 note IS NULL,NULL ILIKE ... 结果是 NULL,该行被过滤。写策略时必须时刻考虑 NULL:

sql
-- 更稳妥:显式处理 NULL
CREATE POLICY p ON orders USING (note IS NOT NULL AND note ILIKE '%approved%');

会话变量:current_setting 的正确用法 ​

多租户场景最常见的是从自定义 GUC 里读上下文:

sql
CREATE POLICY p ON orders
    USING (tenant_id = current_setting('app.tenant_id')::int);

要留意两种情况:

  • 若 GUC 从未设置,current_setting('app.tenant_id') 会报 unrecognized configuration parameter;
  • 若已设为空字符串,''::int 会报 invalid input syntax for type integer。

提供默认值可以避免第一种:

sql
-- 第二个参数是「缺失时返回的默认值」
SELECT current_setting('app.tenant_id', true);

更稳的写法是显式给出兜底,让它"查不到任何行"而不是报错:

sql
CREATE POLICY p ON orders
    USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::int);

这里 NULLIF(..., '') 把空字符串转成 NULL,比较结果为 NULL → 不通过 → 静默返回空集。这比抛错更安全,也不会把数据库内部信息暴露给上层。

LEAKPROOF 与求值顺序 ​

官方文档特别指出:策略过滤条件会先于用户查询条件求值,目的是防止用户自定义函数在策略生效前接触到受保护的数据。唯一的例外是被标记为 LEAKPROOF 的函数——它们被信任不会泄漏信息,优化器可以提前求值。

这对普通开发者意味着:不要在策略里调用不可信的、会抛异常的自定义函数,那些异常本身可能成为信息泄漏通道(例如通过断言失败反推行值)。

视图与策略的 USING / WITH CHECK ​

视图本身不带策略,它只是底层表查询的包装。若视图未启用 security_invoker,则用视图属主的策略检查;启用后,用调用者的策略检查:

sql
CREATE VIEW v_own_orders WITH (security_invoker = true) AS
    SELECT * FROM orders WHERE owner_id = current_setting('app.user_id')::int;

注意 security_invoker 只影响"用谁的权限和策略",视图自己的 WHERE 依旧生效。因此面向多租户的视图通常是"视图内 WHERE + 表上 RLS 策略"双重保险。

组合多条策略时的表达式拼接 ​

多条策略会按命令类型拼接成一个大条件。以 UPDATE 为例(需要 SELECT 与 UPDATE 两类策略):

text
最终条件 =
      RESTRICTIVE_SELECT_1  AND  RESTRICTIVE_SELECT_2  AND ...
    AND ( PERMISSIVE_SELECT_1  OR  PERMISSIVE_SELECT_2  OR ... )
    AND   RESTRICTIVE_UPDATE_1  AND  ...
    AND ( PERMISSIVE_UPDATE_1  OR  PERMISSIVE_UPDATE_2  OR ... )

对应的 WITH CHECK 侧也是同样的结构。实践中可以这样理解:"每一类需要读或写的动作,都必须至少有一条宽松策略放行,并且满足所有限制策略。"

用 pg_policies 核对两个表达式 ​

排查"到底哪个表达式在起作用",最直接的办法是看视图:

sql
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'orders';
text
   policyname  |  cmd   |                        qual                        |         with_check
---------------+--------+----------------------------------------------------+----------------------------
 p_tenant_all  | ALL    | (tenant_id = (current_setting('app.tenant_id'::text))::integer) | (tenant_id = (current_setting('app.tenant_id'::text))::integer)
 p_admin_read  | SELECT | true                                               |
(2 rows)

with_check 为 NULL 的 ALL / UPDATE 策略,说明它没有独立的写入条件,此时 PostgreSQL 会用 qual 的值作校验。这是一条很实用的判断依据。

WITH CHECK 与 CHECK 约束的区别 ​

两者名字相似,作用完全不同:

特性RLS WITH CHECK表级 CHECK 约束
判断依据角色 + 会话上下文 + 行内容只依赖行内容
是否可绕过属主 / 超级用户 / BYPASSRLS 可绕过不可绕过
违反时 SQLSTATE4250123514(check_violation)
求值顺序在其他约束之前在策略检查之后
sql
-- 表级约束:无论谁写都校验
ALTER TABLE orders ADD CONSTRAINT amount_positive CHECK (amount > 0);

两者互补:CHECK 保证数据自洽,WITH CHECK 保证写入者不越权。不要试图用 WITH CHECK 代替数据校验。

ON CONFLICT DO UPDATE 的策略行为 ​

INSERT ... ON CONFLICT DO UPDATE 会同时触发 INSERT 与 UPDATE 两类策略,且有一条严格规定:

  • 若走 UPDATE 路径,会先检查 UPDATE 策略的 USING;
  • 与独立 UPDATE 不同的是,若已有行不通过 USING,直接报错,而不是静默跳过。
sql
INSERT INTO orders (id, tenant_id, amount)
VALUES (5, 7, 100)
ON CONFLICT (id) DO UPDATE SET amount = EXCLUDED.amount;

若 id = 5 那行不属于当前租户,会直接抛 new row violates row-level security policy,而不是"悄悄改成插入新行"或"影响 0 行"。

RETURNING 也需要读权限 ​

INSERT ... RETURNING 与 UPDATE ... RETURNING 会返回行,因此也需要通过 SELECT(或 ALL)策略:

sql
-- 需要 INSERT 策略 + SELECT 策略同时通过
INSERT INTO orders (tenant_id, amount) VALUES (7, 50) RETURNING id;

若只建了 INSERT 策略,RETURNING 会失败:

text
ERROR:  new row violates row-level security policy for table "orders"

这是"明明能插入却报错"的一个隐蔽原因。

实战示例 ​

示例一:只写 USING 造成的越权写入 ​

先搭一个最小实验环境:

sql
CREATE TABLE tasks (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id int    NOT NULL,
    owner_id  int    NOT NULL,
    status    text   NOT NULL DEFAULT 'open',
    title     text   NOT NULL
);
CREATE INDEX ON tasks (tenant_id);

CREATE ROLE app_user LOGIN PASSWORD 'change-me';
GRANT SELECT, INSERT, UPDATE, DELETE ON tasks TO app_user;

ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks FORCE  ROW LEVEL SECURITY;

-- 只写了 USING 的 ALL 策略
CREATE POLICY p_task ON tasks FOR ALL TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::int);

INSERT INTO tasks (tenant_id, owner_id, title) VALUES (7, 1, 'tenant-7 task');

先看正常路径:

sql
BEGIN;
SET LOCAL ROLE app_user;
SET LOCAL app.tenant_id = '7';
SELECT id, tenant_id, status FROM tasks;
text
 id | tenant_id | status
----+-----------+--------
  1 |         7 | open
(1 row)

现在尝试"把自己的行改到别租户":

sql
UPDATE tasks SET tenant_id = 8 WHERE id = 1;
text
ERROR:  new row violates row-level security policy for table "tasks"

这里之所以被挡住,是因为 USING 被自动复用作 WITH CHECK。但下面这个场景就暴露了问题——只写 USING 无法区分"读"与"写"的判定条件。假设业务要求"只有 open 状态的任务可被修改",用户仍能改已关闭任务的状态:

sql
UPDATE tasks SET status = 'closed' WHERE id = 1;   -- 通过了
text
UPDATE 1

若想限制状态流转,必须显式写 WITH CHECK:

sql
BEGIN;
DROP POLICY p_task ON tasks;
CREATE POLICY p_task ON tasks FOR UPDATE TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::int)
    WITH CHECK (
        tenant_id = current_setting('app.tenant_id')::int
        AND status IN ('open', 'doing')
    );

CREATE POLICY p_task_ins ON tasks FOR INSERT TO app_user
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);
CREATE POLICY p_task_sel ON tasks FOR SELECT TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::int);
CREATE POLICY p_task_del ON tasks FOR DELETE TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::int AND status = 'open');
COMMIT;

再试"把任务改成非法状态":

sql
BEGIN;
SET LOCAL ROLE app_user;
SET LOCAL app.tenant_id = '7';
-- 先看当前状态是 closed,不在允许列表内
UPDATE tasks SET status = 'doing' WHERE id = 1;
text
ERROR:  new row violates row-level security policy for table "tasks"

注意这里 USING 是通过的(行本身是本租户的),失败发生在 WITH CHECK——这就是两个子句分工的价值。

示例二:WITH CHECK 与 CHECK 约束的配合 ​

需求:金额必须为正(数据自洽),且不能跨租户写入(权限隔离)。两者分别由 CHECK 与 WITH CHECK 承担。

sql
CREATE TABLE payments (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id int           NOT NULL,
    amount    numeric(12,2) NOT NULL,
    note      text
);

-- 数据自洽:任何写入者都受约束
ALTER TABLE payments ADD CONSTRAINT amount_positive CHECK (amount > 0);

CREATE ROLE pay_app LOGIN PASSWORD 'change-me';
GRANT SELECT, INSERT ON payments TO pay_app;

ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments FORCE  ROW LEVEL SECURITY;

CREATE POLICY p_pay ON payments FOR ALL TO pay_app
    USING (tenant_id = current_setting('app.tenant_id')::int)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

实验一:违反 CHECK 约束(金额为负):

sql
BEGIN;
SET LOCAL ROLE pay_app;
SET LOCAL app.tenant_id = '7';
INSERT INTO payments (tenant_id, amount) VALUES (7, -5);
text
ERROR:  new row for relation "payments" violates check constraint "amount_positive"
DETAIL:  Failing row contains (1, 7, -5.00, null).

实验二:违反 WITH CHECK(跨租户):

sql
INSERT INTO payments (tenant_id, amount) VALUES (8, 5);
text
ERROR:  new row violates row-level security policy for table "payments"

两条报错指向不同的机制,SQLSTATE 也不同(23514 vs 42501),应用层可以据此区分"数据不合法"与"无权操作"。

再看读取时的边界:

sql
SELECT count(*) FROM payments;             -- 只看得到 tenant_id = 7 的行
INSERT INTO payments (tenant_id, amount) VALUES (7, 100) RETURNING id;
text
 count
-------
     0
(1 row)

 id
----
  3
(1 row)

第一次插入为负金额失败、第二次跨租户失败,所以此刻表里没有 tenant_id = 7 的合法行——count(*) 为 0;随后插入 100 成功并返回新行 id = 3。这个顺序直观展示了 USING(读)与 WITH CHECK(写)互不干扰。

注意事项 ​

只写 USING 时它对读写都生效,可能过宽或过严

ALL / UPDATE 未定义 WITH CHECK 时会复用 USING,这条默认行为要主动确认是否符合预期:

sql
-- 等价于 USING 与 WITH CHECK 都写了同一个表达式
CREATE POLICY p ON orders FOR ALL USING (tenant_id = 7);

UPDATE 的第一步失败是静默的

USING 不通过时,UPDATE 影响 0 行而不报错,容易误判为"代码没执行":

sql
-- 若不通过 USING,这里不会报错,只会 UPDATE 0
UPDATE orders SET amount = 1 WHERE id = 5;

DELETE 同理,静默影响 0 行

sql
-- 不会报错,只是没删掉
DELETE FROM orders WHERE tenant_id = 8;

WITH CHECK 复用了 USING 时,跨租户改动的错误信息会误导

报错消息只说"违反行级安全策略",不会告诉你是哪个租户不匹配:

sql
UPDATE orders SET tenant_id = 8 WHERE id = 5;
-- ERROR: new row violates row-level security policy for table "orders"

正确处理 NULL,否则行会被意外过滤或放行

策略里出现 NULL 一律视为不通过,但用户往往没意识到:

sql
-- 若 note 为 NULL,这行会被过滤掉
CREATE POLICY p ON orders USING (note ILIKE '%ok%');
-- 更明确
CREATE POLICY p2 ON orders USING (note IS NOT NULL AND note ILIKE '%ok%');

current_setting 未设置会抛错,不是返回 NULL

这会让整个查询失败,而不是返回空集:

sql
-- 未设置时:ERROR: unrecognized configuration parameter "app.tenant_id"
SELECT current_setting('app.tenant_id')::int;
-- 推荐:给出缺失默认值,再 NULLIF 兜底
SELECT NULLIF(current_setting('app.tenant_id', true), '')::int;

RETURNING 需要读策略

只建 INSERT 策略时,带 RETURNING 的插入会失败:

sql
-- 需要 SELECT 或 ALL 策略同时存在
INSERT INTO orders (tenant_id, amount) VALUES (7, 1) RETURNING id;

ON CONFLICT DO UPDATE 的 USING 失败会直接报错

不同于独立 UPDATE,这里不会静默跳过:

sql
INSERT INTO orders (id, tenant_id, amount) VALUES (5, 7, 1)
ON CONFLICT (id) DO UPDATE SET amount = EXCLUDED.amount;

策略表达式里禁止聚合与窗口函数

sql
-- 错误!
CREATE POLICY p ON orders USING (amount > avg(amount));

不要把 WITH CHECK 当数据校验用

它是权限检查,可被属主 / 超级用户绕过;数据自洽请用 CHECK 约束:

sql
-- 权限:可绕过
CREATE POLICY p ON payments WITH CHECK (amount > 0);
-- 数据自洽:不可绕过
ALTER TABLE payments ADD CONSTRAINT amount_positive CHECK (amount > 0);

最佳实践 ​

USING 与 WITH CHECK 都显式写,不依赖复用

即使两者相同,写出来也让意图明确、评审可读:

sql
CREATE POLICY p_tenant ON orders FOR ALL TO app_user
    USING     (tenant_id = current_setting('app.tenant_id')::int)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::int);

读写条件不同时,拆成 SELECT / INSERT / UPDATE / DELETE 四条策略

比一条 ALL 策略更精确,也更容易解释:

sql
CREATE POLICY p_sel ON orders FOR SELECT TO app_user USING (...);
CREATE POLICY p_ins ON orders FOR INSERT TO app_user WITH CHECK (...);
CREATE POLICY p_upd ON orders FOR UPDATE TO app_user USING (...) WITH CHECK (...);
CREATE POLICY p_del ON orders FOR DELETE TO app_user USING (...);

WITH CHECK 里重复隔离条件,防止"改出去"

UPDATE 的新行必须重新校验隔离维度:

sql
WITH CHECK (tenant_id = current_setting('app.tenant_id')::int)

用 NULLIF(current_setting(..., true), '') 读会话上下文

既避免未设置时抛错,也避免空串转换失败:

sql
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::int)

策略表达式保持 STABLE 且只引用当前行

便于走索引,也避免快照竞态:

sql
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::int)

把"能读到"和"能写进"的验证用例都写进测试

正例反例都要覆盖,且要分别验证静默过滤与报错两种表现:

sql
-- 反例 1:读不到别人的行(静默)
SELECT count(*) FROM orders WHERE tenant_id = 8;   -- 期望 0
-- 反例 2:写不进别人的租户(报错)
INSERT INTO orders (tenant_id, amount) VALUES (8, 1);  -- 期望 42501
-- 反例 3:不能把自己改到别租户(报错)
UPDATE orders SET tenant_id = 8 WHERE id = 1;          -- 期望 42501

应用层捕获 42501 并转换为业务语义

区分"数据非法(23514)"与"权限不足(42501)",给出不同提示:

php
if ($e->getCode() === '42501') { /* 无权访问该数据 */ }
if ($e->getCode() === '23514') { /* 数据不满足约束 */ }

用 pg_policies 核对 qual / with_check 的实际内容

部署完立即确认,别等出问题再查:

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

改策略用 ALTER POLICY,只动需要动的那个表达式

避免整条重建带来的窗口期:

sql
ALTER POLICY p_own ON documents USING (<新条件>);   -- WITH CHECK 保持原样

上线前用 EXPLAIN 确认策略条件未破坏索引

尤其是表达式包装了函数调用时:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE tenant_id = 7;

下一节

继续学习:RLS 绕过机制

参考链接 ​