FDE Times 数据在招 FDE 职位 838▼55 近 7 天在招职位数的变化(同口径比较):不计入本周新开始追踪的公司的职位近 7 天新增 66在招公司 169支持远程 24%美国薪资中位数 $200k招聘最多 Palantir 95
ENVI

前线部署工程师(FDE)的专业媒体

技能指南 · 指南

开了 RLS,AI 助手为何还能读到 HR 文档

问题通常出在应用连接数据库所用的角色,修复只需在正确位置加几行 SQL。

分享微博

要点速览

  • 启用 RLS 但没有策略时,Postgres 会拦下所有行,但所有者、超级用户和 BYPASSRLS 角色仍能绕过。
  • 执行检索查询前,先把部门设为事务级变量,读取用 USING,写入用 WITH CHECK。
  • 宽松策略之间以 OR 组合,因此要加一条限制性策略作为硬性屏障,再建一个以 department_id 开头的索引。
决策树:从助手连接数据库所用的角色出发。超级用户或带 BYPASSRLS 属性的角色会跳过 RLS。表的属主:如果表没有设置 FORCE,就会跳过 RLS(图中高亮,助手使用迁移账号连接时常见的漏洞);如果设置了 FORCE,策略生效。无特权角色则受策略约束。
只有连接所用的角色不能豁免 RLS,策略才会生效。除非表设置了 FORCE ROW LEVEL SECURITY,否则表属主会绕过 RLS。来源:PostgreSQL 行级安全文档,详见正文。

你已经执行了 ENABLE ROW LEVEL SECURITY,认真写好了策略,也在 psql 里测过。可 AI 助手回答销售人员的问题时,引用的仍是 HR 的内部文档。

策略本身没有问题。问题在于应用连接数据库时用的是这张表的所有者角色,而 Postgres 文档写得很清楚:所有者通常会绕过行级安全。

前线部署工程师(FDE)给企业引入检索增强生成(RAG)助手时,经常碰到这种情况:一个共享文档库,多个部门,外加“每人只能看自己的数据”的要求。在提示词里过滤,或在应用代码里加 WHERE 子句,都得指望没有人会忘。RLS 把规则下沉到数据库,每条查询都必须经过它。

本指南在本地 Postgres 上一步步操作:一张 documents 表、一个 assistant_app 角色,以及按事务设定的部门。你需要 Postgres、psql 和创建角色的权限。最后一节面向做向量检索的读者,讲 pgvector。

第 1–2 步:建表,然后看它“锁死”

下面的示例做了精简:没有 embedding,主键手动填写,以免还要授予序列权限。CHECK 约束会拒绝部门为空的行,这样就不会有文档落在所有边界之外。

CREATE TABLE documents (
  id bigint PRIMARY KEY,
  department_id text NOT NULL CHECK (department_id <> ''),
  title text,
  content text
);

INSERT INTO documents VALUES
  (1, 'hr',      '2026 年薪资政策',  '...'),
  (2, 'sales',   '经销商价格表', '...'),
  (3, 'finance', '第四季度预算预测',  '...');

CREATE ROLE assistant_app LOGIN PASSWORD 'change-this-password';
GRANT SELECT, INSERT, UPDATE ON documents TO assistant_app;

接着启用 RLS,并强制所有者也必须遵守:

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

**检查:**以 assistant_app 登录,执行 SELECT count(*) FROM documents;,结果必须是 0。根据 Postgres 文档,启用了 RLS 但没有任何策略的表会采用默认拒绝策略:任何行都不可见,也不可修改。默认就是拦截,这正是你想要的。

FORCE 这一行不是摆设。超级用户和带 BYPASSRLS 属性的角色总是绕过 RLS;所有者也会绕过,除非表设置了 FORCE ROW LEVEL SECURITY。

Render 关于在 pgvector 上构建多租户 RAG 的指南把 FORCE 视为必选项。Supabase 也提示了同类风险:它的 secret key 走的是 service_role 角色,而这个角色带有 bypassrls。

AI 助手绝不能持有这样的密钥。

第 3 步:一条策略,两道门

Supabase 给出了一个好理解的思路:每条策略就像附加在每条查询上的 WHERE 子句。这里的“WHERE”是把 department_id 与一个会话变量比较:

CREATE POLICY dept_isolation ON documents
  FOR ALL
  TO assistant_app
  USING (department_id = (SELECT current_setting('app.department_id', true)))
  WITH CHECK (department_id = (SELECT current_setting('app.department_id', true)));

这条语句有三处值得细读。

USING 和 WITH CHECK 守的是不同的东西。USING 决定哪些已有行可以查看或修改,WITH CHECK 则校验 INSERT 或 UPDATE 产生的新行。没有 WITH CHECK,拥有写权限的智能体就可以先读出本部门的文档,再改掉它的 department_id,把它推到别的部门。

**TO assistant_app 限定了策略的作用范围。**省略它,策略默认作用于 PUBLIC,也就是所有角色,到时很难说清哪条规则影响了谁。

**(SELECT ...) 包住了 current_setting。**据 Supabase 介绍,这样写能让查询规划器只计算一次(initPlan),而不是每行计算一次。这个技巧只在结果不依赖行数据时成立,部门变量恰好满足这个条件。

**检查:**以 assistant_app 登录,开启一个事务但不设定部门,执行 SELECT count(*) FROM documents;,必须看到 0。如果不是,先停下来检查策略,再往下走,因为“忘了设部门”在真实代码里一定会发生。

第 4 步:按事务设定部门

Render 建议在执行检索查询前,把租户 ID 设为事务级变量。在本例中,租户就是部门:

BEGIN;
SET LOCAL app.department_id = 'hr';
SELECT id, department_id, title FROM documents;
COMMIT;

**检查:**只出现 id = 1 那一行。接下来,试着跨部门写入:

BEGIN;
SET LOCAL app.department_id = 'hr';
UPDATE documents SET department_id = 'sales' WHERE id = 1;
ROLLBACK;

Postgres 必须拒绝这条 UPDATE,因为新行违反了 WITH CHECK。相比之下,UPDATE ... WHERE id = 2 能执行,但不会改动任何行,因为 USING 把销售部门的行隐藏了。

关键在 LOCAL 这个词。真实应用使用连接池,会话级变量可能残留到下一个来自其他用户的请求里。事务级变量则在事务结束时失效。

在代码中,不要把部门值拼接进 SQL 字符串。这个值必须来自已认证的登录会话,绝不能来自 LLM 生成的任何内容。

为什么加一条策略反而会扩大访问范围?

假设三个月后,有同事加了一条策略,允许所有部门读取 title 以“Notice”开头的文档。作用于同一查询的所有宽松(permissive)策略之间以 OR 组合。只要有一条写得宽松的策略,比如调试时加上、之后忘了删的 USING (true),整张表就会彻底敞开。

限制性(restrictive)策略正好相反:每一条都必须通过,才能访问某一行。用它作为部门边界的硬性屏障:

CREATE POLICY dept_guardrail ON documents
  AS RESTRICTIVE
  FOR ALL
  TO assistant_app
  USING (department_id = (SELECT current_setting('app.department_id', true)));

**检查:**临时为 assistant_app 创建一条宽松的 USING (true) 策略,然后重新执行第 4 步的查询。你应该仍然只看到 HR 那一行。检查完删掉这条临时策略。

第 5 步:别让策略拖慢检索

Postgres 文档指出,只依赖被检查行自身数值的策略最简单、最快,并建议按这种方式设计。上面的策略正是如此:拿一列与一个事务常量比较,不涉及对其他表的 join 或子查询。

剩下的是索引。Supabase 指出,只有当某列是 btree 索引的首列时,才算真正被索引:

CREATE INDEX documents_dept_idx ON documents (department_id);

现有的 (created_at, department_id) 索引对这条策略毫无帮助。如果需要复合索引,把 department_id 放在第一位。

搭配 pgvector:结果变少,却没有报错

设想一个有 1,000 个段落的库,其中只有 50 个属于 HR。通过 HNSW 索引执行 LIMIT 10 的向量查询,会先在整个库里找最近邻,然后才由 RLS 过滤。结果可能只剩两行,LLM 回答质量变差,日志里却没有任何错误。

Render 描述的正是这种情况:过滤与 HNSW 结合,返回的行数可能少于 LIMIT。要关注的参数是 pgvector 的 hnsw.iterative_scan,它决定索引是否持续扫描,直到填满 LIMIT。

查阅你所安装的 pgvector 具体版本的文档,确认它接受哪些取值,然后在检索查询所在的同一事务中启用它。

无论选哪个值,都要把它变成可度量的指标。用 count(*) 包住你实际使用的检索查询,以 assistant_app 身份、用最小的部门来执行。下面的片段做了简化:ORDER BY 处要粘贴你应用中实际使用的向量距离表达式。

BEGIN;
SET LOCAL app.department_id = 'hr';
SELECT count(*) FROM (
  SELECT id FROM documents
  ORDER BY /* 你的向量距离表达式 */
  LIMIT 10
) AS hits;
COMMIT;

**检查:**HR 有 50 个段落,结果必须是 10。如果得到 2 或 3,说明检索在悄无声息地缺失上下文。把这个计数加入自动化测试,与 LIMIT 和该部门实际段落数中较小的那个比较,并在每次索引配置变更后重新运行。

常见错误,以及它们在客户现场的样子

最常见的错误就是本文开头那个:助手的连接字符串复用了迁移账号,而这个账号是表的所有者;或者为了“省事”用了管理员密钥。第二个错误更难发现。

Postgres 文档警告,外键之类的参照完整性检查会绕过 RLS,可能形成泄露信息的“隐蔽通道”(covert channel);比如,一条违反约束的报错就可能告诉用户,某个 ID 存在于另一个部门。

在客户现场,这项技能在第一次安全会议上就会派上用场。安全团队会问:“你怎么证明助手读不到其他部门的数据?”

有说服力的回答是:一个命名清晰的角色,写在迁移脚本里的策略,以及一套在 CI 中尝试跨边界读写、且必须失败的测试。第一周就申请一个最小权限的 Postgres 账号,别等别人把所有者密码发给你。

如果你想转向 FDE 工作,可以把这个练习做成一个小仓库放进作品集:schema、策略、隔离测试,再加一段 README,解释为什么要用 FORCE 和 AS RESTRICTIVE。

当岗位描述提到“企业数据”“权限”或“多租户 RAG”时,在简历里写上一行具体经历,例如“为 RAG 助手用 Postgres RLS 设计按部门的数据隔离,并在 CI 中测试”。

这样一行字,比罗列一串框架更有分量。

AI 助手会越来越多地获得写权限、工具调用能力,以及自行执行查询的能力。到那时,守住边界的不是系统提示词里一句“不得泄露数据”,而是在数据库层直接拒绝的 SQL。

这篇文章对你有帮助吗?

4 个信源
路线图上的下一篇 · 阶段 3: AI 应用Agent 的 URL 抓取工具,就是通往客户内网的代理黑名单挡不住 SSRF,只有固定解析 IP、经出口代理转发的抓取路径才能把门关上。