~/mesha-vegas$
$ cd ../

2026-07-10

Multi-tenant : l'isolation qu'on ajoute après coup n'en est pas une

Un SaaS marque blanche où chaque client croit être seul au monde. Ce que j'ai appris en construisant l'isolation dès le premier jour plutôt qu'après la première fuite de données.

  • #saas
  • #multi-tenant
  • #marque-blanche
  • #postgres
  • #architecture
  • #securite
$7 min read13 reads
Multi-tenant : l'isolation qu'on ajoute après coup n'en est pas une

Dans Jujutsu Kaisen, l'attaque la plus redoutable s'appelle l'extension du territoire. Le principe : tu crées un espace clos dont tu écris les règles, et à l'intérieur, tes attaques ne peuvent pas manquer. L'adversaire n'est pas seulement combattu, il est enfermé dans un monde qui lui est hostile par construction.

C'est exactement l'image que j'utilise pour expliquer le multi-tenant à un client non technique. Chaque locataire de ton SaaS vit dans son propre territoire. À l'intérieur, il ne voit que ses données, ses utilisateurs, ses règles, ses couleurs. Il ne sait même pas que les autres existent.

Sur Driivio, un socle SaaS marque blanche pour auto-écoles, cette isolation était une exigence commerciale avant d'être technique : deux auto-écoles concurrentes de la même ville allaient utiliser le même produit. Si l'une aperçoit ne serait-ce que le nombre d'élèves de l'autre, le produit est mort.

Les trois modèles, et celui qu'on choisit vraiment

Il existe grosso modo trois approches.

Une base par client. Isolation parfaite, coût d'exploitation délirant. Cent clients, cent bases à migrer, sauvegarder, superviser. Réservé aux contextes très réglementés avec peu de clients à forte valeur.

Un schéma par client. Le compromis apparemment élégant. En pratique, les migrations deviennent un cauchemar dès qu'un schéma diverge, et les connexions Postgres explosent.

Une base partagée, discriminée par colonne. Une colonne tenant_id sur chaque table. C'est le modèle le plus courant, le plus simple à exploiter — et de très loin le plus dangereux, parce que l'isolation ne repose que sur la discipline du code.

J'ai retenu le troisième. Mais avec une règle qui change tout.

La règle : l'isolation ne doit pas dépendre du développeur

Le modèle par colonne échoue toujours de la même façon. Quelqu'un écrit :

const students = await db.from("students").select("*").eq("school_id", schoolId);

Cent fois. Correctement. Puis un jour, à 19h, sur une fonctionnalité urgente :

const students = await db.from("students").select("*");

Et voilà. Une auto-école voit les élèves de sa concurrente. Ce n'est pas de l'incompétence, c'est de la statistique : si l'isolation demande une action volontaire à chaque requête, elle échouera à la première inattention.

La parade s'appelle Row Level Security. Postgres applique le filtre lui-même, au niveau du moteur, quoi qu'écrive l'application :

ALTER TABLE students ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON students
  USING (school_id = current_setting('app.current_tenant')::uuid);

À partir de là, un SELECT * FROM students sans clause WHERE ne renvoie que les lignes du locataire courant. Le développeur distrait ne peut plus provoquer de fuite. La base refuse.

Le changement de nature est important : on passe d'une isolation par convention à une isolation par construction. La première dépend de la vigilance de toute l'équipe, pour toujours. La seconde dépend d'une politique écrite une fois.

Poser le contexte, le vrai point délicat

current_setting('app.current_tenant') doit bien être renseigné quelque part. C'est là que se jouent les vrais bugs.

Deux pièges, tous deux subtils :

Le pooling de connexions. Si tu poses le contexte sur une connexion issue d'un pool et que tu la relâches sans le nettoyer, la requête suivante — d'un autre locataire — hérite du contexte précédent. Fuite garantie, intermittente, impossible à reproduire.

La discipline : SET LOCAL dans une transaction, jamais SET global. SET LOCAL meurt avec la transaction, quoi qu'il arrive.

Les tâches de fond. Le job nocturne de facturation n'a pas de requête HTTP, donc pas de locataire courant. Deux options : il itère explicitement sur chaque locataire en posant le contexte à chaque tour, ou il tourne avec un rôle qui contourne RLS — et alors chaque requête doit être relue comme du code de sécurité.

J'ai vu la première fuite d'un produit venir d'un export CSV lancé par une tâche planifiée. Le parcours web était irréprochable, le job avait été écrit trois mois plus tard par quelqu'un d'autre.

La marque blanche n'est pas qu'un logo

« Marque blanche » se vend facilement et se sous-estime toujours. Ce qu'il faut réellement rendre variable par locataire :

  • Identité visuelle : logo, couleurs, police. La partie facile.
  • Domaine : auto-ecole-x.driivio.cm, voire un domaine propre. Certificats TLS à générer et renouveler automatiquement.
  • Courriels transactionnels : expéditeur, gabarits, signature. Un mail de confirmation signé du nom de la plateforme ruine l'illusion.
  • Règles métier : durée des leçons, barème, seuils de validation. C'est là que ça devient sérieux.
  • Fuseau et langue, dès que tu sors d'un pays.

Le point qui fait mal est celui des règles métier. Les rendre configurables, c'est construire un moteur de règles. Ne pas les rendre configurables, c'est refuser des clients. Il n'y a pas de bonne réponse — seulement une frontière à tracer explicitement dès le cadrage, et à défendre ensuite.

Ma règle empirique : ce qui touche à l'apparence est configurable, ce qui touche au calcul ne l'est pas — sauf besoin identifié chez au moins trois clients. Sinon tu construis un langage de programmation pour tes utilisateurs, et tu le maintiens à vie.

Ce que je fais différemment maintenant

Le tenant_id est dans la clé primaire composite de toutes les tables métier. Pas juste une colonne indexée : un composant de l'identité de la ligne. Ça rend certaines erreurs de jointure structurellement impossibles.

Un test automatisé qui tente la fuite. Dans la suite de tests, un cas dédié : créer deux locataires, insérer des données dans chacun, se connecter en tant que locataire A, et vérifier que zéro ligne du locataire B remonte sur chaque table. Ce test tourne à chaque commit. Le jour où quelqu'un désactive RLS sur une table pour déboguer et oublie de la réactiver, la CI hurle.

Un identifiant public distinct de l'identifiant interne. Les URLs exposent un slug, jamais un entier auto-incrémenté. Un client qui voit /schools/47 déduit qu'il existe 46 autres écoles, et va essayer /schools/46. C'est humain.

Des journaux qui portent le locataire. Sans ça, tu déboguerais un incident client en fouillant les traces de tout le monde.

Le problème actuel, plus large

Le multi-tenant est devenu la norme — presque tout produit B2B moderne l'est. Et pourtant l'écosystème outille très mal ce besoin.

La plupart des ORM traitent le tenant_id comme un champ ordinaire. Les frameworks n'ont pas de notion native de locataire courant. Les tutoriels s'arrêtent à « ajoutez une colonne tenant_id », ce qui est à la sécurité ce que « ajoutez un mot de passe » est à l'authentification : un début, présenté comme une solution.

Résultat : chaque équipe réinvente son isolation, avec ses angles morts, et on découvre les fuites dans la presse plutôt que dans les tests.

Si tu démarres un SaaS multi-tenant aujourd'hui, l'ordre qui te fera gagner le plus de temps :

  1. RLS activé sur toutes les tables métier, avant la première fonctionnalité.
  2. Le test de fuite dans la CI, avant la première fonctionnalité.
  3. Ensuite seulement, le produit.

Ces deux jours investis au début t'épargnent la conversation où tu expliques à un client que son concurrent a vu ses données.


Ce socle est celui de Driivio. Les mêmes questions se posent sur les plateformes de gestion que je développe pour le secteur du soin à domicile. Si tu conçois un SaaS que plusieurs clients vont partager, on peut en parler.