Ch10 · 多租户隔离与权限下推
覆盖提交:
af5283e(038) ·d1796b2(039) ·f94d41a(054) ·f5dda6b(060) 代码位置:GitHub 搜索lingluo1hao / enterprise-ai→advanced_rag_agent.py(_milvus_search:866过滤下推 ·partition_key:714租户物理隔离 ·AccessControlFilter:1090·filter_results:1127应用层兜底(调用点:1491)·_exact_key:364缓存键 · ContextVar:235-236)·ingest/loaders.py(get_access_level:47+ fail-closed 护栏:48-49)·ingest/pipeline.py:118目录定租户 ·ingest/store.py:24路径归一化 ·rag_web_server.py(:368权限取自登录态、客户端不可伪造 ·_kb_visible_files:1732)·erp_common.py(四层定稿:23-24·audit:148·RoleEngine:171·_check_op:464·_visibility_where:498)·config/access_rules.yaml(4 关键字 + 7 业务角色 + 审批规则)·config/tenants.yaml(5 租户) 难度:★★★★☆ 阶段:Day 9–15 关键词:四层隔离 / 过滤下推 / Milvus partition_key / 行级 ACL / 缓存越权 / ContextVar / fail-closed 编号说明:本文件是 v2 课程表第 10 集("让 AI 只看到他能看的")。⚠️ 本集有底稿——ch08_多租户与权限下推.md(旧编号命名,写于 2026-08-30,覆盖到提交 054)。本文件是它对齐 ch01–ch09 固定模板 + 回代码校正 5 处事实之后的版本;底稿保留不动。编号一律以 课程表_第06集起.md v2 为准。
本章导读
上一章(Ch09 · 重排序与去冗余)解决的是"排得准不准"。那是一个质量问题:排错了,答案差一点。
本章要解决的问题在它前面一步,而且性质完全不同:
有些文档,用户压根就不该看到。
这不是质量问题的加强版,是另一个维度——质量差是"答得不好",越权是"答了不该答的"。前者扣分,后者出事。
三集之前埋的那条线,本章要收第一段:Ch06 讲融合、Ch07 讲主链路、Ch09 讲精排——它们全都在讨论"怎么把相关的捞上来"。本章讨论的是反方向:
| 章节 | 目标函数 |
|---|---|
| Ch06 / Ch07 / Ch09 | 尽量多捞对的(recall ↑) |
| Ch10(本章) | 坚决不漏不允许的(漏召 = 失败,越权 = 事故) |
这两件事的优先级不一样。 召回差一点,用户多问一次;越权差一点,就是一次数据泄露。所以本章反复讲的不是"怎么召回得更多",而是"怎么保证不该出去的一条都出不去"。
本章真正想让你带走的,是三个能在别处复用的判断:
- 一个"我以为做了隔离"的漏洞长什么样? —— 缓存键漏了一个权限维度,测试环境几乎不可能复现,因为它要在"高权限用户先查过"之后才触发
- 过滤该放在哪一层? —— 只要检索有 top-k 语义,过滤就必须下推;放在生成层(提示词)是自欺欺人,因为模型已经拿到内容了
- 权限系统该 fail-closed 还是 fail-open? —— 拿不准时拒绝。这一条在本章会从"一句口号"变成"一行代码"
还有一条原则要在本章兑现第四次。「静默降级比崩溃危险一百倍」是全书五条原则的第 2 条(见 README):
Ch05 判断某个降级不配存在,删掉了它; Ch06 判断某个降级可以留,但必须打日志; Ch09 的判断是——降级留不留是次要的,问题是"你怎么知道它降级了"; Ch10(本章)的判断是——最危险的降级,是你从来没写过的那个。
前三次讲的都是"你写的降级没留痕"。本章讲的是第四种形态:except: return True ——它不在你的降级清单上,因为它连"降级"这个名字都没有,它长得像正常代码。
本集学习目标
学完这一章,回到这张表逐一自查——四条,一条都不能少:
| # | 目标 | 达标标准 |
|---|---|---|
| 1 | 说清"四层隔离"到底指哪四层 | 能区分读侧(检索)与写侧(业务动作)两套口径;能说出 erp_common.py 定稿的 L1–L4 各是什么,以及缓存为什么不属于这四层 |
| 2 | 能论证过滤为什么必须下推 | 能用"有权限样本被污染"解释后过滤为什么会漏;能说出判定位置的三档(向量库 / 应用层 / 提示词层)各自的性质 |
| 3 | 会读并会写租户 + 密级的 expr | 能默写本项目的三分支 expr;知道 partition_key 与 expr 各管什么、为什么两样都要;知道租户名从目录来、权限从登录态来 |
| 4 | 能独立定位"缓存越权"并修掉它 | 能说出缓存键必须带哪些维度、为什么用 ContextVar 而不是全局变量;能解释 fail-closed 在"配置加载失败"时具体长什么样 |
目标 1 是本章最容易"以为知道"的部分——"四层"这个词在项目里有两种用法,混用会让你以为"做了四层所以缓存也安全了",而缓存恰恰是本章最隐蔽的坑。 目标 2 是本章最能迁移的部分——它跟 RAG 无关,跟"分页查询、搜索引擎、数据仓库的谓词下推"全都有关系。 目标 4 是本章**唯一一个"测试环境抓不到"**的坑。带着这四个目标往下读,读完回来打钩。
📐 理论基石:权限下推 = 工具护栏,fail-closed 是总纲(P0-06 §六)
动手前先把"权限到底是个什么性质的问题"钉死。这是本章的总开关。
安全不是 AI 应用的一个功能,是它的前提。P0-06 §六把护栏分了层:
| 层 | 防什么 | 手段 |
|---|---|---|
| 输入护栏 | 恶意问题/注入 | 正则/分类器拦截危险输入 |
| 输出护栏 | 有害输出 | 敏感词/合规校验 |
| 工具护栏 | 越权调用 | 权限下推(本章)、审批门(Ch22) |
| 评测护栏 | 质量下降 | faithfulness 门禁(Ch18) |
本章落在"工具护栏"这一格。它的核心命题是:模型能调的工具(检索、写入、改单),必须先过权限关卡,而且关卡要在"动作发生之前"生效。
为什么强调"之前"? 这是本章第二部分要展开的全部内容。先用一句话说清性质:
权限不是"事后删掉不给你看的",而是"压根不让你拿到"。
理论(P0-06 §六)还给了另一句总纲:
fail-closed:拿不准时拒绝执行,而非"猜一个"。
"fail-closed" 这个词听起来像口号,本章第四部分会把它还原成一行具体的代码:
# ingest/loaders.py:47-49
def get_access_level(source: str) -> str:
if ACL_LOAD_FAILED and not ACCESS_RULES_FAIL_OPEN:
return "restricted" # ← fail-closed 就是这一行
...
access_rules.yaml 读失败时,所有文档一律按受限处理——宁可让人多申请一次权限,也不能因为"配置没读到"就默认全开。
本项目真实的四层定稿(erp_common.py:23-24,全项目唯一一处明确写出"四层"的地方):
# erp_common.py:21-24 —— 多用户隔离(四层)
# L1 租户隔离 tenant_id / L2 行级 ACL(角色→resources×scope)
# L3 操作矩阵(ops) / L4 越权审计(logs/audit.log,result=blocked)
这四层和本章的关系:
| 层 | 防的是什么 | 本章展开在哪 |
|---|---|---|
| L1 租户隔离 | A 租户看到 B 租户的数据 | 第三部分(partition_key) |
| L2 行级 ACL | 同租户内,角色越权看单据 | 第四部分(resources × scope) |
| L3 操作矩阵 | 有权限"看"不等于有权限"改" | 第四部分(ops) |
| L4 越权审计 | 出事无法追溯 | 第一部分 + 踩坑总表 |
PN — 为什么"有权限看"和"有权限改"要分成两层? 因为它们失败模式不同:越权读是数据泄露,越权写是数据损坏。前者能靠"事后审计 + 通知"缓解,后者不可逆(库存扣了、单子关了)。 一个角色能查销售单,不代表它能改销售单——
query和update必须分开授权。这就是 L2 与 L3 不能合并的理由。
一句话:读权限回答"你能看见什么",写权限回答"你能改变什么"——它们是两个问题,四层模型把它们分开放。
先记住这条主线,现在开始动手——第一件事,看清楚一次请求到底要过几道门。
第一部分 · 全景:一次请求要过几道门

图 1 四层隔离体系。每一层防的是不同的问题,缺一层就有一个口子。
1.1 一次问答,五道门
先别急着看代码,先看顺序。一个用户提问从进来到答案返回,权限相关的关卡一共五道:
用户提问
│
├─【门 1】入口鉴权:role / user_id / tenant_id 从哪来?
│ ← 只能来自登录态。客户端传什么都不算数
│
├─【门 2】检索下推:expr 里带租户 + 密级 + 拥有者
│ ← 过滤发生在"距离计算之前",不是之后
│
├─【门 3】应用层兜底:对已经返回的结果再筛一遍
│ ← 第二道防线。不是主力,但必须留着
│
├─【门 4】缓存键隔离:缓存键里必须含权限维度
│ ← 漏一个维度 = 一个越权通道(第五部分)
│
└─【门 5】审计留痕:越权被拒也要记一笔
← 没有痕迹的拒绝 = 你不知道有人在试探
这五道门里,门 1 和门 4 是本章最容易翻车的地方,而且翻车方式完全不同:
- 门 1 翻车是"我从客户端读权限"——看着能跑,但等于让用户自己报权限
- 门 4 翻车是"我漏了一个维度"——功能全对,只在特定时序下出错
门 1 的正确写法只有一行注释那么短(rag_web_server.py:368 / :402,两处接口各写一遍):
# rag_web_server.py:368(非流式) / :402(流式) —— 两处完全相同
# role/user/user_id/tenant_id 一律来自登录态,客户端不可伪造(防普通用户提权看受限文档)
user_role = g.current_user["role"]
user = g.current_user["username"]
user_id = g.current_user["user_id"]
tenant_id = g.current_user.get("tenant_id", "default")
这一行注释值一段板书:身份只能由服务端决定。 请求体里就算带了
"role": "admin",代码也根本不读它——data.get("role")在这个文件里不存在。 一个可以立刻用在别处的判据:凡是"权限/身份/租户"出现在请求体里且被直接采信的地方,就是越权入口。 自己传tenant_id= 自己选租户;自己传user_id= 自己选身份。
1.2 "四层"有两套口径,别混
这是本章第一个必须掰清楚的概念。项目里"隔离"这个词,读侧和写侧说的是不同的事:
| 读侧(检索) | 写侧(业务动作) | |
|---|---|---|
| 场景 | "我能搜到哪些文档" | "我能改哪张单子" |
| 载体 | Milvus(向量库) | MySQL(业务库) |
| 手段 | expr 过滤 + partition_key | SQL WHERE + ops 白名单 |
| 定稿位置 | advanced_rag_agent.py:875-884 | erp_common.py:23-24 |
| 维度 | 租户 → 密级 → 拥有者 | 租户 → 行级范围 → 操作类型 |
| 失败后果 | 数据泄露(读到了不该读的) | 数据损坏(改了不该改的) |
读侧的三个维度,全部压在一个 expr 里(advanced_rag_agent.py:875-884):
# advanced_rag_agent.py:874-884
# 权限下推(多租户隔离 + 密级 + 拥有者)
if filter_role == ROLE_SUPER_ADMIN or tenant_id == "__global__":
expr = "" # 跨租户巡检:可见全部
elif filter_role == ROLE_ADMIN:
expr = f'(tenant_id == "{tenant_id}")' # 租户管理员:本租户全部
else:
expr = (f'(tenant_id == "{tenant_id}") and '
f'((access_level == "public") or (user_id == "{user_id}"))')
# 父子文档:主检索只召回「子片段」,父窗口仅作上下文透传
expr = expr + " and (is_parent == false)" if expr else "(is_parent == false)"
写侧的三个维度,则是"两张表"(erp_common.py):
# erp_common.py:498-511 —— L2:行级 ACL → SQL WHERE
def _visibility_where(self, rule, acting_user, tenant_id):
scope = rule.get("scope", "own")
conds = []
if scope == "own":
conds.append(("tenant_id = %s", tenant_id))
conds.append(("created_by = %s", acting_user))
elif scope == "tenant":
conds.append(("tenant_id = %s", tenant_id))
return conds
# erp_common.py:489-495 —— L3:操作矩阵(节选)
if op not in rule.get("ops", []):
audit(acting_user, f"{resource}.{op}", result="blocked",
detail=f"角色「{acting_role}」无权执行 {op}")
raise PermissionDenied(
f"角色「{acting_role}」无权对{RES_NAMES.get(resource, resource)}"
f"执行「{op}」(越权已审计)")
注意
_visibility_where的函数名:"visibility WHERE"——它不是在应用层筛字典,是在拼 SQL 的 WHERE 子句。这是课本级的谓词下推:让数据库只返回可见的行,而不是取回来再过滤。 和读侧expr是同一个思想的两种实现:一个推给 Milvus,一个推给 MySQL。判据也一样:"有 top-k / 有分页语义的检索,过滤必须下推。"(详见第二部分)
1.3 校正记录:底稿把"缓存"写进了四层
这一处必须点出来,因为它是本章最容易复制错误的地方。
旧底稿(ch08_多租户与权限下推.md)给出的"四层"是:
① 租户隔离 ② 行级 ACL ③ 缓存隔离 ④ 审计留痕
↑ 底稿的写法
回代码核对后(erp_common.py:23-24),项目定稿的四层是:
L1 租户隔离 L2 行级 ACL L3 操作矩阵(ops) L4 越权审计
↑ 底稿漏了这一层,多了一个"缓存"
两处差异,性质不一样:
| 差异 | 性质 | 为什么危险 |
|---|---|---|
| 底稿漏了 L3 操作矩阵 | 少了整整一层 | "有权限看 ≠ 有权限改"这个区分整个丢了——而写越权是不可逆的 |
| 底稿把"缓存"当成第四层 | 层级错位 | 会让你以为"四层都做了 → 缓存也安全了"。而缓存越权恰恰不在任何一层里,它是第五部分要单独讲的坑 |
为什么"把缓存写进四层"这件事值得单开一节:因为它给的是一种虚假的完成感。 四层是个封闭的清单——人会下意识觉得"清单打满 = 安全了"。但缓存键漏维度不是"第五层没做",是"第四层之外还有一个完全没被想到的面"。 一个可以立刻用在别处的判据:当你发现自己在用"N 层""N 道防线"这种封闭清单描述安全时,务必再问一句——"还有没有哪个面不在这张清单上?" 清单是边界,不是地图。
底稿另有两处更硬的事实错误,一并在这里说清(后面第四部分展开):
| # | 底稿写的 | 代码里的事实 | 影响 |
|---|---|---|---|
| 1 | access_level 有 public / internal / restricted 三档(:137) | 只有 public / restricted 两档;grep -rn '"internal"' 在代码与配置里零命中 | 写成三档会让人去找一个根本不存在的级别 |
| 2 | 密级由目录决定(knowledge/{tenant}/public/、internal/、restricted/)(:207-209) | 目录只分租户(knowledge/{tenant}/);密级由文件名关键字决定(access_rules.yaml) | 按目录建结构会建出四个不生效的空目录 |
这两条都属于 skill 里说的**"从未存在的值"——标准是"是否存在"**,不是"是否过期"。
第一部分小结 · 对照自查
- 能画出"一次问答的五道门"以及每道门防什么
- 能说出读侧(
expr)与写侧(SQL WHERE +ops)为什么是两套口径 - 能说出
erp_common.py定稿的 L1–L4 各是什么,并且知道缓存不属于这四层 - 记得
rag_web_server.py:368 / :402那行注释:权限取自登录态,客户端不可伪造 - 看到"N 层防护"这类封闭清单时,会条件反射地问"还有哪个面不在清单上"
第二部分 · ★ 为什么过滤必须下推
2.1 两种做法
做法 A(错误):先检索,再过滤
# 1. 检索 top-20
results = milvus.search(query, limit=20)
# 2. 应用层过滤掉无权限的
filtered = [r for r in results if has_permission(r, user)]
return filtered[:5]
做法 B(正确):把过滤条件交给向量库一起算
# 检索时就带上过滤条件(advanced_rag_agent.py:893-897)
dense_hits = self.client.search(
collection_name=self.collection,
data=[qvec], anns_field="dense", limit=top,
filter=expr, # ← 过滤下推:条件先于距离计算生效
output_fields=fields,
)[0]
两种做法的差别,看起来只是"筛的时机不同",实际是"筛的样本不同"。
2.2 为什么 A 一定漏
因为它在一个被污染的样本池里做选择。
场景:用户有权看全库 30% 的文档
做法 A:
全库检索 top-20 → 其中 70% 无权限 → 过滤掉 14 条 → 剩 6 条 → 取 5 条
↑
这 5 条是"前 20 条里有权限的"
不是"全库最相关的 5 条"
做法 B:
只在有权限的 30% 里检索 → 直接返回最相关的 5 条 ✅
一个能一眼看穿的例子:
全库 1000 篇,用户有权 300 篇
正确答案:全库排名第 25(进不了 top-20)
但在用户有权的 300 篇里,排第 3
做法 A:检索 top-20 → 正确答案没进候选 → **漏召**
做法 B:只在 300 篇里检索 → 排第 3 → **命中** ✅
一句话:先检索再过滤,等于在有偏的样本里做选择。 你的召回池被大量无权限文档占位了,真正相关的反而挤不进来。
为什么这件事在向量检索里比在 SQL 里更严重? 因为多了一层 ANN 的锅:
| 环节 | 特性 | 后果 |
|---|---|---|
| ANN 近似检索 | 本来就不保证返回全局最优 | top-20 已经是"近似的最相关 20" |
| 后过滤 | 在近似结果上再砍一刀 | 误差叠加:近似的 top-20 里再筛,剩下的两条误差都不小 |
| top-k 语义 | "最相关的 k 个" | 后过滤把它变成了"前 N 个里过滤后剩下的 k 个"——语义已经变了 |
"我们只要 5 条"不是理由。 正因为只要 5 条,这 5 条才必须是正确样本空间里的前 5 条。
2.3 本项目的实现:同一个 expr,四份拷贝
这里有一个必须诚实说清的事实:本项目的权限表达式没有被抽成一个公共构造函数,而是在四处各写了一遍。
# 全库搜"构造权限 expr"这件事,会找到 4 个地方
$ grep -rn 'access_level == "public"' --include=*.py . | grep -v "^./docs"
| # | 位置 | 函数 | 场景 |
|---|---|---|---|
| 1 | advanced_rag_agent.py:875-884 | _milvus_search | 混合检索(dense + BM25) |
| 2 | advanced_rag_agent.py:934-940 | search_figure_pages | 图页召回(叠加 chunk_type 条件) |
| 3 | advanced_rag_agent.py:971-978 | dense_search_with_distance | 真实距离检索(验收门 ReviewGate 用) |
| 4 | rag_web_server.py:1740-1744 | _kb_visible_files | 文档列表的可见性(client.query,不是搜索) |
# advanced_rag_agent.py:934-940 —— 第 2 处:图页召回,权限条件叠加 chunk_type
if filter_role == ROLE_SUPER_ADMIN or tenant_id == "__global__":
expr = 'chunk_type in ["page", "table", "section"]'
elif filter_role == ROLE_ADMIN:
expr = f'(chunk_type in ["page", "table", "section"]) and (tenant_id == "{tenant_id}")'
else:
expr = (f'(chunk_type in ["page", "table", "section"]) and (tenant_id == "{tenant_id}") and '
f'((access_level == "public") or (user_id == "{user_id}"))')
# advanced_rag_agent.py:971-978 —— 第 3 处:纯 dense + 真实余弦距离
# 函数 docstring 自己写着:「权限下推与 _milvus_search 同一套。」
if filter_role == ROLE_SUPER_ADMIN or tenant_id == "__global__":
expr = "(is_parent == false)"
elif filter_role == ROLE_ADMIN:
expr = (f'(tenant_id == "{tenant_id}") and (is_parent == false)')
else:
expr = (f'(tenant_id == "{tenant_id}") and '
f'((access_level == "public") or (user_id == "{user_id}")) '
f'and (is_parent == false)')
# rag_web_server.py:1740-1744 —— 第 4 处:文档列表可见性
if role == ROLE_ADMIN:
expr = f"(tenant_id == '{tenant}')"
else:
expr = (f"(tenant_id == '{tenant}') and "
f"((access_level == 'public') or (user_id == '{str(uid)}'))")
"同一套"这三个字,是靠三处人工同步维持的,不是靠代码结构保证的。
为什么这值得单独讲一段:它正是 skill 里第 2 条必查错误("全称判断没带作用域")的现场版。 如果本章只写"本项目实现了过滤下推"——这是对的。 但如果写成"所有检索入口都统一走权限过滤器"——这就是错的了,因为它没有统一的过滤器,只有四份手抄。
四份拷贝的代价是"漂移":任何人以后加第 5 个检索入口(比如新增一个按元数据检索的接口),权限条件默认是"不带"——因为根本没有任何东西逼他带。而且这四份已经不完全一样了:第 1、3 处带
is_parent == false,第 2 处带chunk_type,第 4 处两个都不带、用的是单引号(')而不是双引号(")。正确的演进方向是抽一个
build_acl_expr(role, tenant_id, user_id, extra=None) -> str,四处都调它,新增入口时必须传参。这一条留作本章的改进项(见第五部分小结的自查题)。
2.4 三个可选位置的对比
| 位置 | 做法 | 性质 | 评价 |
|---|---|---|---|
| ✅ 向量库层 | 检索时带 expr | 确定性(引擎执行) | 正确 |
| ⚠️ 应用层后过滤 | 先召回再筛 | 确定性,但样本有偏 | 不充分(能兜底,不能当主力) |
| ❌ 提示词层 | 告诉 LLM"别说敏感内容" | 概率性(模型自律) | 自欺欺人 |
第三档的荒谬之处:模型已经拿到了文档内容,你让它"别说",它大概率还是会说——而且你无法证明它没说。 权限必须在数据层解决,不能在生成层解决。 理由不是"提示词不管用",是**"提示词的保证强度不够"**:
- 检索层过滤:确定性的——无权限的文档根本没进 prompt,可以证明
- 提示词约束:概率性的——本轮没说不代表下轮不说,换个问法就可能问出来,无法证明
安全需要"可以证明",不需要"大概率"(本部分详细展开见练习题第 5 题)。
2.5 那 filter_results 为什么还留着?
这是一个必须回答的问题,因为如果"过滤下推"是唯一正确做法,应用层那个过滤器看起来就该删掉——它甚至看起来像是在重复劳动。
先看它是什么(advanced_rag_agent.py:1127-1155):
# advanced_rag_agent.py:1126-1147(节选)
@staticmethod
def filter_results(results, user_role):
if user_role in (ROLE_ADMIN, ROLE_SUPER_ADMIN):
return results # 特权用户:不过滤
filtered = []
for doc, score in results:
source = doc.metadata.get("source", "")
level = AccessControlFilter.get_access_level(source) # ← 按文件名再判一次
if level == "public":
filtered.append((doc, score))
...
它在 advanced_rag_agent.py:1491 被真正调用(不是死代码):
# advanced_rag_agent.py:1486-1493
all_results.sort(key=lambda x: x[1])
# 访问权限过滤:移除用户无权访问的文档片段
# 例如普通用户检索到 JM-S509 指令表的内容 → 过滤掉
all_results = AccessControlFilter.filter_results(all_results, self.user_role)
return all_results[:top_k * 3] # 限制总结果数(放宽以支持多样性检索)
它为什么该留?三个理由,缺一不可:
- 它是第二道防线,不是重复劳动。 下推依赖"入库时
access_level字段填对了"。如果某个历史分片的access_level是错的(比如按旧规则入库),下推就会放行它——而filter_results是回头按文件名重算一遍,判据不依赖入库字段。两条判据的来源不同,才能互为兜底。 - 它覆盖下推覆盖不到的路径。下推写在向量库里;如果有任何一条链路绕过了
expr(比如以后新增的检索入口——见 §2.3 的"第五个入口"),filter_results是最后一关。 - 它有可见的痕迹。过滤发生时打日志:
# advanced_rag_agent.py:1151-1153
if blocked_sources:
print(f" [AccessControl] 🚫 已过滤无权限文档: "
f"{', '.join(blocked_sources)}(用户角色: {user_role})")
这一行日志的价值和 Ch09 那行
[rerank] 失败,回退…是同一个道理:它把一个不可见的拦截变成可见的拦截。 而且它还能当探测器用:这条日志频繁出现,说明下推没生效或者入库密级不准——第二道防线在替第一道擦屁股。
所以定性的正确写法是:
| 组件 | 角色 |
|---|---|
_milvus_search 的 expr | 第一道防线(主力)——保证样本无偏 |
AccessControlFilter.filter_results | 第二道防线(兜底)——保证即使字段错了也不漏 |
| 提示词约束 | 不是防线——只能做表达优化 |
两句话都要有:只讲下推 → 你会删掉 filter_results;只讲 filter_results → 你会以为后过滤够用。本章的立场是"下推为主、后过滤兜底、提示词不算数"。
第二部分小结 · 对照自查
- 能用"有权限样本被污染"解释后过滤为什么漏结果,并给出排名数字的例子
- 知道 ANN 近似检索会放大后过滤的误差(两个误差叠加)
- 能默写本项目的三分支
expr(super_admin / admin / 其他) - 知道权限表达式在项目里有四份拷贝,能说出"抽公共构造函数"为什么是改进方向
- 能说清
filter_results为什么该留:判据来源不同,才能互为兜底 - 知道
[AccessControl] 🚫 已过滤无权限文档这行日志能当"下推是否生效"的探测器
第三部分 · 租户隔离:partition_key
3.1 声明式物理隔离
第一层(L1)要防的是最粗也最严重的那种越权:A 租户搜到 B 租户的文档。
本项目的做法是在集合 schema 里声明式地定义租户字段(advanced_rag_agent.py:712-715):
# advanced_rag_agent.py:712-715
# 多租户物理隔离:partition_key=True 让 Milvus 按租户分片,
# 查询时引擎只扫描本租户分区,跨租户数据物理不可见(引擎级保证)。
FieldSchema("tenant_id", DataType.VARCHAR, max_length=64,
partition_key=True, default_value="default"),
注意注释里"物理不可见(引擎级保证)"这七个字。这是 partition_key 和普通标量字段的本质区别:
普通标量字段 + expr | partition_key | |
|---|---|---|
| 隔离在哪一层 | 查询层——条件写错了就漏 | 存储/路由层——按分区组织 |
| 谁来保证 | 你的 expr 写对 | 引擎的分区路由 |
| 错误形态 | 少写一个条件 → 跨租户可见 | 分区键不对 → 取不到(取不到 ≠ 看得见) |
| 性能 | 全库扫描后过滤 | 只扫本租户分区 |
这个区别的意义:
expr是一种"约定",partition_key是一种"结构"。 约定靠人守,结构靠引擎守。能靠结构的,别只靠约定——这正是 L1 用partition_key而不是只加一个expr条件的理由。 而且它顺手解决了性能:跨租户数据不参与扫描,不只是"不返回"。
顺便把两种"数据边界"分清楚,因为它们常被混为一谈:
| 边界 | 约束 | 边界画在哪 | 靠什么保证 |
|---|---|---|---|
| 系统级 | 数据不出内网 | 这套系统 ↔ 外部 | 本地部署(Ollama + 内网向量库),见 Ch01 / Ch08 |
| 租户级 | 数据不出本租户 | 租户 ↔ 租户 | partition_key + expr(本部分) |
| 用户级 | 数据不出本用户可见范围 | 用户 ↔ 用户(同租户内) | access_level + user_id(第四部分) |
三条边界防的方向完全一样——"别让它流出去",只是边界画在不同位置。 这也解释了为什么本章和 Ch01 是同一根绳子上的结:"本地方案"给了你系统级边界,"多租户隔离"给了你租户级边界。只做前者不做后者,就等于"门锁上了,但屋里的人互相能翻抽屉"。
3.2 三种租户隔离方案怎么选
L1 这一层,行业里有三种典型做法。放在一起对比,才能看出本项目的选择在换什么:
| 方案 A:独立 collection | 方案 B:单 collection + expr | 方案 C:单 collection + partition_key(本项目) | |
|---|---|---|---|
| 隔离强度 | 最强(物理独立) | 最弱(纯查询层) | 强(路由层) |
| 新增租户 | 要建 collection + 索引 | 无需操作 | 无需操作(声明式) |
| 跨租户查询 | 要跨 collection 拼 | 方便(__global__) | 需显式走 __global__ 分支 |
| 运维成本 | 随租户数线性增长 | 低 | 低 |
| 主要风险 | 租户多了管理爆炸 | 一个条件写错就全穿 | 分区键值写错 → 取不到(易发现) |
| 适用 | 租户数少且强合规要求 | 租户少、信任度高 | 租户数中等、要扩展性 |
决策路径:
租户数 < 10 且合规要求极高(每个租户必须物理独立)?
└─ 是 → 方案 A(独立 collection)
└─ 否 ↓
需要跨租户巡检/聚合查询,且租户数会持续增长?
└─ 是 → 方案 C(partition_key)← 本项目
└─ 否 → 方案 B(单 collection + expr)
本项目选 C 是合理的,因为:租户数会增长(当前 tenants.yaml 登记 5 个:default / finance / hr / jm / yh,且注释说明"未登记的租户也可直接使用")、需要 super_admin 跨租户巡检(__global__ 分支)。这两条正好是 A 和 B 各自不擅长的。
但要注意方案 C 的一个隐含前提:它没有"方案 A 那种物理隔离强度"。同一张表里不同租户的数据是邻居——隔离靠的是"引擎不会把别的分区返回给你"。所以它必须配一个测试(见 §3.3)。
⚠️ 无论选哪个方案,这三件事都必须做(和方案无关):
- 有测试证明隔离生效——不是"应该隔离了",是"跑一遍确认隔离了"
- 租户名来源可信——不能从请求体读(§1.1 门 1)
- 跨租户访问要显式留痕——
super_admin的__global__查询应该被审计,而不是静默放行
3.3 租户名从哪来:两个入口,两条路径
这是本节最容易搞混的地方:租户名有两个来源,分别对应两个入口。
入口一:文档摄入(写)—— 租户来自"目录"
# ingest/pipeline.py:118-130
def _derive_tenant(self, path: str) -> str:
"""由文件相对 folder 的路径推断租户:首层子目录即租户名(knowledge/{tenant}/x.pdf);
平铺文件(knowledge/x.pdf)归 default。
"""
rel = os.path.relpath(path, self.folder)
parts = rel.split(os.sep)
if len(parts) > 1 and parts[0]:
return parts[0] # 首层子目录 = 租户名
return "default" # 平铺 = default
# rag_web_server.py:1648-1652 —— 上传侧同构实现
def _kb_derive_tenant(rel_path: str) -> str:
parts = rel_path.replace("\\", "/").split("/")
if len(parts) > 1 and parts[0]:
return parts[0]
return "default"
但这只是"默认推断"——真正上传时,租户是由登录态决定的:
# rag_web_server.py:1635-1645 —— 上传侧:access_level 以函数形式注入
def _kb_build_pipeline(tenant_id: str, user_id, access_level: str = "public"):
"""构造一次摄取管线(单文件上传 / 全量重建共用)。"""
store = MilvusStoreBackend(vector_db.client, vector_db.collection)
return IngestPipeline(
folder=DOC_FOLDER,
embedder=vector_db._embed.embed_documents,
store=store,
tenant_id=tenant_id,
user_id=str(user_id),
access_fn=(lambda s: access_level), # ← 密级由调用方决定,不是猜的
)
入口二:问答检索(读)—— 租户来自"登录态"
# rag_web_server.py:372 / :406
tenant_id = g.current_user.get("tenant_id", "default")
两条路径的对照:
| 摄入(写) | 检索(读) | |
|---|---|---|
| 租户来源 | 目录(knowledge/{tenant}/)+ 上传时以登录态为准 | 登录态(g.current_user["tenant_id"]) |
| 密级来源 | access_fn(上传接口按权限级别注入) | expr 里的 access_level 字段 |
| 谁决定 | 服务端 | 服务端 |
| 危险写法 | 让上传接口从 form 里读 tenant | 让查询接口从 JSON 里读 tenant_id |
access_fn=(lambda s: access_level)这行值得停一下:它把"密级怎么定"从管线里抽走了——管线调access_fn(source)拿密级,具体怎么算是调用方的事。 这就是 Ch04 讲过的依赖注入式可测试性:IngestPipeline不需要知道access_rules.yaml、不需要知道文件名关键字,它只要求"你给我一个函数"。 好处是双份的:① 上传接口可以按"用户选的密级"注入,不用改管线;② 测试可以直接注入lambda s: "restricted",不需要造一个真文件。
tenants.yaml 的真实角色(这里要校正一个常见误解):
# config/tenants.yaml:3
# 文档物理隔离由 Milvus partition_key(tenant_id) 保证;本文件用于运维登记与审计。
配置文件不是隔离的实现,只是登记。 真正的隔离在 schema 的 partition_key。
这是一个重要的区分:配置声明意图,代码保证执行。 如果只有配置没有代码 enforce,那配置就是摆设——而且是最危险的那类摆设,因为它会让人以为已经做了隔离。这正是踩坑总表里"配置只有声明没有执行"那条。 落地动作:任何"配置里写了、但没人验证代码真的执行了"的机制,都必须配一个测试。隔离这种机制,测试长这样:
def test_tenant_isolation(): # 租户 finance 的用户检索,结果里不能有任何别的租户的文档 results = search("...", tenant="finance") assert all(r.tenant_id == "finance" for r in results), "跨租户泄漏!"
第三部分小结 · 对照自查
- 能说出
partition_key与"普通字段 +expr"的本质区别(结构 vs 约定) - 能对比三种租户隔离方案的强度/成本,并说出本项目选 C 的两条理由
- 知道方案 C 不等于物理隔离,因此必须配隔离测试
- 能说出租户名的两个来源:摄入来自目录、检索来自登录态
- 能解释
access_fn=(lambda s: access_level)这行在换什么(依赖注入 → 可测试 + 可覆盖密级) - 记得
tenants.yaml只是登记,不是隔离实现——配置声明意图,代码保证执行
第四部分 · 行级 ACL:密级 + 业务角色
第一层管"哪个租户",第二、三层管"同一个租户里,谁能看到什么、能改什么"。
4.1 文档密级:文件名关键字,不是目录
先校正一个错:密级不是由目录决定的。项目里没有 public/ / restricted/ 这种子目录——knowledge/ 下面只按租户分层:
$ find knowledge -maxdepth 2 -type d
knowledge
knowledge/default
knowledge/jm
knowledge/pic
knowledge/yh # ← 只有租户层,没有密级层
真实的密级判据是"文件名关键字"(config/access_rules.yaml:7-11):
# 文档访问权限规则(ACL)
# 按文件名关键字匹配权限级别;命中 -> 对应级别,否则默认 public。
# 关键字不区分大小写(匹配时统一转小写)。
# 级别:public(本租户可读)/ restricted(仅拥有者 + 管理员可读)
access_rules:
"JM-S509": "restricted"
"指令表": "restricted"
"confidential": "restricted"
"机密": "restricted"
四个关键字,两个级别(public / restricted)。没有第三档:
$ grep -rn '"internal"' --include=*.py --include=*.yaml . | grep -v "^./docs"
(零命中)
代码里的判定逻辑(advanced_rag_agent.py:1112-1124):
# advanced_rag_agent.py:1112-1124
@staticmethod
def get_access_level(source: str) -> str:
"""
根据文档来源路径判断访问级别
:return: "public" 或 "restricted"
"""
basename = os.path.basename(source)
for keyword, level in DOC_ACCESS_RULES.items():
if keyword in basename: # ← 子串匹配
return level
return "public" # ← 未命中 = public
注意 if keyword in basename 是子串匹配——这意味着 "机密" 会命中 "非机密说明.docx"。这是一个已知的取舍,见 §4.4。
4.2 fail-closed:配置读不到时,默认拒绝
这是本节最重要的一段代码,也是理论基石里"fail-closed"落到实处的样子。
配置文件是外部资源,它可能读不到:文件被删了、yaml 没装、权限问题。读不到的时候怎么办?
# ingest/loaders.py:29-43
DOC_ACCESS_RULES: dict = {}
ACL_LOAD_FAILED = False
ACCESS_RULES_FAIL_OPEN = (os.getenv("ACCESS_RULES_FAIL_OPEN", "0") == "1")
try:
import yaml # 可选依赖:未安装时退化为全 public
_cfg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "config", "access_rules.yaml")
if os.path.isfile(_cfg_path):
with open(_cfg_path, "r", encoding="utf-8") as _fh:
_cfg = yaml.safe_load(_fh) or {}
DOC_ACCESS_RULES = {str(k): v for k, v in (_cfg.get("access_rules") or {}).items()}
except Exception:
DOC_ACCESS_RULES = {}
ACL_LOAD_FAILED = True
print("[ingest.loaders] ⚠ access_rules.yaml 加载失败,ACL 默认 restricted(fail-closed);"
"设置 ACCESS_RULES_FAIL_OPEN=1 可放宽")
# ingest/loaders.py:47-55
def get_access_level(source: str) -> str:
if ACL_LOAD_FAILED and not ACCESS_RULES_FAIL_OPEN:
return "restricted" # ← fail-closed:不知道密级 → 按最严处理
"""按文件名关键字判定权限:命中 → 对应级别,否则 public。"""
basename = os.path.basename(source)
for keyword, level in DOC_ACCESS_RULES.items():
if keyword in basename:
return level
return "public"
这 6 行是整个 fail-closed 原则的实体。把它拆开看:
| 元素 | 值 | 作用 |
|---|---|---|
ACL_LOAD_FAILED | 加载失败时置 True | 区分"空规则"和"没读到规则" |
ACCESS_RULES_FAIL_OPEN | 环境变量,默认 "0" | 留一个显式放宽的口子(默认关) |
return "restricted" | 第 48-49 行 | 拿不准 → 拒绝 |
第 1 点是最精妙的:为什么要单独一个 ACL_LOAD_FAILED 标志?
如果只有 DOC_ACCESS_RULES = {}(空字典):
加载成功但规则为空 → {} → 全部 public ← 可能正确(真的没规则)
加载失败 → {} → 全部 public ← 灾难(本该受限的全放开了)
两种情况的内部状态完全一样,无法区分。
所以需要一个独立的布尔标志来"记住发生过错"。
这是 fail-closed 真正的难点:它不是在"拒绝"和"放行"之间选一个,而是要先能"知道自己不知道"。
DOC_ACCESS_RULES = {}这种状态看起来是有效的——遍历一个空字典不会报错,返回"public"也完全合理。错误被一个"正常工作的空值"吞掉了。 这个形态在别处的翻版:config.get("key", {})拿到空字典、os.getenv("X", "")拿到空串、json.loads("{}")——它们都会让"配置缺失"伪装成"配置为空"。凡是"缺失"和"为空"语义不同的地方,都需要一个独立标志。
第 2 点也值得说:项目没有把 fail-closed 钉死,而是留了 ACCESS_RULES_FAIL_OPEN=1。为什么?
- 不是给生产用的,是给本地调试/迁移用的:改密级规则时不想被拦,可以临时放宽
- 默认值是
"0"——默认必须是最严的。留口子和开默认是两件事 - 放宽时会打印告警(
:43),放宽本身是可见的
再看 erp_common.py 里同一个原则的另一处实现(写侧):
# erp_common.py:476-481 —— L2:角色查不到 → 拒绝
rule = self.roles.rule_of(acting_role, tenant_id)
if rule is None:
audit(acting_user, f"{resource}.{op}", result="blocked",
detail=f"未知/未授权角色「{acting_role}」",
quiet=not self.verbose)
raise PermissionDenied(
f"角色「{acting_role}」不存在或未授权(租户 {tenant_id}),拒绝操作")
if rule is None 而不是 if not rule——这个区别和上面 ACL_LOAD_FAILED 是同一个道理:None 是"查不到",空字典可能是"查到了但空"。判"缺失"要用 is None,不要用 not x。
⚠️ 一处留给后续章节的接口:fail-closed 的完整讨论("拿不准时拒绝"在 Agent 决策、提示注入、审批门里的各种形态)在 v2 第 20 集(安全体系)。本章只讲它在权限加载这一个位置上的落地——不重复展开。
4.3 业务角色:resources × scope × ops × level
文档密级只回答"哪些文件不能看"。真正精细的授权在 L2/L3——按角色决定"能碰哪些资源、能看多大范围、能做什么操作、审批到什么级别"。
config/access_rules.yaml 的 order_acl 是业务角色的种子(7 个角色):
# config/access_rules.yaml:16-30(节选)—— 两层角色模型
# 系统角色 admin_users.role(user/admin/super_admin)→ 登录与审批等级
# 业务角色 biz_roles 表(租户级自建)→ 行级 ACL
# 本文件是种子:RoleEngine.seed_builtin() 装库;装库后以 DB 为准,
# 租户管理员可经 RoleEngine.create_role() 自建角色(车间主任/质检员/…)。
#
# name 角色显示名
# level 审批等级 1/2/3(2 及以上才有审批权,金额分级审批依据)
# resources 可见资源(sales_order/repair_order/product/inventory/shipment/
# engineer/customer/supplier/purchase_order/equipment/warehouse/
# fault_code/pm_plan/stock)
# scope 可见范围——own=仅自己创建的 / tenant=本租户 / all=跨租户
# ops 可执行操作(create/query/update/request/approve)
#
# 原则:fail-closed——查不到的角色一律拒绝;本文件缺失时代码内置同构兜底。
# config/access_rules.yaml:31-49(两条示例,实际 7 条)
order_acl:
sales_user:
name: 销售跟单员
level: 1
resources: [sales_order, shipment, product, customer]
scope: own
ops: [create, query, update, request] # update=确认/发货/退货等自身单据流转
repair_user:
name: 维修工程师
level: 1
resources: [repair_order, fault_code, product, equipment, stock]
scope: own
ops: [create, query, update, request] # create=报修建单
warehouse_user:
name: 库管员
level: 1
resources: [stock, shipment, inventory, warehouse, purchase_order]
scope: tenant # ← 注意这里是 tenant 不是 own
ops: [update, query] # ← 注意没有 create
7 个角色一览(grep -c 可核):
| 角色 | 显示名 | level | scope | ops 特点 |
|---|---|---|---|---|
sales_user | 销售跟单员 | 1 | own | 有 create |
repair_user | 维修工程师 | 1 | own | 有 create(报修) |
warehouse_user | 库管员 | 1 | tenant | 无 create,只有 update, query |
purchase_user | 采购员 | 1 | own | 有 create |
dept_manager | 部门经理 | 2 | tenant | 有 approve |
gm | 总经理 | 2 | tenant | 有 approve |
super_admin | 超级管理员 | 3 | all | 全资源全操作 |
四个维度各自的语义:
| 维度 | 回答的问题 | 取值 | 落地位置 |
|---|---|---|---|
resources | 能碰哪些资源 | 15 种资源枚举 | if resource not in rule.get("resources", []) (:482) |
scope | 能看多大范围 | own / tenant / all | _visibility_where 编译成 SQL WHERE (:498) |
ops | 能做什么操作 | create/query/update/request/approve | if op not in rule.get("ops", []) (:489) |
level | 审批等级 | 1/2/3 | 审批门(approval_rules) |
两个"反直觉"的设计,都是故意的:
① 库管员
scope: tenant但ops里没有create。 库管员要处理整个租户的出入库(所以scope是tenant,不是own),但不能自己下单——他只能update(执行)和query(查)。"范围大"和"权力大"是两回事:看得到全租户的库存,不等于能凭空创建一张单子。② 部门经理
level: 2,approve才在 ops 里。level和ops是两道独立的门:level决定能审多贵的单,ops里有approve才能审。审批规则还叠加了金额分级(approval_rules):# config/access_rules.yaml:88-92 sales_order.complete: min_approver_level: 2 amount_rules: - above: 100000 # 金额 > 10 万的关单 min_approver_level: 3 # 须 level=3(超管级)审批而且有一条硬规则写在代码里:发起人与审批人不得为同一人(禁自审自批,
:86注释)。
L3 操作矩阵的实现在 erp_common.py:464-496:
# erp_common.py:464-496(节选)—— _check_op:三道检查,逐级收紧
def _check_op(self, op, acting_role, acting_user, tenant_id="default", resource=None):
# T-04(D7 收尾):租户必填断言——空/None 直接拒绝,
# 防「静默落 default 租户」类错位(调用点已全显式传参,此为防回潮护栏)
if not tenant_id or not str(tenant_id).strip():
raise ValueError("tenant_id 不能为空(多租户必填,防静默错位)")
rule = self.roles.rule_of(acting_role, tenant_id)
if rule is None:
raise PermissionDenied(...) # ① 角色存在吗
if resource not in rule.get("resources", []):
raise PermissionDenied(...) # ② 资源可见吗
if op not in rule.get("ops", []):
raise PermissionDenied(...) # ③ 操作在白名单吗
return rule
注意
:470-473那个tenant_id断言,注释写得非常明确:"防「静默落 default 租户」类错位"。 这和本章的母题是同一件事:default是一个合法的租户名,所以"忘了传租户"和"故意传 default"在数据上看起来一模一样。 一个默认值,就是一条静默降级通道。 那句注释里的"此为防回潮护栏"值得学:作者知道调用点当时都传了参数,但仍然加了断言——因为**"当时都对"不构成"以后都对"**。
4.4 这个设计的取舍与已知边界
"按文件名关键字定密级"是一个明确的工程取舍,不是随手写的。诚实地摆出来:
| 优点 | 代价 |
|---|---|
| 运维改文件名即可改密级,不用改配置 | 子串匹配会误命中("机密" 命中 "非机密说明.docx") |
密级在文件系统可见,ls 就能审计 | 文件改名会静默改密级 |
| 不会漏配(新文件必然落到某个默认值) | 无法给单个文件特殊密级 |
| 零额外元数据维护 | 无法表达复杂规则("财务租户的销售单只给 dept_manager+") |
已知的失效场景(按可能性排序):
| # | 场景 | 后果 | 缓解 |
|---|---|---|---|
| 1 | 关键字误命中(非机密 / 机密性说明) | 该公开的文件被误判为受限 | 改成词边界匹配,或换更独特的关键字 |
| 2 | 文件改名(xxx-机密.docx → xxx.docx) | 密级静默降级 | 变更审计 + 摄入时重算并比对 |
| 3 | 内容敏感但文件名干净 | 应该受限的文件被判 public | 需要显式元数据覆盖 |
| 4 | 多维度冲突(文件在 jm/ 租户但含"机密") | 需定优先级 | 明确"密级优先于租户"或反之 |
关于场景 2,要特别提醒:改名导致的密级降级是"静默的"——ls 看不出异常,日志里也没有告警。它和缓存越权是同一类病(第五部分)。
改进方向(明确标注为"尚未实现"):
- 精确/词边界匹配替代子串匹配
- 优先级 + 冲突检测:多条规则命中时报错,而不是"先到先得"
- 显式元数据覆盖:允许文件自带
access_level声明,优先级高于关键字 - 密级变更审计:摄入时记录密级,与上一次比对,变了就告警
- 向完整 ACL 演进:密级只是"粗粒度",L2/L3 的业务角色才是细粒度
为什么要把"尚未实现"写进讲义:因为一份只说优点的设计说明,会让你在不该用它的时候用它。 本节的价值不在于"项目做得多好",而在于你能不能在写出这句话之前,就预判它会在哪里失效——这是 skill 里"全称判断必须带作用域"的同一种自觉。
第四部分小结 · 对照自查
- 能说出密级的真实判据是文件名关键字,不是目录;只有
public/restricted两档 - 能解释
ACL_LOAD_FAILED这个独立标志为什么必须有(区分"空规则"和"没读到规则") - 能说出
if rule is None与if not rule的区别,以及它和上一条是同一个道理 - 能说出 7 个业务角色里,库管员
tenant范围但没有create、部门经理level:2+approve各说明什么 - 知道
ops和level是两道独立的门(能审 / 能审多贵) - 能说出"按文件名定密级"至少三个失效场景,其中改名导致静默降级最关键
第五部分 · ★ 缓存越权:最隐蔽的漏洞
这一部分是本章的标题级内容,也是唯一一个"功能全对、测试抓不到"的坑。
5.1 现象
管理员搜过的内容,普通用户能搜到。
不是"权限没做",也不是"expr 写错了"——单独的每一步都是对的。问题出在两步之间。
5.2 根因
缓存键只有 query 的哈希,没带任何权限维度。
# ❌ 错误形态
def _cache_key(query):
return f"qa:{hash(query)}"
# 时序:
# 1. 管理员 A 查询"薪资结构"
# → 检索(A 有权限,含受限文档)
# → 生成答案:"根据机密文档,总监级薪资为…"
# → 写入缓存,key = "qa:hash(薪资结构)"
# 2. 普通用户 B 查询"薪资结构"
# → 缓存命中(同一个 key!)
# → 直接返回 A 权限下的答案 ← B 看到了无权查看的内容
关键在于第 2 步"缓存命中"的位置:它在检索之前。所以 expr 过滤、filter_results 兜底、提示词约束——三个防线一个都没被触发,因为压根没走那条路。
这就是它为什么"最隐蔽":它不是某道防线漏了,是整条防线被短路了。 你在图上画的"租户 → 密级 → 拥有者"三层过滤,在缓存命中路径上全都不存在。
5.3 为什么它特别危险
| 特点 | 说明 |
|---|---|
| 并发下才偶发 | 必须"高权限用户先查过"+"低权限用户后查同一个问题" |
| 测试环境几乎不可能复现 | 测试通常单角色、顺序执行——它天然避开了触发条件 |
| 静默无日志 | 缓存命中是"好事",不会告警;看起来一切正常 |
| 泄露的是真实业务数据 | 不是"排错了",是"看到了不该看的" |
| 短路了所有防线 | expr / filter_results / 提示词全都没机会执行 |
"测试环境几乎不可能复现"这一条最值得记:因为它意味着你不能靠"测过了没问题"来交付。 对这类漏洞,唯一的办法是"在设计时就把它列进检查清单"——因为你事后不会有第二次机会发现它。
5.4 真实的缓存键
先看项目现在到底是怎么算的(advanced_rag_agent.py:364-390):
# advanced_rag_agent.py:364-390(节选)
def _exact_key(self, query: str) -> str:
"""
为什么要把 role 拼进哈希?
admin 和 user 可能问同一个问题(如"定位方式"),
但 admin 能看到 JM-S509 指令表的额外信息,答案不同。
如果共用一个缓存键,admin 的答案会泄漏给 user。
拼入 role 后,两个角色各自有独立的缓存空间。
"""
# 标准化:去首尾空白、合并连续空白、全角转半角、转小写
normalized = query.strip()
normalized = re.sub(r'\s+', ' ', normalized)
normalized = unicodedata.normalize('NFKC', normalized).lower()
# 拼入用户角色,确保不同角色的缓存互不干扰
hash_input = f"{normalized}|{self.current_role}"
hash_hex = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()[:16]
# 方案乙:缓存键嵌入知识库版本号,文档 ingest/rebuild 成功后 bump,
# 旧版本 key 全体瞬间失效(无需 SCAN/DELETE)。
ver = get_kb_version(self.current_tenant)
if ver is None:
ver = 0 # P1-R1:读失败降级为版本 0(缓存 key 仍为合法整数,靠 TTL 兜底)
return f"{CACHE_PREFIX}v{ver}:{hash_hex}"
拆开看这个键的构成:
{CACHE_PREFIX}v{kb_version}:{sha256(normalized_query | role)[:16]}
│ │ │
│ │ └── 归一化 query + role 一起哈希
│ └── 知识库版本号(按租户)
└── 前缀
| 成分 | 来源 | 作用 |
|---|---|---|
normalized | query 归一化(去空白 + NFKC + 小写) | 同一问题的不同写法命中同一个键 |
role | self.current_role | 权限维度——不同角色不同缓存空间 |
kb_version | get_kb_version(self.current_tenant) | 租户维度 + 数据时效——文档重建后旧键全量失效 |
两个设计亮点,都要看到:
亮点一:role 进哈希,是为了让"同一问题的不同答案"不串。
这正好是 §5.2 那个漏洞的修法——而且修复的注释直接把这个漏洞当成了设计理由("如果共用一个缓存键,admin 的答案会泄漏给 user")。代码里留下了漏洞的"病历"。
亮点二:kb_version 进键,是"用键的形态换删除的开销"。
# 旧版本 key 全体瞬间失效(无需 SCAN/DELETE)
传统做法是文档重建后 SCAN + DEL 清缓存——在生产 Redis 上 SCAN 是大忌(阻塞、慢、可能扫不完)。把版本号塞进键,就让"清缓存"变成了"改一个数字":旧版本的键自然再也没人查,靠 TTL 自然过期。
这个技巧可以整体搬到别处:凡是"一整批缓存需要同时失效"的场景,都能用"版本号进键"代替"批量删除"。 它的本质是把"撤销"变成"重新命名"——撤销要遍历,命名只要一个自增。
但要注意一处降级(:388-389):
ver = get_kb_version(self.current_tenant)
if ver is None:
ver = 0 # P1-R1:读失败降级为版本 0(缓存 key 仍为合法整数,靠 TTL 兜底)
版本号读失败 → 降级为 0。这是一个有意的、有注释的、有理由的降级(保证键的形态合法,靠 TTL 兜底)。它符合 Ch06 的标准(可以留,但必须留痕)。
5.5 修法:ContextVar 隔离
键里加了 role,但 role 从哪来? 这是修法的第二个难点。
错误做法:用一个全局变量存"当前请求的角色"。
# ❌ 全局变量:并发下必然串号
_current_role = "user" # 模块级
def set_role(r): global _current_role; _current_role = r
为什么必然串——项目把原因直接写在代码注释里了(advanced_rag_agent.py:230-236):
# advanced_rag_agent.py:230-236
# CacheManager 是全局单例(被 orchestrator 持有),但 current_role / current_tenant
# 描述的是「当前这次请求」的属性,二者放一起在并发下必然串:
# T1: admin 设 current_role="admin" → T2: guest 设 current_role="user"
# → admin 的缓存键被算成 user 角色,或反过来把 admin 的答案回给 guest(越权泄漏)。
# 所以这两个值同样下沉到 ContextVar,按执行上下文隔离。
_ctx_cache_role = contextvars.ContextVar("cache_current_role", default=DEFAULT_ROLE)
_ctx_cache_tenant = contextvars.ContextVar("cache_current_tenant", default="global")
三段式解读这段注释:
| 句子 | 含义 |
|---|---|
CacheManager 是全局单例 | 它活得比请求长——所以不能把请求属性存在它身上 |
current_role 描述的是"当前这次请求"的属性 | 它活得和请求一样长 |
二者放一起在并发下必然串 | 生命周期不匹配 → 必然出错 |
这是本章最值得带走的一条判断:"全局对象"和"请求属性"的生命周期不一样,所以它们不能住在一起。 这个错误在任何"单例 + 多请求"的系统里都会重现——它和 RAG 无关,和 Python 也无关。Spring 的
@Component里塞HttpServletRequest、Servlet 里写实例字段,都是同一个病。
正确做法:ContextVar。
# advanced_rag_agent.py:235-236
_ctx_cache_role = contextvars.ContextVar("cache_current_role", default=DEFAULT_ROLE)
_ctx_cache_tenant = contextvars.ContextVar("cache_current_tenant", default="global")
# advanced_rag_agent.py:298-305(节选)—— 以属性形式暴露,读写都走 ContextVar
@property
def current_tenant(self) -> str:
"""当前请求的租户。决定缓存键里的 kb_version 粒度。"""
return _ctx_cache_tenant.get()
@current_tenant.setter
def current_tenant(self, value: str):
_ctx_cache_tenant.set(value)
三种传参方式对比:
| 方案 | 多线程 | 异步 | 侵入性 | 本项目 |
|---|---|---|---|---|
| 全局变量 / 实例字段 | ❌ 串号 | ❌ 串号 | 低 | ❌ 不可用 |
| 函数参数层层传递 | ✅ | ✅ | 高(要改所有签名) | ⚠️ 备选 |
| ContextVar | ✅ | ✅ | 低 | ✅ 本项目 |
ContextVar 是什么:Python 3.7+ 的"上下文局部变量"——每个线程或协程有独立副本,set() 只影响当前执行上下文。可以类比 Java 的 ThreadLocal,但额外支持 async(ThreadLocal 在协程里会因为多个协程跑在同一线程而串号)。
@property这层壳的价值:调用方写的是cache.current_role(看起来像属性),完全不知道底下是 ContextVar。 这既是封装(以后换成别的机制不影响调用方),也是迁移友好(原来读实例字段的代码,改成属性后一行不用动)。
同一处模式在项目里还有一份(legacy 引擎),说明这不是孤例(advanced_rag_agent.py:239-243):
# advanced_rag_agent.py:239-243
# ---- legacy RAGOrchestrator 请求级上下文变量(P0-2)----
# 与 LangGraph 入口同构:user / user_role 做成 ContextVar 属性,
# gthread 每线程独立 Context,请求间不再互相覆盖(防普通用户拿到 admin 角色)。
_ctx_orch_user = contextvars.ContextVar("rag_orch_user", default="anonymous")
_ctx_orch_user_role = contextvars.ContextVar("rag_orch_user_role", default=DEFAULT_ROLE)
而且这处修复是有提交号的:f94d41a(054) fix(cache): 缓存角色/租户改 ContextVar 隔离,防并发越权泄漏。
⚠️ 一处必须点出的现状:修完之后,缓存层是隔离的——但**缓存现在是"精确匹配 only"**了:
# advanced_rag_agent.py:311-317 def lookup(self, query: str) -> Optional[str]: """仅精确匹配(方案 A:语义答案缓存已删除,避免相似问题回旧答案、截杀自进化读路径)。"""语义缓存被删掉了("方案 A")。这降低了越权面(少一条匹配路径 = 少一处需要带权限维度的地方),但它不是本章的主题——它属于记忆/自进化那条线。这里只做一句说明,避免你按旧文档去找一个已经不存在的两级匹配。
5.6 检查清单:缓存键必须带什么
缓存键必须包含全部权限维度。
□ tenant_id —— 跨租户隔离(本项目:经 kb_version 的按租户粒度带入) □ role / 权限等级 —— 角色隔离(本项目:直接进哈希) □ user_id —— 仅当 scope 含 own 时需要("只看自己的") □ 任何影响结果的上下文 —— 时间范围、语言、模型版本、kb_version …
一个可以套用的判据:
两个不同权限的用户问同一个问题,如果他们应该得到不同的答案,那么区分他们的维度就必须进 key。
这条判据可以反过来当审计工具用:拿一个"高权限答案"和一个"低权限答案"比一比,任何差异点都应该能在 key 里找到对应维度。找不到 → key 漏了维度。
再抽象一层:缓存是"用空间换时间",但它顺手也换掉了"每次重新鉴权"的机会。 常规路径上,每一次提问都会重新走一遍权限判断;而缓存命中跳过了这整段。所以缓存不是"性能优化",它是"另一条数据通路"——任何新建的数据通路,都必须重新回答一遍"权限在哪里"这个问题。
这条判断的适用范围远超缓存:异步任务、消息队列、批处理导出的中间文件、模型微调的语料快照——每多一条通路,就多一次"权限维度漏没漏"的检查。
第五部分小结 · 对照自查
- 能画出缓存越权的完整时序(A 查询 → 写缓存 → B 查询 → 命中返回)
- 能说出它为什么"短路了所有防线"(命中发生在检索之前)
- 能说出真实缓存键的三个成分,并解释
role和kb_version各自在防什么 - 能解释"版本号进键"为什么比"
SCAN+DEL清缓存"好(把撤销变成重新命名) - 能说出全局变量传
role为什么必然串号(全局对象与请求属性生命周期不匹配) - 知道
@property那层壳的价值(调用方无感知 + 迁移友好) - 能用"两个用户该不该得到相同答案"这条判据,检查任意一条新数据通路的缓存键
踩坑总表
把这一章的坑摆到一张表上——九个。其中五个是上屏级别,前两个 fatal:
| # | 坑 | 现象 | 根因 | 严重度 |
|---|---|---|---|---|
| 1 | 缓存键漏权限维度 | 管理员搜过的内容,普通用户能搜到 | 缓存键只含 query 哈希;命中发生在检索之前,短路所有防线 | 高危(数据泄露,测试抓不到) |
| 2 | 先检索再过滤 | 权限越窄的用户,召回质量越差 | top-k 语义被破坏;在有偏的样本池里选 top-k,ANN 误差再叠加 | 高危(逻辑性漏召) |
| 3 | 权限从请求体读 | 功能正常,但用户可自己声明为 admin | role/tenant_id 未取自登录态 → 提权入口 | 高危(提权) |
| 4 | ACL 配置读失败后 fail-open | 配置文件一缺,受限文档全部变成可读 | 用空字典表示"加载失败",与"规则为空" 无法区分 | 高危(静默全开) |
| 5 | 权限表达式四份手抄 | 新增检索入口默认不带权限条件 | 没有公共构造函数,四份拷贝靠人工同步 | 高危(新增入口即漏洞) |
| 6 | 密级按目录理解 | 建出 knowledge/{tenant}/public/ 等空目录,以为生效了 | 目录只分租户;密级实际按文件名关键字 | 中等(认知错位) |
| 7 | access_level 写成三档 | 找不到 internal 级别的定义 | 代码只有 public / restricted 两档 | 中等(虚构值) |
| 8 | 文件名改名导致密级静默变化 | 文件名去掉"机密"二字 → 密级降级,无人告警 | 密级在摄入时由文件名确定,改名无人比对 | 中等(静默降级) |
| 9 | tenant_id 静默落 default | 忘了传租户 = 传了 default,数据上无区别 | default 是合法租户名,默认值掩盖了漏传 | 中等(有护栏) |
第 1~5 条是一组,它们是本章的定性:全都是 fatal,而且前两条"跑起来完全正常"。 这五条覆盖了两种不同的失败形态——
形态 典型坑 特征 边界写错 坑 2 / 3 / 5 你知道要防什么,但位置或范围错了 维度漏掉 坑 1 / 4 你根本没想到还有这一面,它不是"写错",是"没写" 第二种永远比第一种危险,因为第一种有错误现场,第二种只有一个安静的正确。
对应解法速查:
# 坑 1:缓存键必须带全部权限维度
# advanced_rag_agent.py:383-390
hash_input = f"{normalized}|{self.current_role}" # ← role 进哈希
hash_hex = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()[:16]
ver = get_kb_version(self.current_tenant) # ← 租户维度 + 版本失效
return f"{CACHE_PREFIX}v{ver}:{hash_hex}"
# 判据:两个用户该不该得到同一个答案?不该 → 区分维度必须进 key
# 坑 2:过滤必须下推——条件先于距离计算
# advanced_rag_agent.py:893-897
dense_hits = self.client.search(
collection_name=self.collection, data=[qvec], anns_field="dense",
limit=top, filter=expr, # ← 下推到引擎
output_fields=fields,
)[0]
# 应用层 filter_results 只做第二道防线(:1491),不当主力
# 坑 3:权限一律取自登录态,客户端不可伪造
# rag_web_server.py:368-372
user_role = g.current_user["role"]
user_id = g.current_user["user_id"]
tenant_id = g.current_user.get("tenant_id", "default")
# 反例(危险):data.get("role") —— 全文件搜不到,这是对的
# 坑 4:fail-closed——"读不到配置"必须与"规则为空"区分开
# ingest/loaders.py:30-31, 48-49
ACL_LOAD_FAILED = False
ACCESS_RULES_FAIL_OPEN = (os.getenv("ACCESS_RULES_FAIL_OPEN", "0") == "1") # 默认关
...
if ACL_LOAD_FAILED and not ACCESS_RULES_FAIL_OPEN:
return "restricted" # 不知道密级 → 按最严处理
# 坑 5:把权限表达式抽成公共构造函数(改进项,尚未实现)
# 目标形态(示意):四处统一调用,新增入口必须传参
def build_acl_expr(role, tenant_id, user_id, extra: str = None) -> str:
if role == ROLE_SUPER_ADMIN or tenant_id == "__global__":
base = ""
elif role == ROLE_ADMIN:
base = f'(tenant_id == "{tenant_id}")'
else:
base = (f'(tenant_id == "{tenant_id}") and '
f'((access_level == "public") or (user_id == "{user_id}"))')
parts = [p for p in (base, extra) if p]
return " and ".join(parts)
# 现状:4 处手抄 —— advanced_rag_agent.py:875 / :934 / :971 · rag_web_server.py:1740
# 坑 6 / 7:密级判据回到代码本身
# 只有两档,只按文件名
# advanced_rag_agent.py:1118 → "public" 或 "restricted"
# config/access_rules.yaml:7-11 → 4 个关键字:JM-S509 / 指令表 / confidential / 机密
# 目录只分租户:knowledge/{tenant}/ (find knowledge -maxdepth 2 -type d 可验)
# 坑 8:密级变更要有痕迹——摄入时比对上次密级
# ingest/pipeline.py:157
d.access_level = self.access_fn(d.source)
# 改进项:与 manifest 里上次记录的密级比对,变化即告警
# (密级在摄入时确定 → 改文件名不会自动生效,但重新摄入也不会被告知"变了")
# 坑 9:租户必填断言——防"静默落 default"
# erp_common.py:472-473
if not tenant_id or not str(tenant_id).strip():
raise ValueError("tenant_id 不能为空(多租户必填,防静默错位)")
学习目标回顾
对着开头的四个目标,逐个 check:
- ✓ 目标 1 · 说清四层隔离——读侧(租户 → 密级 → 拥有者,压在一个
expr里)与写侧(erp_common.py:23-24定稿的 L1 租户 / L2 行级 ACL / L3 操作矩阵 / L4 审计)是两套口径;"缓存"不属于这四层——它是 L1–L4 之外的一个独立面,而它恰恰是本章最大的坑,你拿下了 - ✓ 目标 2 · 论证过滤为什么必须下推——因为后过滤在有偏的样本池里选 top-k,权限越窄漏得越多,且 ANN 近似误差会叠加;三档位置的性质是确定性(引擎)/ 确定性但样本有偏 / 概率性(模型自律),你拿下了
- ✓ 目标 3 · 读并写租户 + 密级的
expr——三分支(super_admin/admin/ 其他);partition_key管结构、expr管约定,两样都要;租户名摄入来自目录、检索来自登录态,你拿下了 - ✓ 目标 4 · 定位并修掉缓存越权——真实键是
{PREFIX}v{kb_version}:{sha256(query|role)[:16]};role防角色串号、kb_version把"批量删缓存"变成"改一个数字";role必须走 ContextVar 而非全局变量(全局对象与请求属性生命周期不匹配);fail-closed 在配置加载失败时就是return "restricted"那两行,你拿下了
四个全过。如果只能带走一句,带这句:
权限不是"事后删掉不给你看的",而是"压根不让你拿到"——所以它必须在数据层解决,不能在生成层解决。
再加一句本章特有的:
最危险的越权,不是你写错的那条判断,而是你没想到的那个维度。
知识点卡片
【知识点】谓词下推(Predicate Pushdown)
定义:把过滤条件尽可能"下推"到数据读取层,而不是取回来再筛。
这不是 RAG 特有的概念——数据库优化器几十年前就在做,而且本章在项目里有两种实现:
数据层 下推形态 位置 Milvus(向量库) filter=expradvanced_rag_agent.py:896MySQL(业务库) 编译成 SQL WHEREerp_common.py:498_visibility_where在向量检索中尤其关键,因为:
- 召回数量有限(top-k)
- 后过滤会破坏 top-k 语义("最相关的 k 个" → "前 N 个里过滤后剩下的 k 个")
- ANN 近似检索本身就不保证全局最优,再过滤是误差叠加
判据:凡是有 top-k / 分页语义的检索,过滤都必须下推。
【知识点】fail-closed 的落地形态
异常时行为 适用 fail-closed 拒绝 认证、授权、权限(必须) fail-open 放行 几乎不适用 难点不在"选拒绝",而在"知道自己不知道":
# ❌ fail-open:缺失伪装成"为空" rules = yaml.safe_load(f) or {} # 读失败 → {} → 全部 public # ✅ fail-closed:用一个独立标志记住"发生过错" ACL_LOAD_FAILED = True # ← 关键:空规则 ≠ 读不到规则 if ACL_LOAD_FAILED and not ACCESS_RULES_FAIL_OPEN: return "restricted"同一个道理的两种写法:
if ACL_LOAD_FAILED(本标志)—— 区分"空"与"缺失"if rule is None(erp_common.py:476)—— 用is None不用not x本项目留了放宽口子:
ACCESS_RULES_FAIL_OPEN=1,默认"0"——留口子和开默认是两件事。【知识点】ContextVar:请求级上下文的正确载体
方案 多线程 异步 侵入性 全局变量 / 单例字段 ❌ 串号 ❌ 串号 低 函数参数层层传递 ✅ ✅ 高(改所有签名) ContextVar ✅ ✅ 低 _ctx_cache_role = contextvars.ContextVar("cache_current_role", default=DEFAULT_ROLE) @property def current_role(self): return _ctx_cache_role.get()为什么必须用它:
CacheManager是全局单例(活得比请求长),而current_role是请求属性(活得和请求一样长)。生命周期不匹配 → 并发下必然串号。
@property壳的价值:调用方无感知(写成属性访问),迁移时一行不用改。适用:request_id、用户身份、租户、追踪链路——所有"贯穿调用链但不想污染函数签名"的上下文。
【知识点】缓存键的权限维度
{CACHE_PREFIX}v{kb_version}:{sha256(normalized_query | role)[:16]}
维度 本项目 防什么 role直接进哈希 角色串号(admin 的答案回给 user) kb_versionget_kb_version(tenant)租户维度 + 文档重建后旧键全量失效 user_id❌ 未进(当前答案不按用户区分) 若 scope 含 own则必须进检查判据:
两个不同权限的用户问同一个问题,如果他们应该得到不同的答案,那么区分他们的维度就必须进 key。
"版本号进键"这个技巧:把"批量删缓存"变成"改一个数字"——用"重新命名"代替"撤销"。撤销要遍历(
SCAN+DEL,生产大忌),命名只要一个自增。【知识点】两种失败形态:边界写错 vs 维度漏掉
形态 例子 特征 危险度 边界写错 expr少写一个条件、位置放错层有错误现场 高,但可发现 维度漏掉 缓存键漏 role、ACL 读失败 fail-open只有一个安静的正确 更高 共同点:两条都不会报错。
防法:用清单外提问代替清单内检查—— 不要问"我的五道门都做了吗",要问 "还有没有哪个面不在这张清单上?"
练习题
基础题
1. 为什么"先检索再过滤"会漏结果?给出具体例子,并说明为什么这个问题在向量检索里比在 SQL 里更严重。
答案
因为 top-k 的语义被破坏了——它把"全库最相关的 k 个"变成了"前 N 个里过滤后剩下的 k 个"。
具体例子:
全库 1000 篇,用户有权看 300 篇
正确答案:全库排名第 25(进不了 top-20)
但在用户有权的 300 篇里,排第 3
做法 A(先检索再过滤):
milvus.search(query, limit=20) → 正确答案不在其中 → 过滤后只剩"前 20 里有权的那几条"
→ **漏召**
做法 B(过滤下推):
milvus.search(query, limit=5, filter=expr) → 只在 300 篇里检索
→ 正确答案排第 3 → **命中** ✅
直观说法:做法 A 是在有偏的样本池里选 top-k。无权限的文档占满了召回位(该场景下占 70%),真正相关的挤不进来。
为什么向量检索里更严重——三层叠加:
| 层 | 问题 |
|---|---|
| ① top-k 语义 | 后过滤后,"最相关的 k 个"已经不成立 |
| ② ANN 是近似的 | top-20 本身就不保证是全局最优 20——它已经是"近似解" |
| ③ 误差叠加 | 在近似结果上再砍一刀 → 剩下的条目,两个误差都占 |
在 SQL 里,WHERE 是精确谓词,先筛后取的语义很清楚;而向量检索的 top-k 是近似的、有预算的,任何后置操作都在污染这个预算。
一句话:过滤不是"筛选结果",是"限定样本空间"。
2. 本项目的缓存键是 {CACHE_PREFIX}v{kb_version}:{sha256(normalized|role)[:16]}。请说明 role 和 kb_version 各在防什么;如果"命中"发生在一个用户的提问上,会跳过哪些本该执行的检查?
答案
两个成分各防一件事:
| 成分 | 防什么 | 不带的后果 |
|---|---|---|
role | 角色串号——admin 和 user 问同一个问题(如"定位方式"),admin 能看到 JM-S509 指令表的额外信息,答案不同 | admin 的答案会泄漏给 user(这正是提交 f94d41a(054) 修的越权) |
kb_version | ① 租户维度(get_kb_version(tenant) 按租户取)② 数据时效——文档 ingest/rebuild 后 bump 版本,旧键全量失效 | ① 跨租户可能命中同一键 ② 文档更新后仍在返回旧答案 |
kb_version 那个技巧值得单独说:它把"清缓存"从 SCAN + DEL(生产 Redis 大忌,可能阻塞、可能扫不完)变成"改一个数字"——旧版本的键自然没人再查,靠 TTL 自然过期。本质是"用重新命名代替撤销":撤销要遍历,命名只要一个自增。
缓存命中会跳过什么——这是本题的重点:
缓存命中发生在检索之前,所以它一次跳过三道:
| # | 本该执行的检查 | 位置 |
|---|---|---|
| 1 | 权限下推 expr | advanced_rag_agent.py:875-884(检索才有 expr) |
| 2 | 应用层兜底过滤 | :1491 filter_results(在检索结果上过滤) |
| 3 | 提示词层约束(虽然它本来就不可靠) | 生成阶段 |
所以缓存越权不是"某道防线漏了",是"整条防线被短路了"。 你在图上画的"租户 → 密级 → 拥有者"三层过滤,在缓存命中路径上全都不存在。
这也是它"测试环境几乎不可能复现"的原因:它需要"高权限用户先查过"+"低权限用户后查同一个问题"这个特定时序,而单角色顺序测试天然避开了它。
顺带一句现状:lookup 现在只做精确匹配(advanced_rag_agent.py:315,语义答案缓存已在"方案 A"中删除)。这减少了匹配路径,但没有改变"缓存键必须带全权限维度"这条结论。
3. access_rules.yaml 读失败时,本项目让 get_access_level 返回 "restricted"。请说明为什么不直接返回 "public",以及代码里为什么需要一个单独的 ACL_LOAD_FAILED 标志。
答案
为什么不返回 "public" —— 这是 fail-closed 原则。
"读不到配置"时你有两个选择:
| 选择 | 后果 |
|---|---|
返回 "public"(fail-open) | 所有受限文档全部变成可读——配置一坏,权限全开 |
返回 "restricted"(fail-closed) | 所有文档变成受限——多申请一次权限,但不泄露 |
代价是不对称的:多拦一次 = 用户体验差一点;漏放一次 = 数据泄露,且不可逆。所以权限系统一律 fail-closed。
但真正的难点在第二个问题:为什么需要 ACL_LOAD_FAILED?
因为**"加载失败"和"规则为空"会产生完全相同的内部状态**:
DOC_ACCESS_RULES = {} # 加载失败 → {}
DOC_ACCESS_RULES = {} # 文件正常但 access_rules 段为空 → {}
两种情况都是空字典,for k, v in {}.items() 直接结束,返回 "public" 完全不报错。错误被一个"正常工作的空值"吞掉了。
所以需要一个独立标志来"记住发生过错":
# ingest/loaders.py:30-31
ACL_LOAD_FAILED = False # 默认没出错
ACCESS_RULES_FAIL_OPEN = (os.getenv("ACCESS_RULES_FAIL_OPEN", "0") == "1") # 默认关
# :48-49
if ACL_LOAD_FAILED and not ACCESS_RULES_FAIL_OPEN:
return "restricted" # 知道"我不知道" → 按最严处理
ACCESS_RULES_FAIL_OPEN 这个环境变量的意义:它留了一个显式放宽的口子,但默认值是 "0"(不放宽)。
- 留口子 ≠ 开默认:本地调试/迁移时可以临时放宽
- 放宽是可见的:加载失败时会打印告警(
:43)
同一个道理在项目里的另一处(写侧):
# erp_common.py:476
rule = self.roles.rule_of(acting_role, tenant_id)
if rule is None: # ← 用 is None,不用 not rule
if rule is None 和 if not rule 的区别:None 表示"查不到",空字典可能是"查到了但空"。判"缺失"要用 is None。
这一课的通用形态:凡是"缺失"和"为空"语义不同的地方,都需要一个能区分二者的机制。 这类伪装在代码里到处都是:config.get("key", {})、os.getenv("X", "")、json.loads("{}") —— 它们都会让"配置缺失"长得像"配置为空"。
进阶题
4. 你的系统要支持 100 个租户,每个租户有自己的文档集。请对比三种隔离方案(独立 collection / 单 collection + expr / 单 collection + partition_key)的取舍,给出决策依据;并说明无论选哪个方案,都必须做的三件事。
参考答案
三种方案对比
| A:独立 collection | B:单 collection + expr | C:单 collection + partition_key(本项目) | |
|---|---|---|---|
| 隔离强度 | 最强——物理独立 | 最弱——纯查询层 | 强——引擎按分区路由 |
| 隔离靠什么保证 | 集合边界(结构) | 你的 expr 写对(约定) | 分区路由(结构) |
| 新增租户 | 建 collection + 索引 | 无需操作 | 无需操作(声明式) |
| 跨租户查询 | 跨 collection 拼接,麻烦 | 方便 | 需显式走 __global__ 分支 |
| 100 租户的运维 | ❌ 100 个集合,索引/备份/监控全部 ×100 | ✅ 单集合 | ✅ 单集合 |
| 主要风险 | 管理复杂度爆炸 | 一个条件写错就全穿 | 分区键值写错 → 取不到(易发现) |
| 性能 | 各自最优 | 全库扫后过滤 | 只扫本租户分区 |
| 典型适用 | 租户数少 + 强合规 | 租户少 + 高信任 | 租户数中等/增长 + 要扩展性 |
决策路径
每个租户必须有物理独立的数据文件(合规硬要求)?
└─ 是 → 方案 A
└─ 否 ↓
租户数会持续增长,或需要跨租户巡检/聚合?
└─ 是 → 方案 C ← 本项目(5 租户登记 + 未登记可用 + super_admin 跨租户巡检)
└─ 否 → 方案 B
本项目选 C 的两条理由(都能从代码读出):
- 租户数会增长——
tenants.yaml:8明确写着"未登记的租户也可直接使用:上传时若指定新租户名,目录会自动创建",即新增租户零代码零配置 - 需要跨租户巡检——
_milvus_search:875的tenant_id == "__global__"分支就是给super_admin用的
方案 C 的隐含代价(必须承认):它不等于物理隔离——同一张表里不同租户的数据是"邻居",隔离靠的是"引擎不会把别的分区返回给你"。方案 A 那种"物理上够不着"的强度,C 是没有的。
无论选哪个方案,都必须做的三件事
① 有测试证明隔离确实生效
不是"应该隔离了",是"跑一遍确认隔离了":
def test_tenant_isolation():
results = search("...", tenant="finance")
assert all(r.tenant_id == "finance" for r in results), "跨租户泄漏!"
为什么必须有:配置和代码都可能"声明了但没执行"(见坑 4)。这个测试是唯一能证明结构真的在生效的手段。
② 租户名来源必须可信
- 摄入侧:目录推断(
_derive_tenant)+ 上传时以登录态为准 - 检索侧:登录态(
rag_web_server.py:372),绝不从请求体读
反例:tenant_id = data.get("tenant_id") —— 用户自己选租户 = 隔离归零。
③ 跨租户访问要显式留痕
super_admin 的 __global__ 查询应该被审计,而不是和高权限在同一个分支里静默放行:
# advanced_rag_agent.py:875-876
if filter_role == ROLE_SUPER_ADMIN or tenant_id == "__global__":
expr = "" # super-admin / 跨租户巡检:可见全部
"可见全部"本身可能是有意的设计,但**"谁在什么时候看了全部"必须有记录**——否则 L4 审计层就缺了最该记的那一类事件。
一句话总结
选方案是在换"隔离强度"和"运维成本"的配比;但"测试证明 + 来源可信 + 跨租户留痕"这三件事,和方案无关,是任何一种隔离的地基。
思考题
5. 本项目把权限做在「检索层」,而不是「提示词层」。有人提出"在提示词里告诉 LLM 哪些不能说"更灵活、还不用改检索。请反驳,并说明什么情况下提示词层的措施是有意义的。
参考答案
反驳:提示词层做权限不是"更灵活",是"没有保证"
理由 1:模型已经拿到内容了 —— 这是马后炮
检索返回 5 条文档(其中 1 条 restricted)
↓
拼进 prompt:"只依据以下文档回答,不要提及受限内容。[文档1][文档2][受限文档]…"
↓
模型已经"读"过受限文档的全部内容
此时约束"不要提及",信息已经进入模型上下文。这不是"没拦住",是"拦在了错误的位置"——约束发生在信息已经流动之后。
理由 2:LLM 的指令遵循是概率性的,不是确定性的
| 情况 | 模型行为 |
|---|---|
| 用户直接问受限内容 | 大概率拒绝(如果指令够强) |
| 用户换个说法问 | 可能回答 |
| 多轮对话后诱导 | 可能泄露 |
| 模型理解偏差 | 可能误判哪些算"受限" |
关键不是"它经常不遵守",而是"你无法证明它遵守了"。
理由 3:无法审计
提示词层没有强制执行的边界——你无法证明"某次回答没有泄露"。这是本题最硬的一条:
| 检索层过滤 | 提示词约束 | |
|---|---|---|
| 保证类型 | 确定性 | 概率性 |
| 可验证性 | 无权限文档根本没进 prompt,可查 | 无法证明本轮没泄露 |
| 失败形态 | 报错/漏召(可见) | 静默泄露(不可见) |
安全需要"可以证明",不需要"大概率"。
理由 4:缓存会放大风险(本章特有)
管理员查询(含受限文档)→ 答案写入缓存
普通用户查询 → **缓存命中** → 拿到受限答案
↑ 提示词层的约束根本没机会执行
(缓存直接 return 了,连 LLM 都没调)
提示词层连"被执行"都不一定——第五章讲过,缓存命中发生在检索之前,它跳过整条防线。
一个类比
把机密文件放在桌子上,然后贴张纸条:"请勿翻阅"
vs
把机密文件锁进保险柜,只把能看的拿出来
前者是"请求遵守",后者是"技术保证"。安全必须是后者。
什么情况下提示词层的措施有意义?
关键区分:提示词可以做"增强",不能做"边界"。
| 措施 | 有效性 | 说明 |
|---|---|---|
| 作为权限的补充 | ✅ | 检索层已过滤后,再提示"不要编造" → 防幻觉(不是防越权) |
| 输出侧过滤/脱敏 | ✅ | 在输出侧做代码检查(见下) |
| 格式化要求("必须带出处") | ✅ | 便于核实与审计 |
| 作为唯一的权限控制 | ❌ | 无效且危险 |
有意义的做法 1:输出侧过滤(这才是"第二层防线")
answer = llm.generate(prompt) # 生成
answer = mask_sensitive(answer) # 输出脱敏:手机号/身份证/金额
if contains_restricted_keywords(answer):
return "抱歉,该信息无权查看"
为什么输出侧有效而输入侧"约束模型"无效:输出侧是确定性的代码逻辑——不管模型说了什么,出去之前都过一遍。它和检索层一样是"技术保证",只是位置在后。
有意义的做法 2:拒绝话术优化(用户体验,不是安全)
检索层已判定无权 → 但可以给用户友好反馈
提示词:"如果文档中没有相关信息,请明确说明'未找到相关资料',不要推测"
有意义的做法 3:引导而非阻止(便于审计)
提示词:"回答时请注明信息来源的文档名和页码"
正确的安全分层
┌─────────────────────────────────────────┐
│ 第 1 层:检索层过滤(技术保证) │ ← 唯一的"边界"
│ 无权限文档根本不进 prompt │
├─────────────────────────────────────────┤
│ 第 2 层:输出侧检查(纵深防御) │ ← 确定性代码检查
│ 敏感词过滤、脱敏 │
├─────────────────────────────────────────┤
│ 第 3 层:提示词引导(体验优化) │ ← 不是安全措施
│ "注明出处"、"不要编造" │
├─────────────────────────────────────────┤
│ 第 4 层:审计日志(事后追溯) │
└─────────────────────────────────────────┘
一句话总结
提示词可以优化表达,但不能保证安全。 安全的边界必须由确定性的代码(检索过滤、输出检查)划定,而不能由概率性的模型自律。
把提示词当安全边界,本质上是把"请求对方遵守"误当成了"技术上阻止"。
再抽象一层:任何"用自然语言约束来替代机制约束"的做法,都值得被怀疑—— 能靠结构的别靠约定,能靠代码的别靠提示。
本章小结
| 收获 | 内容 |
|---|---|
| 一个原则 | 权限过滤必须下推——先检索再过滤是在有偏样本池里选 top-k |
| 一个区分 | 读侧(expr,防泄露)与写侧(WHERE + ops,防损坏)是两套口径 |
| 一个结构 | partition_key 是结构,expr 是约定——能靠结构的别只靠约定 |
| 一个铁律 | 缓存键必须带全部权限维度;判据:两个用户该不该得到同一个答案? |
| 一个工具 | ContextVar 承载请求级上下文——全局对象与请求属性生命周期不匹配 |
| 一个默认 | 权限一律 fail-closed;难点在区分"缺失"与"为空"(ACL_LOAD_FAILED) |
| 一个技巧 | 版本号进缓存键——把"撤销"变成"重新命名",SCAN+DEL 的替代方案 |
| 一个区分 | 提示词可以做增强,不能做边界——概率性保证 ≠ 安全 |
| 一个收口 | 两种失败形态:边界写错(有现场)vs 维度漏掉(只有安静的正确) |
下一章:Ch11 · 大模型调参与效果评测。
本章解决的是"不该看的看不到";下一章要解决一个相反的焦虑:前面五集(06–10)一直在调检索——融合、精排、隔离,可你怎么知道"调好了"?
⚠️ 编号口径:这里挂的是实链 ch11_检索评测.md(v2 第 11 集,★新增)。对照表见 课程表_第06集起.md。
本章的接口:本章的改动全部是"限制"——限制看哪些租户、哪些密级、哪些行。它不提升任何指标,只会让结果变少。所以它是全系列最容易"顺手关掉"的一集:隔离带来的收益是"没有出事",而"没有出事"从来不会出现在报表上。 请记住第五部分那句:你没法用"测试通过了"来证明缓存越权不存在——你只能靠在设计时就想起来。
导航:返回总目录 · 上一篇:重排序与去冗余 · 下一篇:检索评测 →