一句話:決策者 2026-09-15 在 190(1.20.0 正式版)開啟 MFA 後,輸入信箱驗證碼送出回 500,等於開了 MFA 就登不進去。首腦已從 log 追到確切行號與根因,本卡照著修即可,不必重查。

症狀(決策者實際遇到的)

在「安全政策」把 MFA(多因素驗證,登入時除了密碼還要再輸入一次驗證碼)打開之後,登入走到第二關、輸入信箱收到的驗證碼、按送出 → 畫面報錯。再送一次會變成「驗證失敗」。

第二次的「驗證失敗」是假象:驗證碼有時效且用過即失效,第一次送出時其實已經消耗掉了。真正的問題只有第一次那個 500。

根因(首腦從 190 的容器 log 實查,不必重查)

POST /api/1.0/otp-verify → 500
WARNING: session_scope entered without user context — RLS fail-closed (no tenant visible)
         caller=/app/jedi_iam/api/routes/otp_route.py:127 in post
AttributeError: 'NoneType' object has no attribute 'login_name'

關鍵在行號:otp_route.py:127 是「驗證通過之後發 token」那一行(generate_jwt_token),不是「查這個人是誰」那一行(117 行)。也就是說——使用者輸入的驗證碼是對的,卡在發 token。

為什麼會這樣:這是登入的「引導悖論」——走到第二關時使用者還沒有 access token、所以沒有身分;但 FR-094 把租戶隔離改成 fail-closed(沒有身分的查詢一律看不到任何資料,這是刻意的安全設計)之後,沒身分的那段就什麼都查不到。

程式碼裡本來就處理過這件事:117 行查人那段包了臨時提權(_lookup_user_before_login → elevated_readonly_scope)。但 127 行發 token 那段沒有包,於是又掉回沒身分的狀態,下游某處要取 login_name 時拿到 None 就炸了。

時間點佐證:otp_route.py 最後一次改動是 fc1cadb「FR-094.1c 無身分進 session_scope 改 fail-closed,引導型與機器端點接上顯式身分(CM-1788)」——那一棒把引導型端點接上身分,但顯然漏了發 token 這一段。

這不是 1.20.0 這次改動造成的

缺陷在 beta.1/beta.2 就存在,只是當時 MFA 沒開所以沒人走到這條路。決策者這次開了 MFA 才測到。查證時不要往「這波重構造成的」方向找。

在哪裡

jedi-iam/jedi_iam/api/routes/otp_route.py:117   _lookup_user_before_login(已有提權,這段是對的)
jedi-iam/jedi_iam/api/routes/otp_route.py:127   generate_jwt_token ← 炸在這裡,沒有提權
jedi-iam/jedi_iam/api/routes/otp_route.py:156   _lookup_user_before_login 的定義(可參考它的提權寫法)
jedi-common/jedi_common/session/database/elevated_session.py:62   elevated_readonly_scope 定義與使用紀律

🔴 修法要先做一個判斷,不要直接套

最直覺的做法是把 127 行也包進提權,但要先想清楚兩件事,並在回寫時說明你的選擇與理由:

首腦傾向乙(用真實身分而不是繞過隔離),但沒有實查過設身分的可行路徑,所以不預設答案。你查證後自己決定。

另外要確認:同一支檔案的其他端點(OtpCodeResendRoute 在 147 行也呼叫了 _lookup_user_before_login)有沒有同樣的問題——重送驗證碼會不會也炸。一併查一併修。