作者:来自 Elastic Sohan Maheshwar, Carly Richmond
工程部门第一次与销售部门共享文档时,部门筛选机制就会失效,因此,在你的 Elasticsearch 检索增强生成 流程将检索到的内容块传递给大语言模型之前,SpiceDB 会根据关系图逐一检查每个内容块。
通过这个面向搜索 人工智能 的自主式实践学习(self-paced hands-on learning),亲自尝试向量搜索。你现在就可以开始免费云试用(free cloud trial),或在本地计算机(local machine)上试用 Elastic。
你可以使用 SpiceDB 在关系图中实现细粒度授权。授权逻辑不应放在你的 k 近邻(kNN)查询中,更不应放在应用程序代码中。Elasticsearch 根据相关性检索内容,然后在大语言模型(large language model)看到这些内容之前,根据关系图检查每个内容块。共享文档只需写入一条关系,撤销共享只需删除一条关系,两者都不会改动索引。授权本身在一个确定性的 LangGraph 节点中执行,agent 无法绕过该节点,因为模型可能被诱导,在本不应授予访问权限时授予权限。
为什么粗粒度权限在检索增强生成流程中会失效
细粒度访问控制可能很难实现。例如,假设你已经基于 Elasticsearch,为内部知识库构建了一套检索增强生成(retrieval augmented generation)流程。需求相对直接:一个人工智能聊天机器人前端,从十万份经过分块并转换为 dense_vector 嵌入表示(embeddings)的文档中检索答案。权限也相当简单,只需在 kNN 查询中使用一个 department 关键字筛选条件。
现实需求往往复杂得多。也许工程团队只想与销售团队共享一份架构文档,而某位高管需要获得特殊授权,以访问其部门之外的一份文档。你会如何在检索增强生成流程中解决这些问题?
权宜之计很快就会变得难以管理。你可以为每个请求创建一个新的 department,但这会导致角色数量激增,而且系统一有变化,这些角色就会过时。你也可以添加大量条件分支语句,但这并不是个好主意。将安全决策编码到既难以审计、又难以扩展的应用程序代码中,是引发数据泄露的原因之一,也是你应该防范的做法。对于企业以及金融、公共部门等受合规监管的行业,这一点尤其重要。
最新的开放式网络应用程序安全项目大语言模型风险清单(OWASP list for LLM risks)指出了多种普遍存在于行业中、专门针对 大语言模型 应用程序的漏洞,包括:
- 敏感信息泄露。
- 过度代理权限。
- 向量与嵌入弱点。
那么,如何为你的检索增强生成流程添加细粒度权限?答案在于基于关系的访问控制。
迈向基于关系的访问控制的未来:基于关系的访问控制如何运作
2019 年,谷歌发表了《Zanzibar:谷歌的一致性全球授权系统》(Zanzibar: Google's Consistent, Global Authorization System),介绍了支撑谷歌数百项服务的授权系统,包括日历、云端硬盘、地图、相册和 YouTube。如果你使用过这些服务,你的请求很可能已经接受过 Zanzibar 的检查。
这篇论文在身份与访问管理领域引起了轰动。它也被广泛认为推动了基于关系的访问控制的普及,这种方式有别于当时大多数团队使用的模型,包括基于角色的访问控制和限定作用域的令牌。
基于关系的访问控制的核心思想是:权限由_主体_(用户、 agent 、群组)与_资源_(文档、文件夹、项目)之间的_关系_决定。你可以将这些关系存储为一张图:节点代表对象,边代表 “查看者” “编辑者” 或 “所有者” 等关系。这样,权限检查就归结为一个问题:
这个操作主体(actor)是否有权对这个资源(resource)执行这项操作(action)?
你通过检查图中是否存在一条路径来回答这个问题,无须将访问决策硬套进某个角色。
下面是一个实际示例。当 user:jordan 在谷歌文档中创建 document:somedocument 时,Zanzibar 会通过一个关系元组,记录乔丹是该文档的写入者:
document:somedocument#writer@user:jordan
如果乔丹随后将文档以只读权限共享给 user:alex,系统就会创建另一个元组:
document:somedocument#reader@user:alex
还有一条规则:每个写入者也都是读取者。从概念上看,Zanzibar 现在看到的关系图如下:
当 Jordan 尝试读取 somedocument 时,系统会查找一条从文档的 reader 关系指向乔丹的有向路径。虽然没有直接指向乔丹的 reader 边,但 reader 包含 writer,而乔丹是写入者。找到这条路径后,乔丹就可以读取文档。如果不存在这样的路径,乔丹就会看到那个让我们都头疼的“你需要访问权限”页面。
现在轮到你了。亚历克斯能_写入_同一份文档吗?
下次你在谷歌产品中打开(或无法打开)文件时,你就会知道幕后发生了什么。
这与我的检索增强生成流程有什么关系?
Zanzibar 的设计目标是针对按层级组织的机构数据,执行高吞吐量、低延迟的授权检查。事实证明,agent 和检索增强生成流程等人工智能应用恰好需要这种能力。回顾本文开头的需求,基于角色的访问控制或限定作用域的令牌等粗粒度方案,无法灵活支持逐文档共享或跨部门访问,也无法大规模处理个别例外授权。用户与其可访问资源之间的关系构成了一张不断演变的图,而这正是基于关系的访问控制所解决的问题。
但除非你在谷歌工作,否则你实际上无法使用 Zanzibar。幸运的是,它有开源实现。我们将使用 SpiceDB,它的名字源自《沙丘》中的名句:“香料必须流动/The spice must flow.” 这里介绍的技术并非仅适用于检索增强生成;你也可以用它们为企业应用程序、人工智能 agent 以及其他任何你想保护的资源添加权限控制。
定义 SpiceDB 模式、关系和权限
顾名思义,基于关系的访问控制建立在系统中各对象之间的关系之上。因此,为应用程序添加权限控制时,第一步是定义 SpiceDB 模式。它描述了应用程序中的对象类型,以及这些对象之间可以建立怎样的关系。它还定义了从这些关系中派生的权限。对于我们的检索增强生成流程,模式如下:
`
1. definition user {}
3. definition department {
4. relation member: user
5. }
7. definition document {
8. relation owner: user
9. relation viewer: user | department#member
11. permission view = viewer + owner
12. permission edit = owner
13. }
`AI代码解释
这里有不少内容需要逐一理解:
- 三种对象类型分别是用户(
user)、部门(department)和文档(document)。 - 关系如下:
- 用户(
user)可以是部门(department)的成员(member)。 - 用户(
user)可以是文档(document)的所有者(owner)。 - 用户(
user)可以是文档(document)的查看者(viewer)。
- 用户(
- 如果你是文档的查看者(
viewer)或所有者(owner),就可以查看(view)该文档。查看者(viewer)权限可以通过两种方式授予:授予特定用户,_或_授予某个部门的所有成员(department#member)。 - 只有文档(
document)的所有者(owner)才能编辑(edit)它。
模式描述了系统的结构,而且很少发生变化。
在 SpiceDB 中,一个可运行的权限系统由模式和关系共同组成。_关系实例_是某种关系的具体实例。例如,“用户乔丹是文档 readme 的所有者/user jordan is an owner of document readme”可以写成以下关系元组:
document:readme#owner@user:jordan
如果你想表示 alice 属于 engineering,而且 engineering 中的所有人都可以查看 eng-arch-001,该怎么做?这同样只需通过关系来表达:
department:engineering#member@user:alice(“爱丽丝是工程部门的成员。/Alice is a member of engineering.”)document:eng-arch-001#viewer@department:engineering#member(“工程部门的每位成员都可以查看这份文档。/Every engineering member can view this doc.”)
因此,当 alice 点击链接查看 eng-arch-001 时,SpiceDB 会按如下方式遍历关系图:
如果不存在路径,访问就会被拒绝。
这就是 SpiceDB 中权限检查的工作方式。在执行任何操作之前,你都要调用 CheckPermission 应用程序编程接口:
`
1. from authzed.api.v1 import (
2. CheckPermissionRequest,
3. CheckPermissionResponse,
4. Client,
5. ObjectReference,
6. SubjectReference,
7. )
8. from grpcutil import insecure_bearer_token_credentials
10. client = Client("localhost:50051", insecure_bearer_token_credentials("devtoken"))
12. response = client.CheckPermission(
13. CheckPermissionRequest(
14. resource=ObjectReference(object_type="document", object_id="eng-arch-001"),
15. permission="view",
16. subject=SubjectReference(
17. object=ObjectReference(object_type="user", object_id="alice")
18. ),
19. )
20. )
21. allowed = (
22. response.permissionship
23. == CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION
24. )
`AI代码解释
我们将在检索增强生成流程中使用同样的检查,确保用户获得的答案仅基于他们有权查看的数据。
访问规则变化时,写入关系而非角色
还记得我们的检索增强生成项目的需求是如何变化的吗?在基于角色的系统中,这意味着重构角色,或在代码中埋入更多授权逻辑。而使用基于关系的访问控制和 SpiceDB 时,每条新的访问规则都只是一条新的关系:
| 对比项 | 基于角色的访问控制/限定作用域的令牌(RBAC / scoped tokens) | 使用 SpiceDB 的基于关系的访问控制(ReBAC with SpiceDB) |
|---|---|---|
| 访问权限的表达方式 | 分配给用户的角色 | 主体与资源之间的关系 |
| 权限检查的问题 | 该用户是否拥有此角色? | 图中是否存在路径? |
| 为某个人授予一次性例外权限 | 新建角色 | 写入一条关系 |
| 与另一个团队共享 | 为每种组合新建角色 | 通过 department#member 写入一条关系 |
| 访问权限发生变化时 | 重构角色或修改筛选条件 | 写入或删除一条关系 |
| 逻辑所在位置 | 应用程序代码 | SpiceDB |
如果工程团队想与销售团队共享一份文档,你可以添加以下关系:
document:eng-arch-001#viewer@department:sales#member
你也可以为某位高管创建单独的例外授权,如下所示:
document:sales-proposal-001#viewer@user:ceo
在基于角色的访问控制中,你需要创建新角色或重写筛选条件,而在 SpiceDB 中,上述每项操作都只需调用一次 WriteRelationships。下面是与销售团队共享文档的示例:
`
1. from authzed.api.v1 import (
2. ObjectReference,
3. Relationship,
4. RelationshipUpdate,
5. SubjectReference,
6. WriteRelationshipsRequest,
7. )
9. client.WriteRelationships(
10. WriteRelationshipsRequest(
11. updates=[
12. RelationshipUpdate(
13. operation=RelationshipUpdate.Operation.OPERATION_TOUCH,
14. relationship=Relationship(
15. resource=ObjectReference(object_type="document", object_id="eng-arch-001"),
16. relation="viewer",
17. subject=SubjectReference(
18. object=ObjectReference(object_type="department", object_id="sales"),
19. optional_relation="member",
20. ),
21. ),
22. )
23. ]
24. )
25. )
`AI代码解释
这不需要创建新角色、修改代码或重新索引。OPERATION_TOUCH 使写入操作具有幂等性;也就是说,它会创建关系,如果关系已存在,则保持不变。
将 SpiceDB 接入你的 Elasticsearch 检索增强生成流程
让我们把这些整合起来。将内容块索引到 Elasticsearch 时,你会在每个嵌入向量旁存储元数据,标明它来自哪份文档。在我们的代码仓库中,这对应 doc_id 字段。它与 SpiceDB 用于 document 资源的标识符相同,也是连接这两个系统的纽带。
查询流程如下:
- 用户的查询经过嵌入模型(我们的代码仓库中使用 Mistral),生成查询嵌入向量。
- Elasticsearch 执行 k 近邻搜索,检索最相关的内容块。
- 在将文档传递给大语言模型之前,我们将每个内容块的
doc_id和用户标识符发送给 SpiceDB。 - 根据已定义的规则,我们会丢弃所有被 SpiceDB 判定为用户无权查看的内容块。
- 获准访问的文档会被放入提示词中。对于基于 agent 的检索增强生成,相关文档会作为工具输出传递给大语言模型。
这种模式称为_检索后过滤授权_:先检索,再过滤。检查发生在内容传递给大语言模型之前,以确保未经授权的文档不会通过 上下文窗口 传入。这就是安全边界。任何传入模型的内容,都可能出现在它的回答中。
以下是代码仓库中的检索步骤(节选):
`
1. # query_embedding comes from the Mistral embedding model
2. response = es_client.search(
3. index="documents",
4. knn={
5. "field": "embedding",
6. "query_vector": query_embedding,
7. "k": 5,
8. "num_candidates": 50,
9. },
10. source=["doc_id", "title", "content"],
11. )
13. documents = [
14. Document(
15. page_content=hit["_source"]["content"],
16. metadata={"doc_id": hit["_source"]["doc_id"], "title": hit["_source"]["title"]},
17. )
18. for hit in response["hits"]["hits"]
19. ]
`AI代码解释
注意,查询中没有权限逻辑。Elasticsearch 查找相关内容,SpiceDB 决定谁有权查看。
接下来是授权步骤。下面是代码仓库中的 authorization_node。它将检索到的文档交给 langchain-spicedb 软件包中的 SpiceDBAuthorizer,后者通过一次 CheckBulkPermissions 调用,在 SpiceDB 中检查所有文档的访问权限:
`
1. async def authorization_node(state: AgenticRAGState) -> dict:
2. """
3. Deterministic authorization node - ALWAYS runs, cannot be bypassed.
4. """
5. authorizer = _get_authorizer()
7. logger.info(
8. "Starting authorization",
9. extra={
10. "subject_id": state["subject_id"],
11. "document_count": len(state["retrieved_documents"]),
12. },
13. )
15. result = await authorizer.filter_documents(
16. documents=state["retrieved_documents"],
17. subject_id=state["subject_id"],
18. )
20. logger.info(
21. "Authorization results",
22. extra={
23. "authorized": result.total_authorized,
24. "denied": len(result.denied_resource_ids),
25. "denied_doc_ids": result.denied_resource_ids,
26. },
27. )
29. return {
30. "authorized_documents": result.authorized_documents,
31. "denied_count": len(result.denied_resource_ids),
32. "authorization_passed": result.total_authorized > 0,
33. "messages": [
34. SystemMessage(
35. content=f"Authorization: {result.total_authorized}/{result.total_retrieved} documents authorized"
36. )
37. ],
38. }
`AI代码解释
_get_authorizer() 会一次性配置授权器,使用每份文档的 doc_id 元数据作为资源标识符,检查用户(user)主体对文档(document)资源的查看(view)权限。只有在 SpiceDB 中确认存在路径的文档,才会出现在返回的 result.authorized_documents 中。缺少 doc_id 的文档,或单项权限检查出错的文档,都会被丢弃。如果完全无法连接 SpiceDB,调用就会抛出异常,流程会在生成之前停止。
Elasticsearch 的文档级安全功能呢?
Elasticsearch 内置了文档级安全功能(Elasticsearch has document-level security built in),当访问权限可以通过针对文档字段的查询来表达时,例如 department: engineering,它就非常适用。当访问权限取决于引言中提到的关系,例如针对个别用户的例外授权或通过群组共享时,处理起来就会更加困难。其他例子还包括从文件夹和组织继承的访问权限。你必须将这张关系图反规范化,写入每份文档,并将其映射到已定义的基于角色的访问控制角色。每次共享关系发生变化时,你还必须重新索引。SpiceDB 将关系图集中保存在一处,并为你的检索增强生成流程、应用程序和 agent 提供一致的权限判断结果。
绝不要让 agent 决定是否授权
该流程遵循清晰的结构:检索 → 授权 → 生成(可包含推理循环),各步骤均构建为 LangGraph 节点。
核心规则很简单:agent 可以推理该检索什么或生成什么,甚至可以推理如何解释访问被拒绝的原因。但它绝不能推理_是否_需要执行授权检查。
大语言模型本质上具有概率性。如果由模型决定是否检查权限,精心构造的提示注入就可能诱导它跳过检查,从而泄露数据。权限强制执行逻辑位于模型之外的确定性节点中,agent 无法绕过该节点。该节点还必须在发生故障时默认拒绝访问。出现错误或不存在路径,都意味着无权访问。
下面展示了如何使用 LangGraph,通过入口、边和终点将这三个节点连接起来:
`
1. from langgraph.graph import END, StateGraph
3. workflow = StateGraph(AgenticRAGState)
5. # Add nodes
6. workflow.add_node("retrieve", retrieval_node)
7. workflow.add_node("authorize", authorization_node) # ALWAYS runs
8. workflow.add_node("generate", generation_node)
10. # Define flow - start directly at retrieval
11. workflow.set_entry_point("retrieve")
12. workflow.add_edge("retrieve", "authorize") # Deterministic auth
13. workflow.add_edge("authorize", "generate")
14. workflow.add_edge("generate", END)
16. graph = workflow.compile()
`AI代码解释
从 retrieve 到 generate 之间没有直接连接的边。通往大语言模型的唯一路径必须经过 authorize。代码仓库还添加了一个可选的 reason 节点用于重试(这就是 agent 发挥作用的部分),但每条路径仍然必须先经过授权检查。
每个 LangGraph 节点都只是一个函数。你在上一节中已经看过 authorization_node,下面是 generation_node:
`
1. def generation_node(state: AgenticRAGState) -> dict:
2. """Generate final answer incorporating authorization context.
4. This node creates the final response, ensuring transparency
5. about what information was accessible and what was denied.
6. """
7. with log_node_execution(
8. logger,
9. "generation",
10. {
11. "subject_id": state["subject_id"],
12. "authorized_count": len(state["authorized_documents"]),
13. "denied_count": state["denied_count"],
14. }
15. ):
16. llm = get_llm()
18. # Format documents with clear boundaries
19. if state["authorized_documents"]:
20. doc_parts = []
21. for i, doc in enumerate(state["authorized_documents"], 1):
22. doc_parts.append(
23. f"--- Document {i} ---\n"
24. f"Title: {doc.metadata['title']}\n"
25. f"Content: {doc.page_content}\n"
26. f"--- End Document {i} ---"
27. )
28. docs_text = "\n\n".join(doc_parts)
29. else:
30. docs_text = "No authorized documents available."
32. prompt = ChatPromptTemplate.from_messages(
33. [
34. ("system", GENERATION_PROMPT),
35. ]
36. )
38. chain = prompt | llm
40. result = chain.invoke(
41. {
42. "query": state["query"],
43. "authorized_count": len(state["authorized_documents"]),
44. "denied_count": state["denied_count"],
45. "documents": docs_text,
46. }
47. )
49. logger.info(
50. "Generated answer",
51. extra={
52. "answer_length": len(result.content),
53. },
54. )
56. return {
57. "answer": result.content,
58. "messages": [AIMessage(content=f"Answer: {result.content}")],
59. }
`AI代码解释收起代码块
生成节点只读取 authorized_documents,因此被拒绝的内容绝不会进入提示词。GENERATION_PROMPT 指示模型仅根据这些文档回答。它还会传递被拒绝的文档数量,让回答能够指出部分信息无法访问(而不是假装这些信息不存在)。切勿返回被拒绝文档的详细信息,因为这些细节可能泄露用户无权访问的信息。
将细粒度授权投入生产环境
如何从概念验证走向生产级实现?以下是我们的建议:
-
通过超额检索弥补后置过滤造成的召回损失。(Overfetch to recover post-filter recall.) 后置过滤可能导致大语言模型缺乏足够的上下文。例如,用户询问“微服务模式”,但排名前五的命中结果中,有四个指向其无权查看的文档,那么大语言模型就只能依据一份文档作答。解决方法是超额检索:检索 k × N 个候选结果(其中,k 是你希望获得的文档数量,N 是你调整的倍数),然后执行权限检查,保留通过检查的前 k 个结果。注意,超额检索可以提高获得足够结果的概率,但不能保证一定有 k 个结果通过检查。只有当新的 k 超过
num_candidates时,才需要调整该参数;如果不设置,它默认采用 1.5 × k(未设置 k 时则采用 1.5 ×size),并自动随之调整。候选数量参数的上限为 10,000(num_candidates is capped at 10,000),因此超额检索也有上限。如果用户只能查看语料库中极少的一部分,以至于所需候选数量超过此上限,SpiceDB 提供了LookupResources应用程序编程接口,可查找特定主体能够访问的某一类型的全部资源。它最适合结果集规模适中的情况:SpiceDB 文档提醒,结果数量超过约 10,000 后,开销会变得很高。在这种情况下,文档建议使用CheckBulkPermissions进行后置过滤。通常,你可以使用LookupResources获取用户可访问的文档标识符列表,然后将其作为词项预筛选条件,传入 k 近邻查询的filter参数(词项数量最多可达到index.max_terms_count的默认值 65,536)。 -
添加粗粒度元数据预筛选条件。(Add a coarse-grained metadata pre-filter.) 授权很少是检索增强生成流程中的唯一约束。你可能还希望按部门、时间范围或分类限制结果。Elasticsearch 的 k 近邻查询会在向量搜索(vector search)过程中通过
filter应用这些条件,避免让最终会被丢弃的文档占用前 k 个结果的名额。之后,SpiceDB 仍会作出细粒度权限判断:
`
1. response = es_client.search(
2. index="documents",
3. knn={
4. "field": "embedding",
5. "query_vector": query_embedding,
6. "k": 5,
7. "num_candidates": 50,
8. # Coarse pre-filter: this assistant never retrieves confidential docs
9. "filter": {"terms": {"classification": ["public", "internal"]}},
10. },
11. source=["doc_id", "title", "content"],
12. )
`AI代码解释
-
让关系与权威数据源保持同步。(Keep relationships in sync with the source of truth.) 在代码仓库中,初始化脚本会预先填充关系;但在生产环境中,这些关系来自实际管理访问权限的系统,例如文档系统或人力资源目录,也可能来自身份提供商。共享文档或有人加入团队时,写入一条关系;撤销访问权限时,删除相应关系。由于权限保存在 SpiceDB 中,因此这些操作都不需要重新索引。
-
部署 SpiceDB 和 Elasticsearch。(Deploy SpiceDB and Elasticsearch.) 代码仓库在本地 Docker 容器中运行 SpiceDB 和 Elasticsearch,其中 SpiceDB 将关系存储在内存中。生产环境中的 SpiceDB 部署需要 PostgreSQL 等持久化数据存储。你还需要选择运行 SpiceDB 的环境。目前主要有三种部署选项:
- 自行托管 SpiceDB(开源)。(SpiceDB, self-hosted (open source).) 完全在你自己的基础设施上运行和管理。
- AuthZed 云服务。(AuthZed Cloud.) 提供托管、自助服务、按用量付费的 SpiceDB,内置审计日志等企业功能。
- AuthZed 专属服务。(AuthZed Dedicated.) 在你选择的云服务商和区域中配置的私有单租户部署。
同样,Elasticsearch 也可以配套部署,采用 Elastic 云托管服务、Elastic 云无服务器服务(Serverless)或本地部署发行版,具体可参阅文档中列出的选项(options outlined in the documentation)。
Elasticsearch 负责检索,SpiceDB 负责授权
Elasticsearch 查找最相关的内容,SpiceDB 决定谁有权查看。确定性的授权节点确保用户获得的回答仅基于其有权访问的信息。
亲自尝试:
- 克隆示例代码仓库(sample repository),按照自述文件中的说明,使用 Docker Compose 启动 SpiceDB 和 Elasticsearch。
- 以两个不同用户的身份提出相同问题,比较各自得到的回答。
- 阅读带注释的 Zanzibar 论文(annotated Zanzibar paper)。
原文:Fine-grained authorization in Elasticsearch RAG with SpiceDB | Elasticsearch Labs