Jeder Berechtigungsbug sieht im Nachhinein gleich aus. Irgendeine Funktion tief im Call Stack geht davon aus, dass der Aufrufer bereits isAdmin geprüft hat. Hat er nicht. Oder es wird eine neue Rolle hinzugefügt, und du suchst mit grep nach role === 'editor' über 47 Dateien hinweg in der Hoffnung, keine übersehen zu haben.
Capability-based Security behebt das, indem sie Autorität explizit macht. Statt zu fragen „Wer bist du?“ und Berechtigungen nachzuschlagen, übergibst du dem Aufrufer einen Token, der buchstäblich kodiert, was er tun darf. Kein Token, kein Zugriff. Das Typsystem kann das zur Compile-Zeit durchsetzen.
Was Capability-based Security tatsächlich bedeutet
Eine Capability ist ein unverfälschbarer Token, der dem Inhaber das Recht gewährt, eine bestimmte Aktion auszuführen. Der Begriff stammt aus der Betriebssystemforschung der 1960er Jahre, aber die Idee lässt sich genauso auf Anwendungscode anwenden.
In einem traditionellen rollenbasierten System hat ein Benutzer eine Rolle, und du prüfst diese Rolle zum Zeitpunkt der Aktion:
function deleteProject(user: User, projectId: string) {
if (user.role !== 'admin') {
throw new UnauthorizedError();
}
// ... delete logic
}
Dieses Muster ist einfach, bis es das nicht mehr ist. Die Prüfung liegt weit entfernt von der Stelle, an der die Autorität ursprünglich gewährt wurde. Du endest mit redundanten Prüfungen, vergessenen Prüfungen und Logik, die implizit von Prüfungen abhängt, die von Aufrufern zehn Frames weiter oben im Stack durchgeführt wurden.
Capabilities drehen das Modell um. Die Autorität, ein Projekt zu löschen, wird zu einem Wert, den du besitzen musst, um die Funktion überhaupt aufrufen zu können:
function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
// No check needed. If you have the cap, you have the right.
// ... delete logic
}
Wenn du keine ProjectDeletionCapability hast, kannst du diese Funktion nicht aufrufen. Das sagt das Typsystem.
Warum TypeScript gut passt
TypeScripts strukturelle Typisierung ist normalerweise ein Feature, aber für Capabilities ist sie ein Bug. Wenn ProjectDeletionCapability nur ein Interface mit einem projectId: string-Feld ist, wird jedes Objekt mit dieser Form akzeptiert. Du brauchst nominale Typisierung. Du brauchst einen Typ, der nicht versehentlich konstruiert werden kann.
Der sauberste Weg, das in TypeScript zu machen, ist ein branded type mit einem privaten Symbol:
declare const ProjectDeletionCapabilityBrand: unique symbol;
interface ProjectDeletionCapability {
readonly [ProjectDeletionCapabilityBrand]: true;
readonly projectId: string;
readonly grantedAt: Date;
readonly grantedBy: string;
}
Weil ProjectDeletionCapabilityBrand ein unique symbol ist, kann kein Code außerhalb dieses Moduls einen Wert erzeugen, der das Interface erfüllt. Die Marke fungiert als Compile-Zeit-Siegel. Du kannst die Capability nur innerhalb des Moduls konstruieren, das das Symbol besitzt.
Eine konkrete Implementierung
Hier ist ein Muster, das im Produktivcode funktioniert, ohne deine Codebasis in ein abstraktes Kunstprojekt zu verwandeln.
Definiere zunächst ein Capability-Factory-Modul. Das ist der einzige Ort, an dem neue Capabilities geprägt werden können:
// 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;
}
Deine Autorisierungsschicht, was auch immer sie ist, prägt Capabilities, nachdem sie die Claims des Benutzers verifiziert hat:
// 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;
}
Deine Domain-Funktionen konsumieren Capabilities direkt. Keine User-IDs, keine Rollenprüfungen, keine Datenbankabfragen:
// 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);
}
Wenn du versuchst, eine FileReadCapability an writeFile zu übergeben, wird TypeScript sich weigern zu kompilieren. Der Fehler ist sofort und lokal. Du musst nicht durch eine Rollenhierarchie tracen, um zu verstehen, ob ein Aufruf gültig ist.
Capabilities komponieren, ohne Sicherheit zu verlieren
Echter Code muss delegieren. Ein Service könnte mehrere Capabilities halten und Teilmengen an Hilfsfunktionen übergeben. Du kannst das mit Intersection Types modellieren:
function publishDocument(
readCap: FileReadCapability,
writeCap: FileWriteCapability,
docId: string
) {
const draft = await readFile(readCap);
const rendered = renderToPDF(draft);
await writeFile(writeCap, rendered);
await markPublished(docId);
}
Wenn du strenger sein willst, kannst du eine zusammengesetzte Capability definieren:
interface FileReadWriteCapability
extends FileReadCapability,
FileWriteCapability {}
function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
// ...
}
Der Schlüssel ist, dass Autorität durch Werte fließt, nicht durch ambient state. Du kannst jede Capability zurückverfolgen, wo sie geprägt wurde.
Wo dieses Muster an seine Grenzen stößt
Capabilities sind nicht umsonst. Jede geschützte Operation benötigt einen Capability-Wert, der durch die Call Chain gereicht wird. In einer tief geschichteten Anwendung kann das bedeuten, Capabilities durch fünf oder sechs Funktionen zu fädeln, die sie nicht direkt nutzen.
Es gibt auch das Revocation-Problem. Eine Capability, einmal geprägt, ist nur ein JavaScript-Objekt. Sie lebt, bis sie vom Garbage Collector aufgeräumt wird. Wenn du Zugriff sofort widerrufen musst, etwa weil ein Benutzer aus einem Projekt entfernt wurde, kannst du Capabilities, die bereits unterwegs sind, nicht zerstören. Du brauchst einen out-of-band-Check, oder du musst Capabilities in einen Proxy wrappen, der bei jedem Einsatz gegen eine live ACL validiert. Das führt genau den Lookup wieder ein, den du vermeiden wolltest.
Audit-Logging wird auch schwieriger. Bei RBAC loggst du die Rolle des Benutzers zum Zeitpunkt der Aktion. Bei Capabilities könnte die Autorität vor Stunden von einem anderen Service gewährt worden sein. Du musst Metadaten an die Capability selbst anhängen, weshalb die Beispiele oben grantedAt- und grantedBy-Felder enthalten.
Wann man Capabilities verwendet und wann nicht
Verwende Capabilities, wenn du feingranulare, kontextuelle Berechtigungen hast, deren Neuberechnung teuer ist. Ein Benutzer kann dieses spezifische Dokument bis Freitag bearbeiten. Ein Service kann aus diesem Bucket lesen, aber nicht aus jenem. Die Capability kodiert den Kontext. Die Funktion, die sie konsumiert, muss nicht wissen, dass der Kontext existiert.
Verzichte auf Capabilities für grobe globale Rollen. Wenn deine App drei Berechtigungsstufen hat und diese nie je nach Ressource variieren, ist RBAC einfacher und leichter auditierbar. Lass nicht das Perfekte zum Feind des Vernünftig-Sicheren werden.
FAQ
Was ist der Unterschied zwischen einer Capability und einem Token?
Ein JWT oder API-Token beweist Identität. Eine Capability beweist Autorität für eine spezifische Aktion. Du kannst Capabilities in einen Token packen, aber die Konzepte sind unterschiedlich.
Funktionieren Capabilities mit GraphQL oder REST?
Ja. Der Server prägt Capabilities, nachdem er die Anfrage authentifiziert hat, und reicht sie dann an Resolver oder Controller weiter. Die Transport-Schicht muss sich nicht ändern.
Wie speichert man Capabilities?
Normalerweise persistierst du Capabilities nicht. Du persistierst die Regeln, die bestimmen, ob eine Capability geprägt werden kann. Die Capabilities selbst sind kurzlebige Laufzeitwerte.
Ersetzt das OAuth-Scopes?
Nein. OAuth-Scopes sind grobgranulare Capabilities, die über Organisationsgrenzen hinweg delegiert werden. Dieses Muster ist für feingranulare Autorität innerhalb deiner eigenen Anwendung. Sie können koexistieren.
Kann man Capabilities serialisieren?
Wenn du einen branded type serialisierst, verlierst du die Marke bei der Deserialisierung. Wenn du Capabilities über Prozessgrenzen hinweg weitergeben musst, signiere sie mit einem Schlüssel, dem der Empfänger vertraut, oder verwende einen Capability-Server, der sie validiert und neu prägt.