USING 与 WITH CHECK 详解
USING 和 WITH CHECK 是 RLS 策略里最关键、也最容易混淆的两个子句。它们的名字很接近,位置相邻,却分别守卫两个完全不同的方向:
USING守卫"入口"——哪些已有行能被看见、被选中、被操作;WITH CHECK守卫"出口"——哪些新行能被写进表里。
很多线上权限事故的根因,就是只写了 USING(或只写了 WITH CHECK),让行从另一个方向上"漏"了出去。本节把这两个子句的语义、求值时机、组合方式与典型误用讲透。
前置知识
阅读本节前,建议先了解:创建与管理策略
基础概念
一句话区分
┌──────────────────────────────────────────────────────┐
│ │
已有行 │ USING ──▶ 这一行我能不能看见 / 能不能选中? │ 新行
│ │
│ WITH CHECK ──▶ 这一行我能不能写进表里? │
│ │
└──────────────────────────────────────────────────────┘| 维度 | USING | WITH CHECK |
|---|---|---|
| 作用对象 | 表中已存在的行 | INSERT / UPDATE 产生的新行 |
| 失败时的行为 | 静默过滤(行被丢弃,不报错) | 抛错,整个语句回滚 |
| 未定义时 | 相当于 USING (true) | 对 ALL/UPDATE:复用 USING;对 INSERT:相当于 WITH CHECK (true) |
| 适用命令 | ALL、SELECT、UPDATE、DELETE | ALL、INSERT、UPDATE |
| 作用阶段 | 查询/修改前的过滤 | 数据落盘前的校验 |
失败的两种表现:静默 vs 报错
这是两者最重要的行为差异,也是安全模型的核心:
-- USING 不匹配:行"不存在",查询返回空,不报错
SET app.tenant_id = '7';
SELECT * FROM orders WHERE tenant_id = 8; -- 返回 0 行 id | tenant_id | amount
----+-----------+--------
(0 rows)-- WITH CHECK 不匹配:语句直接失败
INSERT INTO orders (tenant_id, amount) VALUES (8, 10.00);ERROR: new row violates row-level security policy for table "orders"USING 的"静默"是刻意的:报错本身会泄露"这一行存在"的信息。而 WITH CHECK 必须报错——用户需要知道自己的写操作被拒绝了。
求值时机
WITH CHECK 的求值时机在官方文档中有精确规定:
- 在
BEFORE触发器之后、实际数据修改之前求值; - 在其他所有约束之前求值。
这带来一个容易忽略的后果:BEFORE ROW 触发器可以修改即将插入的数据,而策略检查看到的是触发器修改后的值。
-- 若 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 的搭配矩阵
FOR | USING | WITH CHECK | 说明 |
|---|---|---|---|
ALL | 可选 | 可选 | 未写 WITH CHECK 时用 USING 兼任 |
SELECT | 可选 | 禁止 | 只读,没有新行 |
INSERT | 禁止 | 可选 | 只写,没有已有行 |
UPDATE | 可选 | 可选 | 未写 WITH CHECK 时用 USING 兼任 |
DELETE | 可选 | 禁止 | 只删,没有新行 |
语法与用法
只写 USING 的 ALL 策略
CREATE POLICY p_tenant ON orders FOR ALL TO app_user
USING (tenant_id = current_setting('app.tenant_id')::int);这条策略看起来简洁,实际等价于:
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 表达式。这让很多场景下只写一个表达式就够了。
显式区分读写条件
当一个表达式无法同时表达"看得见"和"写得进"时,必须分开写:
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:
-- 可见性控制
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
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 单独调整
-- 只改 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 的行,对用户而言如同不存在:
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 行。这是很多"数据似乎删不掉但也不报错"的困惑来源。
DELETE 0
UPDATE 0WITH CHECK 的错误码与消息
违反 WITH CHECK 时,PostgreSQL 抛出 insufficient_privilege 类错误:
INSERT INTO orders (tenant_id, amount) VALUES (999, 1);ERROR: new row violates row-level security policy for table "orders"对应 SQLSTATE 为 42501(insufficient_privilege)。应用层可以通过捕获这个 SQLSTATE 给出更友好的提示:
// 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 是唯一同时涉及两个方向的命令,它的检查流程是:
UPDATE orders SET amount = 100 WHERE id = 5;
│
├── 1. 用 USING 过滤「能改哪些行」
│ id = 5 这行若不通过 USING → 被静默跳过(UPDATE 0)
│
└── 2. 用 WITH CHECK 校验「改完的新行」
新行若不通过 → 报错,整个语句回滚关键差异:第一步失败是静默的,第二步失败是报错的。所以同一个 UPDATE 语句可能对 A 行毫无动静,却因为 B 行报错而整体失败。
-- 假设 tenant_id = 7 的行通过 USING
UPDATE orders SET tenant_id = 8 WHERE id = 5;ERROR: new row violates row-level security policy for table "orders"这条语句如果没有 WITH CHECK,会把行"送出"自己的租户——而 USING 只写了 tenant_id = 7 时,WITH CHECK 复用同样的表达式,恰好挡住了这个越界。
表达式不能含聚合与窗口函数
-- 错误!
CREATE POLICY p ON orders WITH CHECK (amount > avg(amount));ERROR: aggregate functions are not allowed in policy expressions原因很实际:策略是逐行求值的,聚合需要看到整个集合,语义上不成立。想表达"金额不得超过本租户平均值"这类规则,应改用:
- 应用层校验;
CHECK约束(若规则不跨行);- 触发器(若规则需跨行查询)。
-- 用触发器实现跨行规则(不是 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 的效果相同——都不通过:
CREATE POLICY p ON orders USING (note ILIKE '%approved%');若某行的 note IS NULL,NULL ILIKE ... 结果是 NULL,该行被过滤。写策略时必须时刻考虑 NULL:
-- 更稳妥:显式处理 NULL
CREATE POLICY p ON orders USING (note IS NOT NULL AND note ILIKE '%approved%');会话变量:current_setting 的正确用法
多租户场景最常见的是从自定义 GUC 里读上下文:
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。
提供默认值可以避免第一种:
-- 第二个参数是「缺失时返回的默认值」
SELECT current_setting('app.tenant_id', true);更稳的写法是显式给出兜底,让它"查不到任何行"而不是报错:
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,则用视图属主的策略检查;启用后,用调用者的策略检查:
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 两类策略):
最终条件 =
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 核对两个表达式
排查"到底哪个表达式在起作用",最直接的办法是看视图:
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'orders'; 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 可绕过 | 不可绕过 |
| 违反时 SQLSTATE | 42501 | 23514(check_violation) |
| 求值顺序 | 在其他约束之前 | 在策略检查之后 |
-- 表级约束:无论谁写都校验
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,直接报错,而不是静默跳过。
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)策略:
-- 需要 INSERT 策略 + SELECT 策略同时通过
INSERT INTO orders (tenant_id, amount) VALUES (7, 50) RETURNING id;若只建了 INSERT 策略,RETURNING 会失败:
ERROR: new row violates row-level security policy for table "orders"这是"明明能插入却报错"的一个隐蔽原因。
实战示例
示例一:只写 USING 造成的越权写入
先搭一个最小实验环境:
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');先看正常路径:
BEGIN;
SET LOCAL ROLE app_user;
SET LOCAL app.tenant_id = '7';
SELECT id, tenant_id, status FROM tasks; id | tenant_id | status
----+-----------+--------
1 | 7 | open
(1 row)现在尝试"把自己的行改到别租户":
UPDATE tasks SET tenant_id = 8 WHERE id = 1;ERROR: new row violates row-level security policy for table "tasks"这里之所以被挡住,是因为 USING 被自动复用作 WITH CHECK。但下面这个场景就暴露了问题——只写 USING 无法区分"读"与"写"的判定条件。假设业务要求"只有 open 状态的任务可被修改",用户仍能改已关闭任务的状态:
UPDATE tasks SET status = 'closed' WHERE id = 1; -- 通过了UPDATE 1若想限制状态流转,必须显式写 WITH CHECK:
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;再试"把任务改成非法状态":
BEGIN;
SET LOCAL ROLE app_user;
SET LOCAL app.tenant_id = '7';
-- 先看当前状态是 closed,不在允许列表内
UPDATE tasks SET status = 'doing' WHERE id = 1;ERROR: new row violates row-level security policy for table "tasks"注意这里 USING 是通过的(行本身是本租户的),失败发生在 WITH CHECK——这就是两个子句分工的价值。
示例二:WITH CHECK 与 CHECK 约束的配合
需求:金额必须为正(数据自洽),且不能跨租户写入(权限隔离)。两者分别由 CHECK 与 WITH CHECK 承担。
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 约束(金额为负):
BEGIN;
SET LOCAL ROLE pay_app;
SET LOCAL app.tenant_id = '7';
INSERT INTO payments (tenant_id, amount) VALUES (7, -5);ERROR: new row for relation "payments" violates check constraint "amount_positive"
DETAIL: Failing row contains (1, 7, -5.00, null).实验二:违反 WITH CHECK(跨租户):
INSERT INTO payments (tenant_id, amount) VALUES (8, 5);ERROR: new row violates row-level security policy for table "payments"两条报错指向不同的机制,SQLSTATE 也不同(23514 vs 42501),应用层可以据此区分"数据不合法"与"无权操作"。
再看读取时的边界:
SELECT count(*) FROM payments; -- 只看得到 tenant_id = 7 的行
INSERT INTO payments (tenant_id, amount) VALUES (7, 100) RETURNING id; 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,这条默认行为要主动确认是否符合预期:
-- 等价于 USING 与 WITH CHECK 都写了同一个表达式
CREATE POLICY p ON orders FOR ALL USING (tenant_id = 7);UPDATE 的第一步失败是静默的
USING 不通过时,UPDATE 影响 0 行而不报错,容易误判为"代码没执行":
-- 若不通过 USING,这里不会报错,只会 UPDATE 0
UPDATE orders SET amount = 1 WHERE id = 5;DELETE 同理,静默影响 0 行
-- 不会报错,只是没删掉
DELETE FROM orders WHERE tenant_id = 8;WITH CHECK 复用了 USING 时,跨租户改动的错误信息会误导
报错消息只说"违反行级安全策略",不会告诉你是哪个租户不匹配:
UPDATE orders SET tenant_id = 8 WHERE id = 5;
-- ERROR: new row violates row-level security policy for table "orders"正确处理 NULL,否则行会被意外过滤或放行
策略里出现 NULL 一律视为不通过,但用户往往没意识到:
-- 若 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
这会让整个查询失败,而不是返回空集:
-- 未设置时: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 的插入会失败:
-- 需要 SELECT 或 ALL 策略同时存在
INSERT INTO orders (tenant_id, amount) VALUES (7, 1) RETURNING id;ON CONFLICT DO UPDATE 的 USING 失败会直接报错
不同于独立 UPDATE,这里不会静默跳过:
INSERT INTO orders (id, tenant_id, amount) VALUES (5, 7, 1)
ON CONFLICT (id) DO UPDATE SET amount = EXCLUDED.amount;策略表达式里禁止聚合与窗口函数
-- 错误!
CREATE POLICY p ON orders USING (amount > avg(amount));不要把 WITH CHECK 当数据校验用
它是权限检查,可被属主 / 超级用户绕过;数据自洽请用 CHECK 约束:
-- 权限:可绕过
CREATE POLICY p ON payments WITH CHECK (amount > 0);
-- 数据自洽:不可绕过
ALTER TABLE payments ADD CONSTRAINT amount_positive CHECK (amount > 0);最佳实践
USING 与 WITH CHECK 都显式写,不依赖复用
即使两者相同,写出来也让意图明确、评审可读:
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 策略更精确,也更容易解释:
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 的新行必须重新校验隔离维度:
WITH CHECK (tenant_id = current_setting('app.tenant_id')::int)用 NULLIF(current_setting(..., true), '') 读会话上下文
既避免未设置时抛错,也避免空串转换失败:
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::int)策略表达式保持 STABLE 且只引用当前行
便于走索引,也避免快照竞态:
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::int)把"能读到"和"能写进"的验证用例都写进测试
正例反例都要覆盖,且要分别验证静默过滤与报错两种表现:
-- 反例 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)",给出不同提示:
if ($e->getCode() === '42501') { /* 无权访问该数据 */ }
if ($e->getCode() === '23514') { /* 数据不满足约束 */ }用 pg_policies 核对 qual / with_check 的实际内容
部署完立即确认,别等出问题再查:
SELECT policyname, cmd, qual, with_check FROM pg_policies WHERE tablename = 'orders';改策略用 ALTER POLICY,只动需要动的那个表达式
避免整条重建带来的窗口期:
ALTER POLICY p_own ON documents USING (<新条件>); -- WITH CHECK 保持原样上线前用 EXPLAIN 确认策略条件未破坏索引
尤其是表达式包装了函数调用时:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE tenant_id = 7;下一节
继续学习:RLS 绕过机制