Deine processPayment-Funktion hat Zugriff auf die Datenbank, das Payment-Gateway und das Audit-Log, weil sie zufällig im selben Prozess läuft. Wenn ein Angreifer einen Injection-Bug in einem HTTP-Handler findet, erbt er das alles. Die Funktion hat nie nach diesen Rechten gefragt. Sie hat sie sich einfach aufgrund dessen angemaßt, wo sie deployed wurde.

Das ist ambient authority, und es ist das Default-Modell in fast jedem System, das wir bauen. Capability-based Security stellt eine andere Frage: Was wäre, wenn eine Funktion nur das tun könnte, was ihr explizit übergeben wurde?

Das Problem der ambient authority

Die meisten Anwendungen nutzen Access Control Lists, die an der Systemgrenze geprüft werden. Ein Request trifft auf ein API-Gateway, das JWT wird validiert, Rollen werden geprüft, und der Request betritt eine Trust Zone, in der jede Funktion impliziten Zugriff auf alles hat. Die Datenbankverbindung ist ein globaler Singleton. Der S3-Client wird aus einem Shared Module importiert. Jeder Code, der innerhalb des Prozesses läuft, kann all das aufrufen.

Das funktioniert – bis es nicht mehr funktioniert.

Ein Deserialization-Bug in einer Utility-Funktion, eine kompromittierte Dependency oder ein Confused-Deputy-Angriff verwandeln einen kleinen Breach in vollen Systemzugriff. Der Blast Radius ist nicht durch das begrenzt, was die spezifische Operation gebraucht hat. Er ist durch das begrenzt, was der gesamte Prozess hatte.

Capability-based Security kehrt das Modell um. Autorität ist keine Eigenschaft dessen, wer du bist. Sie ist eine Eigenschaft dessen, was du besitzt.

Was ist Capability-based Security?

Capability-based Security ist ein Modell, in dem Zugriffsrechte durch unverfälschbare Tokens repräsentiert werden – sogenannte Capabilities –, die explizit an Funktionen übergeben werden müssen, die sie brauchen. Eine Capability ist sowohl eine Referenz auf eine Ressource als auch die Erlaubnis, sie zu nutzen. Du kannst keinen Zugriff per Name anfordern. Du kannst nur eine Capability nutzen, die du bereits besitzt.

Das ist nicht Role-based Access Control mit extra Schritten. In RBAC hat ein User eine Rolle, und das System prüft diese Rolle zum Zeitpunkt des Zugriffs. In Capability-based Security gibt es keine zentrale Prüfung. Wenn du die Capability besitzt, kannst du sie nutzen. Wenn nicht, dann nicht. Die Capability selbst ist der Nachweis der Autorisierung.

Dieses Modell geht auf Operating-System-Forschung der 1970er Jahre zurück, aber es ist zunehmend relevant, je mehr wir kompartimentalisierte Systeme bauen. WebAssembly-Module, Microservices, Browser-APIs und sandboxed Plugins nutzen alle Capability-ähnliche Patterns, auch wenn sie nicht den Namen verwenden.

Wie Capabilities in der Praxis funktionieren

So sieht das im Code aus. Im Ambient-Authority-Modell kann jede Funktion die Datenbank aufrufen:

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

Beide Funktionen teilen sich eine globale db-Verbindung mit vollem Zugriff. Im Capability-Modell übergibst du eine 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 kann Orders nicht berühren. processOrder kann Users nicht berühren. Der Compiler erzwingt das. Eine kompromittierte processOrder-Implementierung kann nur Orders exfiltrieren, nicht Tabellen droppen oder Password-Hashes lesen. Die Capability ist die Grenze des Verhaltens.

In Sprachen mit stärkeren Type Systems kannst du das weiter treiben. In Rust kann eine Capability einen File Descriptor besitzen. Das Type System stellt sicher, dass er ohne Erlaubnis nicht dupliziert werden kann, und der Borrow Checker stellt sicher, dass er seine Gültigkeit nicht überdauert:

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

Das &mut File ist die Capability. Die Funktion kann keine weitere heraufbeschwören.

Warum Capability-based Security nicht der Default ist

Capability-based Security hat echte Kosten. Die offensichtlichste ist Ergonomie. Jede Function Signature wächst. Du musst Capabilities durch Call Chains durchreichen, was sich wie Dependency Injection im logischen Extrem anfühlt. In einer großen Codebase kann das mühsam werden.

Error Handling wird ebenfalls komplexer. Im Ambient-Authority-Modell ist ein Verbindungsfehler zur Datenbank ein globales Problem, das bei der Initialisierung behandelt wird. Im Capability-Modell muss jede Funktion, die eine Capability erhält, bedenken, was passiert, wenn diese Capability widerrufen oder mitten im Betrieb ungültig wird.

Es gibt auch Debugging-Kosten. Wenn der Zugriff in einem ACL-System verweigert wird, prüfst du die Policy. Wenn eine Capability fehlt, verfolgst du die Call Chain zurück, um herauszufinden, wer sie hätte übergeben sollen. Das ist ein anderer Skillset, und die meisten Developer sind es nicht gewöhnt.

Diese Trade-offs erklären, warum Capability-based Security meist auf Operating Systems, Browser und High-Security-Umgebungen beschränkt blieb. Es hat seinen Preis.

Wo Capability-based Security heute auftaucht

Du hast Capability-based Security bereits genutzt, auch wenn du den Namen nicht kanntest. Im Browser ist fetch eine globale Funktion, aber sie ist durch die Same-Origin-Policy und CORS eingeschränkt. Ein Service Worker erhält spezifische Event-Capabilities. Ein WebAssembly-Modul muss explizit Zugriff auf Memory und Host-Funktionen erhalten.

In Cloud-Infrastruktur sind AWS-IAM-Policy-Conditions und scoped Tokens capability-ähnlich. Eine presigned S3-URL ist eine Capability: ein unverfälschbares Token, das eine spezifische Operation auf einer spezifischen Ressource für eine spezifische Zeit gewährt. Kubernetes-Service-Accounts mit stark eingegrenzten RBAC-Bindings bewegen sich ebenfalls in diese Richtung.

Der Trend geht zu kleineren Compartments mit weniger ambient authority. Container haben sie dem OS entzogen. WebAssembly hat sie dem Browser-Prozess entzogen. Der nächste Schritt ist, sie unseren eigenen Funktionen zu entziehen.

Wie du Capabilities in deinem Code einsetzt

Du musst deine gesamte Anwendung nicht umschreiben. Beginne an den Grenzen, wo der Blast Radius am meisten zählt.

Isoliere deine Data Access Layer in Capability-Objects. Statt einen globalen Database-Pool zu exportieren, exportiere Funktionen, die scoped Handles zurückgeben:

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

Dann übergib nur die Capabilities, die ein Handler braucht:

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 kann keine E-Mails verschicken. Sie kann keine Accounts löschen. Sie kann nur das tun, was ihr übergeben wurde. Wenn dieser Handler einen Deserialization-Bug hat, kann der Angreifer nicht zum Payment-System pivoten, weil die Capability nie übergeben wurde.

Häufig gestellte Fragen zu Capability-based Security

Ist das nicht einfach Dependency Injection?

Es sieht ähnlich aus, aber die Absicht ist eine andere. Dependency Injection geht um Testability und Modularität. Capabilities gehen um Security und Least Privilege. Ein DI-Container könnte trotzdem eine globale Datenbankverbindung injizieren. Eine Capability ist genau auf das eingegrenzt, was der Caller tun dürfen sollte.

Ersetzt das Authentication?

Nein. Authentication beantwortet „Wer bist du?“ Capabilities beantworten „Was kannst du tun?“ Du musst die Identität immer noch an der Grenze verifizieren. Danach schränken Capabilities ein, was der authentifizierte Code berühren kann.

Welche Sprachen unterstützen das gut?

Jede Sprache mit einem Type System kann Capabilities als Interfaces oder Traits ausdrücken. Rust und TypeScript funktionieren beide gut. In dynamisch typisierten Sprachen wie Python oder JavaScript ohne strikte Interfaces verlierst du Compile-Time-Enforcement, aber das Pattern verbessert trotzdem die Code-Clarity und begrenzt versehentlichen Missbrauch.

Ambient authority ist eine Gewohnheit, kein Gesetz. Capability-based Security ist schwerer zu adoptieren als zu verstehen, aber die Richtung der Branche ist klar. Kleinere Grenzen. Weniger implizite Macht. Funktionen, die nur das tun können, was ihnen explizit übergeben wurde.

Beginne mit einem Handler. Übergib ihm eine Capability statt einer Datenbankverbindung. Sieh, was kaputt geht. Meistens ist das, was kaputt geht, eine versteckte Annahme, von der du nicht wusstest, dass dein Code sie macht.