Chaque bug de permission se ressemble avec le recul. Une fonction au fond du call stack suppose que l’appelant a déjà vérifié isAdmin. Ce n’était pas le cas. Ou un nouveau rôle est ajouté, et vous faites un grep sur role === 'editor' dans 47 fichiers en espérant n’en avoir oublié aucun.
La capability-based security corrige cela en rendant l’autorité explicite. Au lieu de demander « qui êtes-vous ? » et de consulter les permissions, vous remettez à l’appelant un token qui encode littéralement ce qu’il est autorisé à faire. Pas de token, pas d’accès. Le type system peut imposer cela au compile time.
Ce que signifie réellement la capability-based security
Une capability est un token inforgeable qui accorde à son détenteur le droit d’effectuer une action spécifique. Le terme vient de la recherche sur les operating systems remontant aux années 1960, mais l’idée s’applique tout aussi bien au code applicatif.
Dans un système traditionnel basé sur les rôles, un utilisateur a un rôle, et vous vérifiez ce rôle au moment de l’action :
function deleteProject(user: User, projectId: string) {
if (user.role !== 'admin') {
throw new UnauthorizedError();
}
// ... delete logic
}
Ce pattern est simple jusqu’à ce qu’il ne le soit plus. La vérification se trouve loin de l’endroit où l’autorité a été initialement accordée. Vous finissez par avoir des vérifications redondantes, des vérifications oubliées, et une logique qui dépend implicitement de vérifications effectuées par des appelants dix frames plus haut dans la stack.
Les capabilities renversent le modèle. L’autorité de supprimer un projet devient une valeur que vous devez posséder pour même appeler la fonction :
function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
// No check needed. If you have the cap, you have the right.
// ... delete logic
}
Si vous n’avez pas de ProjectDeletionCapability, vous ne pouvez pas appeler cette fonction. Le type system l’impose.
Pourquoi TypeScript est un bon choix
Le structural typing de TypeScript est généralement une fonctionnalité, mais pour les capabilities c’est un bug. Si ProjectDeletionCapability n’est qu’une interface avec un champ projectId: string, n’importe quel objet ayant cette shape passera. Vous avez besoin de nominal typing. Vous avez besoin d’un type qui ne puisse pas être construit par accident.
La manière la plus propre de faire cela en TypeScript est d’utiliser un branded type avec un private symbol :
declare const ProjectDeletionCapabilityBrand: unique symbol;
interface ProjectDeletionCapability {
readonly [ProjectDeletionCapabilityBrand]: true;
readonly projectId: string;
readonly grantedAt: Date;
readonly grantedBy: string;
}
Parce que ProjectDeletionCapabilityBrand est un unique symbol, aucun code en dehors de ce module ne peut produire une valeur qui satisfait l’interface. Le brand agit comme un sceau au compile time. Vous ne pouvez construire la capability qu’à l’intérieur du module qui possède le symbol.
Une implémentation concrète
Voici un pattern qui fonctionne en production sans transformer votre codebase en projet d’art abstrait.
D’abord, définissez un module capability factory. C’est le seul endroit qui peut créer de nouvelles capabilities :
// 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;
}
Votre authorization layer, quelle qu’elle soit, crée des capabilities après avoir vérifié les claims de l’utilisateur :
// 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;
}
Vos domain functions consomment les capabilities directement. Pas d’IDs utilisateur, pas de vérifications de rôle, pas de database lookups :
// 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);
}
Si vous essayez de passer une FileReadCapability à writeFile, TypeScript refusera de compiler. L’erreur est immédiate et locale. Vous n’avez pas besoin de parcourir une hiérarchie de rôles pour comprendre si un appel est valide.
Composing capabilities sans perdre la sécurité
Le code réel a besoin de déléguer. Un service peut détenir plusieurs capabilities et passer des sous-ensembles à des helper functions. Vous pouvez modéliser cela avec des 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);
}
Si vous voulez être plus strict, vous pouvez définir une composite capability :
interface FileReadWriteCapability
extends FileReadCapability,
FileWriteCapability {}
function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
// ...
}
La clé est que l’autorité circule à travers des valeurs, pas un état ambiant. Vous pouvez remonter chaque capability jusqu’à l’endroit où elle a été créée.
Où ce pattern montre ses limites
Les capabilities ne sont pas gratuites. Chaque opération protégée a besoin d’une valeur de capability passée à travers la call chain. Dans une application profondément layered, cela peut signifier faire transiter des capabilities à travers cinq ou six fonctions qui ne les utilisent pas directement.
Il y a aussi le problème de revocation. Une capability, une fois créée, n’est qu’un objet JavaScript. Elle vit jusqu’à ce qu’elle soit garbage collected. Si vous devez révoquer l’accès immédiatement, parce qu’un utilisateur a été retiré d’un projet par exemple, vous ne pouvez pas détruire les capabilities déjà en vol. Vous avez besoin d’une vérification out-of-band, ou vous devez envelopper les capabilities dans un proxy qui valide contre une ACL live à chaque utilisation. Cela réintroduit exactement le lookup que vous essayiez d’éviter.
L’audit logging devient plus difficile aussi. Avec RBAC, vous enregistrez le rôle de l’utilisateur au moment de l’action. Avec les capabilities, l’autorité a peut-être été accordée des heures auparavant par un service différent. Vous devez attacher des métadonnées à la capability elle-même, c’est pourquoi les exemples ci-dessus incluent les champs grantedAt et grantedBy.
Quand utiliser les capabilities et quand les éviter
Utilisez les capabilities lorsque vous avez des permissions fine-grained et contextuelles qui sont coûteuses à recalculer. Un utilisateur peut éditer ce document spécifique jusqu’à vendredi. Un service peut lire depuis ce bucket mais pas celui-là. La capability encode le contexte. La fonction qui la consomme n’a pas besoin de savoir que le contexte existe.
Ignorez les capabilities pour les rôles globaux coarse. Si votre application a trois niveaux de permission et qu’ils ne varient jamais selon la ressource, RBAC est plus simple et plus facile à auditer. Ne laissez pas le parfait être l’ennemi du raisonnablement sécurisé.
FAQ
Quelle est la différence entre une capability et un token ?
Un JWT ou un API token prouve l’identité. Une capability prouve l’autorité pour une action spécifique. Vous pouvez mettre des capabilities à l’intérieur d’un token, mais les concepts sont distincts.
Les capabilities peuvent-elles fonctionner avec GraphQL ou REST ?
Oui. Le server crée des capabilities après avoir authentifié la requête, puis les passe aux resolvers ou aux controllers. Le transport layer n’a pas besoin de changer.
Comment stockez-vous les capabilities ?
Habituellement, vous ne persistez pas les capabilities. Vous persistez les règles qui déterminent si une capability peut être créée. Les capabilities elles-mêmes sont des runtime values de courte durée.
Cela remplace-t-il les OAuth scopes ?
Non. Les OAuth scopes sont des capabilities coarse-grained déléguées à travers des frontières organisationnelles. Ce pattern est pour l’autorité fine-grained à l’intérieur de votre propre application. Ils peuvent coexister.
Pouvez-vous sérialiser les capabilities ?
Si vous sérialisez un branded type, vous perdez le brand lors de la deserialization. Si vous devez passer des capabilities entre processus, signez-les avec une clé en laquelle le receiver a confiance, ou utilisez un capability server qui les valide et les recrée.