RAG 工程与检索系统设计

第7章 Metadata 与权限模型:让知识带着边界进入索引

作者 杨艺韬 · 13,070 字

第7章 Metadata 与权限模型:让知识带着边界进入索引

“Security is not a retrieval re-rank. It’s an index-time invariant.” — 企业知识库系统的第一教训

本章要点

  • 每个 chunk 都是带业务边界的知识单元——没有 metadata 的检索只能回答”相似什么”,不能回答”谁能看什么”
  • RAG metadata 分三层:身份层(doc_id / chunk_id / version)、业务层(owner / department / tags)、安全层(access_level / ACL / PII flag)
  • 权限必须索引时嵌入而非检索后过滤——后者会泄露存在性信息、也无法避免向量召回把高敏数据当候选
  • 主流权限模型两类:RBAC 简单但粗ABAC 灵活但复杂——RAG 场景推荐 ABAC 或 RBAC+ 属性过滤混合
  • 多租户隔离靠分区键 + 索引级隔离,不靠 application-level filter

7.1 为什么 metadata 是索引的一等公民

第 6 章把知识切成了 chunk。如果每个 chunk 只有 text 和 embedding,检索系统只能回答一个问题:“和这个 query 语义最相似的 chunk 是哪些?” 但生产 RAG 要回答的问题远不止这个:

  • “用户 A 能看到 chunk X 吗?“(权限)
  • “这段是 2024 年的还是 2022 年的?“(时效)
  • “这段属于哪个业务线?“(归属)
  • “这是内部资料还是公开材料?“(敏感级)
  • “这是中文 chunk 还是英文 chunk?“(语言)
  • “这是哪个文档的哪一节?“(溯源)

上述每一个问题都需要 chunk 携带结构化 metadata。metadata 不是”附带信息”,它是检索系统的一等公民——和向量同等重要。少了它,向量检索就退化成”语义匹配玩具”,做不了生产。

看一个典型事故:某企业 HR 系统接入 RAG,索引时没区分 “全员可见” vs “经理可见” 的政策文档。员工问”今年绩效加薪幅度”,系统召回了仅经理可见的高管薪资表——因为两份文档语义都围绕”加薪”。这次泄漏事故损失的不是数据,是合规审计的一道门槛。索引阶段没带权限 metadata,所有下游过滤都是空中楼阁。

7.2 三层 metadata 设计法则

metadata 不是随意加字段——它有固定的三层结构:

flowchart TB
    classDef id fill:#def,stroke:#06c
    classDef biz fill:#fed,stroke:#c60
    classDef sec fill:#fee,stroke:#c00

    CHUNK[Chunk]
    CHUNK --> L1[身份层]:::id
    CHUNK --> L2[业务层]:::biz
    CHUNK --> L3[安全层]:::sec

    L1 --> L1a[doc_id / chunk_id / version_hash]
    L1 --> L1b[publish_date / last_modified]
    L1 --> L1c[language / source_type]

    L2 --> L2a[owner / department]
    L2 --> L2b[tags / topic / category]
    L2 --> L2c[section_path / doc_type]

    L3 --> L3a[access_level / ACL_tenants]
    L3 --> L3b[PII_flag / compliance_tags]
    L3 --> L3c[retention_policy]

身份层:chunk 的”身份证”

回答”这段 chunk 是从哪来的”。必填字段:

  • doc_id:文档稳定 ID(UUID 或业务键)
  • chunk_id{doc_id}#{chunk_index}#{content_hash} 格式(见第 4 章 §4.5)
  • version_hash:文档内容 hash,变更检测用
  • publish_date / last_modified:时效信号
  • language:ISO 639-1(zh / en / ja
  • source_typepdf / html / markdown / code / api

身份层字段是不可变的——一旦 chunk 入库,这些字段不应该变。变了意味着是一个新 chunk(chunk_id 改变)。

业务层:chunk 的”领域”

回答”这段 chunk 归属哪块业务”。按项目定义,典型字段:

  • owner:文档所有者(人员 ID 或组织 ID)
  • department / org_path:所属部门
  • tags / categories:业务分类
  • doc_typecontract / spec / faq / policy / code
  • section_path:文档内层级(继承自第 5 章解析)

业务层字段可更新——文档 owner 调岗、tag 重新归类都会改这些字段。更新时记 audit log。

安全层:chunk 的”访问契约”

回答”谁能看到这段 chunk”。最关键、也最容易做错的一层。典型字段:

  • access_levelpublic / internal / confidential / restricted(4 级足够多数场景)
  • acl_tenants / acl_groups / acl_users:具体可见人/组
  • pii_flag:是否含个人敏感信息
  • compliance_tagsGDPR / HIPAA / SOX 等合规标签
  • retention_policy:保留周期(30d / 1y / permanent)

安全层字段变更必须强制触发索引更新——一份文档从 confidential 改成 internal 时,对应 chunk 的 access_level 必须立刻同步。任何延迟都是合规风险。

7.3 权限的两个陷阱:索引时 vs 检索后

做权限过滤有两种方式,选错一种会直接翻车

陷阱一:只在检索后过滤

一种常见做法:所有 chunk 不区分权限都进索引,检索时拿 top-k 结果后按 user 权限过滤掉不可见的。问题:

  • 向量召回污染:top-5 里 3 个是用户看不见的 chunk,过滤后只剩 2 个——但这 2 个可能是次优候选。而那个最优、用户可见的 chunk 排在了 top-30——被候选集截断了
  • 存在性泄露:用户发现”我问这个问题召回 0 条,但我知道公司有相关文档”——就能推断那些文档存在但自己没权限。对高敏场景这本身就是信息泄露
  • 延迟浪费:rerank 跑了 top-20、然后 3/4 被权限过滤掉——80% 的 rerank 算力被浪费

陷阱二:索引层硬分库但查询要合并

另一种做法:每个权限级别建一个独立索引。用户 A 查询时只查自己有权限的索引。问题:

  • 用户属于多个权限组时要并行查多个索引再融合——复杂度高
  • chunk 的 acl_users 字段精细到人时,索引数量爆炸
  • 索引热点:public 索引可能包含 99% 的数据,查询压力不均

正确姿势:属性过滤下推到向量库

现代向量数据库(Qdrant、Milvus、Weaviate、pgvector)都支持过滤 + 向量检索一体化

# Qdrant 示例
client.search(
    collection_name="kb",
    query_vector=q_vec,
    query_filter=Filter(
        must=[
            # access_level 用户能看
            FieldCondition(key="access_level", match=MatchAny(["public", "internal"])),
            # chunk 的 ACL 组包含用户所在组
            FieldCondition(key="acl_groups", match=MatchAny(user_groups)),
            # 时效性
            FieldCondition(key="publish_date", range=Range(gte="2024-01-01")),
        ]
    ),
    limit=20,
)

向量库内部先按 filter 剪枝再做 ANN 检索——召回的 20 条天然都是用户可见的。不会出现召回污染、不会浪费 rerank、不会泄露存在性。

这要求 metadata 字段必须作为索引字段建好。payload 索引(Qdrant 叫 payload index、Milvus 叫 scalar index、pgvector 用 btree)是属性过滤的性能关键。没建索引的字段上过滤会线性扫描,延迟爆炸。

7.4 RBAC vs ABAC:权限模型选型

RBAC:基于角色

RBAC(Role-Based Access Control)是老牌方案:用户归属角色、角色关联权限。

用户 alice → 角色 engineering_lead → 权限 [read engineering_docs, write project_X_docs]

优点:简单直观、工具成熟(LDAP / AD / Okta)、审计容易。缺点:粒度粗——相同角色的人看到完全相同的数据。很多真实场景是”工程 lead 能看自己项目的敏感数据但不能看隔壁项目的”——RBAC 要爆炸性地增加角色。

ABAC:基于属性

ABAC(Attribute-Based Access Control)按属性组合判断权限:

allow read if
  user.department == chunk.department
  AND (user.level >= chunk.required_level OR chunk.owner == user.id)
  AND chunk.publish_date >= user.hire_date - 90d

优点:粒度任意细、规则可演化、支持”相对权限”(对自己的项目有权、对别的项目无权)。缺点:规则复杂、决策慢、实现成本高。

RAG 场景的推荐

多数企业 RAG 场景用 RBAC + 属性过滤混合

  • 主骨架用 RBAC——用户归属部门/角色,chunk 有 access_level 和 department 字段
  • 关键维度用 ABAC——项目归属、合同所属客户、工单归属发起人 这类”所有权”关系

具体实现:

  • 用户查询带 user_context = {user_id, roles, groups, department, tenants}
  • 向量库过滤器表达 chunk.access_level IN allowed_levels AND chunk.acl_groups INTERSECTS user.groups
  • 复杂规则(时效、相对权限)在 filter 里组合多个 FieldCondition

这条路径能覆盖 90% 的企业场景、又不会让权限系统过度工程化。第 22 章的生产案例会展示完整实现。

RBAC vs ABAC 对比

维度RBACABAC混合(推荐)
粒度角色级(粗)属性任意组合(细)角色为骨架 + 关键属性细调
实现难度
查询开销低(角色查数据库一次)高(规则引擎求值)低-中
审计易度高(角色清单)中(规则审阅)
权限变更改角色(波及广)改规则 / 属性局部改
典型场景公司内部工具金融 / 医疗 / 政府企业级 SaaS

开源权限引擎

不需要从零写权限系统。开源选择:

  • OPA(Open Policy Agent):最主流的 policy engine、Rego 语言表达规则、可以 sidecar 部署或库集成
  • Casbin:多语言支持(Go / Java / Python / Rust 等)、内置 RBAC/ABAC/ACL 多种模型
  • Cerbos:较新、YAML 配置规则、对比 OPA 上手更快
  • Oso:Rust 写的 policy engine、嵌入式设计

这些引擎负责”根据 user + resource + action 决定 allow / deny”。RAG 里的用法:先用引擎计算出当前用户可访问的 access_levels / tenants / groups 集合,再把集合作为 filter 下推到向量库。不要把引擎放在 inner loop 里为每个召回 chunk 求值一次——会拖垮延迟。

7.5 多租户的三种隔离强度

SaaS 场景下 RAG 要服务多个企业客户,每个企业的知识库不能串。隔离强度三档:

弱隔离:共享索引 + tenant_id filter

所有客户的 chunk 进同一个索引,每条带 tenant_id。查询必须带 tenant_id filter。

  • 优点:成本最低、扩容自动、利用率高
  • 缺点:filter 漏写就是跨租户泄漏;大客户的慢查询会影响小客户

中隔离:按租户分 collection / namespace

每个大客户一个 collection/namespace。同一个 vector DB 实例,但索引物理隔离。

  • 优点:filter 错也不会串、运维层面能按 collection 做资源配额
  • 缺点:客户数多了 collection 数爆炸(Qdrant 单实例推荐 < 10k collections)

强隔离:每个租户一套独立服务

大客户单独一套数据库 + 向量库 + 应用层。物理隔离到服务级别。

  • 优点:完全合规——数据永不共享、可单独部署到客户 VPC
  • 缺点:成本高(每客户独立资源)、运维复杂

选型:中小客户共享 + 大客户独立是常见路径。SaaS RAG 产品公司如 Glean / Notion AI 公开过这种分层策略。

flowchart LR
    classDef w fill:#ffe,stroke:#c80
    classDef m fill:#def,stroke:#06c
    classDef s fill:#efe,stroke:#080

    T[租户分级] --> W[弱隔离]:::w
    T --> M[中隔离]:::m
    T --> S[强隔离]:::s

    W --> W1[共享索引 + tenant_id]
    M --> M1[每租户 collection]
    S --> S1[每租户独立实例 / VPC]

    W1 -.成本低.-> SMB[小客户 / 试用期]
    M1 -.平衡.-> MID[中型客户]
    S1 -.合规严.-> BIG[大客户 / 监管行业]

7.6 PII 和合规 metadata

含个人敏感信息的 chunk 需要额外处理。最低要求:

  • 识别:入库时用 Presidio / spaCy NER / 正则识别 PII(姓名、手机、身份证、邮箱、IP)
  • 标记pii_flag = truepii_types = ["phone", "email"]
  • 脱敏副本:可选生成一份脱敏 chunk 用于非授权用户(Alice → [PERSON]、138xxxx → [PHONE])
  • 审计:含 PII 的 chunk 被检索时记 audit log(谁在何时以什么 query 命中)

合规场景(GDPR / HIPAA / 等保)进一步要求:

  • 用户撤回权retention_policy 字段支持”用户删除后 30 天内索引清除”
  • 跨境data_residency = CNallowed_regions = [CN, HK] 字段控制数据不出境
  • 审计追溯:每次检索的 trace 必须可留存 N 年,能回查”某用户某时刻看到的 chunk 列表”

这些不是可选——合规事故的代价远高于工程成本。企业 RAG 项目立项时就把这些字段放进 schema。

PII 处理的四种模式

不同场景对 PII 的处理有不同模式,按严格度排列:

模式 1:原文入库 + 检索时脱敏。最宽松——chunk 里保留原始 PII,但返回给 LLM 前替换成占位符。问题:LLM 生成的答案里可能已经是”138xxxx1234 的销售是 Alice”,脱敏介入太晚。

模式 2:入库时脱敏 + 原文保留在冷存储。chunk 存脱敏版供检索和生成用,完整原文存加密冷存储。用户需要看原始信息时二次授权。适合客户服务场景——Agent 回答时看脱敏版、客服升级时手动调原文。

模式 3:PII 不入索引。识别到 PII 的段落直接剔除不索引。检索不到就不会泄漏。代价:涉及 PII 的业务问题用户问不出来。适合对外产品——允许不完美的功能性换彻底的安全性。

模式 4:PII 单独索引 + 严格 ACL。PII 和非 PII 分两套索引。PII 索引只有严格授权的用户能访问。需要 PII 答题时走特殊路径 + 审计。适合金融 / 医疗。

选型:消费级 AI 产品用模式 3;企业内部效率工具用模式 2;涉 PII 业务(客服、风控)用模式 4。模式 1 几乎没有合法使用场景。

一个权限泄漏的真实事故

某大厂 HR RAG 上线初期发生过:

  • 事件:一线员工问”公司今年绩效 A 的比例”,系统答”约 15%“并引用了一份《高管薪酬细则》
  • 调查:该份细则仅 HR 总监和 CXO 可见、不该进员工问答的召回范围
  • 根因:索引阶段所有 HR 文档统一打 department = HR,但没有细分 access_level 字段。查询时员工的 allowed_departments 包含 HR(因为员工的工资单、政策都在 HR),没有额外的 access_level 检查,就放进了候选集
  • 修复:给所有 HR 文档补 access_level 字段,重建索引;gateway 层 user_context 注入 allowed_access_levels;向量库 filter 必须同时检查 department 和 access_level

复盘后的教训不是”加一个字段”,而是”每一类过滤维度都必须是独立的 filter 条件——不能期望某一维度的过滤隐式覆盖另一维度”。这个事故让项目组把权限测试加进了 CI——每次索引 schema 变更都跑一遍”员工 A 看不到 CEO 档案”类断言。

7.7 metadata 的版本演化

chunk metadata schema 一旦上线就很难改——索引里几百万 chunk 的 metadata 结构都固定了。演化策略:

向后兼容地加字段

新字段都是 nullable、老 chunk 缺这个字段时默认”未知”。查询 filter 对”未知”要有明确约定——是当”全部可见”(宽松)还是”全部不可见”(严格)?

安全层字段新加时默认严格——老 chunk 没有 pii_flag 的视为 pii_flag=unknown,按最高安全等级处理。宁可降低召回也不能冒泄漏风险。

废弃字段的迁移

某个字段要废弃(比如 access_level 从 4 档改 6 档):

  • 第一阶段:新旧字段共存双写,filter 支持两种
  • 第二阶段:新数据只写新字段,老数据按映射规则 backfill
  • 第三阶段:完全迁移后下线旧字段

每阶段至少留 2 周观察期。生产系统里不要”一刀切改完”——出问题回滚都没地方回滚。

迁移成本估算

backfill 老数据的成本常被低估。100 万 chunk 的重索引大约 30 分钟 GPU(bge-m3),但带着业务字段的 backfill 往往要跑 SQL / 规则引擎做字段填充——串行可能几小时、并行也要几十分钟。双写阶段索引膨胀 2 倍、查询路径也要改成 union 两个字段——期间性能下降 10-20% 是常态。

给每次 schema 变更预留的时间估算:

  • 小字段增加(nullable、无默认):1 天设计 + 1 天 PR + 2 天观察
  • 字段值空间扩展(4 档→6 档):1 周方案 + 2 周双写 + 1 周灰度 + 1 周下线
  • 结构性变更(字段重命名 / 类型改变):按月算

这些时间表对不熟悉数据 migration 的团队往往太乐观——真实生产系统里 schema 迁移是典型的”预估 3 天实际 3 周”项目。架构决策时留足余量。

schema registry

RAG 系统应该有一份明确的 metadata schema registry(JSON Schema / protobuf / pydantic),任何字段变更都要 PR review。不要靠文档——文档和代码会背离。

7.8 查询时的 user_context

有 chunk metadata 还不够,查询时的 user_context 也要结构化。user_context 包含:

  • user_id / email
  • roles:角色列表
  • groups:组列表
  • tenants:可访问的租户 ID 列表(多租户场景)
  • department / org_path
  • hire_date:用于时效相对权限
  • clearance_level:保密级别
  • language_prefs:语言偏好(filter 用)

这份 context 在 API gateway 从 JWT / session 解码出来,注入到每次检索请求。向量库 filter 基于它构造。

为什么 user_context 不能在 RAG 服务里查

有人把”查用户权限”放在 RAG 服务内部——每次请求 RAG 先调 identity service 查用户组。两个问题:

  • 延迟:又多一跳,P99 延迟顶上去
  • 一致性:用户权限刚变,identity service 和 JWT 里的信息不一致,可能给错数据

正确做法:API gateway 层完成身份解析,RAG 服务只负责用拿到的 user_context 做 filter。身份变更靠短 TTL JWT(5-15 分钟)快速生效。

user_context 的缓存策略

RAG 查询高频,user_context 解析也高频。合理的缓存层级:

  • JWT claims 自带:身份、角色、核心属性塞在 JWT 里,解析 JWT 即是 user_context 解析。零额外调用
  • Redis cache TTL 短:JWT 未覆盖的属性(细粒度的 groups、时效性权限)从 identity service 查一次缓存到 Redis,TTL 60-300 秒
  • 绝不缓存敏感决策:具体某个 chunk 的 allow/deny 决策不缓存——太多组合、缓存键爆炸、且容易过期错

实测下来 JWT claims + Redis cache 组合能把权限解析 P99 控制在 5ms 内,对 RAG 总延迟几乎无感。

权限测试的自动化

权限泄漏是最糟的 bug——一旦发生影响面大、不可挽回。CI 里必须跑自动化权限测试:

# 伪代码
@pytest.parametrize("user, forbidden_docs", [
    (alice_employee, ["ceo_compensation.pdf", "m&a_pipeline.pdf"]),
    (bob_contractor, ["internal_roadmap.pdf", "employee_list.pdf"]),
    (tenant_a_admin, ["tenant_b_customer_list.pdf"]),  # 跨租户
])
def test_rag_no_leak(user, forbidden_docs):
    for doc in forbidden_docs:
        # 直接测 doc 的 chunk 不能被 user 召回
        chunks = rag.retrieve(query=doc_summary(doc), user=user, top_k=50)
        assert all(c.doc_id != doc for c in chunks), f"{user.id} 召回了 {doc}"

每次索引 schema 变更 / 权限逻辑修改都跑这套测试。几十个关键 “不能看” 的断言是底线。

7.9 权限变更的传播链:从策略更新到答案失效

前面 8 节讲了权限如何在”正常查询”里起作用。真实生产里更棘手的问题是——权限变了之后:员工离职、角色降级、文档从”内部”升级为”机密”、租户合同终止。这些变更必须在系统里立即生效、否则前一秒”有权限”的用户、下一秒还能查到不该看的内容。这不是小事——合规事故常发生在权限变更的”中间态”。

五条缓存链的失效难题

flowchart TB
    classDef src fill:#fed,stroke:#c60
    classDef cache fill:#def,stroke:#06c
    classDef out fill:#fdd,stroke:#c00

    CHANGE[权限变更事件<br/>员工离职 / 文档升级]:::src
    CHANGE --> C1[向量库 payload filter]:::cache
    CHANGE --> C2[Rerank 结果 cache]:::cache
    CHANGE --> C3[Query 级完整响应 cache]:::cache
    CHANGE --> C4[Memory 里的历史 facts]:::cache
    CHANGE --> C5[用户已下载 / 截图的答案]:::out

    C1 --> I1[立即有效]
    C2 --> I2[TTL 过期后失效]
    C3 --> I3[TTL 过期 或 key 失效]
    C4 --> I4[下次 memory 写入时重校]
    C5 --> I5[无法追回 需政策约束]:::out
  • 向量库 filter:payload 更新后查询立即生效——更新 chunk 的 access_level、下次查询就看不到了
  • Rerank 缓存:按 (query, chunk_id) key、和权限无关——需要把 user_id 加进 cache key、或者权限变更时清空特定 user 的 cache
  • Query 级响应缓存:按 (query, user_ctx) key——user_ctx 里含权限版本号、权限变则 key 变、自动失效
  • Memory 里的历史 facts:Memory 可能存过”用户曾问过 X、答过 Y”——Y 里的引用 chunk 现在无权限、历史 fact 也要失效或重验证
  • 已下载 / 截图:最难——用户已经看过的内容无法回收。只能靠政策约束(“请不要分享”)和事后审计

Time-to-revoke(TTR)作为 SLA

合规规定通常要求权限变更在 X 时间内生效——典型值:

场景TTR 要求
员工离职即刻到 1 小时
角色降级1-24 小时
文档重分类24 小时到 1 周
租户合同终止24-72 小时

TTR 是可测的——生产 RAG 的权限变更应该有端到端测试:触发权限事件 → 等待 → 尝试查询 → 断言”查不到”。这测试跑一次就能暴露哪些缓存链没连上。

权限版本号:最强工具

给每个权限策略加 permission_version 字段——任何策略变更 version +1。所有缓存 key 都带 version、version 变即自动失效。实现:

# user_context 构造时带上
user_ctx = {
    "user_id": "u_123",
    "role": "contractor",
    "permission_version": 47,  # 从策略 DB 读取
    "tenant_id": "t_A",
}

# 缓存 key 自动包含 version
cache_key = f"resp:{hash(query)}:{user_ctx['permission_version']}:{user_ctx['user_id']}"

Version bump 后老 key 永远不会被命中、新 key 触发一次完整重跑。这比”挨个去清缓存”简洁、且不会漏。

事件驱动的权限失效

权限变更来自 HR / IAM 系统——通过事件总线推给 RAG:

  • HR 发 user_deactivated 事件 → RAG 订阅、把该 user 的 query cache、rerank cache 清空、memory 标记 frozen
  • IAM 发 role_changed 事件 → 该用户 permission_version bump、下次查询触发重新拉策略
  • CMS 发 doc_reclassified 事件 → 该 doc 在向量库的 payload.access_level 更新

事件总线保证全链路最终一致——订阅者各自处理、不用一处地方管所有缓存。见第 8 章 §8.3 的事件驱动模式。

已泄漏答案的处理

即使 TTR 做到 1 小时、用户仍可能在权限有效的 1 小时内已经看到答案、截图、转发。对这部分”已泄漏”数据的处理:

  • 政策层面:用户协议里明确”内容保密、不得分享”
  • 技术层面:UI 加屏幕水印(用户 ID + 时间戳)、方便事后追责
  • 审计层面:保留完整 trace、一旦出事能证明”这个答案是在用户还有权限时返回的”

这些不能真的”撤回”内容——但能约束和取证。对合规场景足够、对最严格场景(军工、某些金融)需要额外技术手段(端到端加密 + 可撤销访问)。

跨系统权限同步的延迟

RAG 的权限信息通常从上游系统同步——HR、IAM、CRM 各有权威数据源。同步延迟是常见 bug 来源:

  • HR 系统改了员工状态、IAM 15 分钟才同步
  • IAM 更新了、RAG 的 user_ctx 构造器 5 分钟才刷新
  • RAG 查询的时候用的还是缓存的 15 分钟前的 user_ctx

累加起来 TTR 可能远超 SLA。解决:

  • 关键变更走实时 push:HR 的”离职”不能等批量同步、直接推送
  • user_ctx 每次请求重构:不缓存 user_ctx、每次从权威源读(加一次网络往返、但避免延迟累加)
  • 同步延迟监控:端到端测”HR 变 → RAG 生效”的耗时、超 SLA 告警

权限变更的事故复盘场景

常见事故:某企业员工离职、IT 当天禁用账号、但员工之前问过 “明年薪资调整方案” 的答案被员工同事用浏览器历史查看——因为 query 级 cache 的 key 只含 hash(query)、没含 user_id、第一次离职员工查的答案被另一人命中。

修复:

  1. 紧急:query cache key 强制加 user_id
  2. 短期:所有 RAG cache 审查一遍、无 user_id 的都加上
  3. 长期:user_ctx 结构规定必含 user_id + permission_version、每处缓存都用
  4. 测试:加一类”cache 是否按 user 隔离”的回归

这类事故每年都在不同公司重复——cache key 必含 user 身份是 RAG 的默认安全规则。

7.10 Agent 代理与委托访问的权限

前 9 节的权限讨论默认一个人类用户直接问 RAG。真实生产里越来越多场景是 Agent 代表用户访问 RAG——Agent 是中间件、它的身份和用户身份不完全对齐。这带来权限设计的新难题:Agent 应该拿谁的权限?Agent 自己有权限吗?Agent 跨多个用户操作时怎么审计?这些问题在 2025-2026 年随 Agent 产品普及变得尖锐、多数 RAG 权限模型没准备好。

Agent 权限的三种模型

flowchart TB
    classDef m fill:#fed,stroke:#c60
    classDef pro fill:#dfd,stroke:#080
    classDef con fill:#fdd,stroke:#c00

    M[Agent 权限模型]
    M --> A[模型 A:透明代理]:::m
    M --> B[模型 B:Agent 独立身份]:::m
    M --> C[模型 C:委托 / on-behalf-of]:::m

    A --> A1[Agent 透传用户身份<br/>实现简单]:::pro
    A --> A2[Agent 无法做跨用户操作]:::con
    B --> B1[Agent 独立、可跨用户]:::pro
    B --> B2[审计到 Agent、丢失原用户]:::con
    C --> C1[保留原用户 + Agent 双身份]:::pro
    C --> C2[实现复杂、OAuth OBO]:::con
  • 模型 A 透明代理:Agent 是用户的”附体”、每次 RAG 请求带用户 JWT、RAG 按用户权限返回。简单但 Agent 做不了跨用户事(“帮三个团队成员都找一遍”)
  • 模型 B Agent 独立身份:Agent 是一个独立账号、有自己的权限(通常是 service account 级别)。能跨用户但审计时只看到 Agent、丢失”谁让 Agent 做的”
  • 模型 C 委托 / on-behalf-of:OAuth 的 OBO flow——Agent 有自己身份、同时携带”代表用户 X 行动”的 token。权限是两者交集、审计能追到原用户

生产推荐:模型 C——复杂但信息最完整、合规最放心。模型 A 适合简单 Chatbot、模型 B 适合自动化 pipeline(定时任务、cron)。

OBO token 的设计

OBO(On-Behalf-Of)token 典型字段:

{
  "agent_id": "agent-123",
  "agent_scopes": ["read:knowledge", "write:memory"],
  "acting_on_behalf_of": {
    "user_id": "u-456",
    "tenant_id": "acme",
    "permissions_at_delegation": ["role:eng", "level:internal"]
  },
  "delegation_chain": [
    {"delegator": "u-456", "delegate": "agent-123", "at": "2026-04-25T10:00:00Z"}
  ],
  "exp": "2026-04-25T11:00:00Z"
}

关键字段:

  • Agent 自身 scope:Agent 能做什么类的操作
  • 被代理用户的 permissions_at_delegation:用户委托时的权限快照——后续用户权限变化不自动影响 Agent 已在执行的任务(按需决定)
  • delegation_chain:多层委托的完整链条——Agent A 调 Agent B 时记录
  • 过期时间:OBO token 短期有效(分钟到小时)、防止泄漏后长期滥用

权限计算是 Agent scope ∩ User permissions ∩ 资源 ACL 的三方交集——任一缺失都不给。

委托的权限范围约束

委托不应该是”用户所有权限都给 Agent”——应该 scoped delegation

# 用户明确授权 Agent 只能访问特定范围
delegation = {
    "agent_id": "agent-123",
    "scope": {
        "read_docs": ["pricing", "product-docs"],  # 只能读这两类
        "write_memory": False,                      # 不能写 memory
        "call_tools": ["search", "summarize"],      # 只能调这俩工具
    },
    "duration": "1h",
}

Scope 越窄越安全——Agent 被 prompt injection 攻击后能造成的损害有限。

跨用户 Agent 操作的审计

Agent 模型 B 或 C 做跨用户操作时、审计日志要足够详细:

{
  "event": "rag_query",
  "trace_id": "req-abc",
  "agent_id": "agent-123",
  "initiating_user": "u-456",
  "target_users": ["u-789", "u-101"],  // 查询涉及的其他用户数据
  "resources_accessed": ["doc-A", "doc-B"],
  "permissions_checked": [
    {"user": "u-789", "doc": "doc-A", "decision": "allow"}
  ]
}

这种日志让合规能回答”某用户数据被谁访问过、为什么”。没有 Agent 操作的 audit 在 SOC 2 / ISO 27001 审计里是减分项。

Agent 自身的权限边界

Agent 不只是”用户的替身”、它自己也有身份——需要明确:

  • Agent 不能自授权升权:Agent 不能往自己 memory 里写 “我是管理员”
  • Agent 不能修改自己的 scope:scope 由 Agent 配置 + 用户委托决定、不能 runtime 改
  • Agent 不能跨 tenant 访问:即使 Agent 是 service account、tenant 边界仍是硬约束

这些约束要在权限判定器 layer 强制、不是依赖 Agent 开发者自觉。

Agent chain 的权限传递

Agent 调用其他 Agent(多级委托)时、权限如何传递?

  • 权限单调递减:每层只能减权、不能加权。Agent A 给 Agent B 的 scope 是 A 自己 scope 的子集
  • delegation_chain 完整记录:5 级 Agent 调用要保留整个链、审计时可回溯
  • TTL 累加减少:A 给 B 1 小时 token、B 给 C 时应该 ≤ 剩余 TTL

这套类似 OAuth 2.0 的 scoped delegation 模式。标准但实现上容易偷懒——一出事就难追责。

常见陷阱

  • Agent 直接拿 admin service account:方便但过度授权、Agent 被攻破就是 admin 权限外泄
  • Agent 请求里没带用户 context:后端按 Agent 身份判权限、用户无权数据被返回给 Agent(再返给用户)
  • OBO token 永不过期:泄漏后可长期滥用
  • audit 只记 Agent、不记原用户:合规追责失败
  • 没考虑 Agent 的 memory 也是”用户数据”:memory 的 scope 没和 Agent + user 双维度绑定

和 Memory 的协同

第 18 章 Memory 的 scope 在 Agent 场景下要升级——原来 “per-user memory” 变成 “per-(user, agent) memory” 或 “per-agent memory”

  • 用户在 Agent X 里说的话、不能被 Agent Y 访问
  • Agent 自身的 “使用习惯 memory”(用户偏好)和 “任务上下文 memory”(当前任务进展)要分开

这种双维度 memory scope 是 Agent 产品的标配、不做好就变成 “不同 Agent 互相读到用户的秘密”。

Agent 权限的设计 checklist

上线 Agent 前自检:

  • 明确选了 A / B / C 哪种模型、全团队对齐
  • OBO token 结构固化、包含 delegation chain
  • Agent scope 在配置文件里、不是代码写死
  • 审计日志包含 agent_id + initiating_user + target_users
  • 跨用户 / 跨 tenant 的访问有专门 safety check
  • Agent 自己的 memory 也受 scope 约束
  • 有 agent 权限事故的 runbook

这几项齐全、Agent 上线才有资格说”权限没问题”。

7.11 权限的系统性测试方法

前 10 节讲了权限的设计和传播——但代码改了怎么验证权限没坏?RAG 的权限不是单元测试能全覆盖的——涉及多层(filter、检索、生成、UI)、多维度(用户、租户、文档敏感度、时间)。系统性的权限测试是很多团队的盲区——大多数权限漏洞都是回归测试没发现的。这节给出一套专门的权限测试框架。

权限测试为什么难

flowchart TB
    classDef hard fill:#fdd,stroke:#c00
    classDef cover fill:#dfd,stroke:#080

    H[权限测试难点]
    H --> H1[组合爆炸<br/>用户 × 文档 × 操作]:::hard
    H --> H2[沉默失败<br/>没用户投诉不知道]:::hard
    H --> H3[多层耦合<br/>任一层破就失败]:::hard
    H --> H4[边界模糊<br/>规则 edge case 多]:::hard
    H --> H5[跨系统依赖<br/>IAM / HR / 业务 DB]:::hard
  • 组合爆炸:user 100 个 × doc 1 万个 × 操作 5 种 = 500 万组合、不可能穷举
  • 沉默失败:权限越权没有明显症状(用户拿到了不该有的答案、可能不知道是越权)
  • 多层耦合:filter 层对了但应用层 bug、或应用对了但 cache key 漏——任一层失误就出事
  • 边界模糊:“同部门但跨组”这种规则、边界案例多
  • 跨系统依赖:权限定义在 IAM、HR 变动触发修改、业务 DB 记特殊例外——都要同步

这些让 “permission bugs” 成为 silent killers——必须系统化工程解决、不能靠手工测。

三层测试策略

flowchart LR
    classDef unit fill:#def,stroke:#06c
    classDef int fill:#fed,stroke:#c60
    classDef adv fill:#efe,stroke:#080

    L1[单元:权限函数]:::unit
    L2[集成:端到端查询]:::int
    L3[对抗:负面 gold set]:::adv

    L1 --> L2 --> L3
  • 单元测试:权限判定函数本身(can_access(user, doc))、边界条件覆盖
  • 集成测试:完整 RAG 查询链路、验证 user A 查不到 user B 的数据
  • 对抗测试:专门构造”应该不能拿到”的场景、看系统是否坚守底线

三层必须都做、缺一层就有漏洞可钻。

单元测试:权限函数本身

权限判定函数是所有权限的”基础设施”——单元测试要覆盖:

def test_rbac_basic():
    assert can_access(admin, doc_hr) == True
    assert can_access(regular_employee, doc_hr) == False

def test_abac_multi_attrs():
    # tenant 不同、即使角色对也不能访问
    user_a = User(tenant="A", role="admin")
    doc_b = Doc(tenant="B", level="confidential")
    assert can_access(user_a, doc_b) == False

def test_edge_boundary_expiry():
    # 正好过期那一刻
    user = User(role="contractor", valid_until=date(2026, 4, 25))
    doc = Doc(level="internal")
    with freeze_time("2026-04-25 23:59:59"):
        assert can_access(user, doc) == True
    with freeze_time("2026-04-26 00:00:00"):
        assert can_access(user, doc) == False

边界条件是单元测试的重点——过期时刻、权限转移瞬间、多角色叠加、ABAC 条件组合。

集成测试:端到端查询

单元对了不代表集成对——跨层 bug 很常见:

@pytest.mark.parametrize("user,query,forbidden_docs", [
    (regular_employee, "薪资方案", ["salary_sheet_2026.pdf"]),
    (bob_contractor, "公司内部架构", ["internal_roadmap.pdf"]),
    (tenant_a_admin, "租户 B 客户列表", ["tenant_b_customer_list.pdf"]),
])
def test_rag_no_leak(user, query, forbidden_docs):
    result = rag_query(query=query, user=user, top_k=50)
    for doc in forbidden_docs:
        assert all(
            c.doc_id != doc for c in result.retrieved_chunks
        ), f"{user.id} 召回了 {doc}(越权)"
        assert doc not in result.answer, f"{user.id} 答案里提到了 {doc}"

这类测试每次代码改动都跑——保护”不能越权”的底线。

对抗测试:专门攻击系统

主动构造攻击场景——模拟恶意用户会怎么套话:

# 测试 1:反向 filter 诱导
def test_inverted_filter_attack():
    user = regular_employee
    # 用户尝试用 negation 绕过
    query = "所有我没有权限看的文档里提到的新员工名字"
    result = rag_query(query=query, user=user)
    # 不能包含 user 无权的 doc 里的内容
    assert not contains_confidential_info(result.answer)

# 测试 2:隐含权限信息泄漏
def test_existence_probe():
    user = regular_employee
    query = "公司是否有 salary_sheet_2026 这份文档?"
    result = rag_query(query=query, user=user)
    # 即使不返回内容、也不能确认文档存在
    assert "不存在" in result.answer or "无法访问" in result.answer

# 测试 3:跨用户 cache 污染
def test_cache_isolation():
    # 两个用户同 query、应该得到各自的答案
    result_a = rag_query(query="企业版定价", user=user_a)
    result_b = rag_query(query="企业版定价", user=user_b)
    assert result_a.sensitive_info != result_b.sensitive_info

这些测试要进 red team gold set(§20.17)、和普通 eval 并行跑。

Fuzzing:随机组合测试

除手工构造测试、用 fuzzing 随机生成组合:

def fuzz_permission_tests(n=1000):
    failures = []
    for _ in range(n):
        user = random_user()
        doc = random_doc()
        expected = compute_expected_permission(user, doc)  # 基准实现
        actual = can_access(user, doc)
        if expected != actual:
            failures.append((user, doc, expected, actual))
    assert len(failures) == 0, f"发现 {len(failures)} 个权限错误"

基准实现是简单规则(没 bug 但慢)、实际实现是优化过的(可能有 bug)——fuzzing 对比两者。

CI 集成

权限测试必须进 CI

# .github/workflows/ci.yml
permission-tests:
  steps:
    - run: pytest tests/permissions/unit -v
    - run: pytest tests/permissions/integration -v
    - run: pytest tests/permissions/adversarial -v
    - run: python scripts/permission_fuzz.py --count 10000
    - name: Block merge on failure
      if: failure()
      run: exit 1

任何权限测试失败 → 阻止合并。权限不能”下次再改”——必须当场。

人工抽查作为最后防线

即使自动化齐备、每季度的人工抽查也必不可少:

  • 随机抽 100 条线上请求 + 100 个用户
  • 人工验证”这个用户本来应该看到什么、实际看到什么”
  • 发现 gap 进 gold set 作后续回归

自动化测 “已知规则”、人工抽查发现 “未知 bug”——两者都不可少。

性能 vs 安全的权衡

全部权限测试跑一次可能几分钟甚至几十分钟——怎么平衡 CI 速度和覆盖?

  • 每次 commit:单元 + 小规模集成(< 1 分钟)
  • 每次 PR:完整集成 + 对抗(5-10 分钟)
  • 每日 nightly:fuzzing(几十分钟)
  • 每周 red team gold set:全量(1 小时)

分层跑、关键路径不慢、长尾保覆盖。

权限 bug 的典型根因

实际事故里的权限 bug 集中在几类:

  • cache key 漏 user_id(§7.9 已讲)
  • filter 下推没对:应用层写了 filter、但向量库 API 没应用
  • 新 feature 没加权限检查:产品加了新端点、没走权限中间件
  • 权限变更未传播:HR 改了、RAG 的 user_ctx 还是旧的
  • 边界条件:0/null/空集合等退化情况

这五类的测试必须进自动化——不是靠人记得。

权限测试的组织责任

这不只是工程的事:

  • 工程:写测试、集成 CI
  • 安全 / 合规:审查测试覆盖度、提出对抗用例
  • 产品:提供真实业务场景的权限边界
  • 法务:对高合规场景设定测试门槛

每季度”权限测试健康 review”——跨团队参与、看覆盖率和最新风险。

7.12 Onboarding / offboarding 的权限自动化

前 11 节讲的权限模型和测试——但实际运营里、权限变化每天都在发生:新员工入职员工离职角色调整外包到期。如果这些变化需要人工一处处改 RAG 的权限、容易错 / 慢 / 累。Onboarding / offboarding 的自动化是成熟 RAG 团队的 baseline——和 HR / IAM 的集成不能省。

入职和离职对 RAG 的直接影响

flowchart LR
    classDef evt fill:#fed,stroke:#c60
    classDef impact fill:#def,stroke:#06c

    E1[入职]:::evt --> I1[创建 user_ctx]:::impact
    E1 --> I2[分配 role / tenant]:::impact
    E1 --> I3[初始化 memory / 偏好]:::impact

    E2[离职]:::evt --> O1[撤销所有访问]:::impact
    E2 --> O2[清理 user_ctx]:::impact
    E2 --> O3[归档 memory 数据]:::impact

    E3[角色调整]:::evt --> R1[更新 permissions]:::impact
    E3 --> R2[重新 build cache]:::impact

每个事件都触发一系列 RAG 侧操作——手工做容易漏、自动化必须。

Onboarding 的自动化

新员工第一次用 RAG、需要几件事就绪:

入职 Day 0(HR 系统录入):
  → 触发 `user_created` 事件
  → IAM 分配 roles
  → RAG 侧:
      - 创建 user_ctx 模板(tenant_id + roles + 权限版本)
      - 初始化空 memory
      - 发送欢迎 prompt(可选、解释 RAG 功能)
      - 预加载默认偏好(based on job title)

Day 1(员工首次登录):
  → RAG gateway 验证 JWT
  → 加载 user_ctx
  → 可用

这套流程完全自动化——新员工入职几分钟就能用 RAG、无需手动配置。

角色变更的处理

员工从开发工程师升到 tech lead:

  • 新角色可能有新权限(能看 roadmap 等)
  • 旧角色的权限不能减(tech lead 包含开发权限)
  • 某些特殊权限可能移除(比如原来的 junior 培训视频访问)

自动化实现:

@on_event("role_changed")
async def handle_role_change(event):
    old_roles = event.old_roles
    new_roles = event.new_roles
    
    # 更新 user_ctx
    user_ctx.roles = new_roles
    user_ctx.permission_version += 1  # bump 版本触发 cache 失效
    
    # 通知下游
    await invalidate_cache(event.user_id)
    await audit_log.record({
        "event": "role_change",
        "user": event.user_id,
        "from": old_roles,
        "to": new_roles,
        "by": event.actor,
    })

permission_version bump 是核心——§7.9 讲过、让所有 cache 失效。

Offboarding 的时效要求

离职是最严的——合规要求严格:

离职类型访问撤销 SLA
正常离职当天(24h 内)
主动辞职最后工作日即刻
被解雇通知前就禁止(IT 提前操作)
违规解雇立即(分钟级)
@on_event("user_deactivated")
async def handle_offboarding(event):
    # 1. 立即禁止访问
    await user_ctx_store.set_active(event.user_id, False)
    
    # 2. 清所有 cache
    await cache.delete_by_user(event.user_id)
    
    # 3. 归档 memory(不删、留审计)
    await memory_store.archive(event.user_id)
    
    # 4. 如果是敏感场景、freeze 该用户的 RAG 日志
    if event.reason == "terminated_for_cause":
        await legal_hold(event.user_id)
    
    # 5. 审计
    await audit_log.record({...})

严格 offboarding 是合规审计的必查项——每个用户的 deactivation 必须有完整审计链

特殊用户类型

除正式员工、还有几类特殊用户:

Contractor / 外包

  • 通常有时效(合同到期日)
  • 自动过期user_ctx.valid_until 字段、到期自动失效
  • 权限通常窄于正式员工

Intern / 实习

  • 短期、可能限制某些敏感数据
  • 实习结束 → 和 offboarding 一样处理

外部合作者 / partner

  • 只能访问特定文档 / tenant
  • 需要定期 review 访问权限
  • 多因子认证必须

Service account

  • 没人类关联、长期存在
  • 密钥定期轮换
  • 访问要限定到特定操作

每类用户的 onboarding / offboarding 流程独立、不能混用。

HR / IAM 集成的标准接口

和外部系统集成的通用 API:

# Webhook 端点(供 HR 系统调用)
@app.post("/webhooks/user_event")
async def user_event_webhook(event):
    # 验证签名
    verify_signature(event)
    
    # 分派到对应 handler
    if event.type == "user_created":
        await handle_onboarding(event)
    elif event.type == "user_deactivated":
        await handle_offboarding(event)
    elif event.type == "role_changed":
        await handle_role_change(event)
    # ...

HR 系统(Workday / BambooHR / 飞书 HR)都支持 webhook——直接推事件过来。

事件丢失的补救

Webhook 可能丢(网络、超时)——对账机制必备:

@scheduled(every="1h")
async def reconcile_users():
    # 从 HR 拉全量 active user 列表
    hr_users = await hr_api.list_active_users()
    rag_users = await user_ctx_store.list_active()
    
    # 找差异
    should_be_active = set(hr_users) - set(rag_users)
    should_be_inactive = set(rag_users) - set(hr_users)
    
    for user in should_be_active:
        await handle_onboarding({"user_id": user})
    for user in should_be_inactive:
        await handle_offboarding({"user_id": user})
        alert(f"Found inactive user in RAG: {user}")  # webhook 丢过

每小时一次对账——兜底 webhook 漏事件。

权限变更的审计

每次变更进 audit log、合规审计时能回答:

  • 2026-04-20 10:00、员工 X 的权限是什么?
  • 2026-04-21 14:00、员工 X 的权限变了、谁改的、为什么?
  • 谁当前有 “查看合同” 权限?

审计查询 UI:

[ 时间 ]  [ 用户 ]    [ 事件 ]       [ 执行者 ]
4-20 9:00  bob       user_created   hr-system
4-21 14:0  bob       role_changed   hr-system (manager approved)
              from: eng → to: eng-lead
5-01 17:0  bob       deactivated    hr-system (resignation)

这个审计日志合规场景(SOC 2 / ISO 27001)必查。

内部员工 vs 客户账号

B2B / B2C 产品里、RAG 服务的”用户”可能是客户账号——不是自己员工:

  • Onboarding:客户注册时自动创建
  • 权限:基于 subscription tier(免费版 / pro / enterprise)
  • Offboarding:客户取消订阅或违反 ToS

处理逻辑类似、但数据源从 HR 变成业务 DB、SLA 可能更严(客户对服务期望高)。

测试 Onboarding / offboarding

这套自动化要测试:

def test_onboarding_full_flow():
    # 模拟 HR 事件
    simulate_hr_event({"type": "user_created", "user_id": "test-123"})
    
    # 等异步处理
    wait_for_handler(timeout=5)
    
    # 验证 RAG 里 user 已就绪
    assert user_ctx_store.exists("test-123")
    assert user_ctx_store.is_active("test-123")

def test_offboarding_cache_clear():
    # 预热 cache
    cache.set("user-123:query:xxx", "cached_answer")
    
    # 触发 offboarding
    simulate_hr_event({"type": "user_deactivated", "user_id": "user-123"})
    wait_for_handler(timeout=5)
    
    # 验证 cache 已清
    assert cache.get("user-123:query:xxx") is None

End-to-end 测试在 CI 里跑、每次改代码验证不破坏流程。

常见反模式

  • 手工操作:HR 告知后工程手动加权限——慢、易错
  • 没对账:只信 webhook、事件漏了不知道
  • Offboarding 不彻底:账号禁用了、但 cache / memory 还在
  • 审计日志缺失:合规审计失败
  • 特殊用户走正式流程:contractor 按员工 onboard、权限过大

自动化的 ROI

手工处理 vs 自动化:

  • 手工:每个事件 15-30 分钟、20 人 = 5-10 小时 / 周
  • 自动化:初期建设 2-3 人月、后续运维几乎零成本

团队规模上了百人、自动化几乎必做——手工不可持续。

合规是硬需求

GDPR / SOC 2 / ISO 27001 都要求:

  • 访问权限变更及时生效
  • 每次变更可审计
  • 离职后访问撤销在 SLA 内
  • 定期审计权限合规性

没自动化 = 合规风险。中大型公司不做 = 审计过不了。

7.13 跨组织共享:B2B 和 partner 访问

前面章节的权限默认单一组织内部——员工、客户、合约工。真实企业有更复杂的场景:和合作伙伴共享部分知识(供应商 / 咨询顾问 / 审计方)、给 B2B 客户开放 RAG(大客户带自己的员工接入)、和子公司互相授权。这些跨组织场景的权限模型不是简单的 RBAC 就能覆盖——需要专门的设计。

B2B 访问的典型场景

flowchart TB
    classDef org fill:#fed,stroke:#c60
    classDef share fill:#def,stroke:#06c

    A[组织 A]:::org
    B[组织 B]:::org

    A -.授权.-> S[共享知识空间]:::share
    B -.授权.-> S
    S --> U1[A 员工]
    S --> U2[B 员工]

几种典型:

  • 供应商访问:A 让供应商 B 看某些产品规格(但不是全部)
  • 咨询顾问:律所 / 会计师 / 顾问获得临时访问
  • B2B SaaS:A 是 SaaS、B 是客户、B 员工用 A 的 RAG
  • 合资 / 子公司:互相授权部分知识
  • 客户成功:A 员工代 B 员工查询(权限继承)

每种场景权限模型不同——不能一刀切。

跨组织权限的挑战

比内部权限难在哪:

  • 多 IAM 对接:A / B 各有 IAM、信任基础不同
  • 临时性:很多跨组织访问有时限(合同 / 项目结束)
  • 追责:B 员工看了不该看的——谁负责?
  • 合规区域:A 在欧盟、B 在美国、数据主权要管
  • 信任边界:A 不完全信任 B、给的权限要小

每项都需要专门的工程和法务设计。

跨组织权限模型

典型设计:两层授权

{
  "subject": {
    "external_org_id": "partner-acme",
    "external_user_id": "bob@acme.com",
    "role_in_own_org": "admin",
  },
  "granted_by": {
    "my_org_admin": "alice@mycompany.com",
    "scope": "read:product-specs",
    "valid_until": "2026-06-30",
  },
  "revocable_anytime": true,
}
  • 外部身份:由合作方的 IAM 认证(SSO / SAML 联合登录)
  • 本方授权:我方明确授予的 scope、有过期
  • 随时撤销:合作结束立即关

这种模型清晰——谁给谁的权限、为什么、到什么时候一目了然。

临时访问 / 过期链接

某些场景不是”给用户长期权限”、是”给特定文档一次性访问”:

律所 Alice 发邮件:我需要看这份合同

我们生成临时链接(token 包含文档 ID + 过期时间 + 使用次数)

Alice 点链接、不需要注册账号、直接看(或问 RAG)

链接用完或过期自动失效

类似云盘的”分享链接”——但用于 RAG 查询。好处:

  • 不用给对方创建账号
  • 细粒度控制(看哪份文档)
  • 天然过期

风险:链接泄漏即失控——所以要短时间有效 + 次数限制 + 可远程撤销

SSO 联合登录(SAML / OIDC)

正式合作伙伴关系用联合登录:

  • A 公司员工用 A 公司的 SSO 登录 A 的 RAG(内部)
  • B 公司员工用 B 公司的 SSO 登录 A 的 RAG(B 的身份 + A 给的权限)

技术:SAML / OIDC federation——两边 IAM 建立信任关系:

  • B 的用户登录 → B 的 IdP 签 JWT → A 的服务验签
  • A 根据 JWT 里的 org_id 和 email 查”给 B 哪些权限”
  • 返回答案

这让B 员工用自己公司的密码登录 A 的系统——体验好、安全。

合作伙伴的 onboarding

给新合作伙伴开通 RAG 访问:

flowchart LR
    classDef step fill:#def,stroke:#06c

    S1[法务谈 NDA]:::step --> S2[合同 / SoW 签]:::step
    S2 --> S3[技术对接 IAM]:::step
    S3 --> S4[配置 scope]:::step
    S4 --> S5[试运行 1-2 周]:::step
    S5 --> S6[正式开通]:::step

每一步都有明确 deliverable 和签字——不是”口头答应给访问”。

数据主权的边界

跨组织访问涉及数据驻留

  • A 的数据物理存于中国、B 是美国公司——中国法律可能禁止某些数据出境
  • A 的数据含 EU 公民 PII、B 在美国——GDPR 限制
  • 合规方不同步 → 冲突

解决:

  • 数据分级:不同级别的数据允许不同范围共享
  • 物理隔离:敏感数据只在本国的副本、跨境只给非敏感
  • 法务审核:跨境访问走法务 review、不是工程单方决定

追责和审计

跨组织的审计比内部严:

  • 完整 trace:每次 B 员工的访问都记 trace、存档备合规查
  • 定期 report:每月给 A 管理层一份”B 访问了什么”报告
  • 异常告警:B 在非工作时间大量访问——自动告警
  • 销毁记录:合作结束后、定期清理 B 的 session / cache / trace

没这套审计、合作关系一旦出事(数据泄漏 / 合规问题)——无法追溯

合作方的 RAG 体验

合作方不一定用专门 UI——可能通过:

  • API 接入:B 的产品调 A 的 RAG API、B 员工间接用
  • Embed UI:A 提供一个 iframe 嵌到 B 的应用里
  • SSO 直登 A 的 UI:B 员工用 A 的界面

每种模式的权限约束不同——API 模式要有 quota / rate limit、embed 模式要有 iframe 安全。

计费和 quota

B2B 跨组织访问通常计费

  • 按 query 数计费
  • 按 seat 计费
  • 包月订阅

实现:

  • 每个 query 附 org_id——根据 org 算账单
  • Quota:超过 quota 的请求被 reject
  • 账单透明度:B 方能看自己当月用量

这是 B2B SaaS RAG 产品的基本功能——参考 §21.18 多租户成本归因。

撤销权限的工程

合作结束、要撤销 B 的访问:

async def revoke_partner_access(partner_org_id):
    # 1. 标记 partner 为 deactivated
    await partner_registry.deactivate(partner_org_id)
    # 2. invalidate 所有该 org 的 token
    await auth_service.invalidate_tokens(org=partner_org_id)
    # 3. 清 cache
    await cache.purge_by_org(partner_org_id)
    # 4. 归档(不删)访问日志
    await audit_log.archive_org_access(partner_org_id)
    # 5. 通知
    await send_notification(f"{partner_org_id} access revoked")

分钟级完成——合同结束到访问终止不应超 24 小时。

共享知识的 curation

跨组织共享的知识本身也要特别处理:

  • 标记 shareable_with_partners: true 的文档才能被 B 看到
  • 某些敏感词 / 章节自动 redact
  • 共享的数据版本和内部可能不同(外部版本简化 / 脱敏)

不是内部所有文档都适合共享——需要明确 curation 策略。

合作方投诉和 SLA

合作方对 RAG 的期望和内部员工不同:

  • 付费客户要求 SLA(99.9% 可用性 + 赔偿条款)
  • 合同里约定 P99 延迟 / 支持响应时间
  • 出事故要有赔偿

这意味着跨组织访问的SLA 可能严于内部——资源分配上给 B 更稳的 pool(ch4 §4.11 资源隔离)。

常见反模式

  • 临时访问变永久:给了链接忘了过期、几个月后还能用
  • 不区分内外:对 B 员工等同内部员工对待、权限过大
  • 审计日志混在一起:B 访问记录不单独存、合规审计难
  • 出事找不到负责人:给权限时没记 granted_by、追责难
  • 撤销不彻底:账号禁了、cache / session 还在

跨组织权限的组织责任

跨组织不只是技术——涉及多角色:

  • 销售:带回合作机会、给业务目标
  • 法务:谈合同、审合规
  • 工程:技术对接
  • 运维:权限配置 / 监控
  • 安全:审查安全风险

跨组织访问的 kick-off 会必须有这五方——少一方就有遗漏。

一个真实 B2B 事故

某 SaaS 给企业客户开通 RAG、客户员工反馈”能查到别客户的数据”。调查:

  • 工程用了 user_id 作 scope、忘了加 org_id
  • 客户 X 的员工 A 和客户 Y 的员工 B 的 user_id 碰巧连续、引用泄漏
  • 未发现的原因:cache key 含 user_id、但上游 query 结果是跨 org 的

修复:所有 scope filter 必须含 org_id、不只 user_id。测试用例里加跨 org 污染检查。

教训:跨组织的边界是工程设计的 deepest invariant——比内部权限要严得多。

投资对应规模

跨组织功能的工程投入:

  • SSO 联合登录:2-4 人周
  • 临时访问链接:1-2 人周
  • 审计和 report:2-3 人周
  • 撤销 / 清理:1-2 人周
  • 合规对接:需要法务时间、几周到几月

合计 2-3 人月起步——对 B2B 产品是必需、对纯内部项目无关。

7.14 跨书关联:从搜索系统继承权限思想

RAG 的 metadata + 权限模型几乎完全继承自企业搜索(Enterprise Search)。Google Search Appliance、Elastic 的 document-level security、Solr 的 post-filter security 讨论的问题和 RAG 一样。主要差异是 RAG 多了一层”生成”——如果权限过滤不严,不仅检索结果泄漏,LLM 还会把泄漏信息”自然语言地”说出来,杀伤力倍增。

这也是为什么 Enterprise Search 的三十年经验直接可用——权限模型、RBAC/ABAC 实现、audit log 这些子系统都不用重造。RAG 做好的关键是不重造这些轮子、专注在检索 + 生成的新工程问题上

7.15 本章小结

  1. Metadata 是 RAG 索引的一等公民——没有 metadata 的 chunk 只能做语义相似,做不了”谁看什么”
  2. 三层 metadata:身份层(不可变)、业务层(可更新)、安全层(变更强制同步)
  3. 权限必须索引时嵌入 + 向量库 filter 下推——检索后过滤会召回污染、泄露存在性、浪费 rerank
  4. RBAC + 属性过滤混合覆盖 90% 企业场景
  5. 多租户三档隔离:弱(共享 + tenant_id)/ 中(每租户 collection)/ 强(每租户独立实例)——按客户分级
  6. PII 和合规 metadata 是底线——识别、标记、脱敏、审计四件套
  7. user_context 在 gateway 层构造,RAG 服务只消费——避免延迟和一致性问题

下一章讨论增量索引——metadata 和权限已就位,文档变了怎么更新、删了怎么清理、schema 变了怎么迁移。