あなたの processPayment 関数は、たまたま同じプロセス内に存在するという理由だけで、データベース、決済ゲートウェイ、監査ログへのすべてのアクセス権を持っている。攻撃者が 1 つの HTTP ハンドラーに injection bug を見つけた場合、それらすべてを継承する。この関数はこれらの権力を求めたわけではない。単にデプロイされた場所のおかげで、それを前提としただけだ。

これが ambient authority である。そして、これは私たちが構築するほぼすべてのシステムにおけるデフォルトのモデルだ。capability-based security は異なる問いを投げかける。もし関数が、明示的に渡されたことだけしかできなかったらどうだろうか?

ambient authority の問題

ほとんどのアプリケーションは、システム境界で検証される access control lists を使用する。リクエストが API gateway に到達し、JWT が検証され、roles がチェックされると、そのリクエストは trust zone に入る。そこでは、すべての関数があらゆるものへの暗黙的なアクセス権を持つ。データベース接続は global singleton である。S3 client は shared module からインポートされる。プロセス内で実行されるあらゆるコードが、それらすべてを呼び出すことができる。

これは、うまくいかなくなるまではうまくいく。

utility function 内の deserialization bug、compromised dependency、あるいは confused deputy attack が、小さな 1 つの侵害をシステム全体へのフルアクセスに変える。blast radius は、特定の操作に必要だったものによって制限されるのではない。プロセス全体が持っていたものによって制限されるのだ。

capability-based security はこのモデルを覆す。authority は「あなたが誰であるか」という属性ではない。「あなたが何を持っているか」という属性なのだ。

capability-based security とは何か

capability-based security は、アクセス権が unforgeable token(capability と呼ばれる)によって表現され、必要とする関数に明示的に渡される必要があるモデルである。capability はリソースへの参照であり、同時にそのリソースを使用する許可でもある。名前でアクセスを要求することはできない。すでに持っている capability だけを使用できる。

これは、追加の手順を伴う RBAC ではない。RBAC では、ユーザーは role を持ち、システムはアクセス時にその role をチェックする。capability-based security では、central check は存在しない。capability を持っていれば、使用できる。持っていなければ、使用できない。capability そのものが authorization の証明なのだ。

このモデルは 1970 年代の operating system 研究に遡るが、より compartmentalized なシステムを構築するにつれて、ますます関連性を増している。WebAssembly module、microservice、browser API、sandboxed plugin は、名前を使わなくても、capability-like なパターンを使用している。

実際に capability はどう機能するか

コードではこう見える。ambient authority モデルでは、あらゆる関数がデータベースを呼び出すことができる:

// Ambient authority: any code in this module can use db
import { db } from './db';

async function getUser(id: string) {
  return db.query('SELECT * FROM users WHERE id = ?', [id]);
}

async function processOrder(orderId: string) {
  const order = await db.query('SELECT * FROM orders WHERE id = ?', [orderId]);
  // What if a bug here lets an attacker query arbitrary tables?
  return order;
}

両方の関数は、フルアクセス権を持つ global db 接続を共有している。capability モデルでは、scoped capability を渡す:

// A capability is just a constrained handle
interface UserReadCap {
  getUser(id: string): Promise<User>;
}

interface OrderReadCap {
  getOrder(id: string): Promise<Order>;
}

async function getUser(id: string, db: UserReadCap) {
  return db.getUser(id);
}

async function processOrder(orderId: string, orders: OrderReadCap) {
  return orders.getOrder(orderId);
}

getUser は orders に触れることができない。processOrder は users に触れることができない。コンパイラがこれを強制する。compromised された processOrder の実装ができるのは、orders を exfiltrate することだけであり、テーブルを drop したり password hash を読み出したりすることはできない。capability は振る舞いの境界線なのだ。

より強力な type system を持つ言語では、これをさらに推し進めることができる。Rust では、capability は file descriptor を所有できる。type system は、許可なしに複製できないことを保証し、borrow checker は、その有効性より長く存続しないことを保証する:

use std::fs::File;
use std::io::{self, Write};

fn write_log(file: &mut File, msg: &str) -> io::Result<()> {
    // This function can ONLY write to the file it was handed.
    // It cannot open new files. It cannot read the filesystem.
    writeln!(file, "{}", msg)
}

&mut File が capability である。この関数は、新たに出現させることはできない。

なぜ capability-based security がデフォルトではないのか

capability-based security には実際のコストが伴う。最も明らかなのは ergonomics だ。すべての関数シグネチャが肥大化する。capability を call chain を通じて渡す必要があり、これは dependency injection を論理的極限まで持っていったような感覚である。大規模なコードベースでは、これは面倒になる可能性がある。

error handling もより複雑になる。ambient authority モデルでは、データベースへの接続失敗は、initialization 時に処理されるグローバルな関心事である。capability モデルでは、capability を受け取るすべての関数は、その capability が操作中に revoked されたり invalid になったりした場合に何が起こるかを考慮する必要がある。

debugging cost も存在する。ACL システムでアクセスが拒否された場合、policy を確認する。capability が欠けている場合、call chain を遡って、誰がそれを渡すべきだったかを探す。これは異なる skill set であり、ほとんどの開発者はそれに慣れていない。

これらの trade-off が、なぜ capability-based security が主に operating system、browser、高セキュリティ環境に限定されてきたかを説明する。これは無料ではない。

今日、capability-based security が現れる場所

あなたはすでに capability-based security を使用している。名前を知らなくてもだ。browser では、fetch は global function だが、same-origin policy および CORS によって制約されている。Service Worker は特定の event capability を受け取る。WebAssembly module は、memory および host function へのアクセスを明示的に付与される必要がある。

cloud infrastructure では、AWS IAM policy condition および scoped token は capability-like である。presigned S3 URL は capability である。特定の期間、特定のリソースに対する特定の操作を許可する、unforgeable token なのだ。narrowly scoped な RBAC binding を持つ Kubernetes service account も、この方向性に向かっている。

トレンドは、より少ない ambient authority を持つ、より小さな compartment へ向かっている。Container は OS からそれを取り除いた。WebAssembly は browser process からそれを取り除いた。次のステップは、私たち自身の関数からそれを取り除くことだ。

コードで capability を使い始める方法

アプリケーション全体を書き直す必要はない。blast radius が最も重要となる境界から始めよう。

データアクセス層を capability object に分離する。global database pool をエクスポートするのではなく、scoped handles を返す関数をエクスポートする:

// capabilities.ts
export interface UserStore {
  getById(id: string): Promise<User | null>;
  updateEmail(id: string, email: string): Promise<void>;
}

export interface AuditLog {
  record(event: AuditEvent): Promise<void>;
}

// Hand out capabilities at the application boundary
function createUserStore(db: Pool): UserStore {
  return {
    async getById(id) {
      const row = await db.query('SELECT * FROM users WHERE id = ?', [id]);
      return row ? mapUser(row) : null;
    },
    async updateEmail(id, email) {
      await db.query('UPDATE users SET email = ? WHERE id = ?', [email, id]);
    }
  };
}

そして、ハンドラーが必要とする capability だけを渡す:

async function handleProfileUpdate(
  req: Request,
  users: UserStore,
  audit: AuditLog
) {
  const user = await users.getById(req.userId);
  await users.updateEmail(req.userId, req.body.email);
  await audit.record({ type: 'email_changed', userId: req.userId });
}

handleProfileUpdate はメールを送信できない。アカウントを削除することもできない。渡されたことだけができるのだ。このハンドラーに deserialization bug がある場合、攻撃者は決済システムに pivot することができない。なぜなら、その capability は決して渡されていないからだ。

capability-based security に関するよくある質問

これは dependency injection ではないのか?

似て見えるが、意図は異なる。dependency injection は testability と modularity に関するものだ。capability は security と least privilege に関するものだ。DI container は依然として global database connection を注入する可能性がある。capability は、呼び出し元が許可されるべき正確な範囲にスコープされている。

これは authentication を置き換えるのか?

いいえ。authentication は「あなたは誰か?」に答える。capability は「あなたは何ができるか?」に答える。境界で identity を検証する必要は依然としてある。その後、capability は認証済みコードが触れるものを制約する。

どの言語がこれをうまくサポートしているのか?

type system を持つあらゆる言語が、capability を interface や trait として表現できる。Rust と TypeScript は両方ともうまく機能する。Python や厳密な interface を持たない JavaScript のような dynamically typed language では、compile-time enforcement を失うが、このパターンは依然としてコードの明確性を向上させ、偶発的な誤用を制限する。

ambient authority は習慣であり、法ではない。capability-based security は理解するより採用する方が難しいが、業界の方向性は明確だ。より小さな境界。より少ない暗黙的な権力。明示的に渡されたことだけができる関数。

1 つのハンドラーから始めよう。database connection の代わりに、1 つの capability を渡してみよう。何が壊れるか見てみよう。ほとんどの場合、壊れるものは、あなたのコードが知らないうちに行っていた隠れた前提だ。