Aller au contenu principal
Tous les articles
·5 min de lecture

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.

multi-tenantprismatrpcsecurite

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

  1. Lecturesctx.orgDb (filtre auto via $extends)
  2. Écrituresctx.db avec organizationId dans le where
  3. Jamais findUnique sur les ressources scopées → toujours findFirst
  4. 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.