核心命题:权限过滤必须在检索之前,不在展示之后。
一句话带走:过滤放在哪一层,决定了它是安全边界还是心理安慰;权限过滤必须在检索之前,让无权数据根本不进入链路。
企业知识库问答最容易被忽略的风险,不是模型答错,而是模型回答了当前用户不应该看到的问题。普通的 Web 应用越权,用户最多看到不该看的页面;RAG 应用越权,模型会把不该看的内容「复述」出来,而且用户可能根本不知道这是越权来的——他以为这是模型「自己知道的」,其实是从别人的文档里检索出来的。
更危险的是,很多团队把权限过滤放在最后一步(展示层),觉得「反正用户看不到就行」。但实际上,如果系统先从全量文档中召回,再把结果交给重排模型、Prompt、日志或缓存,最后才在展示层过滤,未经授权的内容已经可能进入系统链路。未授权的内容已经进入了重排模型、Prompt、日志、缓存,任何一处泄露都是安全事故。即使用户界面没有显示,也不能把这种设计当作安全边界。
两个典型事故:
- •某企业知识库问答,销售 A 提问时,模型引用了销售 B 团队的客户跟进记录。原因是检索时没按团队过滤,只在前端展示时判断,结果日志里已经记录了未授权内容
- •某法务知识库,实习生提问「合同模板」,模型返回了仅合伙人可见的保密合同条款。原因是向量库没有权限标签,检索返回了全量结果,前端过滤没拦住
结论:权限治理必须从数据进入检索系统开始设计,贯穿查询、召回、生成、引用、日志和缓存全链路。展示层过滤只是最后一道防线,不是安全边界。本章讲清楚四件事:权限模型要表达哪些维度、数据结构怎么设计、正确的安全链路是什么、怎么用代码实现权限前置过滤。
一、权限模型需要表达哪些维度:不能简化成「一个标签」
一个企业文档至少需要表达以下授权维度,少一个都可能出问题:
| 维度 | 含义 | 示例 |
|---|---|---|
| 租户(Tenant) | 数据属于哪个组织/客户 | tenant_id: 「tenant-001」 |
| 团队(Team) | 数据属于哪个团队 | team_id: 「team-sales-east」 |
| 角色(Role) | 用户的角色决定可见范围 | role: 「sales_rep」 / 「sales_manager」 |
| 客户范围(Customer Scope) | 销售能看哪些客户的数据 | customer_ids: [「cust-001」, 「cust-002」] |
| 文档状态(Status) | 文档是否生效 | effective_status: 「active」 / 「draft」 / 「expired」 |
| 密级(Classification) | 数据的保密等级 | public / internal / confidential |
为什么不能简化成「文档有一个标签,用户也有一个标签」:标签可以帮助检索,但授权判断应基于结构化策略。比如「团队」这个维度,销售 A 属于「华东销售团队」,他能看本团队的客户跟进,但不能看「华北销售团队」的。如果只用一个标签「销售」,那所有销售都能看到所有销售的数据,越权了。
多维度组合判断:权限判断通常是「与」的关系——租户要匹配、团队要匹配、客户范围要包含、文档状态要是 active、密级要在用户可访问范围内。任何一个不满足,就不能访问。这种「与」关系意味着权限模型不能简化成一个布尔值或一个标签,它必须是一组结构化条件的联合判断。
角色(Role)到底参不参与判断:上表把role列为六个维度之一,但在实际判断里它很少单独出现——它更像一个派生维度,负责把「这个人是什么角色」翻译成「他因此拥有哪些具体权限」。映射关系通常有两条:
| role | 派生出的 team 范围 | 派生出的 clearance_level |
|---|---|---|
sales_rep | 仅本人所在团队 | internal |
sales_manager | 本团队 + 下级团队 | internal |
legal_partner | 法务部 | confidential |
intern | 仅本人所在团队 | public |
所以can_read()里不直接比较role,而是比较由 role 派生出的accessible_teams和clearance_level。这样做的意义是:角色与权限的映射集中在一处维护,组织架构调整时改映射表即可,不用改动判断逻辑。反过来,如果把「经理能看到下级数据」这类规则硬写进判断函数,每来一个新角色就要改一次代码。
accessible_teams从哪来、什么时候失效:这个数组不应该由人工在文档上手动配置,而应由组织架构系统定期同步生成——用户转岗、离职、跨部门协作时,权限要自动跟着变。三条纪律:
- •同步而非手工:手工维护的权限清单永远滞后于现实,转岗的人还留着原团队权限是典型事故来源。
- •离职即回收:离职账号要立刻从所有
accessible_teams中移除,并触发一次缓存失效——否则离职人员的会话缓存里可能还留着有权结果。 - •变更要留痕:每次权限变更记录 who / when / why,配合后面的审计日志形成完整链路。权限的「为什么这个人当时能看」必须在事后可回答。
维度之间的优先级:在实际工程中,不同维度的校验顺序也有讲究。租户隔离是最外层的安全边界——如果租户不匹配,后续所有判断都不需要执行,直接拒绝。这是因为多租户架构下,租户间的数据隔离是法律合规的硬性要求,优先级高于一切业务逻辑。密级判断通常放在最后,因为它依赖于前面所有维度都通过后才有意义——一个文档即使密级再高,如果租户不对、团队不对,也没有必要去比较密级。
二、推荐的数据结构:文档和用户上下文都要结构化
文档结构(带上权限字段):
document = { "doc_id": "crm-followup-001", "tenant_id": "tenant-001", # 租户隔离 "team_id": "team-sales-east", # 团队隔离 "owner_id": "user-zhang", # 文档所有者 "accessible_teams": ["team-sales-east", "team-management"], # 可访问团队列表 "customer_id": "cust-001", # 关联客户 "classification": "internal", # 密级:public/internal/confidential "effective_status": "active", # 生效状态 "version": "2024.03", "updated_at": "2024-03-15T10:30:00Z", "content": "客户A本周跟进进展...", "tags": ["销售", "跟进记录"] }用户上下文(从身份系统获取,不可信前端传入):
user_context = { "user_id": "user-li", "tenant_id": "tenant-001", # 必须和文档的 tenant_id 匹配 "team_id": "team-sales-east", # 必须在文档的 accessible_teams 里 "role": "sales_manager", # 角色决定可访问密级 "accessible_customer_ids": ["cust-001", "cust-002"], # 能看的客户范围 "clearance_level": "internal", # 可访问的最高密级 "auth_token": "jwt-xxx" # 用于服务端二次校验 }权限判断需要同时验证:租户、团队、客户范围、文档状态、密级。只要关键条件不满足,就不能把文档送入模型上下文。注意:用户上下文必须从服务端身份系统获取,不能信任前端传的 user_id 和 team_id,否则用户可以伪造。
为什么文档和用户上下文都要结构化:有些团队尝试把权限信息塞进 tags 字段(比如tags: ["tenant:001", "team:sales-east", "classification:internal"]),然后在检索时用字符串匹配来过滤。这种做法有三个致命问题:1)字符串匹配无法表达「列表包含」关系(比如 accessible_teams 是一个数组);2)无法做数值比较(比如密级等级的 >= 判断);3)tags 字段通常不会被向量数据库的索引引擎优化,过滤性能差。正确的做法是把权限字段作为独立的 metadata 字段,利用向量数据库的原生 metadata filter 能力,在检索阶段就完成权限过滤。
三、权限前置过滤:正确的安全主链路
正确的安全主链路应是:
用户提问 → 服务端获取用户上下文(身份系统)→ 检索时注入权限条件 → 只返回授权文档 → 重排 → Prompt → 生成 → 引用展示关键点:权限过滤必须在检索之前或检索之中,不能在检索之后。
✗ 错误链路(后置过滤):
用户提问 → 全量检索 → 拿到 Top-K → 在应用层过滤无权文档 → 重排 → Prompt → 生成问题:无权文档已经进入了检索结果,可能被日志记录、被缓存、被重排模型处理。即使最后过滤掉了,泄露风险已经存在。
✓ 正确链路(前置过滤):
用户提问 → 服务端获取用户上下文 → 把 tenant_id、team_id、customer_ids 等作为检索条件 → 向量库只返回满足条件的文档 → 重排 → Prompt → 生成好处:无权文档根本不会被检索出来,不会进入后续任何环节。
两种链路的本质区别:后置过滤的安全边界是「用户看不到」,前置过滤的安全边界是「数据不存在于链路中」。前者依赖多层防御不出现任何疏漏,后者从源头消除了风险。用一个类比:后置过滤像是在图书馆里先把所有书都搬到桌上,再一本本检查借阅证是否允许;前置过滤是在书架上就把不允许的书锁起来,读者根本拿不到。
工程折中:有些基础设施(如某些向量数据库)暂时不支持检索前置过滤,只能从候选集中过滤。这属于工程折中,而不是理想安全模型。此时必须做到:
- 未授权文档不会进入 Prompt、重排模型、用户可见日志、缓存和调试输出
- 通过越权测试验证(用无权账号测试,确认返回结果中没有无权内容)
- 记录过滤日志,审计哪些文档被过滤掉了
- 在候选集阶段过滤后,如果剩余文档不足 Top-K,不要二次全量检索补充——否则又回到了后置过滤的问题
向量数据库的原生过滤能力对比:主流向量数据库对 metadata filter 的支持程度不同。Milvus 和 Qdrant 支持在 ANN 检索阶段注入 filter 条件,实现真正的前置过滤;Pinecone 支持 metadata filter 但有一些限制;Chroma 和 FAISS 的 filter 能力相对基础。选型时,权限过滤能力应该是核心评估维度之一,而不是等开发完了再补。
四、Python 教学实现:权限前置过滤的最小示例
下面的示例使用结构化授权策略,并采用「所有必要条件都满足」的判断方式。它是教学实现,生产环境还应接入统一身份系统、策略引擎和检索服务的原生过滤能力。
from dataclasses import dataclass, field from typing import List, Optional @dataclass class Document: doc_id: str tenant_id: str team_id: str accessible_teams: List[str] customer_id: Optional[str] classification: str # public / internal / confidential effective_status: str # active / draft / expired @dataclass class UserContext: user_id: str tenant_id: str team_id: str role: str accessible_customer_ids: List[str] clearance_level: str # public / internal / confidential # 密级等级:数字越大越机密 CLASSIFICATION_LEVEL = {"public": 0, "internal": 1, "confidential": 2} def can_read(doc: Document, user: UserContext) -> bool: """ 判断用户是否有权读取文档。 采用"所有必要条件都满足"的策略,任何一条不满足就拒绝。 """ # 1. 租户必须匹配(多租户隔离的第一道防线) if doc.tenant_id != user.tenant_id: return False # 2. 文档必须是生效状态(草稿和过期的不能看) if doc.effective_status != "active": return False # 3. 用户团队必须在文档的可访问团队列表中 if user.team_id not in doc.accessible_teams: return False # 4. 如果文档关联了客户,用户必须在该客户的可访问范围内 if doc.customer_id and doc.customer_id not in user.accessible_customer_ids: return False # 5. 用户密级必须 >= 文档密级(不能看比自己权限高的文档) if CLASSIFICATION_LEVEL[user.clearance_level] < CLASSIFICATION_LEVEL[doc.classification]: return False return True # 测试用例 doc_sales = Document( doc_id="crm-001", tenant_id="tenant-001", team_id="team-sales-east", accessible_teams=["team-sales-east", "team-management"], customer_id="cust-001", classification="internal", effective_status="active", ) user_sales_manager = UserContext( user_id="user-li", tenant_id="tenant-001", team_id="team-sales-east", role="sales_manager", accessible_customer_ids=["cust-001", "cust-002"], clearance_level="internal", ) user_intern = UserContext( user_id="user-wang", tenant_id="tenant-001", team_id="team-intern", role="intern", accessible_customer_ids=[], clearance_level="public", ) print("销售经理能否读取销售跟进记录:", can_read(doc_sales, user_sales_manager)) # True print("实习生能否读取销售跟进记录:", can_read(doc_sales, user_intern)) # False(团队不匹配 + 密级不够)代码中的几个判断具有明确含义:
- 租户匹配:多租户隔离的第一道防线,A 租户永远看不到 B 租户的数据
- 生效状态:草稿和过期文档不能进入检索结果,避免回答过期内容
- 团队匹配:团队粒度的权限控制,不在可访问团队列表里的团队不能看
- 客户范围:销售只能看自己负责的客户数据,不能看其他销售的客户
- 密级判断:用户的可访问密级必须大于等于文档密级,实习生不能看机密文档
生产环境需要补充的:统一身份认证(SSO)、策略引擎(如 OPA)、检索引擎原生过滤(如向量库的 metadata filter)、缓存隔离、导出接口鉴权、越权回归测试。
从教学到生产的距离:上面的can_read函数是一个同步的、内存中的判断逻辑。在生产环境中,它需要演变为:1)从 SSO 获取用户身份和权限属性,而不是硬编码 UserContext;2)使用策略引擎(如 Open Policy Agent)来管理权限规则,而不是写在代码里——因为权限规则会频繁变化,硬编码意味着每次改规则都要发版;3)把权限条件翻译为向量数据库的 metadata filter 查询,在检索阶段就完成过滤,而不是检索后再逐条判断;4)加入缓存层时,缓存的 key 必须包含用户权限维度,避免用户 A 的缓存结果被用户 B 命中。
五、越权测试:怎么证明权限真的挡住了
前面的链路设计和can_read()只是「声称」安全。要证明它真的安全,必须用无权账号去主动尝试越权,并确认系统挡住了。这件事不能靠代码评审替代——评审看的是逻辑,越权测试看的是实际部署出来的那条链路,包括你没想到的缓存、日志、导出接口。
测试用例怎么造:核心思路是「用 A 的身份去要 B 的数据」,每个权限维度至少一条用例。
| 编号 | 测试场景 | 构造方式 | 期望结果 |
|---|---|---|---|
| T1 | 跨租户访问 | 用 tenant-002 的账号检索 tenant-001 的文档 | 检索结果为空,且无任何内容进入响应体 |
| T2 | 跨团队访问 | 用华东团队账号检索华北团队的跟进记录 | 返回空,不返回「部分命中」的中间态 |
| T3 | 跨客户范围 | 用销售 A 账号检索销售 B 负责的客户数据 | 返回空 |
| T4 | 密级不足 | 用clearance_level=public的实习生账号检索 confidential 文档 | 返回空 |
| T5 | 非生效状态 | 检索draft和expired文档 | 返回空 |
| T6 | 缓存穿透 | 先让有权用户查询并写缓存,再用无权用户用相同 query 查询 | 无权用户不命中前者的缓存 |
| T7 | 身份伪造 | 篡改请求里的 user_id / team_id | 服务端以 JWT 为准,篡改无效 |
| T8 | 日志泄漏 | 用无权账号发起查询,检查服务端日志 | 日志中不出现无权文档的正文内容 |
什么算通过:不是「页面上看不到」,而是后端响应体、日志、缓存、调试输出四处都找不到无权内容。T1 到 T8 必须全部通过才算权限链路成立;任何一条挂在「页面上看不到但响应体里有」,都按不通过处理——那是和后置过滤等价的状态。
什么时候跑:三类节点必须跑一遍——1)首期上线前;2)权限模型或检索链路发生任何变更后;3)定期回归(比如每月一次)。前两类是变更驱动,第三类是防止「没人改但基础设施升级把 filter 能力悄悄降级了」。
审计与告警:越权测试是主动验证,审计日志是被动留痕,两者互补。
| 记录项 | 内容 | 用途 |
|---|---|---|
| 查询留痕 | 谁、什么时候、问了什么、返回了哪些 doc_id | 事后回答「这个人当时看到了什么」 |
| 过滤留痕 | 本次查询过滤掉了哪些文档、命中了哪条规则 | 排查「为什么该看到的没看到」 |
| 越权尝试告警 | 同一账号短时间内大量命中过滤规则 | 识别被盗号或主动试探行为 |
| 权限变更日志 | who / when / why,见第一节 | 解释某次访问当时为什么被允许 |
注意:过滤日志本身不能成为新的泄露点。记录「过滤掉了 doc-123」是安全的,记录「doc-123 的正文是……」就是把敏感内容抄进了日志。日志只记标识和规则,不记正文。
把本章翻译成 AI FDE hub 工程契约
企业 AI 应用的权限治理 这件事,最终要落到一份能被四方签收的契约上。下面这份 YAML 就是它的字段模板:
# AI FDE hub · 权限治理 · 工程契约示例 chapter: 004 framework: "AI FDE hub 三段式交付(入场 / 搭建 / 离场)" tags: ["AI", "权限治理", "RAG", "多租户"] scope: role: "AI FDE 工程师" boundary: "对接客户业务负责人、合规、研发、测试四方共同验收" permission_model: dimensions: ["tenant_id", "team_id", "role", "customer_scope", "effective_status", "classification"] filter_position: "检索前置(注入检索条件)" fallback: "候选集过滤 + 越权测试验证" required_checks: - "租户匹配" - "文档生效状态为 active" - "用户团队在可访问团队列表中" - "用户可访问客户范围包含文档关联客户" - "用户密级 >= 文档密级"和相邻章节的关系:本章与前后相邻的第 3 章《AI 项目数据接入:从数据源盘点到可用知识》、第 5 章《AI FDE 如何设计企业知识库检索链路》同处一个主题段,共用同一套「产物 + 退出条件 + 决策门」语言——第 3 章把权限字段写进文档结构并前置到检索,本章把这些字段变成不可绕过的判断,第 5 章再在此基础上保证检索结果的相关性。
Toy Project vs. 生产级系统 · 8 项关键差异
这张表聚焦权限治理在生产环境最容易塌陷的地方。它的特殊之处在于:权限的差距不会以「功能不好用」的形式暴露,而是以「事故已经发生」的形式暴露——所以每一项都要在首期就补上。
| 维度 | Toy Project | 生产级系统 | 差距代价 |
|---|---|---|---|
| 租户隔离 | 单一租户演示数据,无隔离概念 | 多租户架构 + tenant_id 硬隔离 | 客户间数据交叉,一次就是合同违约 |
| 权限粒度 | 硬编码「管理员/普通用户」 | 六维度组合判断(租户+团队+角色+客户+状态+密级) | 只分两级,同公司内互相越权 |
| 过滤位置 | 前端展示层过滤 | 检索前置注入 metadata filter | 无权内容进了日志和缓存,删不干净 |
| 身份来源 | 前端传入 user_id | 服务端 SSO + JWT 二次校验 | 抓包即可伪造身份,权限形同虚设 |
| 密级管理 | 无密级概念 | 分级策略 + 用户 clearance 比对 | 实习生能拿到合伙人级保密条款 |
| 缓存隔离 | 全局缓存,所有用户共享 | 缓存 key 含权限维度,用户间不串 | 甲的答案命中乙的缓存,无声越权 |
| 越权测试 | 无 | 定期用无权账号跑回归测试 | 权限链路改了没人发现,漏洞长期潜伏 |
| 审计日志 | 无日志 | 过滤日志 + 越权尝试告警 | 出事说不清谁看了什么,无法定责 |
客户现场最常见的三种反模式
这三种做法在权限治理上线前后反复出现,共同点是都把权限当成了「可以事后加的一层」,而不是「必须一开始就成立的前提」。提前识别比事后补救成本低一个数量级——权限事故的补救成本,往往是把已泄露的数据当作已泄露来处理。
| 反模式 | 典型表现 | 正确做法 | 不推荐原因 |
|---|---|---|---|
| 先上线再补权限 | 首期不做权限隔离,「后面加个过滤就行」,结果日志和缓存已经泄露了敏感数据 | 权限模型在数据入库时就设计好,首期就做到检索前置过滤 | 泄露已经发生,补过滤只能防住下一次 |
| 把权限塞进 tags | 用tags: ["tenant:001", "team:sales"]做字符串匹配过滤,无法表达列表包含和数值比较 | 权限字段作为独立 metadata 字段,利用向量数据库原生 filter | 检索阶段过滤不了,只能退回后置裁剪 |
| 信任前端传入的身份 | 前端传 user_id 和 team_id,后端直接使用,用户可以通过抓包伪造 | 用户上下文从服务端 SSO 获取,前端只传 auth_token | 改一个请求参数就能看到别人的数据 |
附录:权限治理速查
一句话记法:权限过滤必须在检索之前,不在展示之后——过滤放在哪一层,决定了它是安全边界还是心理安慰。
本章机制表:六个授权维度与判断要点。
| 维度 | 判断要点 | 不满足的后果 |
|---|---|---|
| 租户(Tenant) | tenant_id 必须匹配,最先判断 | 客户间数据交叉,一次就是合同违约 |
| 团队(Team) | user.team_id 必须在 doc.accessible_teams 中 | 同公司内互相越权 |
| 角色(Role) | 作为派生维度,映射到 accessible_teams 与 clearance_level | 组织调整时判断逻辑四处散落 |
| 客户范围(Customer Scope) | doc.customer_id 必须在 user.accessible_customer_ids 中 | 销售看到他人客户数据 |
| 文档状态(Status) | effective_status 必须为 active | 回答草稿或过期内容 |
| 密级(Classification) | user.clearance_level 必须 >= doc.classification | 实习生拿到合伙人级保密条款 |
边界表:前置过滤与后置过滤差在哪。
| 边界 | 安全边界其实是 | 漏洞所在 |
|---|---|---|
| 前置过滤 | 数据不存在于链路中 | 依赖向量库原生 metadata filter 能力 |
| 后置过滤 | 用户看不到(不是安全边界) | 无权内容已进日志、缓存、Prompt |
现场症状速查:
| 现场症状 | 根因判断 | 先做什么 | 不该做什么 |
|---|---|---|---|
| 日志里出现无权文档正文 | 过滤放在展示层,日志已记录全量结果 | 把权限条件前移到检索阶段 | 只在前端加一层过滤 |
| 用户 A 命中用户 B 的缓存 | 缓存 key 未含权限维度 | 让缓存 key 带上用户权限维度 | 继续用全局共享缓存 |
| 抓包即可伪造身份 | 后端信任前端传入的 user_id / team_id | 用户上下文从服务端 SSO 获取 | 直接采用请求里的身份字段 |
| 实习生返回保密合同条款 | 向量库无权限标签,检索返回全量 | 给文档补 metadata 权限字段 | 靠前端裁剪补漏 |
| 权限改了没人发现漏洞 | 缺越权回归测试 | 用无权账号定期跑 T1–T8 | 用代码评审替代越权测试 |
| 转岗后仍能看原团队数据 | 权限手工维护,未随组织架构同步 | 由组织架构系统定期同步权限 | 继续人工在文档上配置 |
一个判断顺序:先比 tenant_id → 再看 effective_status → 再比 team_id 与 accessible_teams → 再比 customer_id 与 accessible_customer_ids → 最后比 clearance_level 与 classification;任一条不满足即拒绝。
一句收尾:越权泄露一旦发生,就没有「修复」这一步,只有「善后」——所以权限只能按前置条件排期,不能按优先级临时补。
和相邻章节的关系:本章与第 3 章《AI 项目数据接入:从数据源盘点到可用知识》、第 5 章《AI FDE 如何设计企业知识库检索链路》同处一个主题段:第 3 章把权限字段写进文档结构并前置到检索,本章把这些字段变成不可绕过的判断,第 5 章再在此基础上保证检索结果的相关性。
思考题
为什么「在前端展示层过滤无权内容」不能算安全边界?请说出至少 3 个理由。
参考答案:1)未授权内容已经进入了检索结果,可能被日志记录,日志如果被未授权人员查看就是泄露;2)未授权内容可能进入缓存,缓存被其他用户命中时可能返回无权内容;3)未授权内容可能进入 Prompt,模型可能在回答中「复述」出来,用户看到的就是越权内容;4)重排模型可能基于未授权内容进行排序,影响最终结果;5)前端过滤是客户端逻辑,用户可以通过抓包、改请求等方式绕过。正确的做法是在检索前置过滤,让无权内容根本不进入系统链路。
换个说法:权限治理和前几章的工程问题有一处根本不同——数据接错了可以重灌,模型答偏了可以调 prompt,但越权泄露一旦发生,就没有「修复」这一步,只有「善后」。所以它不能按优先级排期,只能按前置条件处理:在设计检索链路的第一天,权限就必须是其中一段,而不是上线前临时补的一层。
AI FDE hub
本文由 AI FDE hub 出品
关注「AI PDE 陪跑计划」,获取 AI 现场交付方法论