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

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

原文: https://fdetimes.net/zh/guides/postgres-rls-ai-assistant-department-isolation/

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

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

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

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

## 第 1–2 步：建表，然后看它“锁死”

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

```sql
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，并强制所有者也必须遵守：

```sql
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` 与一个会话变量比较：

```sql
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 设为事务级变量。在本例中，租户就是部门：

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

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

```sql
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）策略正好相反：每一条都必须通过，才能访问某一行。用它作为部门边界的硬性屏障：

```sql
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 那一行。检查完删掉这条临时策略。

**要点:** AI 助手的访问权限应该放在数据库里，而不是提示词里。

## 第 5 步：别让策略拖慢检索

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

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

```sql
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` 处要粘贴你应用中实际使用的向量距离表达式。

```sql
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。

**本周动手试试:**

- 在你正在开发的 AI 助手的连接代码中执行 SELECT current_user，然后检查该角色是否拥有这张表，或是否带有 BYPASSRLS。
- 按上面的步骤，在本地 Postgres 上建一张包含三个部门的 documents 表，再写一个自动化测试：尝试把数据 UPDATE 到另一个部门，并且必须得到报错。
- 审查一张真实表上的所有现有策略，找出默认作用于 PUBLIC 的策略，以及可能意外扩大访问范围的宽松策略。

## 信源

- [PostgreSQL Documentation: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)

- [PostgreSQL Documentation: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)

- [Supabase Docs: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)

- [Multi-tenant RAG with PostgreSQL and pgvector (Render)](https://render.com/articles/multi-tenant-rag-postgresql-pgvector)
