「RAG 實戰筆記」第四篇。企業要導入文件問答,第一個問題永遠不是準不準,而是:機密文件會不會被不該看的人問出來? 這篇拆解我的平台如何回答這個問題——核心原則只有一句話:權限在檢索層強制執行,絕不交給 LLM 把關。從 RBAC 模型、切塊 payload 設計、Qdrant filter 的組法,到幾個「不做就會被繞過」的細節設計。(前情:RAG 基礎、混合檢索拆解)
一、為什麼不能讓 LLM 把關權限
直覺的做法:把權限規則寫進 prompt——「你不可以透露機密文件的內容」。這條路是死路,原因有三個層次:
- LLM 是可以被說服的。 Prompt injection、角色扮演、「我是管理員」——模型的服從性本身就是攻擊面。把權限交給一個「會被聊天內容影響的元件」把關,等於把金庫鑰匙掛在會跟陌生人聊天的警衛身上。
- 進了 prompt 就等於外洩。 就算模型忍住不直接複述,機密內容已經影響了它的回答——摘要、暗示、選字都可能洩漏。真正的邊界必須在內容進入 prompt 之前。
- 連「存在」本身都是資訊。「我找到了相關文件但不能告訴你」——這句話已經洩漏了機密文件的存在與主題方向。
所以我的平台有四條鐵則,第一條就是:權限在檢索層強制執行。使用者無權看的切塊,根本不會出現在檢索結果,不會進 rerank,更不會進 prompt——對整條下游管線來說,那些文件不存在。
二、RBAC 模型:角色決定看得到什麼
權限模型參考 RuoYi 的 RBAC 設計:
1 | 使用者 ──* 角色 ──┬─* 功能權限(能用哪些功能) |
資料權限拆成四個維度,前三個管「範圍」、第四個管「密級」,彼此正交:
| 維度 | 意義 |
|---|---|
public |
全公司公開文件 |
own_dept |
自己部門的文件 |
cross_dept |
其他部門的文件 |
confidential |
機密文件(與範圍正交) |
「正交」是關鍵設計:一份文件可以同時是「人資部 + 機密」,要看到它必須同時具備部門範圍權限與機密權限。預設的角色矩陣:
| 角色 | public | own_dept | cross_dept | confidential |
|---|---|---|---|---|
| 一般員工 | ✅ | ✅ | ✕ | ✕ |
| 部門主管 | ✅ | ✅ | ✕ | ✅ |
| 系統管理員 | ✅ | ✅ | ✅ | ✅ |
| 稽核 | ✅ | ✕ | ✕ | ✕ |
而且這張表不是寫死的——它存在資料庫裡,管理員可以在介面上編輯。這帶出一條隱形的設計紀律:程式碼裡永遠不出現 if role == "admin" 這種硬編碼判斷,一律查權限表。否則日後新增角色時,散落各處的角色判斷就是一顆顆地雷。
三、資料端:權限住在每一個切塊上
檢索層要能過濾,前提是每個切塊自帶權限標籤。文件切塊寫入 Qdrant 時,payload 除了原文和來源,還帶兩個權限欄位:
1 | { |
一個看似小事、實則重要的決定:scope 存部門代號(HR、IT),不存中文名稱。中文名稱只是 departments 表裡的顯示標籤——部門改名時只改標籤,幾十萬個切塊的 payload 一個都不用動。
那如果真的要改代號呢?這裡藏著一個多資料庫架構的經典陷阱:權限表在 SQLite、文件記錄在 MongoDB、但 scope 複製在每一個 Qdrant 切塊的 payload 裡。只改前兩者的話,檢索層還在用舊代號比對——結果是那個部門的員工突然什麼都查不到。所幸方向是安全的(fail-closed:資料消失而不是外洩),但功能就是壞了。所以系統提供了 rename_scope:用 Qdrant 的 set_payload 批次把所有 scope == 舊代號 的切塊改成新代號,並回傳受影響的切塊數供比對。冗餘欄位帶來檢索效率,也帶來同步義務——這是把權限放進檢索層的成本,值得,但要記帳。
四、查詢端:從 JWT 到 Qdrant Filter
完整的請求路徑是三段接力,每一段只做自己的事:
1 | 前端 ──JWT──▶ C# 閘道:驗證身分 → 查角色的 data_scopes → 展開成 perm_filter |
身分由閘道從 JWT 注入——這是鐵則第二條。使用者說自己是誰不算數,LLM 認為使用者是誰更不算數;權限條件是後端查表展開的,前端傳什麼都不採信。
Agent 端把權限條件翻譯成 Qdrant Filter 的核心邏輯(節錄實際程式碼):
1 | def build_permission_filter(perm_filter: dict | None) -> models.Filter | None: |
最值得停下來看的是中間那段刻意的彆扭:當使用者沒有任何可見範圍時,函式不回傳 None,而是回傳一個匹配 scope == "__none__" 的 filter——一個保證撈不到任何東西的條件。因為在這個介面裡 None 的意思是「不過濾」;如果「無權限」被表示成 None,一個上游的疏忽就會讓無權限使用者看到全部文件。這就是 fail-closed 設計:預設值永遠朝安全的方向倒。
最後一哩:這個 filter 套在混合檢索的 prefetch 層(dense 與 sparse 兩路都套)。這意味著權限過濾發生在召回之前,而不是撈回來再刪——差別很實際:
- 召回名額不被無權文件浪費(top-20 全是你看得到的)
- rerank 不用處理註定被丟掉的候選
- 不存在「先看到再遮住」的時間窗
五、應用層的防繞過設計
檢索層是地基,但權限系統的完整性取決於每一條繞過路徑都被堵上。幾個實例:
上傳權與定範圍權是兩個權限。 docs.upload(能上傳)和 docs.scope(能決定文件給誰看)刻意拆開。部門主管有前者沒後者——他上傳的文件一律鎖定為自己部門,用戶端指定什麼 scope 都直接覆寫(並落一筆 SCOPE_OVERRIDDEN 稽核記錄)。同時,事後修改 scope 也被封死(回 403)——只擋上傳的話,「先上傳成部門文件、再編輯成 public」就繞過去了。但改機密等級仍被允許:把自己部門的文件標成機密只會更嚴,不構成風險。每條規則都對應一條被想像過的繞過路徑。
稽核角色能查軌跡、不能藉軌跡讀內容。 稽核能看所有人的問答紀錄(誰問了什麼、引用了哪些文件),但紀錄裡的引用只存檔名與切塊編號,不存全文——稽核權限不會變成讀機密文件的後門。
機密文件連生成階段都被隔離。 檢索結果裡如果含機密切塊,Router 會把生成路由到地端 LLM(文件內容不出內網);地端模型不可用時直接拒絕服務,而非降級改用雲端——「不可用」的正確反應是失敗,不是妥協。
測試測的是繞過,不是正常流程。 權限測試專門驗證攻擊路徑:指定 public 上傳會不會被覆寫?先上傳再編輯會不會被擋?正常流程會過只證明功能存在,繞過測試才證明邊界存在。
小結:權限系統的三層縱深
1 | 資料層 每個切塊自帶 scope + confidentiality(同步義務:rename_scope) |
貫穿三層的是同一個思維:不信任下游。不信任 LLM 會守規矩、不信任前端傳來的參數、不信任「正常使用者不會那樣操作」。權限系統的品質不在正常路徑多順暢,而在每一條歪路是不是都走不通。
RAG 系列四篇到此完成一個段落:基礎邏輯、向量庫與 MongoDB、混合檢索、權限實作。後續主題(語意快取、結構化查詢、評測方法論)等專案推進再寫。
留言