事後來看,每個權限漏洞都長得一模一樣。呼叫堆疊深處的某個函式假設呼叫者已經檢查過 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 本身,這就是上面的範例包含 grantedAt 和 grantedBy 欄位的原因。

何時使用 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。