iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 20

Day 20:Managed Identity 與 Key Vault——最好的 secret 是不存在的 secret

  • 分享至 

  • xImage
  •  

Day 19 把呼叫端的身份收掉了:進來的每個 request 都對應一個驗過簽章的 tidoid。但往外看,這支 API 自己呼叫 Azure OpenAI 時,用的還是 .env 裡的一條 AZURE_OPENAI_API_KEY——呼叫端不再匿名,服務自己還是。Day 10 已經講過這條 key 外洩的代價:流量不經過你,app 層 guardrail 全滅。這天的題目看起來是「把 key 收進 Key Vault」,做完的結論卻是:對這個 stack,更好的答案是讓 key 不存在。

這篇沿路用的都是當天實測(2026-08-05,japaneast),包括一次差 35 秒就下錯結論的插曲。讀完你能對自己的系統做一次秘密盤點,並判斷 Key Vault 對你是終點,還是過渡。

完整文件與 scripts 在 day-20 tag 上:

先盤點:你有幾個 secret,幾個是真的

做秘密管理的任何決策之前,先回答一個更基本的問題:這個系統到底有哪些秘密。這個 backend 走到 Day 20,環境變數裡跟「秘密」沾邊的是這幾個:

設定 它解鎖什麼 真的是秘密嗎
AZURE_OPENAI_API_KEY Azure OpenAI account 的完整 data-plane 存取 是,能用一個 role assignment 取代
AZURE_SEARCH_ADMIN_KEY Search service 的完整寫入權,含 index 管理 是,能用 RBAC role 拆得更細
ENTRA_CLIENT_SECRET Day 19 smoke 工具的 client credential 是,但它 7 天到期、只活在 smoke client,server 從不讀它
endpoint、deployment 名稱、tenant id 設定 否,endpoint 與 GUID 不是憑證

最後一列跟前兩列一樣重要。Key Vault 自己的文件就把這條線劃得很清楚:設定歸設定系統管,configuration settings「should be stored in Azure App Configuration rather than in Key Vault」(secure-secrets,查核 2026-08)。

為什麼要守這條線?因為一個什麼都塞的 vault,會失去「這裡列的就是危險物品」的盤點價值。

盤點完的形狀是三類:能用身份取代的 key(前兩列)、真的無法取代的秘密(第三方 SaaS 的 API key 這類——本 stack 目前沒有)、根本不是秘密的設定(最後一列)。這篇接下來要處理的是第一類,因為它決定了 Key Vault 最後剩下什麼。

訂閱 Owner 打不動模型:control plane 不是 data plane

「用身份取代 key」的第一步,是搞清楚哪種身份有用。一個常見的錯誤模型是「我是訂閱 Owner,我什麼都能做」。實測給的答案很直接。拿一個沒有任何 data-plane role 的訂閱 Owner 身份打 v1 Responses API,得到的是:

401 PermissionDenied: The principal `<oid>` lacks the required data action
`Microsoft.CognitiveServices/accounts/OpenAI/responses/write`
to perform `POST /openai/v1/responses` operation.

Owner 能建立、能刪掉整個 account,但呼叫模型是 data action,只有 data-plane role 給得了。官方 troubleshooting 頁講得更白:「Owner or Contributor don't provide access either」(configure-entra-id,查核 2026-08)。

這個 401 錯誤訊息值得抄下來。它直接點名缺的 data action 全名,是我見過 Azure 授權錯誤裡最誠實的一條。

補一個 role assignment(Cognitive Services OpenAI User,scope 釘在 account 上),同一個呼叫就通了。client 的形狀跟這個系列 Day 4 以來用的一模一樣,唯一的差別是 api_key 的位置交給 azure-identity 的 token provider:

from azure.identity import AzureCliCredential, get_bearer_token_provider
from openai import OpenAI

token_provider = get_bearer_token_provider(
    AzureCliCredential(), "https://cognitiveservices.azure.com/.default"
)
client = OpenAI(
    base_url="https://<account>.openai.azure.com/openai/v1/",
    api_key=token_provider,
)

注意傳的是 callable 本身,不是它的回傳值。系列釘住的 openai 2.45.0 對 api_keystr | Callable[[], str]:給 callable,SDK 每次請求都重新呼叫它,這是長時間跑的服務在第一個 token 過期後換氣的縫。

寫成 api_key=token_provider() 則是把單一 token 凍成字串,服務會在 token 到期那一刻開始失敗。這兩個行為都釘在 lab 的 tests/unit/test_openai_callable_api_key.py,SDK 升版若改了行為會先在 CI 紅掉。

實測(2026-08-05,kind=OpenAI 的 account):responses.createstore=False 回 200,usage block 正常,全程沒有任何一個 API key 出場。誠實補一句:probe 的呼叫都在單一 token 壽命內,過期換 token 這件事沒有被 live 驗證——refresh 主張立足在釘版 SDK 原始碼與上述 regression 測試。這就是「第一類秘密」的下場:一個 role assignment 讓它變得多餘。

「指派成功」不等於「生效」:一條要自己驗證的迴圈

上一段藏了一個時間軸。role assignment 建好之後,我用 az role assignment list 確認過 principal、role、scope 全部正確,然後 data plane 繼續回 401,整整 14 分 44 秒才第一次 200。官方文件寫的是「Azure role assignments can take up to 5 minutes to propagate」(同上頁,查核 2026-08);這次的單一觀測明確超過了它。

期間有一個值得注意的訊號:指派前的 401 點名缺的 data action,指派後的 401 換成了另一句「Principal does not have access to API/Operation.」。role 有進到評估,只是還沒收斂。

所以可靠的操作程序不是「指派完就走」,也不是無限期的等,而是一條有界的驗證迴圈。先把輸入逐項驗一次:principal 的 object id、role、scope、租戶、token audience、資源 kind——control-plane 清單只證明指派物件存在,證明不了 data-plane 的每個輸入都對。然後用 live 呼叫配 backoff 重試到一個明確的 deadline。

視窗內的 401 可能是 propagation,但永遠不是「設定沒錯」的證明;過了 deadline 就停止等待、開始診斷——這時機率已經從 propagation 移向某個輸入是錯的。

https://ithelp.ithome.com.tw/upload/images/20260820/20168288o012OuSi9E.png
忍喵:官方說 up to 5 minutes,實測 14 分 44 秒。這段時間裡最危險的動作是「再改一次設定」——你會把對的改成錯的。

同一個下午還撞上一個更有趣的東西:兩頁現行官方文件對 Azure OpenAI keyless 的 token scope 各說各話。keyless-connectionshttps://cognitiveservices.azure.com/.default,搭的是舊的 AzureOpenAI client。

configure-entra-id 則對 v1 路徑+plain OpenAI client 三度強調要 https://ai.azure.com/.default(皆查核 2026-08)。

後者正是本系列的形狀。哪個對?量:

token scope 結果(kind=OpenAI,2026-08-05)
https://cognitiveservices.azure.com/.default 200
https://ai.azure.com/.default 200

兩個都通。在這台資源上衝突不成立,兩頁各寫了一個可用值。但過程差點翻車:ai.azure.com 的第一次探測回 401,35 秒後重測才 200——那個 401 是 propagation 的尾巴,不是 scope 不能用。如果我在第一次探測後就停手,這篇會寫出「ai.azure.com 在 OpenAI kind 上不可用」這個錯誤結論。Day 13 立的紀律「單次探測不裁決文件」,這次在同一輪 probe 裡自己驗證了自己。

順帶一提,這個結論只及於 kind=OpenAI 的資源;Foundry(kind=AIServices)資源是那頁文件真正的主場,這裡不外推。

反向操作比正向更需要小心:移除 role 不會即時移除存取。managed identity 的 token 以 resource URI 為單位快取「around 24 hours」,而且「Forcing a token refresh isn't supported」(managed-identity,查核 2026-08)。指派生效要等,撤銷生效等更久,而且沒有開關能催它。

DefaultAzureCredential:便利的本體就是風險的本體

講到用 azure-identity 拿 token,幾乎每份教學的第一行都是 DefaultAzureCredential()。它做的事是依序嘗試一串 credential 來源。Python 版的鏈如下(查核 2026-08,credential-chains;注意 .NET 的鏈不一樣,別跨語言搬表):

Environment → WorkloadIdentity → ManagedIdentity → SharedTokenCache(僅 Windows)→ VisualStudioCode → AzureCli → AzurePowerShell → AzureDeveloperCli →(InteractiveBrowser,預設關)→ Broker。

關鍵語意是:它停在第一個「拿得到 token」的成員,不是第一個「有正確權限」的成員。 在開發機上,鏈幾乎總是落到 AzureCliCredential。也就是說,你程式的有效身份是「上次 az login 留下的是誰」。

這不是我在放大檢視。官方文件現在自己這樣寫:「replace DefaultAzureCredential with a specific TokenCredential implementation, such as ManagedIdentityCredential」。理由列了三條——除錯困難(失敗時難辨是鏈上哪個成員的問題)、逐一嘗試的效能開銷、行為不可預測(主機層級的環境變數會全域改變鏈的行為)。

production 側的警世案例出自微軟 .NET 最佳實務頁(與 credential-chains 同一個指引家族,查核 2026-08):有人在 production 主機跑過 az login,某天 managed identity 短暫失敗,鏈默默 fallback,服務開始用一個人類的身份跑。這裡把它當跨 SDK 的機制警示看——本篇沒有在釘版的 Python azure-identity 上重現這個特定 fallback(Python 的鏈與 .NET 不同)。

上一段那面錯誤牆是同一個機制從反面觀測到的樣子:沒有任何成員成功,所以每個成員都報了錯。而「production 換顯式 credential」這個結論,Python 版指引自己就用三條 tradeoff 撐住了,不靠這個故事。

我當天就實測到它的開發機版本。同一段讀 Key Vault secret 的程式碼,唯一的變因是 az 的預設 context 指到另一個訂閱(另一個租戶),結果不是一句「租戶錯了」,而是一個 ClientAuthenticationError 包著七個 chain 成員各自的失敗報告

真正的根因埋在那面牆的中段:AzureCliCredential: AADSTS50020,SDK 從 Key Vault 的 auth challenge 得知 vault 所屬租戶後,az 嘗試用錯的帳號跨租戶取 token。第一眼看過去,你不會知道問題出在「az 預設 context 指錯地方」這種環境狀態。

修法在這個系列其實早就有了:跟 fake/real adapter、headers/entra resolver 一樣,credential 在 composition point 顯式選,啟動時定一次。 本機開發就明寫 AzureCliCredential,身份是開發者本人,寫明之後錯租戶會變成一行清楚的錯誤;production 就明寫 ManagedIdentityCredential(user-assigned 要傳 client_id,它不會自己被發現)。

真的需要一份程式碼跑多種環境時,用 AZURE_TOKEN_CREDENTIALS 環境變數把鏈釘住。注意兩個版本門檻:proddev 分類需要 azure-identity ≥ 1.23.0、指定單一 credential 名稱需要 ≥ 1.24.0,版本沒到門檻這個變數會被靜默忽略。再配 DefaultAzureCredential(require_envvar=True),讓「忘了設」變成啟動失敗而不是整條鏈照跑。fail-fast 在 composition point,跟這個系列每一天的習慣一致。

那 Key Vault 還剩下什麼?

回到題目的另一半。如果前兩類 key 都能用身份取代,Key Vault 是不是就不用學了?還是要,但學的重點跟多數教學不同:它的預設剛翻轉過,而且 rotation 這件事它只解一半。

預設翻轉了。 自 control-plane API 2026-02-01 起,新建 vault 預設 enableRbacAuthorization = true:Azure RBAC 是預設,舊的 access policies 要明寫 --enable-rbac-authorization false 才拿得到。

官方對舊模型的評語也不留情面:legacy access policies「have known security vulnerabilities」(rbac-access-policy,查核 2026-08)。

退場時間表也定了:2026-02-01 之前的 control-plane API 版本 2027-02-27 退役(access-control-default,查核 2026-08)。2026 年前寫的教學在這裡幾乎全數過期。

RBAC 模式有個第一次會愣住的行為:建 vault 的人對 data plane 零權限,連讀自己剛建的 vault 都要先有 role assignment。create-keyvault.sh 因此在建完後立刻幫 signed-in user 指派 Key Vault Secrets Officer

另外兩個當天實測的坑。第一個:從沒建過 vault 的訂閱會直接死在 MissingSubscriptionRegistration,因為 Microsoft.KeyVault provider 預設沒註冊,script 已補 pre-check。

第二個:purge protection 是單向門,開了就關不掉,vault 名稱在保留期內鎖死、不能提前 purge。production 建議開,但一個承諾「每個 create script 都有 teardown」的 lab 開不起,這個張力是真的,lab 的 guide 與 create script 都明寫而不是裝作不存在。

rotation 它只解一半。 把 key 放進 vault 之後,逐項看 rotation 還剩什麼要你做:

服務端:兩個服務各發兩把 key,但兩邊的說法各有各的出處,不互相轉移。Azure OpenAI/AI Services 的 regeneration「the older version of that key stops working immediately」、舊 key 端拿 401——沒有寬限期(rotate-keys,查核 2026-08)。

Azure AI Search 那邊:兩把 admin key 一次只能 regenerate 一把,兩把同時換掉會讓 client 全部 403(search-security-api-keys,查核 2026-08)。共通點是結構性的:單一 vault secret 存「那把 key」,就做不到零停機輪替。

vault 端:每次 secret set 產生新版本、舊版本照樣可讀(實測兩版本並存);versionless URI 永遠解析到最新。但 rotation policy 只有 keys 有,secrets 沒有

通知端:secrets 靠 Event Grid 事件,SecretNearExpiry 固定在到期前 30 天觸發、不可調,而且 secret 沒設到期日就永遠不會響(event-schema-key-vault,查核 2026-08)。

應用端:官方建議快取 secret、也建議「refresh them when secrets are rotated」——機制沒給,自己蓋。

平台端:Azure Container Apps 分兩層(manage-secrets,查核 2026-08)。抓新版這層對 versionless reference 通用:30 分鐘內自動抓最新版本。

自動重啟那層窄得多:只限以環境變數引用該 secret 的 active revisions——官方那句話只涵蓋這種消費形狀,volume mount 等其他形狀不在其內。對 env-var 引用來說 rotation 同時是一次可用性事件;釘版本可以避開自動抓新版,代價是回到手動輪替。

一把 key 的輪替窗時間軸(以 Azure OpenAI 為例):t0 同一刻發生兩件瞬時的事——regenerate 讓舊 key 立即失效、無寬限期,以及 vault secret set 讓新版本就緒而舊版本仍可讀;圖上把這兩件瞬時事件畫成起點的小色塊,只是為了放得下標籤。接著平台端的 versionless reference 最長要 30 分鐘才抓到新版,抓到之後才輪到以環境變數引用該 secret 的 revision 重啟;應用端若自己快取 secret,失效機制得自己蓋。最下面那條紅色的停機窗從 t0 延伸到 30 分、標著「窗的下界」——這段時間 client 帶著舊 key 打過去就是 401,而窗真正關上是在 revision 重啟完成之後,那段時長沒有來源可引,圖上不編也不畫

注意最下面那條紅色:從 regenerate 到應用端真的拿到新 key,這段沒有任何一方替你兜底——服務端已經把舊 key 殺了,平台端還沒輪到它抓新版。而能把這段補起來的做法(兩把 key 交錯輪替),正好被「單一 vault secret 存那把 key」擋住。

把清單讀完會發現一件事:每一項成本都是「因為有一把 key 存在」才要付的稅。 managed identity 沒有 key 可以外洩、沒有版本可以釘、沒有到期日可以錯過。所以這個 stack 的終局是:keyless 落地之後,vault 裡剩下的東西趨近於零——它是遷移期的過渡機制(key 從「貼在部署設定裡」到「不存在」的中繼站),以及第二類秘密(真的無法用身份取代的)的家。「我們有 Key Vault」不是繼續用 key 的理由。

成本補一句,照 Day 9 的規矩把話說在量得到的範圍內:Retail Prices API 目前列出的 japaneast Standard vault meter 是每萬次操作 US$0.03,查詢結果裡沒有常駐的 per-vault meter(查核 2026-08;公開定價頁面渲染的是 placeholder,要可引用的數字得打 API)。

所以「閒置 vault 的操作增量成本趨近於零」——這是對今天價目表的描述,不是永久免費的保證,也不涵蓋 production 可能掛上的周邊(diagnostics、網路);帳務權威仍是 Cost Management 與發票。lab 仍然跑完即拆,因為 teardown 紀律的另一半是可重現,而一個空 vault 什麼都守不了。

被否決的方案

把兩把 key 全搬進 Key Vault,配 Event Grid 自建 rotation。 這是「認真做 key 管理」的標準答案,也是上一節那份清單的全額帳單:near-expiry 配管、rotation Function、應用端快取失效、30 分鐘重啟語意,全都要維運。對一個每個依賴都收 Entra token 的 stack,這是在精緻地管理一個可以不存在的東西。它適用的情境是第二類秘密真實存在的系統——那時這套投資每一分都該花。

DefaultAzureCredential 留在 production,「反正它會自己找到 managed identity」。 官方文件已經明寫不建議,三個理由前面引過;而且它失敗的樣子(七成員錯誤牆)與 fallback 的樣子(默默換一個身份繼續跑)都在把除錯成本往後堆。被否決的原因跟這個系列反覆出現的判準一致:依賴的選擇是設計決策,該在 composition point 顯式做掉,不留給 runtime 環境猜。

本機開發也全面 keyless。 沒被採用倒不是做不到(az login 之後 AzureCliCredential 就能跑),而是這筆帳要算得誠實。先把一個容易混淆的界線劃開:.env 只縮小曝險面(一台筆電、一個 gitignored 檔案),不縮小授權半徑——第一節的盤點表寫得很清楚,這把 key 是完整的 data-plane 憑證,外流之後從任何一台機器重放都有效。

所以「本機留 key」是一筆記在帳上的債:配套是 .env 不進版控、偵測靠 budget alert 與 Cost Management(延遲的,Day 4/9 講過)、止血鍵是 regenerate key。全面 keyless 買掉的是這筆債,代價是每個人多一層登入狀態要管。lab 選擇記債:本機用 key、部署走 keyless,債與配套都明寫在文件裡。

https://ithelp.ithome.com.tw/upload/images/20260820/20168288LVFpmXOS1p.png
忍喵:keyless 是方向,不是宗教。production 先走,本機那把 key 當成記在帳上的債來管——別急著為它開全隊的會。

這天的誠實邊界

  • managed identity 本體沒有被 live 測過。 現在還沒有部署的 compute(Day 24 才有),probe 用的是使用者身份(AzureCliCredential);token 取得與 RBAC 評估走同一條路,但 IMDS 行為、24 小時 token cache、user-assigned 的 client-id 接線,在本篇都是文件主張,不是量測。
  • Search 的 keyless 是文件推論,且文件自己打架。「any tier, including free」與「must be a billable tier (basic or higher)」同時存在於兩頁現行文件(皆查核 2026-08)。照 Day 13 的紀律,這裡記下衝突、不擲硬幣,等 Search 需要 live 的里程碑一併量。
  • propagation 的數字是單次觀測。 14 分 44 秒發生在一個 account、一個 region、一個下午;同一天 Key Vault 的 role 倒是一分鐘內就生效。能帶走的是驗證迴圈這個操作習慣,不是任何一個數字。
  • scope 量測只及於 kind=OpenAI。 Foundry(AIServices)資源上兩個 scope 的行為,本篇沒有證據。
  • 本篇只講 secrets。 Key Vault 的 keys 有 rotation policy、certificates 有 renewal,機制不同,結論不轉移。

下一篇

進來的身份收了(Day 19),出去的憑證也有了答案(這天)。下一個安全邊界不在協定層,在內容層:使用者的 prompt、檢索回來的文件,都會被模型當成指令的一部分讀進去。Day 21:Prompt Injection 與 Tool Abuse——GenAI backend 的安全邊界,為什麼 least privilege 對 tools 不是加分項而是底線。

完整文件與 scripts 在 day-20 tag,CI 綠。

用到的 Azure 服務:Azure Key Vault(standard tier,live probe 後已 delete+purge——以名稱查 active 與 soft-deleted 清單皆為零;Microsoft.KeyVault provider 註冊是刻意保留的訂閱層級一次性變更)、Azure OpenAI in Microsoft Foundry(既有 chat-mini deployment)、Microsoft Entra ID(RBAC role 評估,屬租戶既有條件)。

keyless probe 共 3 次成功呼叫、251 total tokens;probe 用的 role assignment 已移除並以 role assignment 清單讀回為零確認。本次增量支出趨近於零(查核 2026-08)。


本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 19:使用 Microsoft Entra ID 做 API 身份驗證——驗簽不是難題,簽章證明不了的才是
下一篇
Day 21:Prompt Injection 與 Tool Abuse——把安全防線拆進三欄記帳,才知道自己守不守得住
系列文
Backend 工程師的 Azure GenAI 實戰22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言