Multi-tenant avec Prisma et tRPC : isoler les données par organisation
Isoler les données de chaque organisation dans Next.js avec Prisma $extends et tRPC : ce que le filtre automatique couvre, et ce qui reste explicite.
Le vrai problème du multi-tenant
Dans un SaaS multi-tenant, chaque client (tenant) voit uniquement ses propres données. Ça semble simple. En pratique, c'est l'une des sources de bugs les plus critiques : une requête qui oublie le filtre organizationId expose les données d'une organisation à une autre.
Ce n'est pas un bug de feature : c'est une fuite de données.
La solution naïve : ajouter WHERE organizationId = ? partout. Le problème : avec 70+ routers et 160+ modèles, vous allez oublier. Pas si vous êtes fatigué un soir, pas si vous allez vite. Vous oublierez.
La solution robuste : appliquer le filtre automatiquement pour les lectures, et cadrer les écritures par une règle simple.
Approche 1 : Filtre manuel (fragile)
// ❌ Pattern dangereux : oubliable
export const clientRouter = createTRPCRouter({
getAll: staffProcedure.query(async ({ ctx }) => {
return ctx.db.client.findMany({
// Si cette ligne manque → fuite de données entre tenants
where: { organizationId: ctx.session.user.organizationId },
});
}),
});Avec cette approche, chaque développeur qui touche un router doit se souvenir d'ajouter le filtre. Un oubli = incident de sécurité.
Approche 2 : Prisma $extends (recommandé)
Les Client Extensions de Prisma ($extends) permettent d'intercepter les queries et d'y injecter des filtres automatiquement.
// src/lib/prisma-org-scope.ts
// Modèles qui portent un organizationId direct
const ORG_SCOPED_MODELS = new Set([
"client",
"invoice",
"quote",
"project",
"employee",
// ... 30+ autres modèles
]);
export function createOrgDb(db: PrismaClient, organizationId: string) {
return db.$extends({
query: {
$allModels: {
async findMany({ model, args, query }) {
if (ORG_SCOPED_MODELS.has(model.toLowerCase())) {
args.where = { ...args.where, organizationId };
}
return query(args);
},
async findFirst({ model, args, query }) {
if (ORG_SCOPED_MODELS.has(model.toLowerCase())) {
args.where = { ...args.where, organizationId };
}
return query(args);
},
},
},
});
}Résultat : ctx.orgDb.client.findMany() exécute automatiquement WHERE organizationId = 'org_xxx'. Pour les lectures, le filtre ne dépend plus de la mémoire de chaque développeur. (Extrait simplifié : la version complète couvre aussi findFirstOrThrow, count, aggregate et groupBy.)
Intégration dans le contexte tRPC
Le orgDb est créé une fois par requête et injecté dans le contexte :
// src/server/api/trpc.ts
import { createOrgDb } from "~/lib/prisma-org-scope";
async function createTRPCContext({ req }: { req: NextRequest }) {
const session = await auth();
const organizationId = session?.user?.organizationId;
return {
db, // Accès complet (admin, webhooks)
orgDb: organizationId // Accès scopé (routers normaux)
? createOrgDb(db, organizationId)
: null,
session,
};
}Dans les routers :
// src/server/api/routers/client.ts
export const clientRouter = createTRPCRouter({
// ✅ Aucun filtre manual : orgDb s'en charge
getAll: staffProcedure.query(async ({ ctx }) => {
return ctx.orgDb.client.findMany({
orderBy: { createdAt: "desc" },
});
}),
getById: staffProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
// findFirst (jamais findUnique) pour que le filtre organizationId s'applique
const client = await ctx.orgDb.client.findFirst({
where: { id: input.id },
});
if (!client) throw new TRPCError({ code: "NOT_FOUND" });
return client;
}),
});Pourquoi findFirst et jamais findUnique ?
C'est une règle critique. L'extension ci-dessus ne couvre pas findUnique (son where n'accepte que des champs uniques) : le filtre organizationId ne s'applique donc pas :
// ❌ Dangereux : findUnique n'est pas couvert par l'extension
const client = await ctx.orgDb.client.findUnique({
where: { id: input.id },
// organizationId n'est PAS ajouté automatiquement
});
// ✅ Sûr : findFirst est couvert par l'extension
const client = await ctx.orgDb.client.findFirst({
where: { id: input.id },
// organizationId est ajouté par $extends
});Un attaquant avec un ID valide d'une autre organisation peut lire les données si vous utilisez findUnique. Utilisez toujours findFirst pour les ressources scopées.
Isolation des écritures
Le $extends couvre les lectures. Pour les écritures (create, update, delete), vous devez ajouter organizationId manuellement :
// Création : toujours inclure organizationId
create: staffProcedure
.input(createClientSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.client.create({
data: {
...input,
organizationId: ctx.session.user.organizationId, // ← obligatoire
},
});
}),
// Mise à jour : organizationId dans le WHERE, pas seulement dans les data
update: staffProcedure
.input(updateClientSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.client.update({
where: {
id: input.id,
organizationId: ctx.session.user.organizationId, // ← protection IDOR
},
data: input.data,
});
}),Sans organizationId dans le where d'un update, un utilisateur pourrait modifier les données d'une autre organisation s'il connaît l'ID (IDOR : Insecure Direct Object Reference).
RBAC en couche supplémentaire
L'isolation multi-tenant protège les frontières entre organisations. Le RBAC protège les frontières à l'intérieur d'une organisation :
// src/lib/permissions/matrix.ts
export const PERMISSION_MATRIX = {
ADMIN: ["*"], // Tout
DIRECTION: ["facturation:*", "crm:*"], // Finance + CRM
MANAGER: ["crm:read", "crm:create"], // CRM en lecture/création
CLIENT: ["portal:read"], // Portail client uniquement
} as const;// Utilisation dans les routers
export const invoiceRouter = createTRPCRouter({
create: requirePermission("facturation:create")
.input(createInvoiceSchema)
.mutation(async ({ ctx, input }) => {
// Accès garanti : organisation filtrée + permission vérifiée
}),
});Pattern sous-entités (sans organizationId direct)
Certains modèles n'ont pas de organizationId direct (ex: une ligne de facture appartient à une facture). L'isolation passe par un filtre imbriqué :
// InvoiceLine n'a pas organizationId : mais Invoice en a un
const line = await ctx.db.invoiceLine.findFirst({
where: {
id: input.id,
invoice: {
// ← filtre via le parent
organizationId: ctx.session.user.organizationId,
},
},
});Index obligatoire
Chaque modèle scopé doit avoir un index sur organizationId :
model Invoice {
organizationId String
@@index([organizationId])
}Sans cet index, le filtre automatique ajoute bien WHERE organizationId = ?, mais Postgres fait un full table scan pour l'appliquer : ça passe avec 100 lignes, pas avec 100 000.
Test de sécurité
Un bon test d'isolation vérifie qu'un utilisateur d'une organisation ne peut pas accéder aux données d'une autre :
// src/__tests__/security/tenant-isolation.test.ts
it("ne peut pas lire les clients d'une autre organisation", async () => {
const clientOrg1 = await createClient({ organizationId: "org-1" });
const caller = createCaller({ organizationId: "org-2" });
// Doit retourner NOT_FOUND, pas les données de org-1
await expect(
caller.client.getById({ id: clientOrg1.id }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});Récapitulatif : La règle en 4 points
- Lectures →
ctx.orgDb(filtre auto via$extends) - Écritures →
ctx.dbavecorganizationIddans lewhere - Jamais
findUniquesur les ressources scopées → toujoursfindFirst - Sous-entités → filtre imbriqué via le parent
Ce pattern réduit fortement le risque de fuite : les lectures sont filtrées par construction, et les écritures suivent une règle simple à vérifier en revue de code et par des tests. Dans un SaaS B2B, c'est la base de la confiance client.
Checklist avant déploiement
- Tous les modèles multi-tenant ont
organizationId -
organizationIdindexé sur chaque modèle - Aucun
findUniquesur les ressources scopées - Tests d'isolation pour chaque router critique
-
ctx.orgDbpour les lectures, filtre manuel pour les écritures
Pour aller plus loin
Articles connexes
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.
LireTester un SaaS multi-tenant : 7 patterns Vitest indispensables
De l'isolation tenant à la vérification des permissions RBAC, les patterns de test qui rendent un SaaS B2B vraiment fiable. Avec Vitest + tRPC + Prisma.
LireAuthentification multi-tenant avec NextAuth v5
Implémentez un système d'authentification robuste avec rôles, permissions granulaires et isolation multi-tenant pour votre SaaS.
LirePrêt à lancer ton SaaS ?
HeartCo Starter inclut tout ce dont tu as besoin : auth, paiements, IA, mobile, sécurité auditée. À partir de 199 €.