从零到一搭建企业级智能问答系统:Ch10 · 多租户隔离与权限下推

0 阅读56分钟

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(本章)坚决不漏不允许的(漏召 = 失败,越权 = 事故)

这两件事的优先级不一样。 召回差一点,用户多问一次;越权差一点,就是一次数据泄露。所以本章反复讲的不是"怎么召回得更多",而是"怎么保证不该出去的一条都出不去"。

本章真正想让你带走的,是三个能在别处复用的判断:

  1. 一个"我以为做了隔离"的漏洞长什么样? —— 缓存键漏了一个权限维度,测试环境几乎不可能复现,因为它要在"高权限用户先查过"之后才触发
  2. 过滤该放在哪一层? —— 只要检索有 top-k 语义,过滤就必须下推;放在生成层(提示词)是自欺欺人,因为模型已经拿到内容了
  3. 权限系统该 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 不能合并的理由。

一句话:读权限回答"你能看见什么",写权限回答"你能改变什么"——它们是两个问题,四层模型把它们分开放。

先记住这条主线,现在开始动手——第一件事,看清楚一次请求到底要过几道门。


第一部分 · 全景:一次请求要过几道门

Ch10 四层隔离

图 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_keySQL WHERE + ops 白名单
定稿位置advanced_rag_agent.py:875-884erp_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 道防线"这种封闭清单描述安全时,务必再问一句——"还有没有哪个面不在这张清单上?" 清单是边界,不是地图。

底稿另有两处更硬的事实错误,一并在这里说清(后面第四部分展开):

#底稿写的代码里的事实影响
1access_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"
#位置函数场景
1advanced_rag_agent.py:875-884_milvus_search混合检索(dense + BM25)
2advanced_rag_agent.py:934-940search_figure_pages图页召回(叠加 chunk_type 条件)
3advanced_rag_agent.py:971-978dense_search_with_distance真实距离检索(验收门 ReviewGate 用)
4rag_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]  # 限制总结果数(放宽以支持多样性检索)

它为什么该留?三个理由,缺一不可:

  1. 它是第二道防线,不是重复劳动。 下推依赖"入库时 access_level 字段填对了"。如果某个历史分片的 access_level 是错的(比如按旧规则入库),下推就会放行它——而 filter_results 是回头按文件名重算一遍,判据不依赖入库字段。两条判据的来源不同,才能互为兜底。
  2. 它覆盖下推覆盖不到的路径。下推写在向量库里;如果有任何一条链路绕过了 expr(比如以后新增的检索入口——见 §2.3 的"第五个入口"),filter_results 是最后一关。
  3. 它有可见的痕迹。过滤发生时打日志:
# 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 和普通标量字段的本质区别:

普通标量字段 + exprpartition_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. 有测试证明隔离生效——不是"应该隔离了",是"跑一遍确认隔离了"
  2. 租户名来源可信——不能从请求体读(§1.1 门 1)
  3. 跨租户访问要显式留痕——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 可核):

角色显示名levelscopeops 特点
sales_user销售跟单员1own有 create
repair_user维修工程师1own有 create(报修)
warehouse_user库管员1tenant无 create,只有 update, query
purchase_user采购员1own有 create
dept_manager部门经理2tenant有 approve
gm总经理2tenant有 approve
super_admin超级管理员3all全资源全操作

四个维度各自的语义:

维度回答的问题取值落地位置
resources能碰哪些资源15 种资源枚举if resource not in rule.get("resources", []) (:482)
scope能看多大范围own / tenant / all_visibility_where 编译成 SQL WHERE (:498)
ops能做什么操作create/query/update/request/approveif 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 看不出异常,日志里也没有告警。它和缓存越权是同一类病(第五部分)。

改进方向(明确标注为"尚未实现"):

  1. 精确/词边界匹配替代子串匹配
  2. 优先级 + 冲突检测:多条规则命中时报错,而不是"先到先得"
  3. 显式元数据覆盖:允许文件自带 access_level 声明,优先级高于关键字
  4. 密级变更审计:摄入时记录密级,与上一次比对,变了就告警
  5. 向完整 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 一起哈希
     │            └── 知识库版本号(按租户)
     └── 前缀
成分来源作用
normalizedquery 归一化(去空白 + NFKC + 小写)同一问题的不同写法命中同一个键
roleself.current_role权限维度——不同角色不同缓存空间
kb_versionget_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权限从请求体读功能正常,但用户可自己声明为 adminrole/tenant_id 未取自登录态 → 提权入口高危(提权)
4ACL 配置读失败后 fail-open配置文件一缺,受限文档全部变成可读用空字典表示"加载失败",与"规则为空" 无法区分高危(静默全开)
5权限表达式四份手抄新增检索入口默认不带权限条件没有公共构造函数,四份拷贝靠人工同步高危(新增入口即漏洞)
6密级按目录理解建出 knowledge/{tenant}/public/ 等空目录,以为生效了目录只分租户;密级实际按文件名关键字中等(认知错位)
7access_level 写成三档找不到 internal 级别的定义代码只有 public / restricted 两档中等(虚构值)
8文件名改名导致密级静默变化文件名去掉"机密"二字 → 密级降级,无人告警密级在摄入时由文件名确定,改名无人比对中等(静默降级)
9tenant_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:896
MySQL(业务库)编译成 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权限下推 expradvanced_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:独立 collectionB:单 collection + exprC:单 collection + partition_key(本项目)
隔离强度最强——物理独立最弱——纯查询层强——引擎按分区路由
隔离靠什么保证集合边界(结构)你的 expr 写对(约定)分区路由(结构)
新增租户建 collection + 索引无需操作无需操作(声明式)
跨租户查询跨 collection 拼接,麻烦方便需显式走 __global__ 分支
100 租户的运维❌ 100 个集合,索引/备份/监控全部 ×100✅ 单集合✅ 单集合
主要风险管理复杂度爆炸一个条件写错就全穿分区键值写错 → 取不到(易发现)
性能各自最优全库扫后过滤只扫本租户分区
典型适用租户数少 + 强合规租户少 + 高信任租户数中等/增长 + 要扩展性

决策路径

每个租户必须有物理独立的数据文件(合规硬要求)?
  └─ 是 → 方案 A
  └─ 否 ↓

租户数会持续增长,或需要跨租户巡检/聚合?
  └─ 是 → 方案 C   ← 本项目(5 租户登记 + 未登记可用 + super_admin 跨租户巡检)
  └─ 否 → 方案 B

本项目选 C 的两条理由(都能从代码读出):

  1. 租户数会增长——tenants.yaml:8 明确写着"未登记的租户也可直接使用:上传时若指定新租户名,目录会自动创建",即新增租户零代码零配置
  2. 需要跨租户巡检——_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。

本章的接口:本章的改动全部是"限制"——限制看哪些租户、哪些密级、哪些行。它不提升任何指标,只会让结果变少。所以它是全系列最容易"顺手关掉"的一集:隔离带来的收益是"没有出事",而"没有出事"从来不会出现在报表上。 请记住第五部分那句:你没法用"测试通过了"来证明缓存越权不存在——你只能靠在设计时就想起来。


导航:返回总目录 · 上一篇:重排序与去冗余 · 下一篇:检索评测 →