iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
AI Engineering

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

Day 15:多租戶與權限控管——授權不是一個檢查,是查詢唯一能有的形狀

  • 分享至 

  • xImage
  •  

Day 14 結尾承認了一件事:這條 RAG 管線對所有人一視同仁——誰來問,檢索都在整個 index 上跑。公司內部知識庫的第一個現實剛好相反:財務的薪資文件、法務的合約條款、跟全公司都能看的請假規則,住在同一個知識庫裡,但不同的人本來就不該看到同樣的文件

這天把授權接上去,而整篇的主張只有一句:在共用 index 的 RAG 上,授權不能做成「拿到結果之後檢查一下」,它必須是查詢本身唯一能有的形狀——「唯一」指的是本專案 production 檢索邊界上可表達的形狀,這句話的邊界後面會誠實交代。讀完你會知道為什麼 filter 必須在伺服器端組、vectorFilterMode 選錯會以什麼樣子咬你、以及為什麼這個系統刻意沒有 403。

先誠實交代身分是怎麼來的

授權要先有「你是誰」。這天的答案是兩個 HTTP header:X-Tenant-Id(必須恰好一個)與 X-Group-Ids(零或一個,逗號分隔清單)。backend 把它們解析成一個 frozen 的 Principal,之後整條管線都只認這個物件。

先把醜話說完:這兩個 header 是信任 gateway 的輸入,不是身分驗證。任何能直接打到 backend 的人,都可以在 header 裡自稱任何租戶、任何群組——它們是 impersonation 旋鈕。這個設計成立的前提是部署拓撲:前面必須有一個 gateway,把 client 自帶的身分 header 全部剝掉或覆寫,而且流量只能從那個 gateway 進 backend。

真的驗 token(Microsoft Entra ID)是 Day 19 的正題,到時候換掉的是 header 的來源,不是後面整條授權管線——這也是為什麼值得先把管線做對。

解析本身倒是一點都不將就。identifier 限 [A-Za-z0-9_-]{1,64}、群組最多 100 個、原始 header 上限 4096 bytes;群組去重排序,讓後面組出來的 filter 是確定性的。任何違規——重複的 X-Tenant-Id、CSV 裡的空 token(a,,b)、超長超量——一律回 401 unauthorized(帶 WWW-Authenticate: Bearer),不是 422:header 語法是「你是誰」的問題,不是「你問了什麼」的問題,用 422 回會把這兩件事的界線弄糊。

另外兩條紅線:tenant_id 進每一行 log(跟 Day 5 的 correlation id 同一套 ContextVar 機制),group ids 永遠不進 log——群組成員資格本身就是敏感資料。

授權是查詢的形狀,不是查詢後的檢查

直覺的做法是加一層檢查:檢索回來,逐筆看 caller 有沒有權限,沒有就丟掉。這個形狀有一個結構性的弱點:它是可以忘記的。檢查是一個步驟,步驟就有可能被某個新的 callsite 跳過;review 抓得到今天的程式碼,抓不到半年後的。

這天的做法是讓「不檢查」在型別上不存在:

async def search(
    self,
    query_text: str,
    query_vector: Sequence[float] | None = None,
    *,
    mode: SearchMode = SearchMode.HYBRID,
    top: int,
    principal: Principal,  # 沒有預設值
    vector_k: int = DEFAULT_VECTOR_K,
) -> SearchResult: ...

SearchClient.search()principal 參數沒有 default、沒有 overload、沒有任何一條 code path 能發出不帶 filter 的查詢。「沒有授權脈絡」不是這個邊界能表達的值,所以它不可能因為某處忘了傳而發生——忘了傳是靜態檢查(mypy)與執行期都過不去的錯,不是一次靜默的全庫檢索。

filter 由 services/acl.py::build_acl_filter(principal)一個函式組出來,長這樣:

tenant_id eq 'tenant-a' and (
  not allowed_groups/any()
  or allowed_groups/any(g: search.in(g, 'finance,support'))
)

三個語意都值得說:

  • tenant 條件無條件成立——這是租戶隔離的底線,群組再怎麼組合都繞不過它。
  • not allowed_groups/any() 這個「公開」分支對每個 principal 都成立,因為 allowed_groups: [] 的文件定義上就是全租戶可讀。一個自己不帶任何群組的 principal,filter 化簡成只剩這個分支:它只看得到 tenant-wide 的文件,一筆群組文件都看不到。
  • 跳脫是兩層 invariant,程式碼只擋得住一層。escape_odata_literal()(值裡的 ' 寫成 '',跟 SQL 字串跳脫同一招)守的是 OData 字串字面值這層,每個進 filter 的字面值都過它。
  • **第二層在 search.in 的 value list。**群組清單是逗號 join 後塞進 search.in,那層分隔字元文法(查核 2026-08,文件)跳脫函式管不到——靠 identifier charset 禁逗號與空白,就是依賴上游驗證。hostile bypass test 釘住它:放寬 charset 就先紅。

為什麼強調「伺服器端組」?因為另一個常見形狀是讓 client 傳 filter(或 filter 的片段)進來。那等於把授權政策的組裝權交給被授權的對象——client 能組「我的租戶」,就能組「別人的租戶」。filter 必須是 caller 完全碰不到的東西:caller 給的只有身分(而且 Day 19 之後連身分都是驗過的),政策長什麼樣子由伺服器單方面決定。

preFilter:選錯的症狀不像 bug,像檢索變爛

ACL filter 套在向量查詢上有一個容易被跳過的參數:vectorFilterMode。這天每個向量查詢都明寫 preFilter,不依賴 index 建立時的預設值。

差別在 filter 跟 ANN 搜尋的先後。Azure AI Search 的 HNSW 圖是分 shard 存的,GA 的 postFilter 在每個 shard 上不看 filter 先跑完 ANN、選出 unfiltered local top-k,才在 shard 內套 ACL、把倖存者彙整成全域 top-k(查核 2026-08,官方 vector filter 文件)。

另有 preview 的 strictPostFilter 更極端:先組全域 unfiltered top-k 才過濾。本篇不用它。

也就是說:你的文件得先在自己那個 shard 的鄰居賽跑裡贏過所有租戶的文件,才進得了過濾前的名單。在共用 index 上,這個賽跑對每個租戶都不公平:shard 裡別人的文件越多,你的文件越擠不進 local top-k 的名額,過濾完就是回傳變少、甚至變零——官方文件把這個叫 false negatives。

preFilter 與 postFilter 對比(hybrid query 的向量支線):postFilter 是每個 shard 不看 filter 先選出 unfiltered local top-k、shard 內套 ACL 過濾、再彙整倖存者,別的租戶文件越多剩越少,false negatives 症狀像檢索變爛;preFilter 是每個 shard 在 HNSW 走訪過程中就套 ACL、圖展開到湊滿 k 個合格候選再彙整,只要合格文件夠多永遠拿得到自己世界裡的 top-k

這個症狀的險惡之處在於它不像授權 bug。沒有錯誤、沒有 log、沒有任何紅字——只是「檢索好像變爛了」,而且是隨著別的租戶灌資料逐漸變爛。等有人真的追到 vectorFilterMode 這個參數,中間通常已經繞了一圈 embedding 品質、切分策略的冤枉路。preFilter 把順序倒過來:每個 shard 在 ANN 走訪過程中就套 filter,圖會一直展開到湊滿 k 個過得了 filter 的候選——只要 index 裡存在夠多你看得到的文件,回來的就是你的世界裡的 top-k。

https://ithelp.ithome.com.tw/upload/images/20260815/20168288tE66VSKZtK.png
忍喵:這種 ticket 會開成「檢索最近怪怪的」,然後指到 embedding 團隊頭上——權限 bug 偽裝成品質 bug,是最會躲的那種。

這裡有一個誠實註記:preFilter 不是免費的。官方 benchmark(查核 2026-08,同一頁文件)顯示它幾乎總是比 postFilter 慢,index 越大、filter 越挑(selectivity 越高)差距越大——百萬向量、filter 只留 2% 以下時可以慢到七倍。本系列這天只驗了 correctness,沒有量測任何 latency/throughput,所以我不能說自己的量級「碰不到」這個問題,只能說 25 chunks 的 smoke 構不成效能證據。選 preFilter 是把 recall/正確性排在速度前面的專案決策,不是普遍最優解。

這個系統沒有 403

被 filter 擋掉的文件,長什麼樣子?跟從來沒被索引過的文件一模一樣。它在被評分之前就被排除了,管線下游沒有任何訊號能區分「不存在」跟「存在但你不能看」。跨租戶的提問於是走到 Day 14 蓋好的結構:零命中就是 status: "no_answer"——跟語料裡真的沒答案完全同形。

這不是偷懶,是刻意的。403 是一個訊號:它告訴 caller「這裡你不能看的東西」。對知識庫來說,文件的存在性本身經常就是機密——「你無權查看《裁員名單.md》」洩漏的資訊可能比文件內容還多。同形(indistinguishability)才是這裡正確的合約:問了不該問的,得到的體驗跟問了不存在的一樣。

https://ithelp.ithome.com.tw/upload/images/20260815/20168288tBJ31VGzPN.png
忍喵:錯誤訊息也是回應的一部分——「你無權查看」這句話本身,就已經把檔案的存在洩漏出去了。

同一條原則蓋到對話狀態。Day 7 的 conversation store 這天改成以 (tenant_id, conversation_id) 為 key——沒有跳過 tenant 的查法。租戶 B 拿著租戶 A 的 conversation_id 來,得到的是 404 conversation_not_found,跟一個從來沒發出去過的 id 同一個回應,不是 403。而且這次失敗的嘗試不做任何 mutation——A 之後繼續用同一個對話,什麼都沒發生過。

寫入側:兩個「不會默默變公開」的設計

查詢側的 filter 再對,寫入側漏了就全白搭。兩個設計都是同一個原則:fail-closed——出錯的方向必須是「看不到」,不能是「多看到」

第一,allowed_groups 是必填欄位,缺了是 contract error,不是預設公開。 空清單 [] 是一個明確的宣告(tenant-wide 可讀);欄位不在、型別不對,loader 直接拒絕整份文件。如果缺值被解讀成「沒設就是公開」,那任何一個忘了寫 front-matter 的文件都會默默對全租戶敞開——錯誤的方向就反了。

這也是為什麼上線 ACL 前 index 要用 --recreate-index 重建。官方合約說得很清楚的部分是上傳側:collection 欄位不能存 null,上傳時省略會自動變成空 collection [](查核 2026-08,supported data types),而 not allowed_groups/any() 命中的就是空 collection——公開分支。

說得清楚的部分是遷移側:對著既有 index 加欄位、舊文件未重索引時到底是什麼狀態,reindex 文件用泛稱帶過、沒有解開 collection 專屬的行為,我這天也沒有做這個狀態的實測——所以「舊文件會默默變 tenant-wide 可讀」是一個未驗證的風險,不是我證明過的機制。重建的定位因此是 fail-closed 的預防:與其推理一個文件寫不清楚的遷移狀態,不如不讓 ACL 查詢跑在任何早於 ACL 欄位的文件上。

第二,文件 key 用 length-prefix 把租戶編進去。Day 12 的 chunk_id 是從裸的 doc_id 導出來的,跨租戶會撞:兩個租戶各自有一份 returns-policy.mdparent_id 相同,租戶 B 重建索引時的 stale 清理會把租戶 A 的 chunks 當過期資料刪掉。修法是把識別範圍改成 (tenant_id, doc_id),而且編碼用 length-prefix 而不是分隔字元:

parent_id = f"t{len(tenant_id)}={tenant_id}d{len(doc_id)}={doc_id}"
chunk_id = f"{parent_id}-{ordinal:04d}"

為什麼不直接 f"{tenant_id}--{doc_id}"?因為分隔字元不保證唯一解析:("a", "b--c")("a--b", "c") 拼出來是同一個字串 a--b--c——兩對不同的 (tenant, doc) 撞在同一個 key 上,而兩邊的值都沒有任何「不合法」的地方。

length-prefix 把邊界變成一個讀者可以重算的數字(= 後面讀 N 個字元),不是一個值裡可能剛好出現的字元:("a", "b--c")t1=ad4=b--c("a--b", "c")t4=a--bd1=c,長度不同,key 就不同。

=- 都在 Azure AI Search 的 document key charset 裡(字母、數字、_-=;查核 2026-08,naming rules),這個方案沒有引入任何新字元,只是重排既有的。

檢索得到,跟該不該引用,中間還有一條縫

Day 14 記了兩筆債,這天一起清償——它們跟授權是同一個主題的不同層:ACL 管「哪些文件進得了 context」,這兩筆管「進了 context 的文件能對模型做什麼」。

Source fencing:每筆檢索回來的 chunk 用 BEGIN UNTRUSTED SOURCE n / END UNTRUSTED SOURCE n 圍起來,疊在 Day 14「來源是資料不是指令」的模板規則上。要說清楚的是:這是標記,不是沙箱——一筆刻意寫成指令樣子的投毒語料,不會因為被圍起來就失效,它仍在威脅模型上。fence 買到的是讓模型分得清楚哪裡是來源、哪裡是指令,降低誤讀,不是消滅惡意。

Citation validation:模型答案裡的 [n] 標記,現在會對實際入選的來源編號做語法驗證——超界或憑空捏造的 [n] 被剝除、只記編號進 log、不讓請求失敗也不改 status。同樣要說清楚邊界:這驗的是「引用指向一個真的在 context 裡的來源」,不是「被引用的文字真的支持這句話」。後者是 groundedness,屬於評測(Day 28),不是這天的語法檢查能承諾的。

實測:三個探測,各自只證明一件事

合約層的測試(704 unit、BDD tenant_isolation.feature 4 scenarios)跑在 fake 上——fake 不解析 OData,它用 is_document_visible(document, principal) 這個純 Python 判斷式在評分之前先過濾(跟真路徑的 pre-filter 同形,不是選完 top-k 再丟)。

要誠實的是:它跟 build_acl_filter()同一份 policy 的兩套編碼——同住 services/acl.py,review 的時候一眼看得到彼此,但沒有任何機制強制兩邊同步;哪天有人改了一邊忘了另一邊,type checker 不會攔。兩邊的一致性靠的是同一組測試案例打在兩套編碼上,加上 live 探測——是有範圍的證據,不是結構保證。

但 Azure AI Search 是不是照設計解讀這串 filter,只有打真服務才知道。2026-07-31 在 japaneast 的 Free tier 服務上重建 index(25 chunks、兩個租戶的目錄語料),跑三個 target-visibility 探測:

  1. 授權基線:帶正確群組的 principal 查詢,目標文件出現在命中裡——證明 filter 沒有把該看到的擋掉(fail-closed 做過頭也是 bug)。
  2. 缺群組排除:同租戶、不帶該群組的 principal,同一個問題,目標文件不在命中裡——document-level ACL 生效。
  3. 跨租戶排除:另一個租戶的 principal,目標文件不在命中裡——tenant 底線生效。

三探全 PASS。留檔的 evidence 裡,請求本體不含原始 ACL filter 與 tenant/group 字面值,只記 filter_present=true 加 filter 的 SHA-256——證據檔案本身也不能變成群組成員資格的洩漏管道。

這天的誠實邊界

  • Header 身分是 impersonation 輸入,不是 authentication。 成立前提是 gateway 剝除 client 身分 header 且 backend 只經 gateway 可達;直連 backend 等於任意換人。Day 19 換成驗過的 token。
  • 隔離是邏輯的,不是物理的。 所有租戶的 chunks 住在同一個 index,中間隔的是一條 filter——擋在租戶之間的是 services/acl.py 的正確性,不是任何硬體或網路邊界。這是共用 index 的固有代價,不是可以之後修掉的缺口;要物理隔離就是 index-per-tenant,那是另一組成本與運維的取捨。
  • fake 的一致性是有範圍的證據,不是等價證明。 is_document_visiblebuild_acl_filter 是同一 policy 模組裡的兩套獨立編碼,一致性由共用測試案例與 live 探測支撐;「OData 引擎對 filter 的解讀」只有 live 探測蓋到,而那是單次量測(2026-07-31、japaneast、Free tier、api-version 同 Day 13)。
  • 效能完全沒有量測。 這天只驗 correctness/visibility,沒有 preFilter/postFilter 的 latency、throughput、filter selectivity 或 shard 行為的任何數字;官方 benchmark(查核 2026-08)是外部風險參考,不是本 lab 的實測。
  • fencing 是緩解不是免疫,投毒語料仍在威脅模型上;而且這組標記是公開 repo 裡的固定字串,語料作者不需要模型配合,自己在內文打一行結束標記,後面的文字就「走出」了圍欄——這一條記到 Day 21 收;citation 驗證是語法不是證據,groundedness 在 Day 28。
  • per-tenant quota、rate limit、計費歸屬都不在這天——身分還是 header 給的,先做配額只是在幫 impersonation 旋鈕記帳。

下一篇

到這裡,Chat 與 RAG 兩條路都通了、有帳、有權限。Part 3 收工。下一個問題不是「還能加什麼」,而是「什麼時候該停」:很多場景 Chat API 加上好的 prompt 就夠了,硬上 Agent 只是把簡單問題變貴、變慢、變難除錯。Day 16 進 Part 4,先講清楚 Chat API、RAG、Agent 的分界線——不要為了 Agent 而 Agent——再決定哪些問題值得讓模型自己拿工具。

完整程式碼在 day-15 tag,CI 綠。

用到的 Azure 服務:Azure AI Search(Free tier,--recreate-index 重建與三個 ACL 探測,2026-07-31)、Azure OpenAI(embed-small 查詢向量、chat-mini 生成,僅 smoke 期間)。

Free tier Search 收費為 $0,但容量受限(50 MB、最多 3 個 index,查核 2026-08,pricing);用完記得 infra/scripts/delete-search.sh


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


上一篇
Day 14:實作 RAG API——Retrieve → Augment → Generate,以及「我不知道」為什麼不能交給分數或 prompt
下一篇
Day 16:什麼時候需要 Agent——分界線不是任務難度,是控制流的所有權
系列文
Backend 工程師的 Azure GenAI 實戰17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言