192.168.50.188:25432 的 guidant_ai_dev)唯讀查詢 + BE 主專案與 jedi-* 套件原始碼 grep,沒有修改任何程式碼、沒有套用任何 SQL、沒有真的切租戶測試f64f5fb3,branch feature/FR-075(未 push)13 張表、4 支畫面(技術上叫 view)真的把「每個客戶只能看自己資料」這道隔離關掉了,而且已經被首腦實測驗證:拿一把指向不存在客戶的鑰匙去查,待辦任務應該看到 0 筆、實際看到 39 筆;專案應該看到 0 筆、實際看到 213 筆。
好消息:11 張表的修法是純資料庫開關(把已經寫好的規則打開即可),1 張表要先補一段規則再開,1 張表要先做決定(要不要對外分公司開放平台版問卷)。壞消息:有一件事必須先查清楚才能動手——待辦任務清單的畫面(vw_user_job_queue)同時是每天凌晨自動清孤兒資料的背景程式在用的同一批底層資料,那支背景程式現在能跑起來,靠的正是「暫時允許背景程式看到全部客戶資料」這條規則(CM-1559 已經記錄過這條規則本身也是一個洞)。先把兩件事一起規劃,否則修好隔離會連帶讓背景清理程式停擺。
另外查到一件工具/前一棒交接檔沒寫清楚、需要更正的事:平台管理員(原廠管理者)看到全部客戶資料,走的是跟「隔離關掉」完全不同的機制——他們用的是「我是最上層客戶」這條合法通道,不是靠隔離沒開才看得到。所以開隔離不會弄壞平台管理後台,這件事可以放心做。
我們的產品是多家客戶(在系統裡叫「租戶」)共用一套系統的。靠資料庫裡的一道機制把各家的資料隔開,這道機制的技術名字叫 RLS(Row Level Security,白話講就是「資料庫自己會擋,讓每個客戶只查得到自己的資料,不用每支程式各自小心」)。
2026-09-11 決策者實際測試發現:把自己(原廠管理員帳號)切換到一個被指派的「子租戶」身分之後,還是看得到別家客戶的待辦任務與專案。這代表這道隔離機制在某些地方沒有真的生效。
這一棒的工作:把「哪些資料現在該被隔開、實際上有沒有隔開」逐張表盤點清楚,整理成一張對照表,交給決策者決定怎麼修、先修哪個。不修、不改、不套任何 SQL。
產品定的規則(判準,不是本棒發明的):客戶資料分三層——
| # | 這是什麼資料(白話) | 該是哪一層 | 現在是哪一層 | 差什麼 | 誰在讀、開了會不會弄斷 |
|---|---|---|---|---|---|
| 1 | 專案清單(compliance.projects,213 筆) |
租戶層 | ❌ 規則寫好了但開關被關掉 | 打開開關(ENABLE ROW LEVEL SECURITY)即可 |
一般使用者透過正常網頁功能讀。開了不會弄斷——規則本身完整(4 條)。唯一要先查的是「當初為什麼關」(見 §5.1) |
| 2 | 工作流程範本(compliance.workflow_templates,17,544 筆) |
租戶層+平台系統範本混合 | ❌ 規則寫好(含公版可讀規則)但開關被關掉 | 打開開關即可 | 使用者建立稽核流程時讀。開了不會弄斷——但有 191 筆是歷史留存的凍結副本,沒有標記所屬客戶(詳見 §5.2),這批打開後會消失在所有一般客戶眼前 |
| 3 | 客戶雲端硬碟串接設定(public.tenant_drive_integrations,1 筆) |
租戶層 | ❌ 規則寫好但開關被關掉 | 打開開關即可 | 客戶設定 Google Drive 同步時讀寫。開了不會弄斷——目前只有 1 筆資料,規則完整 |
| 4 | 工作流程執行紀錄(compliance.workflow_executions,11,016 筆) |
租戶層 | ❌ 規則不完整(只有新增/改/刪 3 條,缺「查詢」那條)且開關關掉 | 先補一條「查詢」規則,再打開開關 | 見下方「待辦任務清單」——同一批底層資料,兩件事要一起看 |
| 5 | 任務執行紀錄(compliance.job_executions,11,065 筆,就是每一個稽核任務的執行記錄) |
租戶層 | ❌ 連規則都沒寫,開關也關 | 照標準做法補 4 條規則(查/改/刪/新增各一條),再開開關 | 🔴 每天凌晨 3:10 有一支自動清理程式會掃描全部客戶的這張表(找「綁定資料指向的任務已經不存在」的孤兒資料並刪除,core/scheduler.py:386)。這支程式現在能跑,正是因為它沒有客戶身分、被暫時當成最高權限放行(CM-1559 記錄過的那條規則)。如果開了隔離又同時修 CM-1559 那個放行規則,這支清理程式會看不到任何資料、整個失效但不會報錯。兩件事必須一起規劃,見 §6 |
| 6 | 資訊系統清單(compliance.information_systems,82 筆,SSP 文件中登記的資訊系統資產) |
租戶層 | ❌ 連規則都沒寫,開關關掉 | 補 4 條規則,再開開關 | 一般使用者透過 SSP 匯入 / 資產管理功能讀寫。開了不會弄斷——沒有查到背景程式或跨租戶統計會用到它 |
| 7 | 缺失改善計畫(POA&M)(compliance.poams,92 筆) |
租戶層 | ❌ 連規則都沒寫,開關關掉 | 補 4 條規則,再開開關 | 一般使用者在稽核輪次頁面讀寫(走完整的 app service 授權流程,非背景任務)。開了不會弄斷 |
| 8 | 證據自動分類的執行紀錄(compliance.evidence_classification_runs,8 筆) |
租戶層 | ❌ 連規則都沒寫,開關關掉 | 補 4 條規則,再開開關 | 套件內部功能使用,開了不會弄斷(沒有背景程式依賴) |
| 9 | 證據分類的答案對照表(compliance.evidence_classification_ground_truth,1 筆) |
租戶層 | ❌ 連規則都沒寫,開關關掉 | 補 4 條規則,再開開關 | 同上,套件內部功能,開了不會弄斷 |
| 10 | 法規框架解析工作紀錄(oscal.framework_parse_jobs,29 筆,匯入法規框架時的暫存工作) |
租戶層 | ❌ 連規則都沒寫,開關關掉 | 補 4 條規則,再開開關 | 🔴 每天凌晨 1:00 有一支自動清理程式會清掉超過 7 天的舊工作紀錄(core/scheduler.py:202,走無客戶身分的系統路徑)。這支和上面第 5 項同款——開隔離要跟它一起規劃,但因為只是「清舊資料」不是「找孤兒」,影響範圍較小(清理延遲,不影響任何人看資料) |
| 11 | 操作紀錄轉發設定(config.log_forwarding_settings,1 筆,「系統要把操作紀錄轉送到哪台主機」的設定) |
平台層(全系統只有一份設定,不分客戶) | ❌ 連規則都沒寫,開關關掉,而且這 1 筆資料的客戶欄位是空的(本來就對,因為它是全系統共用設定) | 不是這張表的問題——它本來就該是平台層(只有原廠管理者能設定),現有的權限檢查(能力點守門)已經做了這件事,加 RLS 對它意義不大(詳見 §5.3) | 只有原廠管理者能改(既有能力點守門已擋),一般使用者只能讀(也有能力點守門)。這張表建議不動,是產品分類問題不是隔離漏洞 |
| 12 | 使用者角色指派(public.user_roles,41 筆,「誰在哪個租戶被指派了什麼角色」) |
租戶層 | ❌ 連規則都沒寫,開關關掉 | 補 4 條規則,再開開關 | 這張表本身就是判斷「使用者現在有什麼權限」的依據,被最核心的授權查詢直接讀(不經過隔離判斷,是隔離判斷的原料之一)。開了不會弄斷一般查詢,但這張表极度敏感、要優先修——它現在完全沒隔離,等於任何客戶能查到別家客戶「誰是誰的管理員」這類名冊 |
| 13 | 使用者-租戶關聯表(public.user_tenants,41 筆,「這個使用者隸屬哪些租戶」) |
租戶層 | ❌ 連規則都沒寫,開關關掉 | 補 4 條規則,再開開關 | 🔴 這張表是「切換租戶」功能本身的合法性依據——使用者要切到某個租戶,系統會先查這張表確認「你真的隸屬這個租戶」(jedi-iam 套件 middleware/context.py 的 default_membership_check)。這是一張控制access的表,不開隔離反而更危險:任何人現在可以查到全公司「誰能切到哪個租戶」的完整名冊 |
view 是什麼:一個事先存好的查詢,用起來像一張表,但背後其實是即時去問好幾張真正的表湊出來的結果。
| # | 這支畫面是什麼(白話) | 問題 | 誰在讀、開了會不會弄斷 |
|---|---|---|---|
| 1 | 待辦任務列表(vw_user_job_queue,就是使用者登入後看到的「我的任務」清單) |
❌ 繞過隔離。PostgreSQL 這個資料庫軟體有個規則:查詢一個「畫面」時,預設是用「建立這個畫面的人」的身分去查底層資料,而不是「正在查詢的人」的身分。建這支畫面的帳號是資料庫最高權限帳號,所以查詢天生就繞過所有客戶的隔離,除非明確關掉這個「用建立者身分查」的預設行為 | 「我的任務」頁面直接讀(infra/readmodel/tasks/my_grc_jobs_query.py)、稽核儀表板的待辦數量卡片也讀(infra/readmodel/audit/flow_control_dashboard_repo_impl.py:249)。這支畫面底層跨了 12 張表,其中就包含上面第 4、5 項那兩張還沒隔離的表——所以就算只修這支畫面本身,底層那兩張表沒隔離,一樣繞得過去。四項必須一起修(見 §6) |
| 2 | 使用者能力總覽(v_user_capabilities,「這個使用者可以做哪些操作」的完整清單) |
❌ 同上(建立者身分繞過隔離),但這張表本身查的資料沒有客戶欄位(user_roles/role_capabilities/capabilities 三表 JOIN),修法一樣但急迫性較低——因為就算不修,它洩漏的是「這個人有什麼權限」而不是「別家客戶的業務資料」 |
被授權檢查機制(CapabilityGuard)用來判斷「這個使用者現在有沒有權限做這件事」,也被到期通知功能(找「這個租戶的管理員是誰」)用(app/license/adapter/tenant_admin_directory.py)。開了 security_invoker 不會弄斷——它的資料來源本來就沒有租戶區分 |
| 3 | 使用者路由清單(v_user_routes) |
❌ 同上(建立者身分繞過隔離) | 本棒 grep 全 BE 主專案與 jedi-iam 套件,找不到任何 Python 程式碼直接讀這支畫面——選單可見性改走另一條程式路徑計算(jedi_iam 的 UiRouteRepoImpl.get_viewable_by_user_uid,直接查底層表、不經過這支畫面)。這支畫面目前疑似是歷史遺留、沒有實際使用者。修法一樣(加 security_invoker),但建議先確認真的沒人用再排優先序,別花時間修一支沒人讀的畫面 |
| 4 | 角色路由清單(v_role_routes) |
❌ 同上(建立者身分繞過隔離) | 同上,本棒 grep 找不到直接的 Python 讀取者;v_user_routes 的定義有引用它(畫面疊畫面),但既然 v_user_routes 本身查無使用者,這支的實際影響也待確認 |
任務詳細 API 收了「這個任務屬於哪個專案」的參數,卻完全沒有用它來檢查。
白話說明:一個網址路徑收了「專案編號」與「任務編號」兩個參數(GET /grc/project/<project_uid>/job/<job_uid>),照理應該要確認「這個任務編號真的屬於這個專案編號」才回資料,但實際上程式碼只拿任務編號去查,完全沒理會專案編號。首腦已開檔核對:
# api/flow_control/routes/job_route.py:82-92
def get(self, project_uid: str, job_uid: str, job_service=...):
dto = job_service.get_job(job_uid) # ← project_uid 收了但沒用
...本棒繼續往下追,確認同檔的 PUT(改)與 DELETE(刪)也是同款問題——收了 project_uid 但送進 job_service.update_job / delete_job 之後,那兩支方法內部只用 project_uid 檢查「打這支 API 的人是不是這個專案的管理員」,完全沒有檢查「這個任務真的屬於這個專案嗎」:
# app/flow_control/service/job_service.py
def delete_job(self, uid: str, project_uid: str = None) -> bool:
self._require_manager(project_uid) # 只驗「你是不是這個專案的管理員」
...
return self._domain_service.delete_job(uid) # 刪除時完全不比對 uid 是否屬於 project_uid出事會怎樣:一個對「專案 A」有管理權限的人,只要知道「專案 B」裡某個任務的編號(任務編號是 UUID,看起來很難猜,但在任何列出任務的畫面上都看得到),填自己有權限的「專案 A」編號+別人「專案 B」的任務編號,一樣能改掉或刪掉那個任務——因為程式碼從頭到尾沒有檢查兩者是否真的配對。首腦已實測:真編號/全零編號/亂打的字串三種 project_uid 打進去都回 200 且內容相同。
這與 FR-086 B1「只驗身分不驗歸屬」完全同型,修法可併同一張卡。
在哪裡:
api/flow_control/routes/job_route.py:82-92 GET(讀)
api/flow_control/routes/job_route.py:104-140 PUT(改)
api/flow_control/routes/job_route.py:147-167 DELETE(刪)
app/flow_control/service/job_service.py:108-114 get_job(讀,完全不驗)
app/flow_control/service/job_service.py:178-189 delete_job(刪,只驗身分不驗歸屬)
app/flow_control/service/job_service.py:214-238 update_job(改,只驗身分不驗歸屬)
交接檔已指出關閉的 migration:2026-06-25-align-poc-to-stg-*.sql 第 124 行,標記 envs=poc,理由只寫「以 STG 為主」。本棒沒有進一步查到更詳細的理由——這件事建議修正卡動手前先找到原始討論脈絡(或直接問當時做這個決定的人),避免修好一個問題又踩到當初關掉它的原因。
compliance.workflow_templates 有 191 筆(佔 17,544 筆中的一小部分)的客戶欄位是空的,時間集中在 2026-07-27 到 2026-08-09 之間,其中 132 筆現在還連著實際的工作流程執行紀錄(也就是有客戶正在用)。查了程式碼註解,這批是「凍結副本」(app/flow_engine/service/workflow_template_snapshot_service.py,每次有人正式啟動一個稽核流程時,系統會把當時的範本複製一份凍結起來,之後管理員改範本不會影響已經在跑的流程)。這批複製品目前沒有標記屬於哪個客戶——若直接打開隔離,這 191 筆會從所有客戶眼前消失(因為隔離規則的邏輯是「客戶欄位配對不上就看不到」),而其中 132 筆還連著正在使用中的流程執行紀錄,會造成使用者打開自己的稽核流程時看不到當初凍結的範本內容。這批需要先補上客戶欄位(回填正確的客戶歸屬)才能安全開啟隔離,不能跟其他表一樣直接開開關了事。
config.log_forwarding_settings 只有 1 筆資料,而且設計上本來就是全系統共用、不分客戶的一份設定(「系統要把操作紀錄送到哪台外部主機」是原廠層級的維運決定,不是租戶概念)。查證了套件的守門機制(jedi_api_log.forwarding),讀寫已經各自有獨立的權限檢查(能力點 log-forwarding.read / log-forwarding.update)。這張表沒有隔離不是洞,是因為它本來就不該有「客戶」這個維度——交接檔把它跟其他 12 張表放在同一組建議「照標準模式補 4 條」,本棒認為這張需要另外處理:要嘛從盤點清單移除(承認它是平台層資源),要嘛決策者仍希望它形式上符合「有 tenant_id 欄位的表都要有 RLS」的規範就補(但功能上不會有實際差異,因為守門已經做了該做的事)。留給決策者判斷,本棒不擅自排除。
這一項是本棒存在的理由,不是列完清單就結束。
系統有兩支「不需要任何人登入、自動在背景跑」的排程工作,跑的時候用的是最高權限身分(因為沒有登入者,系統暫時假裝是「看得到全部」的身分,這條放行規則本身也是另一個已知問題,記錄在 CM-1559):
| 排程 | 頻率 | 讀的表 | 開隔離的影響 |
|---|---|---|---|
孤兒綁定資料清理(core/scheduler.py:386) |
每天凌晨 3:10 | compliance.job_executions(本棒第 5 項) |
🔴 這支現在能掃全部客戶的任務,是靠它有「暫時最高權限」身分。若之後 CM-1559 那個「暫時最高權限」的放行規則被收緊(那件事遲早要做,本身就是一個已知風險),這支清理程式會突然什麼都掃不到——它不會報錯,只是安靜地不再做事,孤兒資料會一直堆積沒人發現。兩件事要放在同一張規劃裡處理,不能各修各的 |
法規框架解析工作清理(core/scheduler.py:202) |
每天凌晨 1:00 | oscal.framework_parse_jobs(本棒第 10 項) |
同款成因,但影響較小——最壞情況是舊的暫存工作記錄清得比較慢,不影響任何人看得到的資料 |
這兩支排程本身走的是「背景任務,無使用者身分」這條路,資料庫層級開不開隔離不會讓它們「看不到自己的東西」(因為它們本來就沒有「自己」),會出問題的是未來如果連「背景任務暫時給最高權限」這條規則本身也被收緊,兩件事疊在一起才會炸。本棒的結論是:修隔離的人必須知道這兩支排程的存在,安排修復順序時把它們排進同一輪驗證,而不是修完隔離就直接上生產環境。
交接檔原本列了「原廠管理者要看全部租戶,要確認走的是哪條路」這一項待查。本棒已查清楚:平台管理員(原廠層級的帳號)判斷「你是不是平台管理員」,用的函式是 viewer_is_platform_admin()(在 jedi-iam 套件的 authz/platform.py),這個函式呼叫的正是資料庫隔離機制用來判斷「這個人是不是最上層客戶」的同一段邏輯(_session_paths_have_root)。也就是說:平台管理員能看到全部客戶資料,走的是「我本來就是最上層客戶,隔離規則本身允許最上層看下層」這條合法的路,不是靠某個地方隔離沒開才漏看得到。
結論:開啟本棒盤點的這 13 張表+4 支畫面的隔離,不會影響平台管理員後台的任何功能。 這件事原本被列為風險,查證後可以放心排除。
本棒 grep 了所有讀取這 13 張表的程式碼位置(§3.1「誰在讀」欄已逐項列出),沒有找到任何一支「刻意讀取全部客戶資料做統計」的功能——目前找到的統計類查詢(如稽核儀表板 flow_control_dashboard_repo_impl.py)都是先算出「這個使用者參與的專案」清單,再用那個清單去查任務與流程資料,本來就是照使用者身分過濾的,隔離開啟後行為一致。
唯一的例外要另案追蹤:交接檔提到 FR-083 已查出「AI 儀表板有 27 支查詢功能完全不檢查權限」,本棒 grep 了那 27 支查詢的清單(見 FR-083 報告),它們讀的資料範圍與本棒盤點的 13 張表大致重疊但不是同一批程式碼路徑(AI 儀表板是動態組裝任意查詢方法,本棒盤點的是 RLS 資料庫層),兩者是各自獨立的洞,不是同一個機制,修一邊不會自動修好另一邊,也不會互相弄斷。AI 儀表板那 27 支的權限問題請照 FR-083 報告另案處理。
survey.surveys/survey_folders 兩張表沒有「這是平台公版問卷」的標記欄位。本棒查了 DEV 資料庫實際資料:
tenant_id=1(原廠自己)的問卷:8 份
tenant_id=102(某客戶)的問卷:44 份
原廠帳號建的 8 份問卷,因為沒有平台公版標記,子租戶完全看不到。這跟另外兩個「公版資料」的做法不一樣:
| 資料類型 | 怎麼標記公版 |
|---|---|
flow_templates(流程範本) |
用 is_builtin 欄位 |
module_frames/detection_profiles(模組框架/偵測方案) |
用 scope=SYSTEM 欄位 |
surveys/survey_folders(問卷) |
沒有任何標記欄位 |
要決策者裁的問題是:原廠的 8 份問卷,到底該不該讓所有客戶都看得到?
flow_templates——加一個 is_builtin 或類似欄位,原廠建的問卷全客戶可讀。優點是跟其他公版資料的做法一致;缺點是要改 schema、改讀取邏輯、也要決定「哪些既有問卷該回溯標成公版」。本棒不建議,交給決策者選。
| 組 | 內容 | 風險 | 備註 |
|---|---|---|---|
| 甲:純開關(可一支 migration 一起做) | projects、tenant_drive_integrations 開開關 |
低 | 規則現成、無背景依賴、無孤兒資料問題 |
| 乙:補規則再開開關 | job_executions、information_systems、poams、evidence_classification_runs、evidence_classification_ground_truth、framework_parse_jobs、user_roles、user_tenants 照標準模式各補 4 條規則 |
中 | job_executions/framework_parse_jobs 要跟背景排程(§6.1)一起驗證;user_roles/user_tenants 敏感度最高建議優先 |
| 丙:先查後開 | workflow_executions(補 1 條「查詢」規則後開) |
中 | 與待辦清單畫面(丁組)同一批底層資料,要一起修 |
| 丁:畫面補一行設定 | 4 支畫面加 security_invoker |
中 | vw_user_job_queue 跨 12 張表,其中含 job_executions/workflow_executions,丙丁必須同一輪一起驗證;v_user_routes/v_role_routes 找不到實際讀者,建議先確認用途再排優先序 |
| 戊:需要先處理資料才能開 | workflow_templates |
中高 | 191 筆凍結副本無客戶歸屬,要先回填客戶欄位 |
| 己:要先查清楚原因 | projects(併入甲組前,先查當初關閉的完整脈絡) |
— | 不查清楚可能重蹈覆轍 |
| 庚:產品決策後再動 | surveys/survey_folders 平台旗標 |
— | 等決策者裁 §7 選項 |
| 辛:建議重新分類,不當隔離缺口修 | log_forwarding_settings |
— | 見 §5.3,這張本質是平台層資源 |
| 壬:應用層洞,與 FR-086 併 | 任務詳細 API 不驗 project_uid(§4) | 中 | GET/PUT/DELETE 三支一起改 |
表格與規則現況(§3 的「現在是哪一層」欄):可信度高。 每一項都是本棒直接對 DEV 資料庫下唯讀 SQL 查出來的(pg_class/pg_policy/information_schema),SQL 語句與交接檔給的一致,逐項比對與交接檔一字不差(13 張表、4 支畫面,數字、規則條數全部吻合)。
「誰在讀、開了會不會弄斷」欄:這是本棒的核心產出,方法是 grep BE 主專案與 jedi- 套件原始碼。* 有把握的地方:
core/scheduler.py 原始碼確認)viewer_is_platform_admin() 與 RLS 用同一段判斷邏輯,這是本棒本身查證出來、更正了交接檔待查項目的部分)不保證的地方:
job_executions/workflow_executions/user_roles)額外用「誰 import 這個 ORM model」交叉確認,但沒有對全部 13 張表逐一做這層交叉確認。合起來的建議:甲組(純開關)與壬組(應用層洞)證據最扎實,可以直接排修正卡。乙丙丁戊組修之前,建議修正卡的人自己再對照一次程式碼(尤其 §6.1 提到的兩支背景排程),因為「開了隔離不會弄斷什麼」這件事的代價是「弄斷了才會發現」。
FR-080/handoff/fr080-to-fr075-HANDOFF.md §二docs/changelog/2026-06-29-feat-tenant-scope-data-model-b2.md、2026-06-30-feat-compliance-framework-platform-write.mddocs/system-design/database/RLS_DESIGN.mdsecurity-scan-consolidated/.claude/skills/security-scan-lead/SKILL.md