☰
企业 AI 应用的权限治理:别让 RAG 成为越权入口
2026/10/2 12:34:42 网站建设 项目流程

核心命题:权限过滤必须在检索之前,不在展示之后。

一句话带走:过滤放在哪一层,决定了它是安全边界还是心理安慰;权限过滤必须在检索之前,让无权数据根本不进入链路。

企业知识库问答最容易被忽略的风险,不是模型答错,而是模型回答了当前用户不应该看到的问题。普通的 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 → 生成

好处:无权文档根本不会被检索出来,不会进入后续任何环节。

两种链路的本质区别:后置过滤的安全边界是「用户看不到」,前置过滤的安全边界是「数据不存在于链路中」。前者依赖多层防御不出现任何疏漏,后者从源头消除了风险。用一个类比:后置过滤像是在图书馆里先把所有书都搬到桌上,再一本本检查借阅证是否允许;前置过滤是在书架上就把不允许的书锁起来,读者根本拿不到。

工程折中:有些基础设施(如某些向量数据库)暂时不支持检索前置过滤,只能从候选集中过滤。这属于工程折中,而不是理想安全模型。此时必须做到:

  1. 未授权文档不会进入 Prompt、重排模型、用户可见日志、缓存和调试输出
  2. 通过越权测试验证(用无权账号测试,确认返回结果中没有无权内容)
  3. 记录过滤日志,审计哪些文档被过滤掉了
  4. 在候选集阶段过滤后,如果剩余文档不足 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(团队不匹配 + 密级不够)

代码中的几个判断具有明确含义:

  1. 租户匹配:多租户隔离的第一道防线,A 租户永远看不到 B 租户的数据
  2. 生效状态:草稿和过期文档不能进入检索结果,避免回答过期内容
  3. 团队匹配:团队粒度的权限控制,不在可访问团队列表里的团队不能看
  4. 客户范围:销售只能看自己负责的客户数据,不能看其他销售的客户
  5. 密级判断:用户的可访问密级必须大于等于文档密级,实习生不能看机密文档

生产环境需要补充的:统一身份认证(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 现场交付方法论

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询