Votre fonction processPayment a accès à la base de données, à la passerelle de paiement et au journal d’audit parce qu’elle se trouve par hasard dans le même processus. Si un attaquant trouve une faille d’injection dans un HTTP handler, il hérite de tout cela. La fonction n’a jamais demandé ces pouvoirs. Elle les a simplement supposés en vertu de l’endroit où elle était déployée.

C’est l’autorité ambiante, et c’est le modèle par défaut dans presque tous les systèmes que nous construisons. La sécurité basée sur les capacités pose une question différente : et si une fonction ne pouvait faire que ce qu’on lui avait explicitement remis ?

Le problème de l’autorité ambiante

La plupart des applications utilisent des listes de contrôle d’accès vérifiées à la frontière du système. Une requête atteint une passerelle API, le JWT est validé, les rôles sont vérifiés, puis la requête entre dans une zone de confiance où chaque fonction a un accès implicite à tout. La connexion à la base de données est un singleton global. Le client S3 est importé depuis un module partagé. N’importe quel code qui s’exécute à l’intérieur du processus peut invoquer n’importe lequel d’entre eux.

Ça marche jusqu’à ce que ça ne marche plus.

Un bug de désérialisation dans une fonction utilitaire, une dépendance compromise ou une attaque de député confus transforment une petite brèche en un accès complet au système. Le rayon d’action n’est pas limité par ce dont l’opération spécifique avait besoin. Il est limité par ce que tout le processus possédait.

La sécurité basée sur les capacités renverse le modèle. L’autorité n’est pas une propriété de qui vous êtes. C’est une propriété de ce que vous détenez.

Qu’est-ce que la sécurité basée sur les capacités ?

La sécurité basée sur les capacités est un modèle où les droits d’accès sont représentés par des jetons infalsifiables, appelés capacités, qui doivent être explicitement passés aux fonctions qui en ont besoin. Une capacité est à la fois une référence à une ressource et l’autorisation de l’utiliser. Vous ne pouvez pas demander l’accès par son nom. Vous ne pouvez utiliser qu’une capacité que vous possédez déjà.

Ce n’est pas du contrôle d’accès basé sur les rôles avec des étapes supplémentaires. En RBAC, un utilisateur a un rôle, et le système vérifie ce rôle au moment de l’accès. En sécurité basée sur les capacités, il n’y a pas de vérification centrale. Si vous détenez la capacité, vous pouvez l’utiliser. Si vous ne la détenez pas, vous ne pouvez pas. La capacité elle-même est la preuve d’autorisation.

Ce modèle remonte à la recherche sur les systèmes d’exploitation dans les années 1970, mais il est de plus en plus pertinent alors que nous construisons des systèmes plus compartimentés. Les modules WebAssembly, les microservices, les API de navigateur et les plugins en bac à sable utilisent tous des patterns de type capacité même s’ils n’utilisent pas le nom.

Comment les capacités fonctionnent en pratique

Voici à quoi cela ressemble en code. Dans le modèle d’autorité ambiante, n’importe quelle fonction peut appeler la base de données :

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

Les deux fonctions partagent une connexion db globale avec un accès complet. Dans le modèle de capacité, vous passez une capacité limitée :

// 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 ne peut pas toucher aux commandes. processOrder ne peut pas toucher aux utilisateurs. Le compilateur l’impose. Une implémentation compromise de processOrder ne peut exfiltrer que des commandes, pas supprimer des tables ou lire des hachages de mots de passe. La capacité est la limite du comportement.

Dans les langages avec des systèmes de types plus forts, vous pouvez aller plus loin. En Rust, une capacité peut posséder un descripteur de fichier. Le système de types garantit qu’il ne peut pas être dupliqué sans autorisation, et le vérificateur d’emprunt garantit qu’il ne dépasse pas sa validité :

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

Le &mut File est la capacité. La fonction ne peut pas en conjurer une autre.

Pourquoi la sécurité basée sur les capacités n’est pas la valeur par défaut

La sécurité basée sur les capacités a des coûts réels. Le plus évident est l’ergonomie. Chaque signature de fonction s’allonge. Vous devez faire passer les capacités à travers les chaînes d’appels, ce qui ressemble à une injection de dépendances poussée à son extrême logique. Dans une grande codebase, cela peut devenir fastidieux.

La gestion des erreurs devient également plus complexe. Dans le modèle d’autorité ambiante, un échec de connexion à la base de données est une préoccupation globale gérée à l’initialisation. Dans le modèle de capacité, chaque fonction qui reçoit une capacité doit considérer ce qui se passe si cette capacité est révoquée ou invalide au milieu d’une opération.

Il y a aussi un coût de débogage. Quand l’accès est refusé dans un système ACL, vous vérifiez la politique. Quand une capacité est manquante, vous remontez la chaîne d’appels pour trouver qui était censé la passer. C’est un ensemble de compétences différent, et la plupart des développeurs n’y sont pas habitués.

Ces compromis expliquent pourquoi la sécurité basée sur les capacités a été principalement confinée aux systèmes d’exploitation, aux navigateurs et aux environnements de haute sécurité. Ce n’est pas gratuit.

Où la sécurité basée sur les capacités apparaît aujourd’hui

Vous avez déjà utilisé la sécurité basée sur les capacités, même si vous ne connaissiez pas le nom. Dans le navigateur, fetch est une fonction globale, mais elle est contrainte par la politique de même origine et le CORS. Un Service Worker reçoit des capacités d’événement spécifiques. Un module WebAssembly doit se voir explicitement accorder l’accès à la mémoire et aux fonctions hôtes.

Dans l’infrastructure cloud, les conditions de politique AWS IAM et les jetons à portée limitée sont de type capacité. Une URL S3 présignée est une capacité : un jeton infalsifiable qui accorde une opération spécifique sur une ressource spécifique pendant une durée spécifique. Les comptes de service Kubernetes avec des liaisons RBAC étroitement limitées vont aussi dans cette direction.

La tendance est vers des compartiments plus petits avec moins d’autorité ambiante. Les containers l’ont retirée du système d’exploitation. WebAssembly l’a retirée du processus de navigateur. La prochaine étape est de la retirer de nos propres fonctions.

Comment commencer à utiliser les capacités dans votre code

Vous n’avez pas besoin de réécrire toute votre application. Commencez aux frontières où le rayon d’action compte le plus.

Isolez votre couche d’accès aux données dans des objets de capacité. Au lieu d’exporter un pool de base de données global, exportez des fonctions qui retournent des handles limités :

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

Puis passez seulement les capacités dont un handler a besoin :

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 ne peut pas envoyer d’emails. Elle ne peut pas supprimer de comptes. Elle ne peut faire que ce qu’on lui a remis. Si ce handler a un bug de désérialisation, l’attaquant ne peut pas pivoter vers le système de paiement parce que la capacité n’a jamais été transmise.

Questions fréquentes sur la sécurité basée sur les capacités

Ce n’est pas juste de l’injection de dépendances ?

Ça ressemble à de l’injection de dépendances, mais l’intention est différente. L’injection de dépendances concerne la testabilité et la modularité. Les capacités concernent la sécurité et le moindre privilège. Un container DI peut toujours injecter une connexion de base de données globale. Une capacité est limitée exactement à ce que l’appelant devrait être autorisé à faire.

Cela remplace l’authentification ?

Non. L’authentification répond à « qui êtes-vous ? » Les capacités répondent à « que pouvez-vous faire ? » Vous devez toujours vérifier l’identité à la frontière. Après cela, les capacités contraignent ce que le code authentifié peut toucher.

Quels langages supportent cela bien ?

N’importe quel langage avec un système de types peut exprimer des capacités comme des interfaces ou des traits. Rust et TypeScript fonctionnent tous les deux bien. Dans les langages à typage dynamique comme Python ou JavaScript sans interfaces strictes, vous perdez l’application au moment de la compilation, mais le pattern améliore toujours la clarté du code et limite les usages accidentels.

L’autorité ambiante est une habitude, pas une loi. La sécurité basée sur les capacités est plus difficile à adopter qu’à comprendre, mais la direction de l’industrie est claire. Des frontières plus petites. Moins de pouvoir implicite. Des fonctions qui ne peuvent faire que ce qu’on leur a explicitement remis.

Commencez avec un handler. Passez-lui une capacité au lieu d’une connexion de base de données. Voyez ce qui casse. La plupart du temps, ce qui casse est une supposition cachée que vous ne saviez pas que votre code faisait.