Architecture multi-tenant avec Prisma et tRPC : isolation des données garantie
Comment implémenter une isolation multi-tenant robuste dans Next.js avec Prisma $extends et tRPC — le pattern qui rend les fuites de données impossibles.
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 : rendre l'isolation impossible à oublier au niveau architectural.
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é)
Prisma 6 introduit $extends — une façon d'intercepter toutes 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'. Impossible d'oublier.
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. findUnique bypasse les extensions Prisma — le filtre organizationId ne s'applique pas :
// ❌ Dangereux — findUnique ignore $extends
const client = await ctx.orgDb.client.findUnique({
where: { id: input.id },
// organizationId n'est PAS ajouté automatiquement
});
// ✅ Sûr — findFirst respecte $extends
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,
},
},
});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 rend les fuites de données structurellement impossibles — pas juste improbables. Dans un SaaS B2B, c'est la base de la confiance client.