Tous les articles

Tous les articles

Le multi-tenant dès le premier modèle

Comment l'isolation des organisations est entrée dans le modèle de base dès la première semaine, où s'arrête le filtre automatique, et ce qu'il reste à faire.

Dans la plateforme, aucun endpoint n'a besoin de filtrer par organisation. C'est la session de base de données qui s'en charge : chaque lecture, mise à jour et suppression sur une table qui appartient à une organisation reçoit le filtre d'organisation en route vers PostgreSQL, quel que soit le code qui a écrit la requête. Cette règle est entrée dans le modèle de base le 4 février 2026, trois jours après le premier commit, avant qu'il existe la moindre table à migrer.

Une base de données, un filtre#

Le plan écrit le premier soir avait choisi la séparation logique dans une seule base de données. Les lignes de tous les tenants vivent dans les mêmes tables, distinguées par une colonne d'organisation, plutôt que dans une base ou un schéma par tenant. Ce que ça apporte : un seul schéma, une seule chaîne de migrations et un seul pool de connexions, quel que soit le nombre d'organisations inscrites. Ce que ça coûte : l'isolation devient le travail de l'application à chaque requête, et une requête qui oublie est une fuite.

La décision qui comptait était donc l'endroit où vit le filtre. Le laisser à chaque endpoint, c'est compter sur chaque requête future pour s'en souvenir. Le placer sous l'ORM, c'est obliger une requête à faire un effort délibéré pour y échapper.

Scinder le modèle de base#

Le premier modèle de base ajoutait la colonne d'organisation à tout. Le quatrième jour, je l'ai scindé en deux : un Model de base avec un identifiant, des horodatages et l'auteur des modifications, et par-dessus un modèle tenant qui ajoute la colonne d'organisation. Un utilisateur n'appartient pas à une seule organisation, un enregistrement utilisateur n'a donc aucune raison d'en porter une, et la scission a fait de cette différence quelque chose que le type exprime plutôt que quelque chose dont chaque modèle doit se souvenir.

La sous-classe s'appelait alors TenantModel. En avril, quand le multi-tenant a été reconstruit autour des organisations et d'appartenances many-to-many, elle a repris le mot d'origine du plan et est devenue OrganizationModel. Le mécanisme, lui, n'a pas changé avec le nom.

Le filtre lui-même#

Trois jours après la scission, la couche de base de données asynchrone est arrivée avec deux hooks SQLAlchemy. Un listener sur chaque exécution ORM ajoute un critère de chargement à chaque SELECT, UPDATE et DELETE qui touche un modèle tenant, organization_id égal à l'organisation du contexte de requête, et il s'applique aussi aux alias, de sorte qu'une jointure sur la même table est filtrée des deux côtés. Un second listener appose l'organisation du contexte sur toute nouvelle ligne tenant qui n'en nomme pas.

Le premier test d'isolation écrivait une ligne pour chacune de deux organisations, puis lisait la table au nom de chacune et vérifiait que chacune ne voyait que la sienne. Il allait ensuite chercher la ligne de l'autre organisation par son identifiant, exactement ce que ferait un attaquant qui devine des identifiants, et vérifiait que la réponse était vide. L'échappatoire était testée aussi : une requête peut se soustraire explicitement au filtre, et le test vérifiait qu'une requête qui le faisait voyait bien les deux lignes. Cette option, et l'identité système sous laquelle tourne le travail interne privilégié, sont les seuls moyens de passer outre, et les deux sont visibles dans le code qui s'en sert.

La première migration a oublié les tenants#

Le même commit mettait en place Alembic pour les migrations, mais les tests construisaient leurs tables directement à partir des modèles, si bien que pendant un mois rien n'a eu besoin d'un fichier de migration. Début mars, quand l'application s'est mise à insérer des données au démarrage, elle a planté en écrivant dans une table de tenants qui n'existait pas : sa migration n'avait jamais été écrite. La correction tenait en un fichier généré.

Les migrations ont reçu leur propre règle de multi-tenant plus tard. En avril, un hook s'est mis à refuser toute contrainte ou tout index unique sur une table tenant qui ne comprend pas la colonne d'organisation, pour qu'une valeur ne puisse être unique qu'au sein d'une organisation. Une contrainte sur toute la table laisserait les données d'une organisation décider de ce qu'une autre peut enregistrer, et l'erreur apprendrait à la seconde que la valeur existe quelque part.

Là où le filtre ne va pas#

Le filtre couvre les lignes qu'une organisation possède. Tout ce qui est partagé entre organisations reste en dehors par construction, et les utilisateurs en sont le principal exemple : un utilisateur est un seul enregistrement, membre d'autant d'organisations qu'on veut. Ce qu'un autre membre peut voir d'un utilisateur relève alors des règles d'accès.

C'est à cette frontière qu'une vraie fuite est apparue. En avril, une permission de lecture sur les utilisateurs s'est révélée accordée trop largement, si bien que n'importe quel compte connecté pouvait lister les utilisateurs de toutes les organisations. Le filtre tenant faisait exactement ce pour quoi il était conçu ; les utilisateurs n'en avaient jamais fait partie. La correction a resserré la règle et ajouté un test avec deux utilisateurs dans deux organisations, chacun incapable de trouver l'autre. Elle est arrivée des mois avant que l'application ait le moindre environnement en dehors du développement et de la CI.

La seconde faille était le contexte de requête lui-même. Le filtre fait confiance à l'organisation que nomme le contexte, donc le contexte ne vaut que ce que vaut le contrôle qui le définit. En mai, une requête nommant une organisation dont l'utilisateur n'est pas membre est devenue un 403, là où elle se rabattait auparavant sur l'union des droits de l'utilisateur dans toutes ses organisations.

Ce qu'il me reste à faire#

Le filtre vit dans l'application. Il voit ce qui passe par l'ORM, et une requête SQL écrite à la main ne passe pas par l'ORM. La couche que je veux en dessous, c'est la row-level security de PostgreSQL, avec un rôle de base de données d'exécution qui ne peut pas la contourner et une politique qui ne renvoie aucune ligne quand aucune organisation n'est définie. Elle est décrite et pas encore construite, en septembre 2026.