事後來看,每個權限漏洞都長得一模一樣。呼叫堆疊深處的某個函式假設呼叫者已經檢查過 isAdmin。結果沒有。或者新增了一個角色,你在 47 個檔案裡用 grep 搜尋 role === 'editor',祈禱自己沒有漏掉任何一處。

Capability-based security 透過將權限明確化來解決這個問題。與其問「你是誰?」然後去查詢權限,不如直接交給呼叫者一個 token,它明確編碼了他們被允許執行的操作。沒有 token,就沒有存取權。型別系統可以在編譯時強制執行這一點。

Capability-based security 到底是什麼意思

Capability 是一種不可偽造的 token,授予持有者執行特定操作的權利。這個術語源自於 1960 年代的作業系統研究,但這個概念同樣適用於應用程式碼。

在傳統的 role-based 系統中,使用者擁有一個 role,你在執行操作的地方檢查該 role:

function deleteProject(user: User, projectId: string) {
  if (user.role !== 'admin') {
    throw new UnauthorizedError();
  }
  // ... delete logic
}

這個模式很簡單,直到它不再簡單為止。檢查與最初授予權限的地方相距甚遠。最終你會得到冗餘的檢查、遺漏的檢查,以及隱含依賴於堆疊上方十層呼叫者所做檢查的邏輯。

Capability 翻轉了這個模型。刪除專案的權限變成了一個你必須擁有的值,才能呼叫該函式:

function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
  // No check needed. If you have the cap, you have the right.
  // ... delete logic
}

如果你沒有 ProjectDeletionCapability,你就無法呼叫這個函式。型別系統就是這樣規定的。

為什麼 TypeScript 很適合

TypeScript 的 structural typing 通常是一個優點,但對於 capability 來說它是一個缺陷。如果 ProjectDeletionCapability 只是一個帶有 projectId: string 欄位的 interface,任何符合該形狀的物件都會通過。你需要 nominal typing。你需要一種無法被意外建構的型別。

在 TypeScript 中做到這一點最乾淨的方式,是使用 private symbol 的 branded type:

declare const ProjectDeletionCapabilityBrand: unique symbol;

interface ProjectDeletionCapability {
  readonly [ProjectDeletionCapabilityBrand]: true;
  readonly projectId: string;
  readonly grantedAt: Date;
  readonly grantedBy: string;
}

因為 ProjectDeletionCapabilityBrand 是一個 unique symbol,這個模組之外的任何程式碼都無法產生滿足該 interface 的值。brand 就像編譯時期的封條。你只能擁有該 symbol 的模組內部建構 capability。

具體實作

這裡有一個適用於正式環境程式碼的模式,而且不會把你的程式庫變成抽象藝術品。

首先,定義一個 capability factory 模組。這是唯一可以鑄造新 capability 的地方:

// capabilities.ts
import { randomUUID } from 'crypto';

declare const FileReadCapabilityBrand: unique symbol;
declare const FileWriteCapabilityBrand: unique symbol;

export interface FileReadCapability {
  readonly [FileReadCapabilityBrand]: true;
  readonly fileId: string;
  readonly scope: 'public' | 'private';
}

export interface FileWriteCapability {
  readonly [FileWriteCapabilityBrand]: true;
  readonly fileId: string;
}

// The capability factory. This is the only way to create capabilities.
export function mintFileReadCapability(
  fileId: string,
  scope: 'public' | 'private'
): FileReadCapability {
  return { [FileReadCapabilityBrand]: true, fileId, scope } as FileReadCapability;
}

export function mintFileWriteCapability(fileId: string): FileWriteCapability {
  return { [FileWriteCapabilityBrand]: true, fileId } as FileWriteCapability;
}

你的授權層,無論它是什麼,在驗證使用者的 claims 之後鑄造 capability:

// auth.ts
import { mintFileReadCapability, mintFileWriteCapability } from './capabilities';

export async function getCapabilitiesForUser(userId: string, fileId: string) {
  const perms = await db.permissions.find({ userId, fileId });
  const caps = [];

  if (perms.canRead) {
    caps.push(mintFileReadCapability(fileId, perms.scope));
  }
  if (perms.canWrite) {
    caps.push(mintFileWriteCapability(fileId));
  }

  return caps;
}

你的領域函式直接取用 capability。沒有 user ID、沒有 role 檢查、沒有資料庫查詢:

// files.ts
import { FileReadCapability, FileWriteCapability } from './capabilities';

export async function readFile(cap: FileReadCapability): Promise<Buffer> {
  return storage.read(cap.fileId);
}

export async function writeFile(
  cap: FileWriteCapability,
  data: Buffer
): Promise<void> {
  return storage.write(cap.fileId, data);
}

如果你試圖將 FileReadCapability 傳給 writeFile,TypeScript 會拒絕編譯。錯誤是即時且局部的。你不需要追蹤整個 role hierarchy 來理解呼叫是否有效。

組合 capability 而不犧牲安全性

真實的程式碼需要委派。一個服務可能持有多個 capability,並將子集傳遞給輔助函式。你可以用 intersection types 來建模:

function publishDocument(
  readCap: FileReadCapability,
  writeCap: FileWriteCapability,
  docId: string
) {
  const draft = await readFile(readCap);
  const rendered = renderToPDF(draft);
  await writeFile(writeCap, rendered);
  await markPublished(docId);
}

如果你想要更嚴格,可以定義一個複合 capability:

interface FileReadWriteCapability
  extends FileReadCapability,
    FileWriteCapability {}

function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
  // ...
}

關鍵在於權限是透過值流動的,而不是透過環境狀態。你可以將每個 capability 追溯回它鑄造的地方。

這個模式在什麼地方會崩潰

Capability 不是免費的。每個受保護的操作都需要一個 capability 值傳遞過整個呼叫鏈。在一個深度分層的應用程式中,這可能意味著要將 capability 穿過五到六個並不直接使用它們的函式。

還有撤銷的問題。Capability 一旦鑄造,就只是一個 JavaScript 物件。它會一直存在直到被垃圾回收。如果你需要立即撤銷存取權,例如因為使用者被從專案中移除,你無法銷毀已經在流通中的 capability。你需要一個 out-of-band 檢查,或者你需要將 capability 包裝在一個 proxy 中,每次使用時都針對即時的 ACL 進行驗證。這會重新引入你原本試圖避免的查詢操作。

稽核日誌也變得更困難。使用 RBAC 時,你會記錄使用者執行操作當下的 role。使用 capability 時,權限可能是幾小時前由另一個服務授予的。你需要將 metadata 附加到 capability 本身,這就是上面的範例包含 grantedAtgrantedBy 欄位的原因。

何時使用 capability,何時跳過它們

當你擁有細粒度、具備情境的權限,且重新計算的成本很高時,就使用 capability。使用者可以編輯這份特定文件直到星期五。服務可以從這個 bucket 讀取,但不能從那個 bucket。Capability 編碼了情境。取用它的函式不需要知道情境的存在。

對於粗粒度的全域 role,就跳過 capability。如果你的應用程式只有三個權限等級,而且它們從不隨資源變化,RBAC 更簡單且更容易稽核。不要讓完美成為合理安全的敵人。

常見問題

Capability 和 token 有什麼區別?

JWT 或 API token 證明身分。Capability 證明執行特定操作的權限。你可以將 capability 放在 token 裡面,但這兩個概念是不同的。

Capability 可以和 GraphQL 或 REST 一起使用嗎?

可以。伺服器在驗證請求後鑄造 capability,然後將它們傳入 resolvers 或 controllers。傳輸層不需要改變。

你如何儲存 capability?

通常你不會持久化儲存 capability。你儲存的是決定 capability 是否可以被鑄造的規則。Capability 本身只是短暫的執行時期值。

這會取代 OAuth scopes 嗎?

不會。OAuth scopes 是跨組織邊界委派的粗粒度 capability。這個模式適用於你自己應用程式內部的細粒度權限。它們可以共存。

你可以序列化 capability 嗎?

如果你序列化一個 branded type,在反序列化時會失去 brand。如果你需要跨行程傳遞 capability,請用接收者信任的金鑰簽署它們,或者使用一個驗證並重新鑄造它們的 capability server。