RAG为什么会检索到其他租户的文档?多租户权限泄露完整治理方案

文章摘要

企业RAG最严重的故障不是答案不准确,而是用户能够通过自然语言检索到其他租户、其他部门或更高密级的文档。常见根因包括租户ID来自请求参数、过滤只在生成后执行、Top K先召回再删权限、文档Metadata缺失、缓存Key不含租户和权限、重排器接触无权限内容等。本文给出从身份、入库、检索、缓存、重排、生成和审计七层构建权限闭环的完整方案。

一、权限泄露通常是怎么发生的

一个典型错误链路:

用户登录租户A
→ 提问“公司今年收入是多少”
→ 向量库在全量文档中召回
→ Top5中包含租户B财务报告
→ 应用层只检查最终答案
→ 模型引用租户B数据

或者系统在应用层删除无权限结果:

全库Top5
→ 删除4条无权限文档
→ 剩1条或0条

这不仅会降低召回质量,更重要的是:

无权限文档已经进入检索结果、日志、Trace、重排器或大模型上下文。

权限治理必须从候选集合产生之前开始。

二、第一原则:租户ID不能来自用户自由输入

错误接口:

{
  "question": "查询经营数据",
  "tenantId": "T002"
}

用户可以把自己的T001改为T002。

正确链路:

Access Token
→ 服务端验证签名
→ 解析user_id
→ 权限服务查询tenant_id和角色
→ 构造检索过滤器

Controller示例:

@PostMapping("/ask")
public AnswerResponse ask(
        @AuthenticationPrincipal
        LoginUser user,
        @RequestBody
        AskRequest request
) {
    RetrievalPrincipal principal =
            permissionService.resolve(user.id());

    return ragService.answer(
            principal,
            request.question()
    );
}

用户请求中可以有业务筛选,但不能覆盖服务端解析出的身份范围。

三、权限模型不要只设计一个tenant_id

企业RAG通常存在多层权限:

租户
组织
部门
项目
角色
文档密级
用户白名单
有效期

建议定义统一权限主体:

public record RetrievalPrincipal(
        String userId,
        String tenantId,
        Set departmentIds,
        Set projectIds,
        Set roles,
        int securityLevel,
        String permissionVersion
) {
}

文档Metadata:

{
  "tenant_id": "T001",
  "department_ids": ["D01", "D02"],
  "project_ids": ["P100"],
  "allowed_roles": ["MANAGER"],
  "security_level": 2,
  "visibility": "DEPARTMENT",
  "status": "EFFECTIVE"
}

四、权限过滤必须下推到向量数据库

错误方案:

先全库向量搜索
→ 再由Java过滤

正确方案:

服务端权限上下文
→ 生成数据库原生过滤条件
→ 在有权限候选中执行向量搜索

逻辑示例:

tenant_id = T001
AND status = EFFECTIVE
AND security_level  metadata
    ) {
        require(metadata, "tenant_id");
        require(metadata, "visibility");
        require(metadata, "security_level");
        require(metadata, "status");
    }

    private void require(
            Map metadata,
            String key
    ) {
        if (!metadata.containsKey(key)) {
            throw new IllegalArgumentException(
                    "缺少权限字段:" + key
            );
        }
    }
}

还需要保证同一文档的所有Chunk继承相同权限,不要出现:

正文Chunk有tenant_id
表格Chunk没有tenant_id
图片OCR Chunk没有tenant_id

七、权限变化后如何同步

员工转岗、项目结束或文档密级调整后,向量库权限必须及时更新。

推荐:

权限系统变更
→ 发布PermissionChanged事件
→ 更新向量库Payload
→ 清理缓存
→ 更新permission_version

每次查询记录:

permission_version

这样可以追踪某次结果使用的是哪一版权限快照。

对于高风险文档,可以采用查询时实时鉴权;对于大规模普通文档,可以使用权限Payload加短期版本缓存。

八、缓存是最容易遗漏的泄露点

错误缓存Key:

hash(question)

租户A问:

今年销售额是多少?

缓存了答案。

租户B提出相同问题,可能命中同一个缓存。

正确Key至少包括:

tenant_id
user_or_role_scope
permission_version
knowledge_version
question_hash
prompt_version
model

示例:

String cacheKey = String.join(
        ":",
        principal.tenantId(),
        principal.permissionVersion(),
        knowledgeVersion,
        sha256(question)
);

如果权限精确到用户,还要包含用户或权限集合哈希。

九、语义缓存也必须权限隔离

语义缓存会根据相似问题复用答案。

即使问题不完全相同,也可能命中:

A:华东区域本月销售如何?
B:这个月华东卖得怎么样?

语义缓存检索本身也必须带:

tenant_id
permission_hash
knowledge_version

不能建立一个全局答案向量库后跨租户搜索。

十、重排器能否看到无权限文档

错误链路:

全库召回Top100
→ Cross-Encoder重排
→ 过滤权限

即使最终结果过滤了,重排服务已经读取无权限内容。

正确顺序:

权限过滤
→ 召回
→ 融合
→ 重排

如果使用第三方Rerank API,还要确认:

  • 文档是否允许发送给第三方;
  • 数据是否保存;
  • 是否用于训练;
  • 区域和合规;
  • 日志保留。

十一、LLM上下文前再做一次防御校验

数据库过滤是第一道防线,进入Prompt前可以再校验一次Metadata:

List authorized =
        chunks.stream()
                .filter(chunk ->
                        accessEvaluator.canRead(
                                principal,
                                chunk.metadata()
                        )
                )
                .toList();

这不是替代数据库过滤,而是纵深防御。

如果发现未授权Chunk:

阻断请求
记录安全事件
不继续调用模型

不要静默删除后继续回答,因为这说明前置权限链已经失效。

十二、生成阶段如何避免信息侧漏

模型可能根据对话历史复述旧内容。

需要确认:

  • Chat Memory是否按租户和用户隔离;
  • Conversation ID是否全局唯一;
  • 历史消息是否含其他用户数据;
  • 摘要是否跨用户复用;
  • Prompt缓存是否隔离;
  • 工具结果是否进入正确会话。

Memory Key推荐:

tenant_id
+user_id
+conversation_id

不能只使用:

conversation_id = 1

十三、日志和Trace也属于数据访问面

即使用户看不到,运维日志可能包含:

  • 检索原文;
  • 完整Prompt;
  • 客户合同;
  • 身份信息;
  • 工具结果。

建议普通日志只记录:

document_id
chunk_id
score
metadata_hash

详细内容进入受控审计存储,并设置:

  • 权限;
  • 保留期;
  • 脱敏;
  • 下载审计;
  • 删除机制。

十四、权限查询失败时应该怎样处理

错误策略:

权限服务超时
→ 不加过滤继续检索

这是Fail Open,会直接造成泄露。

高风险系统应Fail Closed:

权限无法确认
→ 拒绝检索
→ 返回稍后重试

同时区分:

权限不足
权限服务不可用
知识库无结果

十五、公开文档也要绑定租户吗

“公开”可能有不同范围:

互联网公开
平台所有租户公开
当前租户公开
组织内部公开

不要只用一个:

visibility = PUBLIC

建议:

PUBLIC_INTERNET
PUBLIC_PLATFORM
PUBLIC_TENANT
DEPARTMENT
PROJECT
PRIVATE

并明确每种范围的租户约束。

十六、自动化安全测试

构造两个租户:

T001文档包含秘密代码ALPHA-7788
T002用户无权限

测试:

  • 直接问代码;
  • 使用同义表达;
  • 请求总结全部文档;
  • 通过多轮对话诱导;
  • 使用拼写错误;
  • 请求列出来源;
  • 命中语义缓存;
  • 重排服务异常;
  • 权限服务超时。

预期:

T002永远不能获得ALPHA-7788

十七、建议监控指标

unauthorized_candidate_count
permission_filter_error_count
missing_tenant_metadata_count
permission_service_timeout
cross_tenant_cache_hit
blocked_context_count
security_incident_count

任何:

unauthorized_candidate_count > 0

都应该作为安全告警,而不只是普通检索指标。

十八、生产级权限链

认证
→ 解析用户与租户
→ 获取权限快照
→ 构造数据库过滤
→ 在权限范围内召回
→ 融合与重排
→ 上下文二次校验
→ 调用模型
→ 结果审计
→ 权限隔离缓存

总结

多租户RAG权限泄露通常来自:

身份来源不可信
过滤执行太晚
Metadata缺失
缓存未隔离
重排器先看到全量文档
Memory跨用户复用
权限失败时Fail Open

企业RAG必须把权限设计成贯穿入库、召回、重排、生成、缓存和日志的完整链路,而不是在最终答案上增加一次字符串检查。

延伸阅读

如果你正在关注企业级 AI 应用、RAG、Agent、MCP 与大模型工程化落地,欢迎访问 智元界

https://www.zyentor.com/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。