Alice 是 bamboo-hq 的員工。她對 Agent 說:「請找月兔實驗室的費用政策。」脆弱版 RAG 採信請求中的租戶名稱,真的回來一份 moon-rabbit-lab-private 文件。
接著 Mallory 先查一次「月兔實驗室內部採購規範」,把結果寫進全域 cache;Alice 使用相同 query 時,又從 cache 拿到那份外租戶文件。最後即使完全不走 search,只要直接傳入 document ID,Alice 還是讀得到它。
這不是三次不同的意外,而是同一個 tenant invariant 在三條 read path 消失:
| 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 是工作區與治理範圍,不是租戶結界。大魔術熊貓工程司若在同一個 Project、model deployment 或 Search index 服務多家終端客戶,應用程式仍要把 SaaS tenant 帶過每一條資料路徑。
本篇的 bamboo-hq、moon-rabbit-lab、Alice、Mallory 與所有文件都是合成資料。本機 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 的 request 要求 tenant=all 時,脆弱 Search path 回傳外租戶文件。

Caller 要求的 all scope 不能取代 server principal 的 tenant boundary。 可觀察狀態:subject=alice、principal tenant=bamboo-hq、requested_tenant_id=all;results 含 moon-rabbit-lab-private。 Claim boundary:不是 Azure AI Search query 或真實 identity token。
D15-A01 的 user_message 就是開頭那句「請找月兔實驗室的費用政策」。Stage 沒有走一個不存在的 retrieval HTTP API,而是把應用層解析出的 query 與未信任 scope 送給目前的 component:
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,資料邊界就已經失守。最後回答沒有引用它,只能算這次運氣不錯。
畫面判讀目標: 看見相同 query 的 Mallory cache fill 被 Alice replay 重用並回傳外租戶 ID。

Search filter 正確,不代表 cache partition 正確。 可觀察狀態:Mallory 與 Alice 使用相同 query、不同 principal tenant;cache_bleed_ids=[moon-rabbit-lab-private]。 Claim boundary:只測 deterministic cache;未涵蓋 production cache eviction 或 distributed nodes。
D15-A02 先讓 Mallory 搜尋「月兔實驗室內部採購規範」,把 moon-rabbit-lab-private 存進 cache。脆弱 key 只有 normalized query:
moon rabbit private policy
Alice 再送相同 query 時直接得到 cached document IDs;如果 cache hit 後不重新授權,search filter 寫得再漂亮也不會被執行。
這類旁路常出現在效能優化之後。團隊修了主要 query path,cache hydrate、citation expansion、conversation restore 與 background prefetch 卻還沿用舊資料形狀。
畫面判讀目標: 看見 direct by-ID sibling path 如何繞過 search filter。

修 search query 不會自動修好 direct-ID path。 可觀察狀態:subject=alice、principal tenant=bamboo-hq、requested ID=moon-rabbit-lab-private;by_id_bleed_ids 回傳同一 foreign ID。 Claim boundary:不證明真實 Search index 可被相同 ID 讀取。
最後呼叫:
retriever.by_ids(
["moon-rabbit-lab-private"],
principal=alice,
)
如果 by_ids() 只照 ID 取資料,不重新比對 document tenant,known ID 就成為 filter bypass。Search 結果上的 citation、瀏覽器歷史、trace 或 cache 都可能洩露 ID;ID 很難猜不是授權控制。
安全資料流如下:
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 的欄位與 operator 固定,value 由 server principal 派生。

只 escape trusted value,不能讓 request 選欄位或 operator。 可觀察狀態:expected filter 與 actual filter 精確相等,foreign result 為零。 Claim boundary:沒有執行 Azure AI Search 或 native 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 再做 tenant assertion。這是 defense-in-depth,不是用 post-filter 取代 server-side filter;若先把全部文件載回應用程式,資料早已擴大到另一個 trust boundary。
畫面判讀目標: 確認 cache key 綁定 tenant 且 hit 後重新授權文件。

Cached result 不是永遠有效的 authorization decision。 可觀察狀態:tenant partitions 不同,每次 hit 對應一次 by_ids eligibility recheck。 Claim boundary:不涵蓋 policy epoch、group membership 或 distributed cache races。
安全 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。
這仍不是 RFC 8785 或跨語言 canonical JSON 宣稱。Version prefix 讓日後可以遷移 key schema,structured material 解決本次 delimiter ambiguity,但 v1 尚未納入 authorization/policy epoch。
Cache value 也不能當授權結果。命中後只拿 document IDs,重新走安全 by_ids()
載入,重驗目前的 tenant、核准 revision、integrity 與 validity,再進 pre-model
assertion。Partition 降低誤命中;這條路沒有檢查 role、group membership 或
policy epoch,因此不能籠統寫成所有權限變化都已重新授權。
可執行 fixture 會讓 Alice 對「大魔術熊貓工程司費用政策」查兩次:第一次寫入 secure cache,
第二次必須得到 cache_hits=1 與 cache_hit_reauthorizations=1,而且 hit 後的 document
IDs 與第一次相同。這不是從 code path 猜測,而是本篇 acceptance 與 renderer
實際輸出的計數。
畫面判讀目標: 確認 by-ID 與最終 context assembly 都保留 tenant invariant。

同一 principal 必須走到模型前最後一刻。 可觀察狀態:by-ID 零結果;manual mix 在 pre-model assertion fail closed。 Claim boundary:只涵蓋列出的三條 read paths 與 fixture documents。
安全 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 的位置。
截至 2026-08-03,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-05-01-previewAzure AI Search 的 POSIX-like ACL、RBAC scope、Purview label 與 SharePoint ACL 等原生 document-level permissions,官方目前放在 2026-05-01-preview。Query-time enforcement 同時需要:
x-ms-query-source-authorization 帶入終端使用者 token;針對直接查詢 index 的 query-time ACL enforcement,官方文件說明 ACL evaluation 失敗時回 5xx,不回部分過濾結果;Knowledge Base retrieve 另有自己的 partial-result 語意,不能把這句話直接搬過去。permission freshness 仍受 indexer、push update 或 resync 影響。
Foundry IQ/Azure AI Search knowledge-base retrieve 還有一個需要特別標紅的行為:對 permission-enabled knowledge source,若沒有 end-user identity token,官方文件寫明結果會以未過濾方式回傳;若 ingestion 時沒設定 permission metadata,即使傳 header 也仍是 unfiltered。因此 wrapper 必須在呼叫前檢查 token 與 knowledge-source configuration,缺少就 fail closed,不能期待服務替應用程式猜出 Alice 是誰。
Foundry readiness 頁面目前將 Knowledge(Foundry IQ)列為 Partial GA:API-level GA,Portal access 仍為 Preview;permission enforcement 子功能又使用 preview API。文章與架構決策必須標出自己使用的是哪個 surface。
雲端驗證:
PENDING-CLOUD
本篇沒有建立 Azure AI Search index,沒有取得 OData request、Search data-plane 403/200、x-ms-query-source-authorization或 Foundry IQ retrieve 證據。本機 oracle 只能證明 V2 application invariant。
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
2026-08-03 以 cache-safe pytest command 重新實跑結果為 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,沒有產生 PolicyDecision 或 tool Receipt。第一份 structured capture 因此明寫 decision not observed;第二份 structured capture 顯示的 PRE_MODEL_TENANT_ASSERTION:... 是 RetrievalSecurityError 的 reason string,不應冒充 policy engine 的 deny decision。
Acceptance suite 實際還驗:
by_ids(),且 counter 顯示一次 hit 對一次文件資格重驗;攻擊/before-state UI 要讓三條 vulnerable paths 同時出現,並標出同一份 foreign document。只截 search 漏 filter,會掩蓋 cache 與 direct-ID 旁路。Stage JSON 裡的 external_calls=0、cloud_calls=0 沒有 network instrumentation,不能當成量測;app UI screenshot 會正規化成 null/not observed。這些本機 UI 圖都不能用來推論 Azure AI Search 或 Foundry IQ 的行為。
修補/test UI 要顯示 exact server filter、不同 cache partition、by-ID 零結果與 pre-model assertion,最後 foreign hits 歸零。
正式發布時,本篇 required app UI 圖必須由目前相關原始碼經 repository UI capture pipeline
產生。schema 3 screenshot manifest 必須以 source-tree SHA-256 與檔案數綁定實際輸入,並由
strict verifier 重算。正式圖只支持本機 retriever 與 trusted fixture,不是 Azure AI Search/Foundry IQ cloud
evidence,也未證明真實 token 產生的 principal。
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 拆開,補上真正的身分來源。
以下資料均於 2026-08-03 查閱:
明天會把 test harness 注入的 Principal 換成可驗證的 Entra 使用者與 workload identity。Tenant filter 要先知道「Alice 到底是誰」,否則每條 read path 都只是整齊地相信錯誤身分。