延續上一篇的問題

上一篇文章中,我們遇到了一個問題:Link Table 資料遺失導致使用者沒有角色,所有 API 都回傳 401

文章最後我拋出了一個問題:

如果你的系統把角色資訊 cache 在 Redis、JWT claim、或 BFF 層,當 Link Table 資料不正確時,系統應該:

名詞解釋:

  • Redis:一種高速的記憶體資料庫(In-Memory Database),常用於快取(Cache)熱門資料,避免每次都查詢主資料庫
  • JWT claim:JWT Token 內的資料欄位。例如把使用者角色直接寫在 Token 裡:{ "sub": "user_123", "role": "admin" },這樣就不用每次都查 DB
  • BFF(Backend For Frontend):一種架構模式,在前端和後端 API 之間多一層「專為前端服務的後端」,常會在這層做權限快取
  • 立即全站拒絕?
  • 繼續相信 cache?
  • 還是進入 degraded mode?

我的選擇是:進入 Degraded Mode

這篇文章會解釋為什麼,以及如何實作。

一句話定義

Degraded Mode = 系統已知自己「部分不可信」,主動降級功能以維持安全與可用性。

不是壞了、不是裝沒事,而是:

  • 我知道哪裡壞
  • 我知道哪些功能不能給
  • 我知道要保住什麼

為什麼不選另外兩個方案?

❌ 方案 A:立即全站拒絕

所有 API → 401/503 → 業務全掛

問題:

  • 使用者完全無法使用系統
  • 對業務衝擊太大
  • 「寧可錯殺一百」的策略在商業系統中代價過高

適用場景: 金融交易、醫療處方等「錯了比沒有更糟」的場景

❌ 方案 B:繼續相信 Cache

JWT claim 說你是 admin → 就讓你當 admin

問題:

  • 如果 DB 資料才是真相,cache 可能已經過時
  • 權限外洩風險
  • 「裝沒事」是最危險的策略

適用場景: 幾乎沒有。這是最糟的選擇。

✅ 方案 C:進入 Degraded Mode

JWT 有效 → 信 identity
Role 不可信 → 降級功能
寫入操作 → 靜默拒絕

這是平衡安全性可用性的最佳解。

Degraded Mode 的三個核心原則

Mermaid Diagram

原則 1:Fail Closed(權限要保守)

不確定能不能給 → 就不要給

這是安全系統的基本原則。當授權資料不可信時,預設行為應該是「拒絕」而非「允許」。

原則 2:功能降級 ≠ 系統死亡

核心服務要活,但敏感操作要關

使用者還是可以登入、可以看資料,只是不能做寫入操作。這比全站掛掉好得多。

原則 3:可觀測、可恢復

系統要知道「我現在在 degraded」

這不是 runtime exception,是一個明確的系統狀態。要能監控、要能告警、要能手動恢復。

狀態機設計

Mermaid Diagram

觸發 AUTHZ_DEGRADED 的條件:

// 啟動時自檢
async function checkRoleIntegrity(): Promise<boolean> {
  // 條件 1:Link Table 是否有資料
  const linkCount = await db('users_roles_lnk').count();
  if (linkCount === 0) return false;

  // 條件 2:使用者是否都有角色
  const orphanUsers = await db.raw(`
    SELECT COUNT(*) FROM users u
    LEFT JOIN users_roles_lnk lnk ON u.id = lnk.user_id
    WHERE lnk.role_id IS NULL
  `);
  if (orphanUsers.count > 100) return false; // 容忍少量異常

  return true;
}

功能矩陣:什麼能做、什麼不能做

功能NORMALDEGRADED說明
登入認證層正常
讀取個人資料唯讀安全
讀取公開內容無權限需求
建立訂單寫入操作
修改個人設定寫入操作
管理後台高權限操作
刪除資料破壞性操作

原則:唯讀 + 最小暴露

實作:Middleware 層的守門員

// 系統狀態管理
enum SystemState {
  NORMAL = 'NORMAL',
  AUTHZ_DEGRADED = 'AUTHZ_DEGRADED',
}

class SystemStateManager {
  private state: SystemState = SystemState.NORMAL;

  setDegraded() {
    this.state = SystemState.AUTHZ_DEGRADED;
    logger.warn('[SYSTEM] Entering AUTHZ_DEGRADED mode');
    this.notifyOps(); // 發送告警
  }

  isDegraded(): boolean {
    return this.state === SystemState.AUTHZ_DEGRADED;
  }

  private notifyOps() {
    // Slack、PagerDuty、Sentry...
  }
}

const systemState = new SystemStateManager();
// Middleware:根據操作類型決定是否放行
function degradedGuard(requiredLevel: 'read' | 'write') {
  return async (ctx, next) => {
    if (systemState.isDegraded() && requiredLevel === 'write') {
      // 靜默拒絕,不回傳錯誤
      ctx.status = 204; // No Content
      return;
    }
    return next();
  };
}

// 使用方式
router.get('/api/me', degradedGuard('read'), getProfile);
router.post('/api/orders', degradedGuard('write'), createOrder);
router.delete('/api/users/:id', degradedGuard('write'), deleteUser);

關鍵設計:靜默拒絕

// ❌ 不要這樣
ctx.throw(503, 'System in degraded mode');

// ✅ 這樣做
ctx.status = 204; // 操作被吃掉,但不報錯

為什麼?因為 Degraded State 是系統內部狀態,不是產品狀態

為什麼選擇「靜默」?

使用者不需要知道系統哪裡壞了

❌ 暴露 degraded 給前端:
- 前端要寫 if (degraded) { ... }
- UX 出現模糊的警告訊息
- PM 問:什麼時候會好?
- 系統狀態污染產品語意

✅ 靜默處理:
- 按鈕自然不能按
- 表單自然不能送
- 沒有錯誤碼、沒有恐慌訊息
- 系統像「變慢、變保守」,但沒壞

這叫 Graceful Degradation(優雅降級)

責任分界

系統該負責的事產品不該知道的事
判斷是否 degraded哪張表壞了
決定哪些能力可用RBAC 是否完整
阻止高風險操作Link Table 有沒有資料
發送內部告警系統內部狀態

產品只關心「能力」,不關心「原因」。

JWT 怎麼處理?答案是「半信」

在 Degraded Mode 下:

// ✅ 信 identity(使用者是誰)
const userId = jwt.sub; // 這個可以信

// ❌ 不信 role/permission(使用者能做什麼)
const userRole = jwt.role; // 這個不能信

JWT 在 degraded mode 下只剩下:

{
  "sub": "user_12345",
  "iat": 1705123456
}

Role 和 permission 一律從「不信任」的角度處理。

監控:沒有觀測就等於沒做

至少需要三個 signal:

// 1. 系統狀態指標
metrics.gauge('system_state', {
  state: systemState.isDegraded() ? 'AUTHZ_DEGRADED' : 'NORMAL'
});

// 2. 被擋下的寫入請求數量
metrics.counter('degraded_write_blocked', {
  endpoint: ctx.path
});

// 3. Degraded 持續時間
metrics.gauge('degraded_duration_seconds', {
  since: degradedStartTime
});

這些只會出現在:

  • Grafana Dashboard
  • Slack 告警
  • Sentry Tag

使用者永遠看不到。

資料層的保護

在 Degraded Mode 下,資料層要做到「什麼都不做」:

async function createOrder(data) {
  // 最後一道防線
  if (systemState.isDegraded()) {
    logger.info('[DEGRADED] Order creation skipped', { data });
    return null; // 靜默返回
  }

  // 正常邏輯
  return await db('orders').insert(data);
}

Degraded Mode 下禁止:

  • ❌ 寫入 DB
  • ❌ 更新 Cache
  • ❌ 發送 Event
  • ❌ 進入 Queue

寧願什麼都不做,也不要做錯。

這是一種產品哲學

我選擇的是:

「系統不穩定,不應該成為使用者的心理負擔。」

這在醫療系統、金融系統、長期使用型 App 中,是非常重要的價值觀。

使用者打開 App,看到「系統維護中」會焦慮。但如果他只是發現「今天好像不能下單」,他可能只會想「等一下再試」。

差異在於:我們沒有把系統問題轉嫁給使用者。

結論

當授權資料不可信時:

選項安全性可用性使用者體驗
全站拒絕⭐⭐⭐😱 恐慌
相信 Cache⭐⭐⭐😊 但有風險
Degraded Mode⭐⭐⭐⭐😐 正常但受限

Degraded Mode 是平衡的藝術。

最後一句話:

當授權資料不可信時,最危險的不是服務中斷,而是系統假裝一切正常。


系列文章

  1. 資料庫同步的隱藏陷阱:Link Table 的重要性
  2. 當授權資料不可信時,我選擇讓系統安靜地退後一步(本文)