事後來看,每個權限漏洞都長得一模一樣。呼叫堆疊深處的某個函式假設呼叫者已經檢查過 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。