昨天我們看著一個請求進門:先貼上 correlation id a1b2c3d4,再驗 cookie、還原出「他是誰」,把身分放進請求上下文一路帶下去。今天要追問的是那一步的反面——萬一驗身分這件事當下做不到呢?身分中心連不上、回應逾時,或回了一個你看不懂的東西,這一刻 Portal 該放他進來,還是把他擋在門外?這個問題沒有「都對」的答案,只有「依場景選一邊」。而 Portal 在不同的門口,刻意選了相反的兩邊。
本篇結構:
先把問題的形狀講清楚。驗身分不是每一步都是純本地運算——驗章本身是純運算(Day 8 講過,重算一次 HMAC 跟簽章比對就好,不需要查表),這一步不會「故障」;真正會出事的,是它周邊那些環節:跟身分中心換取資訊、查某個會逾時的服務,或者 cookie 解出來的內容對不上預期格式(雖非連外,但同樣讓「確認身分」當場失敗)。這些都可能在某個瞬間失敗。
關鍵在於,這不是一個可以「等一下再說」的問題。請求就在眼前,使用者正盯著畫面等回覆,你必須現在就決定:要嘛當他通過、放他往下走,要嘛當他沒通過、回絕他。沒有第三條路。系統設計裡把這個岔路命名得很直白:
| 方向 | 出錯時往哪倒 | 行為 | 代價 |
|---|---|---|---|
fail-open |
往「開」 | 放行 | 可能放進一個其實沒驗證過的人 |
fail-closed |
往「關」 | 拒絕 | 可能擋掉一個其實合法的人 |
兩個都有代價,差別只在「你寧可承受哪一種代價」。而這個答案,跟「這道門背後是什麼操作」綁死。
Portal 的做法是:不替整個系統選一邊,而是逐門選。一座閘道上有不只一道關卡,每一道按它守的東西和影響的量級,各自決定倒向哪邊。最典型的就是這兩扇門。
第一扇門是行員提問這條主線。小林輸入:
我的員編是 123456,AD 帳號被鎖了,順便想查內湖分行的營業時間。
這是一個查詢,最壞的後果是查到一筆本來就對全體行員公開的分行營業時間。這條路徑採 fail-open:身分若驗不出來,不擋,而是放行、把他標記成匿名往下傳。理由很實際——身分中心是會偶爾抖一下的外部依賴,你不該讓「身分中心打了個噴嚏」就拖垮整個 AI 助理,讓全公司問不了問題。可用性在這裡的權重,高於「絕對確認他是誰」。
第二扇門是後台的危險操作,例如熱重載供應商設定、停用某一家 LLM(這套熱控制 Day 15 會專門講)。這類操作會改變系統行為、影響的是所有人。它採 fail-closed:沒有正確的管理員憑證,一律拒絕,連「可能是合法管理員只是驗證服務剛好掛了」這種情況也照樣擋。理由同樣實際——這種操作做錯一次的代價,遠大於「合法管理員多等十分鐘等服務恢復」。安全在這裡的權重,反過來壓過可用性。
把這兩扇門並排攤開,不對稱就一目了然:
| 維度 | 行員提問主線 | 後台危險操作 |
|---|---|---|
| 典型動作 | 查詢、提問 | 熱重載供應商設定、停用某家 LLM |
| 影響面 | 單次查詢,最壞查到公開資訊 | 改變系統行為,影響所有人 |
| 容錯方向 | fail-open:放行並標匿名 |
fail-closed:未持有效憑證一律拒絕 |
| 權重取捨 | 可用性 > 絕對確認身分 | 安全 > 可用性 |
| 故障時回應 | HTTP 200 + 匿名旗標,對話繼續 |
HTTP 503,明確告知「這扇門不開」 |
用設定把這個不對稱攤開來看,會更直觀:
portal:
identity:
# 行員提問主線:身分中心故障時放行、標匿名
on-failure: fail-open # fail-open | fail-closed
admin:
# 後台危險操作:未持有效管理員權杖一律拒絕
on-failure: fail-closed
# 連權杖都沒設定時,預設直接回 503,而不是「沒設=不檢查=放行」
secret: "" # 留空 → 啟動後該端點一律 503
注意 admin.secret 留空那一段的潛台詞:fail-closed 不只管「驗證失敗」,連「根本沒設定好驗證」都算失敗,照樣關門。這是 fail-closed 心態的精髓——任何模稜兩可,都往安全那邊倒。換成 fail-open 的設定哲學,「沒設=不檢查」很容易被寫成「那就放行吧」,這在後台這種門是災難。一寬一嚴不是隨興,是跟著「這扇門背後操作的風險量級」走的。
故障時的回應也呼應這個差異:主線提問就算遇到狀況,傾向回 HTTP 200 加一個匿名旗標,讓對話繼續;後台危險操作驗不過,直接回 HTTP 503,明確告訴你「這扇門現在不開」。
光是「選對方向」還不夠。fail-open 真正危險的地方,不在「放行」這個決定本身,而在實作時手一滑,把不該放的也一起放了。這裡有兩個必須講清楚的細節。
fail-open 的寫法直覺上是「try 驗證、catch 到就當匿名往下走」,但這個 try 的範圍是會悄悄擴張的——如果你貪圖省事,把驗證、業務邏輯、回應組裝全包進同一個 try-catch,那麼後面任何一個真正的業務錯誤(模型呼叫炸了、檢索鏈出問題),都會被這個「fail-open 當匿名」的 catch 給接住、默默吞掉,使用者拿到一個看起來正常、其實內部早就出錯的回應。這是把「對身分故障寬容」誤用成「對所有故障都寬容」,性質完全不同。
正確的寫法是把那層保護收到極窄——只裹著驗章那一個動作,catch 到就降級成匿名身分,然後立刻跳出這層保護,後面的業務流程該炸就讓它炸、該回錯就回錯:
// ✗ try 太大:把業務例外也一起吞了,錯誤被偽裝成「匿名成功」
try {
identity = verifyIdentity(cookie)
answer = runChatPipeline(identity) // ← 這裡的失敗不該被當成「身分故障」
return ok(answer)
} catch (e) {
return ok(runChatPipeline(ANONYMOUS)) // 全部錯誤都被當匿名重跑,真 bug 被藏住
}
// ✓ try 只包驗證這一步:身分故障 → 降級匿名;業務例外 → 照常往上拋
identity = try {
verifyIdentity(cookie)
} catch (e) {
log.warn("identity verify failed, fallback anonymous req=a1b2c3d4")
ANONYMOUS
}
answer = runChatPipeline(identity) // 這行若失敗,是真錯誤,讓它正常浮上來
return ok(answer)
差別就在那個 try 的邊界畫在哪。包得剛好,fail-open 是一個受控的降級;包得太寬,它就變成一台把所有故障都消音的機器——這比沒做容錯還糟,因為它連讓你發現問題的機會都偷走了。
第二個細節在 fail-closed 那扇門:管理員權杖的比對要用常數時間(constant-time),這招 Day 8 驗章時提過。一般字串比對是「逐字元比,遇到第一個不一樣就馬上回 false」——這意味著比對花的時間,會洩漏「你猜對了前幾個字元」。攻擊者拿這個時間差,可以一個字元一個字元地把權杖猜出來,這類手法叫計時攻擊(timing attack)。常數時間比對的做法是不管對不對,每次都把整段比完才回結果,讓回應快慢跟「猜中幾個字」徹底脫鉤。後台這扇門守的是改變系統行為的權限,值得連這種側通道(side channel)都堵上。
把這篇收束成可帶走的結論:fail-open 與 fail-closed 沒有優劣,只有適配。判準永遠是同一個問題——這扇門背後的操作,做錯一次的代價有多大?
fail-open,別讓上游打噴嚏拖垮全場。fail-closed,寧可錯擋也不錯放。同一座 Portal 上兩扇門選相反方向,看似矛盾,其實是同一個原則的兩種應用。
也要誠實講邊界。fail-open 標匿名放行,意味著下游每一站都得有「如果這人是匿名,該怎麼辦」的明確行為——比如匿名就只給公開資訊、不碰任何跟個人相關的功能。它把「擋」的責任從門口推遲到了下游,這條鏈得整條都接得住才行,不是門口放了就沒事。這也是為什麼這套不對稱必須搭配 Day 9 的 correlation id:當你選擇 fail-open 放一個匿名請求進來,事後要能循著 req=a1b2c3d4 把「這筆是在身分故障下放行的」追查回來,容錯才不會變成黑箱。
還有一個容易混淆、值得單獨切開的點:「無法確認身分」其實有兩種,不該一律當成同一種故障。一種是可信的身分服務暫時聯絡不上或逾時——這是真的「當下沒辦法驗」,降級成匿名是合理的,前提是下游只給公開能力。另一種是 cookie 格式壞了、簽章對不上、宣告內容不合法:這其實是「確定驗不過」,不是「當下沒辦法驗」,而且它是攻擊者可以操弄的輸入,別把它敘述成一般的服務故障,更別因此推導出「驗章失敗也一律 fail-open」——在匿名並不安全的門前,那會是個漏洞。本篇主線的提問路徑因為匿名頂多拿到公開資訊,這兩種剛好收斂到同一個安全結果;但把這套 fail-open 搬去別的場景之前,先把這兩種分清楚。
放行不是不管,是「放行+留痕+下游自負其責」三件事一起成立。
明天 Day 11,我們把視角從「行員怎麼進 Portal 的門」轉到「Portal 怎麼進下游 Engine 的門」——下游憑什麼相信來敲門的真的是 Portal?答案不是在程式裡塞長期金鑰,而是服務間(service-to-service)認證:s2s OIDC(OpenID Connect)。