後になって振り返ると、すべての権限バグは同じ顔をしている。コールスタックの奥深くにある関数が、呼び出し元がすでに isAdmin をチェックしたと想定している。実際にはチェックされていない。あるいは新しいロールが追加され、47ファイルを横断して role === 'editor' を grep し、見落としていないか祈ることになる。

ケイパビリティベースセキュリティは、権限を明示的にすることでこれを解決する。「あなたは誰ですか?」と尋ねて権限を調べるのではなく、呼び出し元に対して、文字通り何が許可されているかをエンコードしたトークンを渡す。トークンがなければアクセスはない。型システムがコンパイル時にこれを強制できる。

ケイパビリティベースセキュリティが実際に意味するもの

ケイパビリティは、保持者に特定のアクションを実行する権利を与える改ざん不可能なトークンである。この用語は1960年代に遡るオペレーティングシステムの研究に由来するが、その考え方はアプリケーションコードにも同様に適用される。

従来のロールベースのシステムでは、ユーザーがロールを持ち、アクションの実行時にそのロールをチェックする:

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

このパターンは、シンプルであると思われるが、実際にはそうではない。チェックは、権限が最初に付与された場所から遠く離れている。結果として、冗長なチェック、忘れられたチェック、そしてスタック上10フレーム上の呼び出し元が行ったチェックを暗黙的に依存するロジックが生じる。

ケイパビリティはこのモデルをひっくり返す。プロジェクトを削除する権限は、その関数を呼び出すために持っていなければならない値になる:

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

ProjectDeletionCapability がなければ、この関数を呼び出すことはできない。型システムがそう定めている。

TypeScript が適している理由

TypeScript の構造的型付けは通常は利点だが、ケイパビリティにとってはバグである。ProjectDeletionCapabilityprojectId: string フィールドだけを持つインターフェースである場合、その形状を持つあらゆるオブジェクトが通過してしまう。名目的型付けが必要だ。偶発的に構築できない型が必要なのだ。

TypeScript でこれを行う最もクリーンな方法は、プライベートシンボルを使ったブランド型である:

declare const ProjectDeletionCapabilityBrand: unique symbol;

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

ProjectDeletionCapabilityBrandunique symbol なので、このモジュール外のコードはインターフェースを満たす値を生成できない。ブランドはコンパイル時の封印として機能する。ケイパビリティはシンボルを所有するモジュール内でのみ構築できる。

具体的な実装

コードベースを抽象美術作品に変えずに、実運用コードで機能するパターンを紹介する。

まず、ケイパビリティファクトリモジュールを定義する。これは新しいケイパビリティを発行できる唯一の場所だ:

// 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;
}

認可レイヤーは、ユーザーのクレームを検証した後にケイパビリティを発行する:

// 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;
}

ドメイン関数はケイパビリティを直接消費する。ユーザーIDも、ロールチェックも、データベース検索も不要だ:

// 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);
}

FileReadCapabilitywriteFile に渡そうとすると、TypeScript はコンパイルを拒否する。エラーは即座に局所的に検出される。呼び出しが有効かどうかを理解するために、ロール階層をたどる必要はない。

安全性を損なわずにケイパビリティを合成する

実際のコードでは委譲が必要だ。サービスは複数のケイパビリティを保持し、そのサブセットをヘルパー関数に渡すことがある。これは交差型でモデル化できる:

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

より厳密にしたい場合は、複合ケイパビリティを定義できる:

interface FileReadWriteCapability
  extends FileReadCapability,
    FileWriteCapability {}

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

重要なのは、権限が周囲の状態ではなく値を通じて流れるということだ。すべてのケイパビリティは、どこで発行されたかまで遡ることができる。

このパターンが破綻する場面

ケイパビリティはタダではない。すべての保護された操作には、コールチェーンを通じて渡されるケイパビリティ値が必要だ。深いレイヤーを持つアプリケーションでは、これは直接ケイパビリティを使用しない5〜6の関数を通じてケイパビリティを渡すことを意味する。

失効の問題もある。一度発行されたケイパビリティは、単なる JavaScript オブジェクトに過ぎない。ガベージコレクションされるまで存続する。たとえばユーザーがプロジェクトから削除されたためにアクセスを即座に取り消す必要がある場合、すでに飛行中のケイパビリティを破壊することはできない。帯域外チェックが必要になるか、あるいは毎回の使用時にライブACLに対して検証するプロキシでケイパビリティをラップする必要がある。それは避けようとしていた正確なルックアップを再導入することになる。

監査ログも難しくなる。RBAC では、アクション実行時のユーザーのロールをログに記録する。ケイパビリティでは、権限は数時間前に別のサービスによって付与された可能性がある。ケイパビリティ自体にメタデータを添付する必要がある。これが上記の例に grantedAtgrantedBy フィールドが含まれている理由だ。

ケイパビリティを使うべき場合と使わないべき場合

再計算が高価な、きめ細かく文脈に依存する権限がある場合にケイパビリティを使う。ユーザーは金曜日までこの特定のドキュメントを編集できる。サービスはこのバケットから読み取れるが、あのバケットからは読み取れない。ケイパビリティは文脈をエンコードする。それを消費する関数は、文脈が存在することを知る必要はない。

粗いグローバルロールにはケイパビリティを使わない。アプリに3つの権限レベルがあり、リソースごとに変化しない場合、RBAC の方がシンプルで監査もしやすい。完璧を、十分に安全なものの敵にするな。

よくある質問

ケイパビリティとトークンの違いは何ですか?

JWT や API トークンはアイデンティティを証明する。ケイパビリティは特定のアクションに対する権限を証明する。トークンの中にケイパビリティを入れることはできるが、概念は異なる。

ケイパビリティは GraphQL や REST で使えますか?

はい。サーバーはリクエストを認証した後にケイパビリティを発行し、それをリゾルバーまたはコントローラーに渡す。トランスポートレイヤーを変更する必要はない。

ケイパビリティはどのように保存しますか?

通常、ケイパビリティ自体は永続化しない。ケイパビリティを発行できるかどうかを決定するルールを永続化する。ケイパビリティ自体は短命なランタイム値である。

これは OAuth スコープを置き換えますか?

いいえ。OAuth スコープは組織境界を越えて委譲される粗粒度のケイパビリティだ。このパターンは自分のアプリケーション内でのきめ細かい権限のためのものだ。両者は共存できる。

ケイパビリティをシリアライズできますか?

ブランド型をシリアライズすると、デシリアライズ時にブランドを失う。プロセス間でケイパビリティを渡す必要がある場合は、受信側が信頼するキーで署名するか、検証して再発行するケイパビリティサーバーを使う。