Day 07 已確認文件版本有沒有通過核准,Day 14 也處理了輸出遮罩。今天再補一個更前面的條件:Alice 是否有權把這份文件放進 context?
我們檢查三條讀取路徑:正常 search、cache hit,以及直接提供 document ID。脆弱版本都可能把 moon-rabbit-lab-private 交給 bamboo-hq 的 Alice;修補後則要在每條路徑維持相同的租戶限制。
| Case | 路徑 | Vulnerable 結果 |
|---|---|---|
D15-A01 |
Alice 用普通句子查外租戶政策,search 採信 request scope | 回傳 moon-rabbit-lab-private |
D15-A02 |
cache key 只有 query | Alice 命中 Mallory 的 cached result |
D15-A03 |
by_ids 沒有 tenant gate |
直接讀取 moon-rabbit-lab-private |
D15-F01 |
安全版重放相同外租戶請求 | 外租戶文件不進入 context |
D15-B01 |
Alice 查自家費用政策 | 仍能回傳 bamboo-hq 文件 |
Foundry Project 是工作區與治理範圍,應用程式仍要自己處理 SaaS tenant。這次使用本機 retriever 與合成文件,不會連接 Azure AI Search。
Azure AI Search 的 data-plane RBAC 和應用程式的 document authorization 回答不同問題:
如果每次 query 都使用同一個 managed identity,而且沒有把終端 subject/tenant 帶進 filter 或 token-based permission enforcement,那麼 workload 會成為能讀整個 index 的 deputy。
Foundry architecture 文件也說明,Foundry Project 管理 files、agents、evaluations 等 project assets;Azure AI Search、Storage、Key Vault 等 connected resources 是獨立 Azure resources,各自有 networking、access policy 與 compliance boundary。即使 Project 本身有 RBAC,也不會自動替 Search index 裡的大魔術熊貓工程司 tenant 欄位加上 filter。
ACL 2024 的 RAG privacy 研究指出,RAG 會增加 retrieval database 的隱私外洩面。本文不把該論文當成 cross-tenant authorization benchmark,而是用它提醒一件事:先分清秘密來自 model training、retrieval index 還是 tool,修補才放得對位置。本日 scorer 計數的 MRL-POLICY-REF-7421 明確來自 foreign index document,不是 Alice 自己在 prompt 裡念出來的測試答案。
Alice 要求 tenant=all 時,先看脆弱的搜尋路徑會回傳哪些文件。

Alice 的 principal 屬於 bamboo-hq,請求卻填了 requested_tenant_id=all,結果就包含 moon-rabbit-lab-private。呼叫者要求的範圍不能取代伺服器掌握的租戶限制。這裡沒有呼叫 Azure AI Search,也沒有使用真實的身分 token。
D15-A01 沿用開頭的「請找月兔實驗室的費用政策」。這裡直接測 retrieval component,把 query 與應用程式解析出的未信任 scope 傳進去:
vulnerable.search(
"expense policy",
alice,
requested_tenant_id="all",
)
requested_tenant_id 不是使用者要在對話裡解釋的 JSON 欄位,而是應用層根據請求解析出的未信任 scope。脆弱 Repository 直接採信它,所以回傳外租戶文件:
[
"moon-rabbit-lab-private"
]
這份文件只會像一份真正的內部規範,不會自報它在測試裡承擔什麼用途:
月兔實驗室內部採購規範:新供應商須經兩位主管覆核;
內部參考編號 MRL-POLICY-REF-7421。
檢查點要放在 foreign chunk 進入 reranker、model context 或 trace 以前。最後回答沒有引用外租戶資料,也不能抹去前面已經發生的未授權讀取。
先由 Mallory 查詢並寫入 cache,再讓 Alice 送出相同查詢。

兩人的 query 相同、principal tenant 不同,Alice 卻讀到 cache_bleed_ids=[moon-rabbit-lab-private]。即使搜尋本身有 filter,cache 分區仍可能出錯。這裡只測固定的本機 cache,沒有涵蓋正式系統的快取淘汰或分散式節點。
D15-A02 先讓 Mallory 搜尋「哪一份採購規範要求兩位主管覆核?」,把 moon-rabbit-lab-private 存進 cache。脆弱 key 只有 normalized query:
哪一份採購規範要求兩位主管覆核?
Alice 再送相同 query 時直接得到 cached document IDs;如果 cache hit 後不重新授權,search filter 寫得再漂亮也不會被執行。
加入 cache 後,請求可能不再走原本的 query path。因此除了主搜尋,cache hydrate、citation expansion、conversation restore 與 background prefetch 也要列進檢查,確認沒有沿用缺少租戶資訊的結果。
接著跳過搜尋,直接提供文件 ID,檢查這條路徑有沒有同樣的租戶限制。

Alice 屬於 bamboo-hq,要求的 ID 是 moon-rabbit-lab-private,by_id_bleed_ids 仍回傳了這份外租戶文件。搜尋過濾不會自動保護直接讀取 ID 的路徑;這份結果也不能證明真實 Search index 可以用相同 ID 讀取。
最後呼叫:
retriever.by_ids(
["moon-rabbit-lab-private"],
principal=alice,
)
by_ids() 也需要確認文件 tenant。文件 ID 可能出現在 citation、瀏覽紀錄、trace 或 cache,取得 ID 的人並不因此擁有閱讀權;這條直接讀取路徑要和 search 使用同樣的授權條件。
安全資料流如下:
trusted principal fixture
-> server-derived tenant filter
-> tenant + subject + query cache partition
-> by-ID tenant authorization
-> pre-model context assertion
Day 15 的 principal 仍由 test harness 固定,用來隔離 retrieval invariant。真正的 Entra identity boundary 會在 Day 16 接上;在那之前不能採信 request body 裡的 tenant。
修補時,先固定 OData filter 的欄位與運算子,只讓伺服器的 principal 提供租戶值。

expected filter 與 actual filter 完全相同,外租戶結果為 0。程式只 escape 可信來源的 value,沒有讓 request 自選欄位或 operator。這是本機檢查,尚未執行 Azure AI Search 或原生 ACL Preview API。
呼叫端不能提供完整 OData expression。Builder 固定 tenant_id 與 eq,只處理 server-derived value:
def escape_odata_string(value: str) -> str:
return value.replace("'", "''")
def tenant_filter(principal) -> str:
tenant = escape_odata_string(principal.tenant_id)
return f"tenant_id eq '{tenant}'"
輸入像:
bamboo-hq' or tenant_id ne 'bamboo-hq
應被包在單一 string literal 內:
tenant_id eq 'bamboo-hq'' or tenant_id ne ''bamboo-hq'
這只驗 escaping contract,不代表任意 tenant 字串都可接受。Principal 本身仍要驗 token issuer、audience、subject 與 tenant,Day 16 才處理。
Search response 回來後,application 再檢查一次 document tenant,避免不符合條件的資料進入模型。這層 assertion 補在 server-side filter 後面;若先讀回所有文件才過濾,資料已經跨過更多邊界。
再確認不同租戶使用不同 cache key,而且命中後仍會重新檢查文件。

兩個租戶的 partition 不同,每次 cache hit 也都對應一次 by_ids 文件資格檢查。快取不能永久保存一次授權結果;不過這裡還沒檢查 policy epoch、群組成員變更或分散式 cache 的競態。
安全 cache key 至少包含 tenant、subject 與 normalized query:
def secure_retrieval_cache_key(principal, query: str) -> str:
material = {
"tenant_id": principal.tenant_id,
"subject": principal.subject,
"query": normalize_retrieval_query(query),
}
return "retrieval:v1:" + sha256_json(material)
sha256_json() 以 repository 的 stable JSON contract 固定欄位順序與 separator,再輸出 64 位小寫 hex。新增 regression 故意建立兩組在舊式 tenant:subject:query 下會得到相同字串的 principal,確認 retrieval:v1:<digest> 仍不同;同一 principal 的 " POLICY " 與 "policy" 則正規化成同一 key。
Version prefix 預留日後遷移 cache key 格式的空間,structured material 則解決這次的分隔字元碰撞。這份穩定 JSON 格式限於 lab,沒有宣稱跨語言 RFC 8785 相容,v1 也尚未包含 authorization 或 policy epoch。
Cache value 也不能當授權結果。命中後只拿 document IDs,重新走安全 by_ids() 載入,重驗目前的 tenant、核准 revision、integrity 與 validity,再進 pre-model assertion。Partition 降低誤命中;這條路沒有檢查 role、group membership 或 policy epoch,因此不能籠統寫成所有權限變化都已重新授權。
測試讓 Alice 對「大魔術熊貓工程司費用政策」查兩次。第一次寫入 cache,第二次應得到 cache_hits=1 與 cache_hit_reauthorizations=1,而且文件 IDs 與第一次相同。這兩個計數可以確認 cache 確實命中,也確實重新檢查了文件。
最後看直接讀取與 context 組裝,確認租戶限制一路保留到模型前。

直接讀取外租戶 ID 得到 0 筆;手動混入外租戶文件,也會在模型前的 assertion 被拒絕。這組結果涵蓋本文列出的三條讀取路徑與測試文件,其他入口仍需另測。
安全 by_ids() 會拒絕 foreign tenant、quarantined、integrity mismatch 與 invalid document。最後在組裝模型 context 前再檢查一次:
def assert_pre_model_context(self, documents, principal):
self.pre_model_assertions += 1
foreign = [
doc.document_id
for doc in documents
if doc.tenant_id != principal.tenant_id
]
if foreign:
raise RetrievalSecurityError(
"PRE_MODEL_TENANT_ASSERTION:" + ",".join(sorted(foreign))
)
Search、cache、by-ID 都可能各自正確,後面的 merge、rerank 或 batch assembly 仍可能混入別的 request。把 assertion 放在敏感 sink 前,可以讓錯誤停在最靠近模型 context 的位置。
拿今天的 bamboo-hq/moon-rabbit-lab 案例接雲端時,先確定是應用程式自己加 OData filter,還是讓 Search 的原生文件權限處理。兩條路需要不同的 index metadata 與 caller identity;Foundry 的 knowledge connection 也不能替遺漏的 end-user token 補出授權。三條讀取入口——query、cache、by-ID——仍要一起回歸。
Microsoft 官方文件提供兩條不同路徑:
Security filter 以 filterable string field 儲存 user/group identity,query 時由應用程式把 caller identity 放入 filter。官方的 document-level access overview 將這個應用端 pattern 列為 API-agnostic 且 generally available;它使用一般 OData filter 操作,不是下一節的 preview native document ACL。Search 在這條路只做字串比較,不會替你證明傳入的 tenant_id 真屬於 Alice。漏套 filter 的 sibling path 仍會洩漏。
2026-08-01-previewAzure AI Search 的 POSIX-like ACL、RBAC scope、Purview label 與 SharePoint ACL 等原生 document-level permissions 仍是 Preview,官方範例目前使用 2026-08-01-preview;SharePoint site group 與 elevated read 則從 2026-05-01-preview 開始提供。Query-time enforcement 同時需要:
x-ms-query-source-authorization 帶入終端使用者 token;針對直接查詢 index 的 query-time ACL enforcement,官方文件說明 ACL evaluation 失敗時回 5xx,不回部分過濾結果;沒有帶使用者 token 時,自 2025 年 11 月起只回傳所有人都能看的公開文件。Knowledge Base retrieve 另有自己的 partial-result 語意,不能把這句話直接搬過去。permission freshness 仍受 indexer、push update 或 resync 影響。
Foundry IQ/Azure AI Search 的 knowledge-base retrieve 還有一個要注意的行為:對已啟用權限的 knowledge source,若缺少 end-user identity token,官方文件說明會回傳未過濾結果。Ingestion 沒有設定 permission metadata 時,也不會只因傳入 header 就開始過濾。
所以 wrapper 要在呼叫前確認 token 與來源設定,缺少就拒絕。這個檢查要由應用程式完成,不能等服務猜出 Alice 可以看哪些文件。
Foundry readiness 頁面目前將 Knowledge(Foundry IQ)列為 Partial GA:API-level GA,Portal access 仍為 Preview;permission enforcement 子功能又使用 preview API。文章與架構決策必須標出自己使用的是哪個 surface。
NIST SP 800-207/207A 的 Zero Trust 原則不因網路位置、資源所有權或共用 Project 而給予隱含信任。本文把它落成「每一條 document read 都攜帶 subject,對目前 resource tenant/revision 重驗;cache hit 也不能直接信任 cached result」。本機證據只涵蓋 tenant/revision invariant,role、group membership 與 policy epoch 的 reauthorization 尚未包含在內。
從 repository root 執行:
cd day15
uv sync --locked
uv run pytest tests/stages/day15/test_acceptance.py -q
uv run python scripts/render_stage_evidence.py --day 15 --evidence-id d15-cross-tenant-retrieval-attacks
uv run python scripts/render_stage_evidence.py --day 15 --evidence-id d15-filtered-retrieval-control
這組本機測試的保存結果為 2 passed,兩個 renderer 亦成功完成。Bounded evidence 以兩份 structured capture 分開呈現:
slot 1 case_ids=[D15-A01,D15-A02,D15-A03]
missing_filter_ids=[policy-authoritative,moon-rabbit-lab-private,policy-conflicting]
cache_bleed_ids=[moon-rabbit-lab-private]
by_id_bleed_ids=[moon-rabbit-lab-private]
foreign_canary_hits=3
decision.reason_observed=false
slot 2 case_ids=[D15-F01]
foreign_canary_hits_before_after=[3,0]
server_filter_exact_match=true
secure_cache_key_aliases_distinct=true
cache_hits=1
cache_hit_reauthorizations=1
pre_model_assertions=8
decision.reason_codes=[PRE_MODEL_TENANT_ASSERTION:moon-rabbit-lab-private]
external_calls=0
cloud_calls=0
這份 stage 測的是 retrieval component,沒有工具 Receipt 或 PolicyDecision。PRE_MODEL_TENANT_ASSERTION:... 是 RetrievalSecurityError 的 reason string;看見它代表模型前的文件檢查失敗,不能把它記成 policy engine 的工具拒絕。
Acceptance suite 實際還驗:
by_ids(),且 counter 顯示一次 hit 對一次文件資格重驗;把三條路徑並排,就能看見 search、cache 與 direct-ID 都曾回傳同一份外租戶文件。JSON 裡的 external_calls=0、cloud_calls=0 沒有接網路觀測器,畫面因此以 null/not observed 顯示;這裡實際確認的是文件讀取結果。
修補後,再核對伺服器產生的 filter、不同使用者的 cache partition、直接讀 ID 的結果與模型前檢查,確認三條路徑的外租戶命中都歸零。
Structured hash 已消除本日測到的 delimiter collision,但 key 仍缺 authorization_epoch 或 policy version。角色撤銷、群組 membership 更新或 policy version 改變時,舊 entry 不會因 tenant/subject/query 相同而自動失效,而且本日 by_ids() 不會判斷這些變化。正式系統要加入 policy epoch、短 TTL、精確 eviction, 並在 cache hit 後走完整的 current authorization,而不只重驗文件 tenant/revision。
Native ACL 也有同步延遲。來源系統撤權後,permission metadata 尚未經 indexer、push update 或 resync 更新前,Search 看到的仍可能是舊權限。高風險資料需要 authoritative post-check、短暫 deny 或同步完成證據。
Chunking 會再製造旁路。Document ACL 若只留在母文件,chunk projection 沒帶過去,模型讀到的仍是未受控 chunk;citation expansion、reranker、hybrid search、suggest/autocomplete 與 background summarization 也要逐路徑檢查。
最後,Day 15 的 principal 仍是固定 fixture。若正式 API 從 request body 取得 tenant,再精確的 OData filter 都只是把攻擊者提供的值執行得非常精確。Day 16 會把 Alice、FastAPI workload、Foundry Project managed identity 與 Agent identity 拆開,補上真正的身分來源。
明天會把 test harness 注入的 Principal 換成可驗證的 Entra 使用者與 workload identity。Tenant filter 要先知道「Alice 到底是誰」,否則每條 read path 都只是整齊地相信錯誤身分。