ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

告别API Key!用Entra ID RBAC保护Azure AI Search访问

告别API Key!用Entra ID RBAC保护Azure AI Search访问

1. 为什么放弃API Key?Entra ID RBAC认证要解决什么问题

在Azure AI Search的日常使用里,API Key几乎是大部分人上手时的默认选择。控制台里点两下就生成一个Key,代码里塞进Header就能调通,确实简单。但一旦项目进入多人协作、自动化部署、多环境隔离的阶段,API Key的问题会非常让人头疼。

我在实际项目里就遇到过这些场景:一位开发者在本地调试时把Key硬编码在配置文件中,结果代码提交到了共享仓库,Key被同事无意间同步到了自己的环境里。还有一次,轮换Key之后,生产环境忘了更新配置,整个搜索服务在业务高峰期直接不可用。API Key本质上是一个“无限权限”的静态凭证,只要泄露,就等于把搜索服务的数据读取能力完整交给了攻击者,而且你无法追溯是谁在使用它。

Entra ID RBAC(基于角色的访问控制)则完全换了一套思路。它不再用一串静态字符串来识别调用方,而是让调用方先通过Entra ID完成身份认证,拿到一个短期有效的访问令牌,再用这个令牌去调用Azure AI Search的服务。令牌的权限范围由RBAC角色决定,比如你是只读角色还是读写角色,一目了然。而且令牌有过期时间、可以绑定到具体的应用或用户,一旦泄露,影响面被大大缩小。

更关键的是,RBAC认证对国内区部署的Azure AI Search同样适用。国内区很多企业客户对合规和数据边界要求很高,API Key那种“一把钥匙管全部”的模式在审计和权限管控上很难过关。改用Entra ID RBAC之后,谁在什么时间用什么身份访问了搜索服务,都能通过Entra ID的日志体系追踪到,这对企业内部安全审计是实打实的硬需求。

2. 动手前的设计:身份选择与权限规划

2.1 先想清楚:是给“人”用,还是给“应用”用

这是最容易忽略的一步。很多教程上来就让你创建应用注册、生成客户端密钥,却没说清楚这套流程到底适合什么场景。

如果你的使用者是开发者本人,比如在本地调试代码、用Postman测试接口,那大概率适合用“用户身份”走交互式登录。这种情况下,代码或工具会跳出登录弹窗,用户输入账号密码完成认证,然后拿到令牌。这样做的好处是无需维护任何客户端密钥,权限跟着个人账号走,出了问题也能直接定位到人。

如果你的调用方是一个部署在云上的应用、函数、容器,或者一台CI/CD流水线里的构建机,那就需要用“应用身份”。应用本身没有交互式登录能力,只能靠客户端密钥或证书来证明自己是谁。这也是生产环境中用得最多的一种方式。

我在给某公司的搜索服务做改造时,一开始直接把开发者的个人账号用在了服务到服务(server-to-server)的调用里,结果后面这个开发者离职,他的账号被禁用,所有依赖他的搜索请求全部瞬间401。后来才改成了独立的应用注册,权限和生命周期都独立管理,再也没出过这种问题。

2.2 内置角色别乱选,按权限边界来定

Azure AI Search提供了两个与数据平面紧密相关的内置角色:Search Index Data Reader(搜索索引数据读取者)和Search Index Data Contributor(搜索索引数据贡献者)。

只看名字容易产生误解。Data Reader并不代表能读取搜索服务的所有配置,它只允许调用方查询索引和执行搜索请求,比如发起POST /indexes/{indexName}/docs/search这类操作。它不能创建索引、不能改索引结构、不能看服务本身的设置。Data Contributor则在Data Reader基础上增加了维护索引数据的权限,可以新增、修改、删除索引中的文档,但同样不能修改索引的Schema。

这两个角色之外的“Search Service Contributor”属于管理平面角色,管理整个搜索服务的生命周期,包括创建、扩容、删除搜索服务。日常业务代码里几乎用不到这个角色,如果给应用分配了这个角色,反而会扩大攻击面,完全没必要。

2.3 官方角色不够用?自定义角色是备选方案

如果内置角色满足不了细粒度需求,比如你想让某个应用只能访问特定几个索引,不能访问其他索引,内置角色做不了这件事。这时需要自定义RBAC角色,在Azure门户里通过JSON定义权限范围,把DataReader或DataContributor的actions限定在具体索引资源路径下。

自定义角色是“最后一道手段”,它确实灵活,但配置复杂度也上来了。我的建议是:绝大多数场景先用内置角色,等技术团队对RBAC模型足够熟悉、权限边界确实有更细粒度的需求时,再考虑自定义角色。不然分布式团队搞出几十个自定义角色,最后没人说得清楚每个角色的具体范围,又是一笔运维负担。

3. 一步步配置:从应用注册到角色分配

3.1 创建应用注册并准备客户端凭据

开始之前,你要先确认一件事:当前所在的目录里,你是否有权限创建应用注册。没有权限的话,直接去找目录管理员开通,否则下面的步骤一步也走不下去。

在Azure门户里导航到Entra ID服务,左侧菜单找到“应用注册”,点击“新注册”。这里有几个需要认真填的字段:

  • 名称:建议格式是类似search-app-prod这样能直接看出环境和用途的名字,别起成test1这种阶段性名字;
  • 受支持的账户类型:如果应用只在你自己的目录内使用,选“仅此组织目录中的账户”即可,不要选“任何组织目录”,减小不必要的暴露面;
  • 重定向URI:本地调试或需要交互式登录时再填,纯服务到服务调用不填也行。

创建成功后,你会得到一个“应用程序(客户端)ID”,类似xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,这串ID要记好,它相当于应用的账户名。同时还要记录你的“目录(租户)ID”,后面换令牌时两个ID都要用到。

接下来是客户端凭据。在应用注册页面里找到“证书和密码”,点击“新客户端密码”,设置一个过期时间(我一般建议最长不要超过一年,有些严格团队会要求半年一换)。创建完成后,页面只会展示一次“值”,你要立刻复制保存。这个值就是你的客户端密钥,请务必存放在安全的密钥管理服务里,不要出现在任何代码仓库里。

3.2 为应用分配AI Search的RBAC角色

这一步的目标只有一个:让刚才创建的“应用”拥有访问Azure AI Search数据平面的权限。

在Azure门户里进入你的搜索服务资源页面,左侧菜单找到“访问控制(IAM)”,点击“添加” -> “添加角色分配”。在角色列表里搜索“Search Index Data Reader”或“Search Index Data Contributor”,按你的业务需要选一个。分配访问权限的对象类型选“用户、组或服务主体”,然后在成员一栏搜索你刚创建的应用注册名称,选中它。最后点“查看 + 分配”完成。

这里有一个很隐蔽的坑:Azure门户的搜索框对应用注册名称的搜索基于名称前缀匹配,而且搜索范围默认可能没覆盖到服务主体。有时候你输入完整名称也搜不到,别着急,把名称输入换成客户端ID的前几位再搜,成功率会高很多。我踩过这个坑,当时还以为是IAM分配接口出了问题,其实只是门户搜索逻辑太“智能”了。

另外,如果你是用PowerShell或Azure CLI做自动化,角色分配的核心命令是New-AzRoleAssignment或az role assignment create,将--assignee指定为应用注册的对象ID(不是客户端ID,是对象ID,这个也容易搞混)。下面是一段可供参考的PowerShell示例:

$searchServiceId = "/subscriptions/<订阅ID>/resourceGroups/<资源组>/providers/Microsoft.Search/searchServices/<搜索服务名>" New-AzRoleAssignment -ObjectId "<应用的对象ID>" -RoleDefinitionName "Search Index Data Reader" -Scope $searchServiceId

3.3 网络层面的配合:当搜索服务开启了IP白名单

很多生产环境会给Azure AI Search配置网络访问限制,只允许特定IP网段或特定虚拟网络调用。这种情况下,即使你的RBAC配置完全正确,令牌也没问题,请求依然可能因为网络策略直接失败。

配置RBAC认证时不一定要处理网络问题,但你要有这样的意识:在隔离的网络环境下,能拿到有效令牌只是第一步,你的请求来源IP或虚拟网络也必须被搜索服务信任。否则你会在排查时陷入“身份没问题、网络有问题”的混合困境,两个维度都很难快速定位。

如果服务开启了“从门户访问”的选项,你在Azure门户里调试时可能正常,但本地代码一跑就超时或403。遇到这种迹象,果断先去IAM里检查权限,再去网络隔离设置里看看自己的出口IP是否在白名单内。

4. 核心环节实现:用Python代码完成RBAC连接

4.1 理清原理:代码里到底在做什么

用RBAC连接Azure AI Search,代码层的核心本质是“先用Entra ID换令牌,再用令牌换搜索数据”。整个过程分三步:

  1. 用应用注册的客户端ID、客户端密钥和租户ID,向Entra ID的令牌端点发起认证请求;
  2. 从认证响应中解析出访问令牌(access_token);
  3. 把这个令牌放在HTTP请求的Authorization Header中,以Bearer <token>的形式请求Azure AI Search。

为什么需要用令牌而不能直接用客户端密钥调搜索接口?因为Azure AI Search对RBAC请求的校验是基于Entra ID签发的令牌来完成的,搜索服务会校验令牌的签名、有效期和权限范围。客户端密钥只是用来向Entra ID证明“你是一个合法的应用”,搜索服务本身并不认识你的客户端密钥。

这里要特别注意一个细节:请求令牌时的scope(作用域)参数,值必须是https://search.azure.cn/.default的形式,而这里需要根据你使用的区域确认Azure AD的Endpoint。在国内区环境,租户认证的Endpoint是https://login.partner.microsoftonline.cn/,而非国际版的https://login.microsoftonline.com/;同理,search服务的scope也对应国内区域名https://search.azure.cn/.default。这些区域相关的Endpoint配置一旦搞错,最常见的结果就是拿到的令牌有效,但请求搜索服务时却无法被接受。

4.2 代码实现与解析

我用azure-identity这个官方SDK来做RBAC认证,它比直接用HTTP请求去换令牌更省心,能帮我们处理令牌缓存的逻辑,而且国内版SDK也适配了对应Endpoint。下面是一套可以直接跑通的Python代码示例:

from azure.identity import ClientSecretCredential from azure.search.documents import SearchClient from azure.search.documents.models import VectorizableTextQuery from azure.core.credentials import AzureKeyCredential # ========== 配置区 ========== tenant_id = "你的租户ID" client_id = "你的应用客户端ID" client_secret = "你的客户端密钥" # 国内区AI Search服务地址 search_endpoint = "https://<你的搜索服务名>.search.azure.cn" # 搜索服务里创建好的索引名 index_name = "hotels-sample-index" # ========== 配置区结束 ========== # 使用客户端密钥进行Entra ID认证,获取访问令牌 credential = ClientSecretCredential( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret ) # 创建SearchClient时,不传AzureKeyCredential,而是传credential # SDK会自动帮我们完成令牌的获取与刷新 search_client = SearchClient( endpoint=search_endpoint, index_name=index_name, credential=credential ) # 发起一个简单搜索请求 results = search_client.search( search_text="hotels", query_type="simple", top=5 ) for result in results: print("HotelId:", result.get("HotelId")) print("HotelName:", result.get("HotelName")) print("---")

有几个容易出问题的细节,我单独拿出来说一下:

  • ClientSecretCredential中的三个参数顺序是tenant_id、client_id、client_secret,别写反。一旦写反,认证会直接报错,且错误信息不会明确提示你“参数顺序错误”,它会拢成一句“Authentication failed”指数。
  • 用SDK时,不需要手动去获取access_token,SearchClient内部会通过TokenCredential协议调用凭据的get_token方法,所以只需要把credential对象整体传进去即可。
  • azure.search.documents这个SDK包版本需要在安装时确认一下,不同版本对RBAC支持情况有所差异。我实践中azure-search-documents 11.4.x以上版本对RBAC支持已经非常稳健,可以放心使用。

4.3 手动验证SDK之外的另一条路

有时候你想快速验证RBAC配置是否成功,而不想引入SDK依赖,也可以用纯HTTP请求方式。先用requests库向Entra ID换令牌,再向搜索服务发请求,代码如下:

import requests # 第一步:向Entra ID换取访问令牌 token_url = "https://login.partner.microsoftonline.cn/{tenant_id}/oauth2/v2.0/token" token_headers = { "Content-Type": "application/x-www-form-urlencoded" } token_data = { "grant_type": "client_credentials", "client_id": "你的客户端ID", "client_secret": "你的客户端密钥", "scope": "https://search.azure.cn/.default" } response = requests.post(token_url, headers=token_headers, data=token_data) access_token = response.json().get("access_token") # 第二步:使用令牌调用搜索接口(这里以索引列表为例) search_api_url = "https://<你的搜索服务名>.search.azure.cn/indexes?api-version=2023-11-01" search_headers = { "Authorization": f"Bearer {access_token}" } response = requests.get(search_api_url, headers=search_headers) print(response.json())

这段代码的价值在于让你看到“换令牌”和“用令牌”是两个独立的请求,方便理解整个链路。但生产环境我不建议直接用这种裸HTTP方式,因为令牌缓存的逻辑、刷新逻辑、异常重试逻辑都需要你自己实现,而SDK已经把这些都做完了。

4.4 用Postman或RestClient调试的小结

如果你不太想写代码,也可以先用Postman调试整个流程。你在Postman里创建一个新的POST请求,URL填https://login.partner.microsoftonline.cn/{tenant_id}/oauth2/v2.0/token,Body选x-www-form-urlencoded,填上client_id、client_secret、grant_type=client_credentials、scope=https://search.azure.cn/.default,发送后就能拿到包含access_token的返回值。再把token复制到搜索服务的请求头里,紧跟在Bearer后面即可。

用Postman调试时,拿到的token中间有一段灰色的部分,那部分就是JWT的Payload。你可以把它复制到jwt.io或解码工具里看看里面的aud(受众)和roles字段,这对后面排查问题非常有帮助。aud应该指向https://search.azure.cn,roles里应该能看到你在IAM里分配的角色名称。只有这两处都符合预期,RBAC链路才算真正通了。

5. 常见问题与排查技巧实录

5.1 请求返回403 AuthorizationError:优先级最高的排查路径

这是RBAC接入时出现频率最高的问题。403代表服务器认识你是谁,但它认为你没有权限。这里有一个简单粗暴但高效的排查顺序:

第一步,确认你调用的具体操作是数据平面还是管理平面。如果是https://<服务名>.search.azure.cn/indexes/{indexName}/docs/search这类带/docs/路径的操作,属于数据平面;如果只是/indexes,则取决于具体API版本和语义,很多列表类操作也算数据平面。角色分配错误的话,直接看IAM页面里应用是否被分配了对应角色。

第二步,确认角色和操作匹配。执行搜索请求,需要Search Index Data Reader或更高权限;执行新增或更新文档,需要Search Index Data Contributor。只分配了Reader却执行写操作,403是必然的。

第三步,如果你确认角色没问题,还是在403,检查一下令牌的roles声明里是否真的有角色名称。有时IAM分配生效后有1到2分钟的延迟,你可以等几分钟后重新换一个令牌试试。

5.2 令牌拿到了,但请求提示“Authentication failed”

这种情况比403更隐蔽。403说明你的令牌被搜索服务接受了,但权限不足;而“Authentication failed”说明搜索服务根本没能验证你的令牌。

最大概率的坑在于令牌是“国际版”的。国内区搜索服务并不接受国际版Entra ID签发的令牌。如果你不小心用https://login.microsoftonline.com(国际版Endpoint)换了令牌,拿到的令牌拿到国内区搜索服务上,就真的会触发“Authentication failed”。同样的,国内区服务token scope必须用https://search.azure.cn/.default。

判断自己是否中招的方法:解码JWT,看iss(发行者)字段。国内区的令牌iss应该是类似https://sts.chinacloudapi.cn/{tenant_id}/的地址,而你拿到的若是https://login.microsoftonline.com/{tenant_id}/感觉明显不对劲,那就是换错Endpoint了。

5.3 IAM里找不到分配目标应用

这个我说过一句,但值得再单独列出来。在IAM的成员搜索框里,如果你输入应用注册的完整名称搜不到,试着改用客户端ID的前8到12位字符搜索,或者用对象ID来搜索。我怀疑门户的搜索索引对应用名称的索引粒度不如ID字段准确,反正用ID搜索基本一搜一个准。

另外,你要确保--assignee使用的是应用的对象ID(Object ID),而不是“应用程序(客户端)ID”那串UUID。这两个ID都在应用注册的“概述”页面上,但一个是对象的ID,一个是应用的ID。搞混之后,角色分配给了一个不存在的“应用”,自然没有任何效果。

5.4 凭据报错信息过于笼统时,先自查这四点

SDK报错时经常把真实原因藏在几层异常里,外层只显示一个笼统的提示。我一般按以下顺序自查:

  • 检查客户端ID、租户ID是否和门户中完全一致。手敲非常容易出错,建议直接复制。
  • 检查客户端密钥是否过期。客户端密钥在创建时可以设有效期,如果你是在很久之前创建的密钥,而中间过了有效期,那无论角色配置得多完美,认证都会失败。重新生成密钥并更新配置,问题立刻解决。
  • 检查scope是否有拼写错误。https://search.azure.cn/.default这个字符串里一定不能多空格、不能少下划线,.default后缀是固定格式。
  • 检查代码里有没有把client_id和client_secret的位置写反。两个字段在语言模型里看起来都很像UUID,写反了是最不容易发现的问题之一。

6. 我踩过的一些细节坑与建议

6.1 应用身份不能和人身份混用

很多人把应用注册的客户端密钥当成“万能钥匙”,一个开发者在本地调试时也用生产环境的应用注册身份,理由是“这样认证能通,不用再配一套”。这个做法非常危险,本地调试的请求会被令牌标记为生产身份,而一旦出现安全问题或审计需求,所有生产环境中的访问日志都会混入开发者的调试流量,权限边界形同虚设。

我建议至少分三套应用注册:开发环境、测试环境、生产环境。每套环境用自己的客户端密钥,用自己的角色分配范围。虽然多了一些管理成本,但这种隔离带来的安全收益远大于那点维护负担。

6.2 密钥建议做好自动化轮换

RBAC替我们解决了“静态无限权限”的问题,但客户端密钥依然是静态凭据,也难逃泄露的风险。在有条件的团队里,建议把客户端密钥的轮换做成自动化。在密钥管理服务中,可以设置两个密钥轮换池,在旧密钥过期前提前半月把新密钥配置到线上,经过缓冲期后再废弃旧密钥。

如果你一个人维护项目,也别偷懒到“密钥永远不过期”。我给自己的最低要求是:每次发版本时,检查一下当前密钥剩余有效期,超过6个月就准备换。一旦密钥过期发生在线上,你面临的可不只是一个简单的更新配置的活儿,整个搜索服务的中断事故会让你非常被动。

6.3 国内区域SDK版本区域Endpoint的一点心得

关于国内版SDK与Endpoint,我自己在这个问题上吃过不小的亏。一开始接触国内区服务时,我以为SDK和公共云完全一致,代码里写的是国际版的默认Endpoint,然后就开始排查各种奇怪的认证失败,折腾了一整天才反应过来说不定是国内区Endpoint和scope不对。

如果你也是第一次接触国内区Azure AI Search,建议你优先把官方文档的国内版SDK示例通读一遍,确认azure.identity的authority、endpoint、scope这三处都指到了国内区。密码学的错误最难看,因为报错信息外在表现五花八门,根因往往就那么一条。

6.4 多索引场景的角色细分策略

如果你一个搜索服务下有很多索引,而且不同应用只允许访问其中一部分索引,建议先从资源组的拆分上思考一次:是不是该把不同业务的索引用独立的搜索服务承载?服务级别的IAM隔离永远比索引级别的自定义角色好维护得多。

只有当服务拆分不现实(比如成本原因、数据关联强需要跨索引搜索),才值得考虑自定义角色加特定索引资源路径的授权方案。真走到这一步时,建议将自定义角色的JSON定义纳入版本管理,并在代码仓库里写上清晰的注释,说明这个角色为什么存在、覆盖哪些索引、授予谁使用。

最后再分享一个小技巧:在代码里封装一个get_search_client(index_name)的函数,把身份认证逻辑统一收敛到这一处,全项目只维护一套认证入口,后面换密钥、换角色、调整Endpoint配置时都只改一处。我就是这么做的,之前各个服务各自写认证逻辑,出了几次“本地改了、线上没改”的乌龙之后,我下决心把认证全部抽成了公共模块,从那以后RBAC相关的配置问题少了非常多。

如果你正在把现有API Key代码迁移到RBAC,不妨按这个节奏推进:先在测试环境完整的跑通RBAC链路并验证核心搜索请求,确认结果与API Key模式一致后,再存量切换。整个过程核心就是三件事——身份用对、角色给对、Endpoint填对,只要这三件事做扎实,RBAC连接Azure AI Search基本不会再有拦路虎。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进