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

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.

prismasecuritymulti-tenantdatabase

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
  • organizationId indexé sur chaque modèle
  • Aucun findUnique sur les ressources scopées
  • Tests IDOR pour chaque router critique
  • ctx.orgDb pour 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.