RAG权限回归实战:缓存隔离、撤权与旧索引

知识库把文档权限收回以后,旧的检索缓存会不会继续把内容送给模型?本文用两个租户、四个测试身份和五份虚构文档,复现缓存串用、过滤顺序和索引更新延迟,再用可运行的回归测试验证修正后的结果。

这套实验面向正在接入RAG的开发者,检查的是进入模型之前的上下文与引用资料。检索采用真实SQLite FTS5关键词查询,不调用大模型,也不模拟向量相似度。这样可以先把权限状态的变化测清楚,而不把生成随机性混进结果。

下载权限回归实验包:完整源码、固定数据、结果样例和19项测试(ZIP,约8KB)

建立一张可以逐项核对的权限表

所有数据都由实验程序在内存中创建。red租户有Alice、Bob和guest,blue租户有blue_user;guest也是已定义的测试身份,不代表程序实现了匿名认证。正文中的特殊字符串只是方便查找的虚构标记。

文档 租户 允许读取 正文标记
1:alpha预算 red Alice A_ONLY_120
2:beta预算 red Bob B_ONLY_230
3:公共指南 red red租户各测试身份 PUBLIC_RED
4:blue预算 blue blue_user BLUE_ONLY_900
5:blue公共指南 blue blue租户各测试身份 PUBLIC_BLUE

这里的public只表示租户内公开,不等于跨租户公开。同样查budget,Alice初始应看到文档1和3,Bob应看到2和3。只要Bob的上下文出现A_ONLY_120,或引用标题出现budget alpha,就已经越过了本例的权限边界,不需要等待模型真的把它复述出来。

先跑实验,不要急着接API

解压后进入rag-access-lab-v1目录,运行下面两条命令。只需要Python 3.11+及带FTS5的sqlite3;本次运行环境为macOS、Python 3.14.4、SQLite 3.53.2。程序不联网,也不创建磁盘数据库。

python3 run_lab.py
python3 -m unittest -v

第一条打印对照结果,第二条执行19项测试。若提示no such module: fts5,当前Python链接的SQLite缺少该模块,需要先解决环境前提;本包不会下载不明扩展,也不会悄悄换成手写排名冒充同一实验。

故障一:缓存键只有问题,用户身份就丢了

错误实现先替Alice正常检索,把结果缓存到query对应的槽位。Bob随后问同一个budget,缓存命中直接返回旧内容,甚至不会再次走检索。下面是故意错误的关键逻辑,完整对照方法名带unsafe前缀。

key = normalize(query)
if key not in cache:
    cache[key] = retrieve(actor, query)
return cache[key]
顺序 错误缓存返回ID 按身份隔离后返回ID
Alice先查询budget 1、3 1、3
Bob再查询同一问题 1、3(文档1越权) 2、3

问题不在模型是否听从“不要泄漏”的提示,而在于未授权片段已经被选出来。修正实现把actor、tenant、规范化查询、limit和状态epoch一起作为缓存键,避免相同问题覆盖不同身份的访问范围。limit也要参与,否则“返回一条”与“返回三条”的请求可能互相复用错误结果。

只把用户名加进缓存键,还处理不了撤权

接下来让Alice先命中一次缓存,再撤掉她对文档1的权限。即使缓存已经按用户隔离,同一个Alice仍可能读到自己曾经有权访问的片段。因此本例同时做变更失效和命中后复核。

key = (actor, tenant, normalized_query, limit, epoch)
cached = cache.get(key)
if cached is not None and all(authorized(actor, item) for item in cached):
    return deepcopy(cached)
return retrieve_and_cache(actor, query, limit)

authorized重新读取权威文档状态,检查当前租户、active、revision与ACL。撤权后,本次实测Alice只返回文档3;文档1的正文与标题都不进入上下文。测试还特意直接改ACL、跳过epoch更新,验证复核层能识别原缓存已经过期。

这些机制有明确合同:正常授权、内容、状态和索引更新应走变更方法,推进epoch并清理缓存。若直接新增授权却不通知失效,旧的空缓存可能仍然为空;若修改正文却不提升revision,版本检查也无从识别变化。本文不把违反这些合同后的状态假装成已经解决。

故障二:先截断候选,再过滤,Bob会查不到自己的资料

固定样本中,全库相关性第一名是Bob无权读取的文档。错误做法先取全库top1,再过滤,结果为空;同样的limit=1,把租户、可见状态及ACL作为候选约束后,本次返回了Bob可读的文档2。

SELECT d.id, d.title, d.revision, search_index.body
FROM search_index
JOIN docs d ON d.id = search_index.doc_id
           AND d.revision = search_index.revision
WHERE search_index MATCH ?
  AND d.tenant = ? AND d.active = 1
  AND (d.public = 1 OR EXISTS (
      SELECT 1 FROM acl a WHERE a.actor = ? AND a.doc_id = d.id
  ))
ORDER BY bm25(search_index), d.id
LIMIT ?

这是完整实现使用的查询结构,参数通过绑定传入。它说明最终候选必须符合权限约束,再决定返回数量;不能由这个关键词查询实验推断某个向量数据库的ANN召回率、分片行为或成本。接入实际向量检索服务时,应把这组“权限很窄、候选靠后”的用例原样带过去重新测。

故障三:权限正确,正文却来自旧索引

文档1的正文更新为含A_NEW_121的新版本,但故意不立即重建索引。权威文档的revision已增加,索引仍记录旧revision。如果只检查ACL,仍可能把旧正文送给模型。

步骤 Alice返回ID 关键结果
更新前 1、3 包含旧标记A_ONLY_120
正文更新、索引滞后 3 文档1暂不返回
重建文档1索引后 1、3 包含新内容,旧标记消失

本例选择“版本不一致就暂不返回”,代价是索引追上来之前会少一份资料。这个代价可观察、可恢复,比把不匹配的旧文本当作当前版本更容易控制。真实产品应决定这段时间如何提示用户、是否允许明确标注的历史版本,而不是让读取层自行猜测。

重建索引也会推进epoch,使此前缓存的空结果或较少结果重新查询。撤下公共文档3后,guest返回空列表;另一项测试故意给blue_user增加red文档的ACL,租户约束仍阻止它跨租户读取。

回归测试要包含状态变化,而不只测首次登录

测试组 实际覆盖的行为
基线与泄漏对照 Alice/Bob/guest权限、错误缓存越权、修正后隔离、私有标题不返回
缓存生命期 真实缓存命中、撤权、撤下、空缓存后新增授权、limit隔离
内容生命期 正文revision增加而索引滞后、重建索引恢复、旧标记消失
防误用分支 未知身份拒绝、错误跨租户授权、输入边界、返回对象不能污染缓存
补偿检查 跳过epoch直接撤权、撤下或提升revision,命中后复核仍能阻断旧结果

19项测试中包含故意错误实现的复现断言。例如“错误缓存确实把文档1给了Bob”是对照成立,不是安全验收通过。真正的准入条件是safe_context相关用例没有返回不应出现的ID、标题和标记。排错时先看失败的测试名和状态变化,再分析是哪一层漏了检查。

把最小实验迁入实际系统时,需要补上的部分

首先,actor和tenant必须来自服务端可信会话,而不是由提问者在请求中任意指定。本包使用固定名字调用方法,只是在模拟已认证身份;它没有实现登录,也不能直接作为公开服务暴露。OWASP建议在每次请求上验证授权,缓存路径同样属于请求路径。

其次,本例为了让失效过程容易观察,采用全局epoch和清空缓存。任意变更都会降低全体缓存命中率,生产系统可以改成用户、租户或文档范围的失效,但需要证明各节点都收到了变化,并处理通知丢失与重试。增加一层缓存时,也应增加相应的撤权测试。

最后,这个单连接内存实验没有覆盖并发撤权和流式生成。一次检索检查之后、回答发出之前仍可能发生权限变化,必须定义撤权在什么时间点生效,并让引用及最终答案缓存遵守同一约束。已经发给模型或用户的内容不能靠事后删链接收回。

你可以先把自己的两名测试用户、几份受限文档和撤权动作接入同样的结果矩阵:记录每一步允许的ID集合,再同时检查片段正文、引用标题和缓存命中情况。这样“权限已实现”才有一组可以在改代码、换索引或换缓存后反复运行的证据。

下载包SHA-256:

47768f561a061c136251b2196ba155d0a4380965f10cb74639ac723637c89715

参考资料与相关阅读

资料查阅与编辑日期:2026年10月17日。作者:玖柒。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享