Comment gérer un système de permissions sans devenir fou (grâce à Discord)
- BITFLAGS
- Permissions
- BACKEND
- TYPESCRIPT
- ARCHITECTURE
- RBAC
Comment gérer un système de permissions sans devenir fou (ou : comment Discord m'a sauvé)
Un jour, dans le cadre de mon travail, on m'a confié une mission qui paraissait simple sur le papier : construire un système de permissions pour notre application. L'objectif ? Pouvoir attribuer des droits différents selon les rôles — superadmin, admin, comptable, agent, et j'en passe — sans que ça devienne une usine à gaz à maintenir.
Spoiler : ça ne l'était pas, simple.
La solution "brute force" (celle qu'il ne fallait pas faire)
Ma première idée, la plus instinctive, a été la plus basique possible : ajouter une liste de permissions directement dans la table users, et la faire évoluer au fil du temps en y ajoutant des rôles au fur et à mesure des besoins. Côté code, la vérification se résumait à une succession de if / else et de .includes().
Ça fonctionnait. Mais ça sentait le sapin à trois kilomètres :
- Chaque nouveau rôle voulait dire retoucher le code de vérification un peu partout.
- La table
usersallait grossir indéfiniment. - Impossible de combiner ou de composer des permissions proprement.
- Et surtout : zéro scalabilité dès qu'on ajoutait des entités avec des règles différentes.
Bref, une solution qui tient debout au début, mais qui s'effondre à la première évolution un peu sérieuse.
Le déclic : et si on regardait comment font les pros ?
Mon instinct d'ingénieur (oui, celui qui dort parfois) a fini par se réveiller. Je me suis dit qu'il devait exister une manière plus propre et plus pérenne de faire ça. En en discutant avec mon collègue de bureau, Jérémy, on s'est demandé comment des plateformes qui gèrent des millions de rôles et de permissions s'y prenaient.
Réponse : Discord.
Discord utilise une méthode assez élégante pour gérer les permissions de ses utilisateurs, basée sur des bitflags (ou "bitmasks"). L'idée est documentée dans leur propre référence technique, et elle m'a immédiatement semblé être exactement ce qu'il me fallait : rapide, compacte, et infiniment plus scalable que ma liste de if/else.
J'ai décidé de reproduire le principe, adapté à notre contexte.
Le principe : une permission, un bit
L'idée centrale des bitflags est simple une fois qu'on l'a comprise : au lieu de stocker une liste de permissions sous forme de tableau ou de texte, chaque permission correspond à un bit unique dans un nombre entier.
export enum PermissionsEnum {
NONE = 0, // Aucune permission
ALL = 1 << 0, // Accès total
CREATE_USER = 1 << 1, // Créer un utilisateur
EDIT_USER = 1 << 2, // Modifier un utilisateur
DELETE_USER = 1 << 3, // Supprimer un utilisateur
CREATE_CONTENT = 1 << 4, // Créer du contenu
EDIT_CONTENT = 1 << 5, // Modifier du contenu
DELETE_CONTENT = 1 << 6, // Supprimer du contenu
VIEW_REPORTS = 1 << 7, // Consulter des rapports
MANAGE_BILLING = 1 << 8, // Gérer la facturation
CUSTOMER_SUPPORT = 1 << 9, // Accès au support client
SUPERADMIN = 1 << 10, // Accès Super Admin
// ...
}
Chaque 1 << n décale un bit à la position n. Résultat : chaque permission occupe un emplacement unique dans un même entier, et on peut en combiner autant qu'on veut avec un simple OR binaire (|).
Un utilisateur qui a le droit de créer du contenu ET de consulter les rapports ? On combine simplement les deux valeurs :
const permissions = PermissionsEnum.CREATE_CONTENT | PermissionsEnum.VIEW_REPORTS;
Et voilà : un seul entier — stocké tel quel en base de données — encode l'ensemble des droits d'un utilisateur. Plus besoin de tableaux à rallonge, plus besoin de jointures compliquées pour vérifier un droit.
Une classe pour manipuler tout ça proprement
Pour ne pas avoir à jongler avec des opérations binaires à la main partout dans le code, j'ai construit une classe Permissions qui encapsule toute la logique :
hasPerms(perms)— vérifie si l'utilisateur possède une permission donnée.addPerms(perms)— ajoute une permission.removePerms(perms)— en retire une.setPerms(perms)— remplace l'ensemble des permissions existantes.listRoles()— retourne la liste lisible des permissions actives, sous forme de noms (["EDIT_USER", "CREATE_CONTENT"]) plutôt qu'un entier brut.convertPermissionsToBitmask(permissionEnums)— convertit une liste de permissions en un seul bitmask, prêt à être stocké en base.
Ce dernier point est celui qui change vraiment la donne au quotidien : on définit les permissions d'un rôle sous forme de liste lisible dans le code, et on ne manipule le bitmask brut qu'au moment de le sauvegarder ou de le comparer.
La vérification côté serveur
Le vrai gain se voit surtout dans le controller. Au lieu d'une cascade de conditions, la vérification tient en une seule opération binaire :
protected verifyPermissions(
userPermissions: number,
requiredPermissions: PermissionsEnum | PermissionsEnum[]
): boolean {
const permissions = Array.isArray(requiredPermissions)
? requiredPermissions
: [requiredPermissions];
const requiredBitmask = Permissions.convertPermissionsToBitmask(permissions);
return (userPermissions & requiredBitmask) !== 0; // true si l'utilisateur a la permission
}
Un simple AND binaire (&) suffit à savoir si les bits requis sont présents dans le bitmask de l'utilisateur. Pas de boucle, pas de requête supplémentaire, pas de comparaison de chaînes de caractères. Juste une opération sur des entiers, quasi instantanée.
Cette fonction est ensuite appelée à l'entrée de chaque endpoint sensible, via une méthode qui va chercher l'utilisateur connecté, récupère son bitmask de permissions (calculé selon son rôle et son type d'entité), puis vérifie qu'il a bien les droits requis — sinon, direction une 403.
Ce que ça a changé concrètement
Avec le recul, ce choix a réglé plusieurs problèmes d'un coup :
- Compacité : un seul entier stocké en base au lieu d'un tableau ou d'une chaîne à parser.
- Rapidité : les vérifications se font en opérations binaires, ce qui est quasi gratuit en performance.
- Scalabilité : ajouter une nouvelle permission, c'est ajouter une ligne dans l'enum — rien d'autre à casser.
- Lisibilité malgré tout : grâce à
listRoles()et aux enums nommés, on garde un code lisible même si, sous le capot, tout n'est qu'un empilement de bits.
Le revers de la médaille
Tout n'est pas parfait pour autant. Le principal inconvénient des bitflags, c'est qu'ils sont limités par la taille de l'entier utilisé. Avec un entier classique sur 32 bits, on ne peut pas dépasser 31 permissions distinctes (le bit restant posant souvent problème avec le signe de l'entier) avant de devoir changer de stratégie.
Deux options existent quand on approche de cette limite :
- Passer sur des
BigInt, qui permettent de dépasser les 32 bits mais compliquent un peu les opérations binaires et le stockage. - Découper les permissions en plusieurs bitmasks (un par "domaine" fonctionnel), au prix d'un peu plus de complexité dans la vérification.
C'est donc une technique très efficace tant que le nombre de permissions reste raisonnable, mais elle demande d'anticiper cette limite si l'application est amenée à multiplier les cas d'usage dans le temps.
Pour conclure
Ce qui m'a le plus marqué dans cette histoire, ce n'est pas tant la technique en elle-même — les bitflags existent depuis des décennies — mais le réflexe qu'il faut avoir en tant qu'ingénieur : avant de foncer tête baissée sur la première solution qui marche, regarder comment les acteurs qui gèrent ce problème à très grande échelle s'y prennent. Discord gère les permissions de millions d'utilisateurs avec ce système ; il n'y avait aucune raison qu'il ne tienne pas la route pour notre application.
La prochaine fois que vous devez concevoir un système de rôles et de permissions, posez-vous la question avant d'écrire le premier if : est-ce que ce problème a déjà été résolu, ailleurs, à plus grande échelle ?