本卡屬 FR-085(母卡 CM-XXXX,建完補號),第 1 棒:授權鏈核心(jedi monorepo)。只掃不修。 建議最先跑。
掃 jedi-common 的 session/+identity/+handler/ 共 29 檔。這是全站每一次資料庫存取的入口:它負責告訴資料庫「現在是誰在查、能看哪些客戶的資料」(RLS,就是「每個客戶只能看自己資料」的資料庫隔離機制,靠 session 變數 app.user_id/app.allowed_tenant_paths/app.is_super_admin 生效)、記住「目前這個 request 是誰」、以及把程式出錯翻譯成回給前端的訊息。要找的是:隔離能不能被繞過或注入、身分會不會串線、錯誤訊息會不會把資料庫內部細節洩給前端。只找問題、不修問題。
這三個目錄是唯一能造成「跨租戶看到別人資料」的區塊。RLS 注入(session/database/db.py)、身分 contextvar(session/auth/auth_context.py)、例外處理(handler/)三者互相咬合:身分取不到時回 None → session 變數改設 super admin → 若這時再炸個 SQL 例外,原文回給前端。拆開看會漏掉這條合流。宿主 import 數:session.database 201 處、handler.exception 184 處、session.auth 96 處。
首腦與偵察 agent 讀過程式碼後的起點,未經驗證。工具不會照這份清單走(連續四個 arc 如此),工具沒答的你要自己開檔查,並在報告標明「(工具未報,runner 人工查證)」。
session/database/db.py:95(SET LOCAL app.user_id = '{str(user_ctx.id)}')、:108(allowed_tenant_paths = '{...}')、:113(allowed_org_paths),全部沒有 bind parameter。要追:這三個值從哪來(JWT claim?DB 查出來的 ltree 路徑?signed_token?)、有沒有任何一條路讓攻擊者控制其中的字元(尤其單引號)。若能帶 ' 就能脫逸字串再接 ; SET LOCAL app.is_super_admin = 't'。追到源頭為止,不要停在「看起來是內部值」。app.can_manage_orgs = 't'(db.py:97-99),沒有任何條件。要追:DB 端有哪些 policy 讀這個變數(grep BE repo scripts/init/02-schema.sql 與 scripts/sql/ 的 can_manage_orgs)?如果有 policy 靠它判斷「能不能改組織」,等於那些 policy 對所有登入者放行。db.py:115-118 elif is_pg: 無 user context 即 super admin;db.py:102 is_super = (not user_ctx.tenant_id) or ... 讓 signed_token 路徑換 super admin。要追的是它們周邊還有沒有同型:session/auth/auth_context.py:17-21 get_user_context() 在 user 為 None 時回 None(原本的 raise 被註解掉)——這是 fail-open 的上游;還有哪些呼叫端假設它一定回值?db.py:30-37 取不到 dialect 時回 True(當作 PostgreSQL)。方向是 fail-closed,但要確認反面:非 PostgreSQL(SQLite 測試、或 dialect 判錯)時整條 RLS 靜默不設,會不會有 consumer 在那個模式下跑正式流量?handler/sql_exception.py:35-38 return str(exc).splitlines()[0], code——SQLAlchemy 例外第一行含 SQL 片段、表名、欄位名、約束名。對照 handler/handler.py:33-36 的 catch-all 有正確吞掉 traceback,兩者要一起看:哪些例外走哪條路?handler.py:28-31 HTTPException 分支回 exception.name 有沒有洩漏?getattr 到 ORM model。 session/database/repository/base_repository_impl.py(757 行,全站 repo 基底):177、:272(getattr(self.model, sort_spec.field))、:477-479(_in_ filter)。要追:前端能不能傳 sort=password_hash 或 sort=__class__ 之類的欄位名?有沒有白名單?__as_real_column 擋得住什麼?jsonb_extras.py 的 col.op("->>") 鍵名來自哪裡?auth_context.py 用 contextvars 存 UserContextDTO。要追:request 結束有沒有 reset?gunicorn worker、APScheduler 執行緒、eventlet 三種承載下會不會殘留到下一個 request(身分串線)?session/database/db_mw.py 與 session_context.py 的 set/reset 配對完整嗎?identity/ 的 resolver 降級。 identity/resolvers.py 查失敗回空 dict(不 500)。要追:這個「安全降級」會不會把「查不到=沒權限」變成「查不到=跳過檢查」?宿主唯一接線在 BE core/upload_file_wiring.py。session/redis/redis.py:連線參數從哪來、有沒有 TLS 驗證(CM-1565 曾修過 jedi-iam 的同款)、key 命名有沒有租戶前綴。X-Tenant-ID: 0 利用路徑已被 FR-069.16 擋死,根因仍在。本棒不重報它,但可以報「它周邊的同型」。utils/response_util.py(在第 3 棒 C3)是例外走到信封的最後一站,:23-27 把例外訊息當 data 回前端。本棒允許越界讀它來追完整條「例外 → 前端」的路,報告標明越界即可。session/database/base_repository.py 是舊名轉發殼、真身在 session_mixin.py(該檔 docstring 自陳),別當重複檔漏看。