Aller au contenu principal
Tous les articles
5 min de lecture

Tester l'isolation multi-tenant d'un SaaS

Douze familles de tests tirées du vrai code : IDOR, webhooks, rate limiting, sessions. Ce qu'elles prouvent, et ce qu'elles ne prouvent pas.

Pourquoi une fuite entre tenants arrive

Un filtre organizationId oublié ne se voit ni en revue de code rapide ni en recette manuelle : les deux organisations de test ont chacune leurs propres données, personne ne clique exprès sur l'ID d'une autre. La fuite dort jusqu'au jour où un client curieux (ou malveillant) change un identifiant dans l'URL.

Les causes concrètes, dans l'ordre où elles arrivent vraiment :

  • findUnique au lieu de findFirst : son where n'accepte que des champs uniques, donc organizationId ne peut pas y être ajouté. Un filtre automatique côté lecture (type $extends Prisma) ne le couvre pas.
  • update ou delete sans organizationId dans le where : le filtre automatique ne s'applique qu'aux lectures. Une écriture qui ne le répète pas explicitement modifie n'importe quelle ligne dont on connaît l'ID.
  • Sous-entité sans organizationId direct : une ligne de facture, un message dans une conversation. Sans filtre imbriqué vers le parent, l'ID de la ligne suffit à y accéder.
  • Chemin secondaire non couvert : un webhook, un job planifié, un export, une recherche plein texte. Le router principal est propre, mais personne n'a vérifié le chemin qui contourne la procédure habituelle.

Une checklist n'empêche pas ces oublis. Un test automatisé qui échoue à la moindre régression, si.

Douze familles de tests, tirées du vrai code

Ce sont les fichiers réellement présents dans le produit (src/__tests__/security/), pas une liste générique. Chacun couvre un chemin d'attaque précis.

  1. cross-tenant-isolation : deux organisations mockées, on vérifie que chaque lecture (factures, devis, conversations, événements d'agenda) filtre par organizationId avant même de toucher une base réelle.
  2. idor-comptabilite, idor-conversations, idor-messages, idor-notifications : un test par domaine métier sensible, parce qu'un filtre correct sur les factures ne dit rien sur les messages.
  3. idor-iopole-events : les événements entrants d'une intégration tierce (webhooks de plateforme de facturation électronique) sont aussi une surface IDOR, pas seulement les routes internes.
  4. webhook-security : la vérification de signature (Stripe, plateformes de facturation) compare le HMAC avec une comparaison à temps constant, jamais un === qui fuite l'information par le temps de réponse.
  5. open-redirect : un paramètre ?next= non validé peut rediriger vers un domaine externe après connexion. Classique, oublié.
  6. rate-limiting : protection contre l'abus (force brute, spam de requêtes coûteuses), pas seulement contre l'IDOR.
  7. session-security : la configuration des cookies de session (NextAuth v5 change leur préfixe entre les versions), le genre de détail qui casse silencieusement l'authentification en production sans jamais planter en développement.
  8. env-security : aucun secret serveur ne doit fuiter dans le bundle client. Une variable d'environnement mal préfixée suffit à l'exposer.
  9. comprehensive-audit : un test générique qui parcourt tous les modèles scopés par organisation et vérifie que chaque findMany passe par le filtre automatique ou déclare explicitement organizationId. Il attrape un nouveau modèle qu'on aurait oublié d'ajouter à la liste des modèles scopés.

Le dernier point compte double : un test écrit à la main pour chaque modèle ne protège que les modèles déjà connus. Un test qui parcourt le schéma protège aussi les modèles qui n'existent pas encore.

NOT_FOUND, jamais FORBIDDEN

Sur une ressource qui n'appartient pas au tenant courant, la réponse ne doit jamais être « accès interdit ». Ça confirme que la ressource existe, avec un identifiant valide, dans une autre organisation.

// Mauvais : confirme l'existence de la ressource
if (invoice.organizationId !== ctx.session.user.organizationId) {
  throw new TRPCError({ code: "FORBIDDEN" });
}
 
// Bon : indiscernable d'un ID qui n'existe pas
const invoice = await ctx.orgDb.invoice.findFirst({ where: { id } });
if (!invoice) {
  throw new TRPCError({ code: "NOT_FOUND" });
}

Un test d'IDOR qui vérifie uniquement expect(result).rejects.toThrow() passe aussi bien avec un FORBIDDEN qu'avec un NOT_FOUND. Il faut vérifier le code précis, sinon le test protège contre le bug qu'il ne fallait pas confondre avec la fuite d'information.

Automatiser plutôt que se souvenir

Ces tests n'ont de valeur que s'ils tournent à chaque changement, pas seulement avant un audit annuel. Trois conditions pour que ça tienne dans la durée :

  • Dans la CI, sur chaque pull request, pas en local à la discrétion de chacun.
  • Rapides, avec un contexte tRPC mocké plutôt qu'une vraie base de données : une suite de sécurité qui prend dix minutes finit par être désactivée sous pression de deadline.
  • Un test générique par catégorie (comme comprehensive-audit ci-dessus) en plus des tests spécifiques, pour couvrir ce qu'on ajoute demain sans y penser aujourd'hui.

Ce que ces tests ne prouvent pas

Une suite verte n'est pas une preuve d'absence de faille, seulement l'absence des failles qu'on a pensé à tester. Ce qu'elle ne couvre pas :

  • Les vulnérabilités de dépendances tierces (à suivre séparément, avec un outil dédié).
  • Les erreurs de configuration d'infrastructure (permissions de bucket, règles réseau).
  • L'ingénierie sociale et le phishing, hors du périmètre du code.
  • Tout chemin d'attaque auquel personne n'a encore pensé : c'est pour ça qu'un audit externe ponctuel reste utile en complément, pas à la place, des tests automatisés.

Pour aller plus loin

Partager

Prêt à lancer ton SaaS ?

HeartCo Starter inclut tout ce dont tu as besoin : auth, paiements, IA, mobile, sécurité auditée. À partir de 199 €.

Satisfait ou remboursé 30 jours