26 权限隔离:让无权内容无法进入 RAG 上下文
权限隔离:让无权内容无法进入 RAG 上下文
RAG 权限隔离的底线不是“最终答案不展示秘密”,而是用户无权访问的 chunk 从一开始就不能进入检索候选、Prompt、日志、缓存和来源接口。
[TOC]
1. 本节目标
普通 RAG Demo 往往默认所有用户共享一个知识库。
真实系统通常包含:
多个租户
多个部门
多个知识库
公开和私有文档
管理员和普通用户
临时授权和权限撤销
如果权限过滤缺失,可能发生:
用户A检索到用户B的文档
普通员工看到管理层资料
模型把无权chunk摘要进答案
日志和缓存保存了越权正文
旧会话来源绕过新权限
完成本节后,应能够:
区分认证和授权
构造统一AuthorizationContext
设计租户、知识库和文档权限字段
把权限条件直接加入向量检索SQL
避免先全库召回再在应用层过滤
为来源查看接口重新鉴权
处理缓存、日志和对话历史中的权限
设计跨租户和权限撤销测试
理解PostgreSQL RLS的作用和边界
2. 认证和授权
两个概念必须分清:
认证 Authentication:你是谁
授权 Authorization:你可以访问什么
认证后得到身份:
userId
tenantId
roles
departmentIds
授权再计算:
允许访问哪些knowledgeBaseId
允许访问哪些documentId
是否允许查看原文
是否允许上传、删除和管理
Spring Security 可以保护 HTTP 请求和方法调用,但 RAG 还必须把授权范围传到检索查询中。
仅仅保护 /rag/chat 接口“必须登录”是不够的。
3. 权限风险链路
graph TD;
A["用户提交问题"] --> B["身份认证"];
B --> C["构造授权上下文"];
C --> D["带权限条件执行检索"];
D --> E["只返回允许访问的 chunks"]; E --> F["构造 Prompt"]; F --> G["模型生成答案"];
G --> H["返回经过授权的 sources"]; H --> I["点击来源时再次鉴权"];
权限必须覆盖:
文档列表
文档上传和删除
文档解析任务
chunk检索
Prompt上下文
回答来源
对话历史
反馈记录
调试接口
缓存
日志和追踪数据
只要漏掉其中一个入口,就可能泄露数据。
4. 统一授权上下文
请求进入系统后,建议构造服务端可信的授权上下文:
public record AuthorizationContext(
String userId, String tenantId, Set<String> roles, Set<String> departmentIds, Set<String> readableKnowledgeBaseIds, boolean administrator) {
}
它应该来自:
已验证的登录凭证
服务端用户和权限数据库
可信身份提供方
不能直接相信客户端提交:
{
"tenantId": "tenant_admin", "knowledgeBaseIds": ["secret_kb"]}
客户端参数只能表达“想查询哪个知识库”,服务端必须验证它是否属于允许范围。
推荐流程:
请求knowledgeBaseId
-> 与readableKnowledgeBaseIds求交集
-> 无权限则返回403
-> 有权限才进入检索
5. 权限数据模型
5.1 知识库表
CREATE TABLE rag_knowledge_base (
id uuid PRIMARY KEY, tenant_id varchar(64) NOT NULL, name varchar(255) NOT NULL, visibility varchar(32) NOT NULL, owner_user_id varchar(64), enabled boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
visibility 可以先定义:
PRIVATE:仅所有者或明确授权用户
DEPARTMENT:指定部门可访问
TENANT:同租户可访问
PUBLIC:系统公开,仍需明确业务边界
5.2 文档表
ALTER TABLE rag_document
ADD COLUMN tenant_id varchar(64), ADD COLUMN owner_user_id varchar(64), ADD COLUMN visibility varchar(32) DEFAULT 'PRIVATE';
关键权限字段最好使用普通列:
tenant_id
knowledge_base_id
owner_user_id
visibility
status
不要只把关键权限藏在 JSONB metadata 中。
5.3 用户授权表
CREATE TABLE rag_knowledge_base_user_acl (
knowledge_base_id uuid NOT NULL, user_id varchar(64) NOT NULL, permission varchar(32) NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (knowledge_base_id, user_id, permission));
权限可以包括:
READ
WRITE
MANAGE
5.4 部门授权表
CREATE TABLE rag_knowledge_base_department_acl (
knowledge_base_id uuid NOT NULL, department_id varchar(64) NOT NULL, permission varchar(32) NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (knowledge_base_id, department_id, permission));
第一版可以只实现:
tenant隔离
知识库级READ授权
管理员角色
之后再扩展部门和单文档授权。
6. Chunk 是否需要冗余权限字段
向量检索最终查询的是 chunk。
可以选择:
6.1 通过 JOIN 获取权限
FROM rag_chunk c
JOIN rag_document d ON d.id = c.document_id
JOIN rag_knowledge_base kb ON kb.id = d.knowledge_base_id
优点:
权限数据来源统一
文档权限变化后立即生效
减少冗余字段同步问题
6.2 在 chunk 冗余常用范围字段
tenant_id
knowledge_base_id
document_id
优点:
检索SQL更直接
减少JOIN
某些向量数据库更容易做metadata过滤
代价:
权限变化时必须同步更新chunk
容易出现文档与chunk权限不一致
使用 PostgreSQL 第一版时,优先通过文档和知识库 JOIN 过滤,更容易保证一致性。
7. 检索时权限过滤
权限条件必须进入同一条检索 SQL。
示例:
SELECT
c.id, c.document_id, c.content, c.title_path, c.page_start, c.page_end, c.embedding <=> CAST(:query_embedding AS vector) AS distanceFROM rag_chunk c
JOIN rag_document d
ON d.id = c.document_idJOIN rag_knowledge_base kb
ON kb.id = d.knowledge_base_idWHERE kb.tenant_id = :tenant_id
AND kb.id = ANY(:readable_knowledge_base_ids) AND kb.enabled = true AND d.status = 'EMBEDDED' AND c.embedding IS NOT NULLORDER BY c.embedding <=> CAST(:query_embedding AS vector)
LIMIT :top_k;
如果只查询一个知识库:
AND kb.id = :knowledge_base_id
关键原则:
无权 chunk 必须在数据库查询阶段排除,不能先进入候选集再由应用代码删除。
8. 为什么不能先召回再过滤
错误流程:
全库向量检索Top100
-> 应用层删除无权限结果
-> 剩余结果交给模型
风险:
无权chunk已经进入数据库查询结果
调试日志可能记录无权正文
缓存可能保存无权候选
监控和追踪可能泄露文档信息
过滤后候选数量可能不足
代码某个分支可能忘记过滤
正确流程:
先确定授权范围
-> 授权范围进入SQL条件
-> 数据库只返回允许访问的候选
9. 默认拒绝
以下情况必须默认拒绝:
tenantId缺失
用户身份解析失败
知识库权限服务异常
授权缓存不可用且无法回源
readableKnowledgeBaseIds为空
请求知识库不在允许范围
不能降级为:
权限服务失败 -> 查询全部知识库
tenantId为空 -> 不加tenant条件
ACL为空 -> 当作公开
权限系统中的合理降级通常是“拒绝访问”,而不是“扩大访问范围”。
10. Prompt 不能代替权限
错误做法:
把所有chunk交给模型
然后在Prompt里说:不要回答用户无权看的内容
模型已经看到了数据,泄露可能通过:
直接回答
摘要
间接暗示
引用来源
后续多轮对话
Prompt 只能约束回答风格,不能承担数据库授权职责。
正确边界:
授权系统决定哪些数据可以读取
检索系统只返回允许的数据
Prompt只接收已经授权的上下文
11. 来源接口再次鉴权
回答中的来源可能被点击、复制和分享。
来源查看流程:
graph TD;
A["请求查看来源"] --> B["读取当前登录用户"];
B --> C["根据documentId查询所属租户和知识库"];
C --> D["重新计算当前访问权限"];
D --> E{"允许访问?"};
E -- "否" --> F["返回403"];
E -- "是" --> G["返回文档片段或预览"];
不能使用:
曾经出现在回答中
用户知道chunkId
用户拥有旧会话链接
作为当前仍有权限的证明。
12. 对话历史权限
历史消息可能保存:
旧答案
旧sources
文档名称
证据片段
权限撤销后:
新问题不能继续使用旧无权来源作为上下文
点击旧来源必须重新鉴权
历史页面是否隐藏旧正文要按产品合规要求决定
会话摘要不能继续带入已撤销内容
读取对话历史时至少校验:
conversation.tenant_id
conversation.user_id
当前用户是否有权访问该conversation
13. 缓存权限
缓存 Key 必须包含安全范围:
tenantId
userId或权限版本
knowledgeBaseId集合
questionHash
检索配置版本
危险缓存 Key:
rag:answer:{questionHash}
不同用户问同一个问题,可能命中包含其他租户来源的答案。
更安全的思路:
rag:answer:{tenantId}:{permissionVersion}:{kbScopeHash}:{questionHash}
权限变化时:
提升permissionVersion
或主动清理相关缓存
第一版可以不做答案缓存,先把权限边界做正确。
14. 日志与可观测性
日志建议记录:
traceId
userId
tenantId
请求knowledgeBaseId
授权后的知识库数量
最终召回chunkId
权限拒绝原因
不要记录:
完整JWT
API Key
用户密码
无权chunk正文
敏感文档全文
审计日志可以记录:
谁
在什么时间
访问了哪个知识库或文档
执行了什么操作
结果是允许还是拒绝
审计日志与普通调试日志应区分保存周期和访问权限。
15. 管理员权限
管理员可以拥有更大范围,但不要散落:
if (user.isAdmin()) {
// 跳过所有过滤
}
更合理:
由统一授权服务计算管理员可访问范围
管理员操作进入审计日志
区分租户管理员和平台管理员
高敏感知识库可要求额外权限
平台管理员也不一定默认拥有所有文档正文读取权限。
16. PostgreSQL Row-Level Security
PostgreSQL 支持 Row-Level Security(RLS),可以在数据库层限制每个角色可见的行。
示意:
ALTER TABLE rag_document ENABLE ROW LEVEL SECURITY;
再创建策略:
CREATE POLICY document_tenant_policy
ON rag_document
USING (tenant_id = current_setting('app.tenant_id', true));
RLS 的优点:
增加数据库层最后一道保护
某些遗漏tenant条件的SQL仍会被拦截
需要谨慎处理:
连接池中的session变量清理
表owner可能绕过RLS
后台任务和管理员访问
迁移、备份和运维账号
查询计划和性能
第一版先在应用 SQL 中正确过滤。理解清楚连接池和角色模型后,再评估 RLS,不能只复制一段策略就认为安全完成。
17. 权限变更
权限不是静态数据。
变更场景:
用户离职
部门调整
知识库授权撤销
文档从TENANT改为PRIVATE
管理员角色取消
变更后需要同步处理:
授权缓存
检索缓存
答案缓存
会话摘要
来源访问
长期任务
正在执行的长请求是否立即中止,要根据业务安全等级决定。
高敏感场景可以在返回答案前再次检查权限版本。
18. 安全测试矩阵
18.1 跨租户
用户属于tenantA
请求tenantB的knowledgeBaseId
期望:403或空授权范围
期望:日志中不出现tenantB正文
18.2 同租户不同知识库
用户能访问kb1,不能访问kb2
kb2包含更相似的答案
期望:检索结果仍不能出现kb2
18.3 文档权限撤销
先生成带来源的回答
随后撤销文档权限
再次点击来源
期望:403
18.4 缓存隔离
两个租户提出相同问题
期望:不能共享包含来源的答案缓存
18.5 调试模式
普通用户提交debug=true
期望:不能看到越权候选、Prompt或完整metadata
18.6 管理员审计
管理员读取敏感知识库
期望:访问成功并产生审计记录
19. 常见问题
19.1 权限过滤放检索前还是检索后
必须进入检索查询本身。
应用层可以再次防御性检查,但不能只依赖检索后的过滤。
19.2 关键权限字段能放 JSONB 吗
不建议只放 JSONB。
租户、知识库、所有者和状态等高频安全字段应使用结构化列并建立必要索引。
19.3 管理员可以看所有数据吗
取决于业务规则。
管理员范围必须明确、可审计,不能靠代码里的隐式跳过。
19.4 Prompt 注入属于权限问题吗
它不是传统授权本身,但可能诱导模型泄露已进入上下文的数据。
最重要的防线仍是:无权数据不进入上下文。
19.5 权限服务失败时能否返回公开结果
除非公开范围可以在本地可靠验证,否则应默认拒绝。
20. 练习清单
区分认证和授权
设计AuthorizationContext
为知识库和文档增加tenant_id
设计用户和部门ACL表
写出带tenant和知识库范围的向量检索SQL
确保无权chunk不进入候选集
为来源查看接口增加二次鉴权
设计包含权限范围的缓存Key
完成跨租户、权限撤销和缓存隔离测试
了解PostgreSQL RLS的作用和风险
21. 小结
权限链路:
identity
-> authorization context -> authorized retrieval scope -> authorized chunks -> prompt -> answer and sources -> source re-authorization
本节最重要的结论:
权限不是 Prompt 规则,而是数据查询边界。无权数据必须在检索之前被排除,并且不能通过缓存、日志、历史或来源接口重新泄露。