L’authentification n’est pas l’autorisation
Ces deux termes sont souvent utilisés l’un pour l’autre, alors qu’ils ne désignent pas la même chose. L’authentification répond à la question « qui êtes-vous ? » et aboutit à un principal fiable : un identifiant utilisateur, un tenant, et un moyen de vérifier que la requête provient bien de lui. L’autorisation répond à la question « qu’avez-vous le droit de faire ? » ; elle intervient après l’authentification, à chaque requête, par rapport à une cible spécifique.
Confondre les deux est à l’origine de certains des bugs de sécurité les plus courants sur le web. Un système de connexion parfaitement implémenté ne dit rien sur la capacité d’un utilisateur connecté à lire la facture d’un autre client. Une session valide prouve l’identité, mais elle n’accorde aucun droit. Chaque endpoint doit toujours décider, explicitement, si ce principal peut effectuer cette action sur cette ressource.
Ce guide traite de la seconde question. Il part du principe que vous avez déjà résolu la première — avec une session ou un token permettant d’identifier un utilisateur — et se concentre sur le modèle et les vérifications qui transforment cet utilisateur en une autorisation ou un refus. Si l’authentification n’est pas encore en place, lisez d’abord le guide sur l’authentification par session.
Le modèle RBAC
Le Role-Based Access Control (contrôle d’accès basé sur les rôles) est le modèle d’autorisation le plus utilisé car il correspond à la manière dont les organisations fonctionnent réellement. Il repose sur trois concepts :
- Les Principals sont les entités qui agissent : utilisateurs, comptes de service, clés API. Chaque principal appartient à un tenant.
- Les Roles sont des bundles nommés de capacités :
viewer,editor,admin,billing. - Les Permissions sont les atomes : une action unique sur un seul type de ressource, écrite sous la forme
posts:updateoubilling:read.
Un principal se voit attribuer un ou plusieurs rôles, et chaque rôle est mappé à un ensemble de permissions. L’ensemble des permissions effectives pour une requête est l’union de toutes les permissions de tous les rôles du principal. Un contrôle d’autorisation se résume alors à un simple test d’appartenance à un ensemble, auquel s’ajoutent d’éventuelles règles spécifiques à la ressource.
L’élégance de ce système réside dans le fait que les permissions sont stables tandis que les rôles sont fluides. Vous pouvez ajouter un rôle moderator, y déplacer posts:delete, et aucun handler n’a besoin d’être modifié. La règle “qui peut supprimer un post” réside dans les données, et non dans une chaîne d’instructions if dispersées dans tout le codebase.
Utilisateurs, rôles et permissions
La structure relationnelle se compose de quatre tables et de deux jointures plusieurs-à-plusieurs. Il est utile de bien l’assimiler car presque toutes les implémentations RBAC en sont une variation.
CREATE TABLE permissions (
id bigserial PRIMARY KEY,
action text NOT NULL UNIQUE
);
CREATE TABLE roles (
id bigserial PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE role_permissions (
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
permission_id bigint NOT NULL REFERENCES permissions (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
L’initialisation (seeding) de ces données dans une migration est cruciale. Les permissions et les mappings de rôles font partie du contrat de votre application ; ce n’est pas quelque chose qu’un administrateur doit improviser en production. Conservez le seed dans votre versioning pour que chaque environnement s’accorde sur la signification de editor, et traitez toute modification de celui-ci avec la même rigueur qu’un changement de schéma.
Le chargement des permissions effectives se fait via une seule requête :
SELECT DISTINCT p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1;
Mettez le résultat en cache par requête. Le charger une seule fois et l’attacher à req.user permet d’éviter de répéter la requête à chaque vérification, et un cache avec un TTL court, indexé par l’ID de l’utilisateur, permet de sortir la base de données du chemin critique (hot path).
Hiérarchies de rôles
Les organisations réelles fonctionnent par niveaux. Un rôle senior peut généralement tout faire ce qu’un rôle junior peut faire, et plus encore. Modéliser cela en copiant chaque permission dans chaque rôle est un piège en termes de maintenance : si vous modifiez posts:read, vous devez vous souvenir des cinq rôles qui l’incluent.
À la place, permettez aux rôles d’hériter. Ajoutez une table de jointure parent_role_id ou role_inherits, et développez la hiérarchie lors de la construction de l’ensemble des permissions. Une structure courante est viewer → editor → admin, où chaque niveau ajoute des capacités.
CREATE TABLE role_inherits (
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
parent_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, parent_id)
);
L’expansion se fait via une requête récursive ou, plus simplement, via une “closure table” précalculée qui stocke chaque paire d’ancêtres. La closure table sacrifie un peu d’espace de stockage pour une recherche triviale et optimisée par index, ce qui est généralement le meilleur choix car les vérifications de permissions sont bien plus fréquentes que les modifications de rôles.
Protégez-vous contre les cycles. Un rôle qui hérite de lui-même, directement ou via une chaîne, provoquera une boucle infinie lors de l’expansion. Validez lors de l’écriture : rejetez tout parent qui créerait un cycle. Gardez des hiérarchies peu profondes — trois ou quatre niveaux sont largement suffisants — car les arbres profonds sont difficiles à appréhender pour les humains et faciles à mal configurer.
Pourquoi les permissions sont supérieures aux chaînes de rôles
L’erreur RBAC la plus courante est de ne pas mettre en place de RBAC du tout. Cela consiste à parsemer des vérifications de rôles dans tout le codebase :
if (req.user.role !== "admin") return res.sendStatus(403);
Cela semble inoffensif et s’apparente à une décision de politique intégrée dans un handler. Cela indique que seul admin peut effectuer cette action, ce qui était peut-être vrai au moment de l’écriture. Lorsque le produit ajoute un rôle support qui a également besoin d’un accès, quelqu’un doit retrouver chacune de ces vérifications et les modifier — et il en oubliera forcément une. La règle est désormais dispersée dans des dizaines de fichiers sans aucune source de vérité unique.
Vérifier une permission inverse la dépendance. Le handler demande « ce principal peut-il mettre à jour un article ? » et la réponse provient des données :
if (!can(req.user, "update", post)) return res.sendStatus(403);
Désormais, accorder à support la capacité de mettre à jour des articles se résume à une ligne dans role_permissions, et non à une modification du code. La politique est ainsi révisable, testable et cohérente. Le handler décrit l’intention plutôt que d’encoder un rôle spécifique.
La règle d’or : les rôles sont pour les humains, les permissions sont pour le code. Une interface utilisateur peut afficher « Les administrateurs peuvent gérer la facturation », mais la vérification sous-jacente doit demander billing:manage.
Le pipeline d’autorisation
Chaque requête suit la même séquence, et chaque étape a un rôle unique et précis.
- Authentification. Résolution de la session ou du token en un principal : un id utilisateur, un id tenant et une liste de rôles. En cas d’échec, la requête est considérée comme anonyme et les routes protégées retournent une erreur 401.
- Chargement des rôles. Récupération des attributions de rôles, généralement depuis la base de données ou un cache alimenté lors de la connexion.
- Expansion vers les permissions. Aplatissement des rôles, y compris ceux hérités, en un ensemble unique de chaînes de caractères représentant les permissions.
- Vérification de l’action sur la ressource. Appel de
can(user, action, resource)pour la cible concrète, après l’avoir chargée. - Autorisation ou refus. En cas de succès, exécution du handler. En cas d’échec, retour d’une erreur 403 sans effets de bord.
- Journalisation de la décision. Enregistrement du principal, de l’action, de la ressource et du résultat.
L’ordre est crucial pour deux raisons. L’authentification doit intervenir en premier car tout le reste dépend d’un principal fiable. Les vérifications de ressources doivent avoir lieu après le chargement de la ressource, car il est impossible d’évaluer la propriété d’un enregistrement qui n’a pas encore été récupéré.
Le principe du “fail-closed” (refus par défaut) est non négociable. Si le chargement des rôles échoue ou si le cache est inaccessible, la réponse par défaut est le refus. Un système d’autorisation qui autorise l’accès en cas d’erreur est pire que l’absence totale de système, car il procure un faux sentiment de sécurité.
Créer un helper can()
Centraliser la décision dans une seule fonction permet d’éviter que les guards de route et les vérifications de ressources ne divergent. La signature est simple : un principal, une action et une ressource optionnelle.
export type Action = "read" | "create" | "update" | "delete" | "manage";
export function can(
user: Principal,
action: Action,
resource?: Resource
): boolean {
const permission = `${resource?.type ?? "global"}:${action}`;
if (!user.permissions.has(permission)) return false;
if (resource && resource.tenantId !== user.tenantId) return false;
if (resource && action !== "read" && resource.ownerId !== user.id) {
return user.permissions.has(`${resource.type}:manage`);
}
return true;
}
Trois règles sont encodées ici, par ordre d’importance. L’ensemble des permissions constitue le premier filtre : si aucun rôle n’accorde l’action, on s’arrête là. L’isolation des tenants vient ensuite et elle est absolue — un principal ne doit jamais agir en dehors de son tenant, quelles que soient ses permissions. Enfin, les écritures sur une ressource nécessitent la propriété de celle-ci ou l’octroi explicite d’un manage, ce qui permet à un éditeur de modifier ses propres brouillons tandis qu’un admin peut tout modifier.
Le helper est pur. Il prend des données brutes et retourne un booléen, sans aucun appel à la base de données à l’intérieur. Cela rend les tests unitaires triviaux avec une matrice de principals, d’actions et de ressources, et cela signifie que la même fonction peut être exécutée dans un guard de route, un service, un job d’arrière-plan ou un composant UI qui décide s’il doit afficher un bouton.
Pour l’UI, exposez cette même fonction au client via un endpoint ou un objet de permissions rendu côté serveur. Le client doit masquer les contrôles que l’utilisateur ne peut pas utiliser, mais le serveur doit tout de même appliquer chaque vérification, car un bouton masqué n’est pas un contrôle de sécurité.
Application au niveau des routes
Un route guard constitue la première ligne de défense : il détermine si ce type d’action est accessible ou non pour ce principal. Il s’exécute avant le handler et avant toute opération sur la base de données, ce qui en fait un moyen peu coûteux de rejeter les refus évidents.
export function requirePermission(
action: Action,
type: string
): RequestHandler {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "unauthorized" });
if (!can(req.user, action, { type, ownerId: req.user.id, tenantId: req.user.tenantId })) {
return res.status(403).json({ error: "forbidden" });
}
next();
};
}
Montez-le sur le router afin que la règle soit visible là où les routes sont définies :
router.get("/posts", requirePermission("read", "post"), listPosts);
router.post("/posts", requirePermission("create", "post"), createPost);
Il est important de retourner un code 401 pour un principal manquant et un 403 pour un accès refusé. 401 signifie « Je ne sais pas qui vous êtes » ; 403 signifie « Je sais qui vous êtes et vous n’êtes pas autorisé à faire cela ». Les clients et les outils de monitoring les traitent différemment, et les confondre rend le débogage plus difficile.
Les route guards sont nécessaires mais insuffisants. Ils répondent à la question « ce principal peut-il mettre à jour des posts en général ? », et non « peut-il mettre à jour le post 42 ? ». Cette seconde question nécessite l’accès à la ressource.
Application au niveau de la ressource
C’est lors de la vérification au niveau de la ressource que l’on trouve la majorité des vulnérabilités réelles, car c’est l’étape que l’on oublie le plus souvent. Un endpoint comme PATCH /posts/:id reçoit un id provenant du client. S’il fait confiance à cet id sans vérifier la propriété de la ressource, n’importe quel utilisateur authentifié peut modifier n’importe quel post en devinant ou en énumérant les ids. C’est ce qu’on appelle l’Insecure Direct Object Reference, ou IDOR.
La solution suit toujours le même schéma : charger la ressource, puis la vérifier.
router.patch("/posts/:id", requireAuth(), async (req, res) => {
const post = await db.post.findById(req.params.id);
if (!post) return res.status(404).json({ error: "not_found" });
const allowed = can(req.user!, "update", {
type: "post",
ownerId: post.authorId,
tenantId: post.tenantId,
});
if (!allowed) return res.status(403).json({ error: "forbidden" });
const updated = await db.post.update(post.id, req.body);
res.json(updated);
});
Il existe un choix subtil concernant l’ordre des opérations pour les ressources multi-tenants. Si un utilisateur du tenant A demande un post appartenant au tenant B, renvoyer une erreur 403 confirme que le post existe, ce qui entraîne une fuite d’informations entre tenants. De nombreux systèmes renvoient alors une erreur 404 afin que la ressource soit indiscernable d’une ressource inexistante. Quel que soit votre choix, restez cohérent et documentez-le.
Le même modèle s’applique aux ressources imbriquées. Avant d’agir sur /teams/:teamId/projects/:projectId, vérifiez que le principal peut accéder à l’équipe et que le projet lui appartient. Chaque id présent dans le chemin est contrôlé par l’attaquant et doit être vérifié.
La matrice des permissions
La matrice des permissions est un tableau où les rôles constituent les lignes et les permissions les colonnes, rempli par des droits d’accès. C’est l’artefact qui rend un système d’autorisation auditable.
posts:read posts:create posts:update posts:delete billing:read
viewer x
editor x x x
admin x x x x x
billing x x
Gardez-la dans votre gestionnaire de version à côté du code, et générez vos migrations de seed à partir de celle-ci pour éviter toute divergence entre la documentation et les données. Lorsqu’un nouveau rôle est proposé, la première question est de savoir quelles colonnes il reçoit — et la réponse se trouve dans un diff de ce tableau, et non dans une recherche fastidieuse à travers les handlers.
Deux habitudes rendent cette matrice utile. Premièrement, nommez vos permissions de manière cohérente sous la forme resource:action, afin que le tableau soit lisible et que les chaînes de caractères soient prévisibles. Deuxièmement, revoyez la matrice dès qu’un rôle change, car une seule colonne supplémentaire est facile à ignorer dans une migration et peut accorder bien plus de droits que prévu.
Rôles multi-tenants
Dans une application multi-tenant, une même personne peut détenir des rôles différents selon les organisations. Le propriétaire d’une agence peut être administrateur de son propre tenant et simple spectateur chez un client. Une seule colonne globale role ne peut pas exprimer cela.
La solution consiste à limiter la portée des attributions de rôles par tenant. Ajoutez tenant_id à user_roles et intégrez-le à la clé primaire, afin qu’un utilisateur puisse détenir des rôles distincts par tenant. Lorsque vous construisez l’ensemble des permissions pour une requête, vous le faites pour un seul tenant — celui dans lequel la requête s’exécute.
SELECT DISTINCT p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND ur.tenant_id = $2;
Le tenant doit provenir d’une source fiable : la session, un sous-domaine que vous contrôlez ou le token. N’acceptez jamais cette information depuis le corps d’une requête ou une chaîne de requête (query string) sans vérifier que le principal y appartient. Une fois établie, l’isolation des tenants est la règle numéro un en can() et s’applique avant toute considération de permission, garantissant qu’aucun droit d’accès ne puisse jamais franchir cette frontière.
Le changement de tenant constitue un changement de privilèges. Si le tenant actif est stocké dans la session, mettez-le à jour côté serveur et régénérez tout ensemble de permissions mis en cache afin que les droits de l’ancien tenant ne puissent pas fuiter dans le nouveau contexte.
ABAC et moteurs de politiques
Le RBAC répond à la plupart des besoins, mais certaines règles dépendent d’autres facteurs que le rôle et la propriété : l’heure de la journée, la sensibilité des données, le département de l’utilisateur ou le score de risque de la requête. Ce sont des règles basées sur les attributs, et tenter de les encoder sous forme de rôles conduit à une explosion combinatoire.
L’ABAC (Attribute-Based Access Control) évalue des politiques en fonction des attributs du principal, de la ressource, de l’action et de l’environnement. Une règle pourrait être : “un utilisateur peut lire un document si son département correspond à celui du document et que la classification n’est pas secrète”. Cela est exprimable, testable et auditable d’une manière qu’une matrice de rôles ne permet pas.
Les moteurs de politiques rendent cela concret. Open Policy Agent évalue des politiques écrites en Rego et peut être interrogé via un sidecar ou une bibliothèque, permettant ainsi d’appliquer les mêmes règles à travers différents services et langages. Casbin propose une approche plus légère basée sur un modèle et un adaptateur, supportant le RBAC, l’ABAC et des combinaisons des deux, et est très populaire directement dans le code applicatif.
Adoptez un moteur de politiques uniquement lorsque vos règles dépassent réellement les capacités du RBAC, pas avant. Cela ajoute un nouveau langage, une surface de déploiement et une courbe d’apprentissage. Un helper can() bien factorisé avec des règles claires peut gérer un volume surprenant de cas, et vous pourrez toujours l’envelopper autour d’un moteur de politiques plus tard lorsqu’une décision spécifique nécessitera plus de contexte.
Tester l’autorisation
Les bugs d’autorisation sont des failles de sécurité ; les tests doivent donc traiter les cas de refus comme des priorités absolues. Pour chaque action protégée, rédigez une matrice de tests : un appelant anonyme, un principal sans la permission, un propriétaire, un non-propriétaire possédant la permission, et un principal provenant d’un autre tenant.
describe("PATCH /posts/:id", () => {
it("rejects anonymous users", async () => {
await request(app).patch("/posts/1").send({ title: "x" }).expect(401);
});
it("rejects users without posts:update", async () => {
await request(app).patch("/posts/1").set("Cookie", viewerCookie).expect(403);
});
it("allows the owner", async () => {
await request(app).patch("/posts/1").set("Cookie", ownerCookie).expect(200);
});
it("rejects a non-owner editor", async () => {
await request(app).patch("/posts/1").set("Cookie", editorCookie).expect(403);
});
it("rejects a user from another tenant", async () => {
await request(app).patch("/posts/1").set("Cookie", otherTenantCookie).expect(404);
});
});
Testez can() directement comme une fonction pure, avec un tableau de principals, d’actions et de ressources. Cela permet de couvrir la logique de manière exhaustive et efficace, tandis que les tests d’endpoint prouvent que la vérification est bien implémentée. Un échec courant consiste à avoir un helper correct que le handler a oublié d’appeler ; seul un test d’intégration peut détecter ce genre d’erreur.
Initialiser les permissions via des migrations
Les permissions et les correspondances de rôles font partie du contrat de votre application ; elles doivent donc figurer dans les migrations, et non dans un panneau d’administration dont la configuration diverge selon les environnements.
Rédigez une migration de seed qui effectue un upsert des permissions par nom, puis réconcilie les droits de chaque rôle avec la matrice. Les upserts rendent la migration idempotente, ce qui est essentiel car elle peut être exécutée sur des bases de données contenant déjà certaines lignes.
INSERT INTO permissions (action) VALUES
('posts:read'), ('posts:create'), ('posts:update'), ('posts:delete'),
('billing:read'), ('billing:manage')
ON CONFLICT (action) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.action IN ('posts:read', 'posts:create', 'posts:update')
WHERE r.name = 'editor'
ON CONFLICT DO NOTHING;
Supprimer une permission est plus risqué que d’en ajouter une. Vérifiez les utilisations dans le code et, si elle est référencée quelque part, renommez-la ou retirez-la progressivement. Une migration qui supprime posts:update alors qu’un handler la vérifie encore transformera chaque requête en refus : c’est sécurisé, mais déroutant jusqu’à ce que quelqu’un examine le diff du seed.
Mise en cache de l’ensemble des permissions
Une vérification de permission ne doit jamais solliciter la base de données. Construisez l’ensemble effectif une seule fois par requête, attachez-le au principal et réutilisez-le pour chaque vérification au sein de cette même requête.
Pour les systèmes à fort trafic, mettez l’ensemble en cache par utilisateur pour une courte durée, avec une clé basée sur l’utilisateur et le tenant. Un TTL de 30 à 60 secondes est généralement suffisant pour retirer la requête du chemin critique tout en rendant les changements de rôles visibles rapidement. Lorsqu’un rôle est modifié, invalidez explicitement le cache plutôt que d’attendre l’expiration du TTL, afin qu’une permission révoquée cesse de fonctionner immédiatement.
async function permissionsFor(userId: string, tenantId: string) {
const key = `perm:${tenantId}:${userId}`;
const cached = await redis.get(key);
if (cached) return new Set(JSON.parse(cached));
const rows = await db.query(permissionQuery, [userId, tenantId]);
const set = new Set(rows.map((r) => r.action));
await redis.set(key, JSON.stringify([...set]), "EX", 60);
return set;
}
Il existe un compromis de sécurité concernant le TTL. Plus le cache est long, plus la fenêtre durant laquelle un rôle révoqué reste actif est large. Privilégiez l’invalidation explicite lors de chaque modification d’attribution de rôle, et maintenez un TTL court comme filet de sécurité en cas d’oubli d’invalidation.
Bonnes pratiques
- Vérifiez les permissions, et non les noms de rôles, dans le code de l’application ; considérez les rôles comme des bundles destinés aux humains.
- Centralisez la décision dans une seule
can(user, action, resource)pure. - Appliquez le principe du refus par défaut et échouez en mode fermé si les rôles ou les permissions ne peuvent pas être chargés.
- Appliquez l’isolation des tenants avant toute vérification de permission, et récupérez le tenant depuis une source fiable.
- Chargez la ressource avant d’autoriser une action sur celle-ci, afin de prévenir les IDOR.
- Retournez une erreur 401 pour les requêtes non authentifiées et 403 pour celles refusées.
- Mettez en cache l’ensemble des permissions effectives par requête et invalidez ce cache lorsque les rôles changent.
- Conservez la matrice des permissions sous contrôle de version et générez les seeds à partir de celle-ci.
- Testez les cas négatifs : utilisateur anonyme, permission incorrecte, non-propriétaire, autre tenant.
- Loguez les décisions d’autorisation et de refus avec suffisamment de contexte pour pouvoir les expliquer ultérieurement.
Erreurs courantes
- Considérer une session ou un token valide comme une preuve d’autorisation.
- Baser la logique sur
user.role === "admin"partout dans le code. - Vérifier la route mais jamais la ressource, laissant ainsi une faille IDOR.
- Faire confiance à un tenant id provenant du corps de la requête ou de la query string.
- Accorder des permissions
managetrop larges pour éviter de modéliser une règle réelle. - Construire des hiérarchies de rôles trop complexes que plus personne ne peut comprendre.
- Mettre les permissions en cache indéfiniment, laissant des accès obsolètes après un changement de rôle.
- Retourner une erreur 403 alors qu’une 404 éviterait de révéler l’existence d’une ressource appartenant à un autre tenant.
- Se contenter de masquer des boutons dans l’UI au lieu d’appliquer une validation côté serveur.
- Oublier de vérifier chaque id dans un chemin de route imbriqué.
Et après ?
Le RBAC est le modèle d’autorisation que vous utiliserez le plus souvent, et il s’intègre parfaitement avec tout le reste de votre architecture. Si vos principals arrivent sous forme de tokens, le guide sur les JWT explique comment intégrer les claims comme les rôles et pourquoi vous devez toujours les vérifier côté serveur. Le principal lui-même provient de l’authentification par session ou, pour les machines, des clés API. Et comme l’autorisation concerne toujours une cible, le guide REST est le compagnon idéal pour modéliser vos ressources et leurs identifiants. Lorsque vos règles commenceront à dépendre du contexte plutôt que des rôles, revenez à la section ABAC et tournez-vous vers un moteur de politiques.