Tous les articles

Tous les articles

Le contrôle d'accès en DSL déclaratif, puis les rôles et les capacités

Des règles d'accès déclarées sur chaque modèle, compilées en filtre SQL et en contrôle unitaire, puis trois niveaux de droits devenus rôles et capacités.

Chaque modèle de la plateforme déclare qui peut faire quoi avec lui, sous forme d'attributs de classe à côté de ses champs, et un seul moteur transforme chaque déclaration en deux choses : une clause SQL WHERE qui restreint chaque requête, et un contrôle Python sur un enregistrement. Une liste et une fiche ne peuvent donc pas diverger sur ce qu'une personne a le droit de voir, puisque les deux lisent la même règle. Le moteur est arrivé dix jours après le premier commit. Le modèle de droits qu'il vérifie a mis jusqu'en juin à se stabiliser.

Pourquoi les règles vivent sur le modèle#

L'alternative habituelle, c'est un contrôle dans chaque handler : la route charge ce dont elle a besoin et vérifie que l'appelant peut y accéder. Ça tient jusqu'à ce que la deuxième route qui lit la même table oublie le contrôle, ou qu'un endpoint de liste filtre en Python après avoir chargé plus de lignes qu'il n'en renvoie. Je voulais qu'une erreur soit difficile à écrire plutôt que facile à repérer. Les règles sont donc sur le modèle, et les routes génériques des ressources, les tâches de fond et les événements temps réel passent toutes par le même moteur.

Une règle nomme le droit d'accès auquel elle s'applique, les actions qu'elle accorde, éventuellement une condition sur la ligne, et éventuellement les champs qu'elle couvre :

sec_read = Rule(
    role="user",
    allow=READ,
    when=(Self.owner_id == User.id) | (Self.is_public._is_true()),
)

Self désigne la ligne consultée et User l'appelant. Les comparaisons utilisent les opérateurs de Python, combinés avec & et |, et le moteur compile l'arbre en SQLAlchemy pour une requête ou l'évalue sur une instance pour un contrôle. Les règles que détient l'appelant se combinent par un OU, et l'absence de règle applicable vaut refus : false en SQL, une exception sur un enregistrement. Deux jours après la première version, j'ai remplacé les littéraux booléens dans les conditions par des méthodes explicites comme _is_true(), parce qu'un littéral était ambigu et qu'une condition doit signifier exactement la même chose en SQL et en Python.

Ce qu'une liste de champs restreint vraiment#

Une règle peut porter une liste de champs autorisés, et la première surprise a été sa portée. La liste qui limite ce qu'une règle permet de modifier masque aussi ce qu'elle permet de lire : une seule règle ne peut donc pas accorder une lecture large et une écriture étroite. C'est précisément ce dont avait besoin un membre du personnel qui consulte le profil d'un collègue, et c'est devenu deux règles, l'une pour lire, l'autre pour gérer.

La deuxième surprise a été l'effet du masquage. Un champ masqué n'est pas absent de la réponse : l'enregistrement est construit sans lui et le champ prend sa valeur par défaut du schéma. Pour la plupart des champs, c'est sans conséquence. Pour la date d'archivage, un collègue archivé réapparaissait comme actif, puisque cette valeur par défaut est nulle, et pour l'identifiant, ce serait un UUID tout neuf vers lequel pointeraient tous les liens. Les deux font partie des champs lisibles à dessein, et l'identifiant refuse d'être masqué, quoi qu'en dise une règle.

Des libellés de rôle aux droits d'accès#

La première version vérifiait des libellés de rôle. En mars, l'autorisation est passée à des droits d'accès explicites, des slugs atomiques comme le droit de modifier les groupes, regroupés en privilèges et accordés via des groupes qu'un utilisateur détient dans une organisation donnée, pour que l'API, les tâches de fond et les écrans qui attribuent les accès répondent tous de la même façon à la même question. L'initialisation des données et les workers tournent dans un contexte system qui contourne les règles et le cloisonnement entre organisations : c'est ainsi qu'ils touchent aux données sans se faire passer pour un utilisateur.

Le moteur doit aussi décider quoi faire quand il ne peut pas être sûr. Quand le cache qui contient les tokens révoqués est injoignable, un token à la signature valide reste accepté pour que les lectures continuent de fonctionner, mais la requête est marquée comme non vérifiée et toute écriture est refusée. Un token révoqué ne peut rien modifier pendant une panne.

Une garde contre l'escalade, puis l'héritage#

Dès que les administrateurs d'une organisation ont pu attribuer des groupes à leurs membres, le trou évident était un administrateur qui accorde ce qu'il ne détient pas lui-même. La garde est un contrôle d'inclusion : les droits que portent les groupes demandés doivent être contenus dans les droits effectifs de l'appelant, et elle s'exécute avant toute modification, si bien qu'un refus ne laisse rien d'écrit à moitié. L'interface ne propose que les groupes que l'appelant peut accorder, et c'est le serveur qui calcule cette liste, parce que les réponses de liste ne portent pas les droits de chaque groupe et que les reconstituer dans le navigateur coûterait une requête par groupe.

Les privilèges ont ensuite appris à s'impliquer l'un l'autre : le privilège d'administrateur porte tout ce que porte celui de membre sans le répéter. La résolution calcule la fermeture transitive, l'héritage vaut donc aussi dans la garde contre l'escalade, et un lien qui fermerait un cycle est refusé. Le plus dur n'a pas été le graphe mais l'ORM en dessous. Le framework nommait les deux colonnes d'une table de jonction many-to-many déduite d'après le nom de la table, ce qui entre en collision quand une table pointe vers elle-même, et le code qui synchronise ces liens écrivait dans les mauvaises colonnes pour toute jonction nommée à la main. La correction lit les colonnes de jointure que SQLAlchemy a réellement résolues au lieu de se fier à une convention de nommage.

Trois niveaux qui semblaient en faire deux#

Fin juin, je me suis demandé si les groupes apportaient quoi que ce soit par rapport aux privilèges, puisque les données initiales les faisaient correspondre un à un et que les groupes portaient leur slug comme nom. Ils apportent quelque chose. Un groupe est la seule unité qu'on peut attribuer à un utilisateur au sein d'une organisation, et la garde contre l'escalade s'y rattache, alors qu'un privilège n'a ni organisation ni moyen d'être attribué. Fusionner les deux aurait demandé une nouvelle table de liaison propre aux organisations, le déplacement de la garde et une migration des données.

La redondance était dans les noms, la correction aussi. Les groupes sont devenus des rôles, nommés comme des rôles (administrateur d'organisation), et les privilèges des capacités, nommées comme des capacités (administration de l'organisation). Les données initiales ont reçu un nom et une description pour chacun, aucun schéma n'a changé et aucun slug n'a bougé, si bien que la résolution des droits et la garde contre l'escalade sont restées intactes. Les rôles ont ensuite reçu leur propre relation d'implication, purement déclarative, pour que l'écran des permissions présente administrateur et membre comme un seul choix de niveau plutôt que deux interrupteurs qui pourraient se contredire, les droits eux-mêmes ne passant toujours que par les capacités.