Sécuriser un SaaS multi-tenant avec Prisma : le guide ultime
Isolation des données par organisation, filtres automatiques, et patterns pour éviter les fuites de données entre tenants.
Le problème #1 des SaaS B2B
Dans un SaaS multi-tenant, chaque organisation doit voir uniquement ses données. Un oubli de filtre organizationId et c'est la catastrophe : un client voit les factures d'un autre.
Ce n'est pas un scénario théorique. C'est le bug le plus critique (et le plus courant) des SaaS mal construits.
La solution : Prisma $extends
Au lieu de compter sur la discipline des développeurs (spoiler : ça ne marche pas), on automatise le filtre avec Prisma Client Extensions :
// src/lib/prisma-org-scope.ts
export function createOrgScopedClient(organizationId: string) {
return db.$extends({
query: {
$allModels: {
async findMany({ args, query }) {
args.where = { ...args.where, organizationId };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, organizationId };
return query(args);
},
async update({ args, query }) {
args.where = { ...args.where, organizationId };
return query(args);
},
async delete({ args, query }) {
args.where = { ...args.where, organizationId };
return query(args);
},
},
},
});
}Comment ça marche dans tRPC
// src/server/api/trpc.ts — middleware
export const staffProcedure = protectedProcedure.use(({ ctx, next }) => {
const orgDb = createOrgScopedClient(ctx.session.user.organizationId);
return next({ ctx: { ...ctx, orgDb } });
});Maintenant, chaque requête via ctx.orgDb est automatiquement scopée. Impossible d'oublier.
Les 5 règles de sécurité multi-tenant
1. findFirst — jamais findUnique
// ❌ DANGEREUX — pas de filtre org
const invoice = await db.invoice.findUnique({ where: { id } });
// ✅ SÉCURISÉ — filtre org automatique
const invoice = await ctx.orgDb.invoice.findFirst({ where: { id } });findUnique ne supporte que les champs @unique dans le where. Vous ne pouvez pas ajouter organizationId au filtre. Utilisez toujours findFirst.
2. Sous-entités : filtre imbriqué
Pour les modèles sans organizationId direct (ex: lignes de facture) :
const line = await db.invoiceLine.findFirst({
where: {
id: input.lineId,
invoice: { organizationId: ctx.session.user.organizationId },
},
});3. IDOR : répondre NOT_FOUND
Si une ressource n'appartient pas au tenant, ne dites jamais "accès interdit" — ça confirme que la ressource existe :
if (!invoice) {
throw new TRPCError({ code: "NOT_FOUND" });
// Pas FORBIDDEN — un attaquant ne doit pas savoir si l'ID existe
}4. Index obligatoire
Chaque modèle scopé doit avoir un index sur organizationId :
model Invoice {
organizationId String
@@index([organizationId])
}Sans cet index, vos requêtes font un full table scan — ça passe avec 100 lignes, pas avec 100 000.
5. Tests de sécurité automatisés
// __tests__/security/idor.test.ts
it("ne permet pas à org-B de voir les factures de org-A", async () => {
const caller = createCallerAs(orgBUser);
const result = await caller.invoice.getById({ id: orgAInvoiceId });
expect(result).toBeNull(); // Pas d'erreur, juste null
});Checklist avant déploiement
- Tous les modèles multi-tenant ont
organizationId -
organizationIdindexé sur chaque modèle - Aucun
findUniquesur les ressources scopées - Tests IDOR pour chaque router critique
-
ctx.orgDbpour les lectures, filtre manuel pour les écritures
La sécurité multi-tenant n'est pas une feature qu'on ajoute après. C'est une fondation qu'on pose au jour 1.