Multi-tenancy dans NestJS — stratégies et pièges
TL;DR — Le multi-tenant a trois saveurs : shared DB / shared schema (une seule DB, un
tenant_idpartout, le moins cher, le plus risqué), shared DB / schema per tenant (un schéma Postgres par tenant, isolation au niveau DDL, migrations multipliées), DB per tenant (isolation totale, coût et complexité ops maximaux, idéal pour clients régulés). La résolution du tenant se fait par sous-domaine, header, ou claim JWT — chacun ayant ses gotchas. Le contexte de tenant doit voyager dans tout le call-stack viaAsyncLocalStorage(ALS), pas via DI REQUEST-scoped (qui pète les workers et tue la perf). Postgres Row-Level Security (RLS) est l'arme de défense en profondeur. Cet article disserte la mécanique complète.
🧠 Mental model — ASCII + analogie
Trois analogies, une par stratégie.
Shared schema : une seule maison, plusieurs colocataires, chacun a sa boîte à lettres marquée par son nom. Économique, mais si le facteur se trompe, ton courrier finit chez le voisin. Le tenant_id doit être présent dans CHAQUE requête, sinon fuite immédiate.
Schema per tenant : un immeuble avec plusieurs appartements indépendants, mais une seule fondation. Chacun a son frigo, son placard, sa salle de bain. Très propre, mais la plomberie commune (DB engine) impose des migrations sur tous les apparts à chaque changement.
DB per tenant : un quartier de maisons individuelles. Isolation totale, mais c'est cher (chaque maison a ses fondations, sa toiture, son syndic).
┌──────────────────────────────────────────────────────────────────┐
│ Choisir sa stratégie │
├────────────────┬────────────────┬────────────────────────────────┤
│ Critère │ Shared schema │ Schema/tenant │ DB/tenant │
├────────────────┼────────────────┼────────────────┼───────────────┤
│ Isolation │ Faible (RLS) │ Moyenne │ Totale │
│ Coût stockage │ Très bas │ Bas │ Élevé │
│ Coût ops │ Très bas │ Moyen │ Très haut │
│ Migrations │ 1 fois │ N fois │ N fois │
│ Backups │ Globaux │ pg_dump -n │ Par DB │
│ Noisy neighbor │ Oui (CPU/IO) │ Oui │ Non │
│ SLA différencié│ Difficile │ Possible │ Naturel │
│ Compliance │ Limitée │ Bonne │ Maximale │
│ Scalabilité │ Sharding │ Limité par DB │ Sharding nat.│
└────────────────┴────────────────┴────────────────┴───────────────┘
Cycle de vie d'une requête multi-tenant (shared schema + RLS):
1. Request arrives ──► Middleware extracts tenant from JWT/host/header
2. Validates tenant exists and user has access
3. Stores tenant in AsyncLocalStorage context
4. Sets PG session: SET LOCAL app.tenant_id = '<uuid>'
5. Each query is auto-scoped by RLS policies
6. Response sent ──► ALS context disposed🛠️ Code minimal (ts)
Le TenantContext basé sur ALS, accessible de partout sans DI.
// src/tenant/tenant-context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export interface TenantInfo {
id: string;
slug: string;
plan: 'free' | 'pro' | 'enterprise';
features: ReadonlySet<string>;
}
export class TenantContext {
private static readonly als = new AsyncLocalStorage<TenantInfo>();
static run<T>(tenant: TenantInfo, fn: () => Promise<T> | T): Promise<T> | T {
return TenantContext.als.run(tenant, fn);
}
static current(): TenantInfo {
const t = TenantContext.als.getStore();
if (!t) throw new Error('TenantContext accessed outside of a tenant-scoped request');
return t;
}
static tryCurrent(): TenantInfo | undefined {
return TenantContext.als.getStore();
}
}Le middleware qui résout le tenant et ouvre l'ALS.
// src/tenant/tenant.middleware.ts
import { Injectable, NestMiddleware, UnauthorizedException, NotFoundException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { TenantContext, TenantInfo } from './tenant-context';
import { TenantRegistry } from './tenant-registry.service';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private readonly registry: TenantRegistry) {}
async use(req: Request, res: Response, next: NextFunction): Promise<void> {
const tenant = await this.resolve(req);
if (!tenant) throw new NotFoundException('Tenant not resolved');
TenantContext.run(tenant, () => next());
}
private async resolve(req: Request): Promise<TenantInfo | undefined> {
// 1. JWT claim (most secure, signed)
const jwt = (req as any).user;
if (jwt?.tid) return this.registry.byId(jwt.tid);
// 2. Custom header (internal services)
const headerId = req.header('x-tenant-id');
if (headerId) {
if (!req.header('x-internal-token')) throw new UnauthorizedException();
return this.registry.byId(headerId);
}
// 3. Host-based (acme.app.com → acme)
const host = req.hostname;
const sub = host.split('.')[0];
if (sub && sub !== 'app' && sub !== 'www') return this.registry.bySlug(sub);
return undefined;
}
}Le wrapper Prisma qui injecte automatiquement le tenantId ET active RLS via session variable.
Piège #1 (vu en revue de code 100 fois) :
client.$extends(...)ne mute pasthis, il retourne un NOUVEAU client. Appelerthis.$extends(...)dans le constructeur et ignorer la valeur de retour est un no-op silencieux : ton filtre tenant n'est JAMAIS appliqué. Comme$extendsretourne un type différent (DynamicClientExtensionThis, pasPrismaClient), on ne peut pas redéfinirthis. Le pattern correct est une factory provider qui expose le client étendu, pas une sous-classe.
// src/db/prisma-base.service.ts
// La sous-classe ne gère QUE le cycle de vie de la connexion.
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaBaseService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({ log: ['warn', 'error'] });
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}// src/db/tenant-prisma.provider.ts
// La factory applique l'extension ET RETOURNE le client étendu — c'est lui qu'on injecte.
import { Provider } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaBaseService } from './prisma-base.service';
import { TenantContext } from '../tenant/tenant-context';
export const TENANT_PRISMA = Symbol('TENANT_PRISMA');
// Le type du client étendu, dérivé automatiquement.
export type TenantPrisma = ReturnType<typeof extendWithTenant>;
function extendWithTenant(base: PrismaBaseService) {
return base.$extends({
query: {
$allModels: {
async $allOperations({ args, query, operation }) {
const tenant = TenantContext.tryCurrent();
if (!tenant) return query(args); // ex: jobs cron globaux, migrations
const t = tenant.id;
switch (operation) {
case 'findMany':
case 'findFirst':
case 'findFirstOrThrow':
case 'count':
case 'aggregate':
case 'groupBy':
case 'update':
case 'updateMany':
case 'delete':
case 'deleteMany':
(args as any).where = { ...((args as any).where ?? {}), tenantId: t };
break;
case 'create':
(args as any).data = { ...((args as any).data ?? {}), tenantId: t };
break;
case 'createMany': {
const data = (args as any).data;
(args as any).data = Array.isArray(data)
? data.map((d: object) => ({ ...d, tenantId: t }))
: { ...data, tenantId: t };
break;
}
case 'upsert':
(args as any).where = { ...((args as any).where ?? {}), tenantId: t };
(args as any).create = { ...((args as any).create ?? {}), tenantId: t };
break;
// ⚠️ findUnique by id ne PEUT PAS recevoir un filtre tenantId composite
// sauf si l'unique inclut tenantId. C'est pour ça que RLS reste obligatoire.
}
return query(args);
},
},
},
});
}
export const tenantPrismaProvider: Provider = {
provide: TENANT_PRISMA,
inject: [PrismaBaseService],
useFactory: (base: PrismaBaseService) => extendWithTenant(base),
};Piège #2 —
findUnique({ where: { id } }): Prisma rejette tout champ non-unique dans lewhered'unfindUnique. Tu ne peux donc pas y injectertenantIdà moins que la contrainte unique soit@@unique([tenantId, id]). Dans une architecture shared-schema saine, les clés primaires sont composites(tenantId, id)ou tu remplaces tous lesfindUniqueparfindFirst. Sans cela,findUniqueest une porte de fuite — d'où l'insistance sur RLS comme deuxième rempart.
L'ouverture de session RLS, version paramétrée (jamais d'interpolation de string dans du SQL, même pour un UUID — défense en profondeur + cohérence) :
// src/db/tenant-session.ts
import { TenantContext } from '../tenant/tenant-context';
import type { TenantPrisma } from './tenant-prisma.provider';
// set_config(setting, value, is_local=true) est l'équivalent paramétrable de SET LOCAL.
// Il est scopé à la transaction → impossible de fuiter sur une autre requête du pool.
export async function withTenantSession<T>(
prisma: TenantPrisma,
fn: (tx: unknown) => Promise<T>,
): Promise<T> {
const tenant = TenantContext.current();
return prisma.$transaction(async (tx) => {
await (tx as any).$executeRaw`SELECT set_config('app.tenant_id', ${tenant.id}, true)`;
return fn(tx);
});
}Piège #3 —
SET LOCAL+ PgBouncer transaction mode :SET LOCAL(etset_config(..., true)) ne vit que le temps de la transaction. C'est exactement ce qu'on veut avec PgBouncer en modetransaction: le setting ne survit pas à la libération de la connexion, donc pas de fuite vers la requête suivante. UnSETsansLOCAL(session-level) serait au contraire catastrophique avec un pooler transaction-mode : la connexion retourne au pool en gardant letenant_iddu tenant précédent.
Les policies RLS Postgres (migration).
-- migrations/20260524_enable_rls.sql
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id::text = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id::text = current_setting('app.tenant_id', true));
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
USING (tenant_id::text = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id::text = current_setting('app.tenant_id', true));
-- Bypass for migrations: the migrator role has BYPASSRLS.
CREATE ROLE app_user;
CREATE ROLE app_migrator BYPASSRLS;
GRANT app_user TO app_migrator;Le module qui câble le tout.
// src/tenant/tenant.module.ts
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { TenantMiddleware } from './tenant.middleware';
import { TenantRegistry } from './tenant-registry.service';
import { PrismaBaseService } from '../db/prisma-base.service';
import { tenantPrismaProvider, TENANT_PRISMA } from '../db/tenant-prisma.provider';
@Module({
providers: [TenantRegistry, PrismaBaseService, tenantPrismaProvider, TenantMiddleware],
exports: [TenantRegistry, TENANT_PRISMA],
})
export class TenantModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
// ⚠️ Sous Nest, un middleware classe ne peut PAS ouvrir un ALS qui survit à `next()`
// de façon fiable sur toutes les plateformes (Fastify notamment). Pour la robustesse,
// préférer un guard ou un interceptor global (voir « ALS via interceptor » plus bas),
// ou un middleware fonctionnel monté tôt. On garde le middleware ici pour la lisibilité.
consumer.apply(TenantMiddleware).exclude('/health', '/auth/login', '/auth/register').forRoutes('*');
}
}Subtilité ALS + middleware Nest :
TenantContext.run(tenant, () => next())fonctionne en Express parce quenext()exécute la suite de la chaîne synchroniquement dans le callback. En Fastify, le hook middleware peut résoudre la requête en dehors durun. La voie la plus portable est un interceptor global (NestInterceptor) qui enveloppenext.handle()dansals.run(...), car l'interceptor encadre tout le pipeline du handler. Le pattern interceptor figure dans la section « SaaS RH » plus bas.
🎯 Patterns courants
ALS vs REQUEST-scoped DI. Nest propose Scope.REQUEST qui recrée le provider à chaque requête, parfait pour porter un contexte. Mauvaise idée à 99 % : (1) la pénalité de perf est sévère (recréation graph entier, ~30 % plus lent), (2) le scope contamine en cascade (tout provider injectant un REQUEST-scoped devient lui-même REQUEST-scoped), (3) ça ne marche PAS dans les workers BullMQ, schedulers, event handlers — qui n'ont pas de requête HTTP. ALS résout tout : zéro overhead DI, fonctionne dans les jobs, transparent. Le seul cas où REQUEST-scope reste valable : injecter @Inject(REQUEST) req dans un guard pour des raisons d'API Nest. Sinon, ALS.
Pour rendre ALS visible dans les workers BullMQ, on wrap le handler de job.
// src/queue/tenant-worker.ts
import { Worker, Job } from 'bullmq';
import { TenantContext, TenantInfo } from '../tenant/tenant-context';
import { TenantRegistry } from '../tenant/tenant-registry.service';
export function createTenantAwareWorker<T extends { tenantId: string }>(
queueName: string,
registry: TenantRegistry,
handler: (job: Job<T>) => Promise<void>,
): Worker<T> {
return new Worker<T>(
queueName,
async (job) => {
const tenant: TenantInfo = await registry.byId(job.data.tenantId);
return TenantContext.run(tenant, () => handler(job));
},
{ connection: { host: process.env.REDIS_HOST, port: 6379 } },
);
}Le pattern complémentaire côté producer : intercepter queue.add pour injecter automatiquement le tenantId.
// src/queue/tenant-queue.ts
import { Queue } from 'bullmq';
import { TenantContext } from '../tenant/tenant-context';
export class TenantQueue<T> {
constructor(private readonly inner: Queue<T & { tenantId: string }>) {}
async add(name: string, data: T, opts?: Parameters<Queue['add']>[2]) {
const tenant = TenantContext.current();
return this.inner.add(name, { ...data, tenantId: tenant.id } as T & { tenantId: string }, opts);
}
}Tenant resolution : ordre de priorité. JWT claim (cryptographiquement signé) > header signé interne (service-to-service) > sous-domaine (UX-friendly). Ne JAMAIS faire confiance à un header brut X-Tenant-Id sans contre-signature, c'est un trivial vector d'IDOR. Pour les API publiques, exiger JWT toujours. Pour les requêtes inter-services, signer un JWT court (30 s) avec tid claim.
Shared schema + tenant_id + index composite. Toujours indexer en (tenant_id, ...) plutôt que juste (...). Pourquoi : Postgres choisira l'index préfixé par tenant_id pour une query filtrée par tenant. Sans le préfixe, scan plus large. Concrètement : CREATE INDEX idx_tasks_tenant_due ON tasks (tenant_id, due_date); est meilleur que (due_date) pour des queries WHERE tenant_id = X AND due_date < Y.
Schema per tenant : connexion pooling. Avec 500 tenants = 500 schémas Postgres. Une seule connexion pool de 20 connexions partagées, on SET search_path TO tenant_<id> à l'acquisition. Problème : pg-bouncer en mode transaction ne préserve pas le SET cross-statement. Solution : SET LOCAL dans une transaction explicite, ou utiliser un pooler comme pgcat qui supporte les session-level sets, ou mode session de pg-bouncer (mais moins efficace).
DB per tenant : connexion dynamique. Un pool de connexions par tenant chargé lazy. Stratégie : Map<tenantId, PrismaClient>, instanciation à la première requête, eviction LRU au-delà de 100 entrées. Pour Drizzle, idem avec un cache de drizzle(neon(url)). Coût RAM : ~30 MB par client Prisma chargé. Donc max ~50-100 tenants actifs simultanément par instance Nest, plus = sharder l'app.
// src/db/tenant-db-factory.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { LRUCache } from 'lru-cache';
import { TenantContext } from '../tenant/tenant-context';
@Injectable()
export class TenantDbFactory implements OnModuleDestroy {
private readonly cache = new LRUCache<string, PrismaClient>({
max: 100,
dispose: (client) => client.$disconnect(),
});
async getCurrent(): Promise<PrismaClient> {
const tenant = TenantContext.current();
const cached = this.cache.get(tenant.id);
if (cached) return cached;
const url = await this.resolveDbUrl(tenant.id);
const client = new PrismaClient({ datasources: { db: { url } } });
await client.$connect();
this.cache.set(tenant.id, client);
return client;
}
private async resolveDbUrl(tenantId: string): Promise<string> {
const base = process.env.TENANT_DB_TEMPLATE!;
return base.replace('{id}', tenantId);
}
async onModuleDestroy(): Promise<void> {
for (const client of this.cache.values()) await client.$disconnect();
}
}Migrations multi-tenant. Shared schema : prisma migrate deploy, classique. Schema per tenant : itérer sur la liste des tenants et appliquer la migration. prisma migrate diff --to-schema-datasource permet de générer le SQL, puis on l'exécute sur chaque schéma dans une boucle (en parallèle limité à 10, sinon DB sature). DB per tenant : pareil, mais entre vraies DB. Toujours avoir un workflow « migration sandbox » qui exécute la migration sur une copie d'une vraie DB tenant avant prod.
Backups par tenant. Cas conformité (RGPD, droit à l'effacement, droit à la portabilité). Shared schema : impossible nativement de back up un tenant, il faut exporter via app (SELECT ... WHERE tenant_id = X). Schema per tenant : pg_dump --schema=tenant_<id> propre. DB per tenant : pg_dump <db> standard. Pour l'effacement : shared schema = DELETE FROM ... WHERE tenant_id = X sur N tables, schema/DB = DROP SCHEMA/DATABASE.
Billing et quotas. Compter les ressources par tenant : nombre de requêtes, stockage utilisé, jobs exécutés. Stratégies : compteur Redis incrémenté par middleware (INCR usage:${tenantId}:${month}), pg view agrégée, métriques OpenTelemetry tagged par tenant_id. Limites enforced via guard custom : if (await this.quota.exceeded(tenant.id, 'api_calls')) throw new ForbiddenException();. Soft limit (alerte) à 80 %, hard limit à 100 %.
// src/billing/quota.service.ts
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { TenantContext } from '../tenant/tenant-context';
interface QuotaLimits {
api_calls: number;
storage_mb: number;
jobs_per_day: number;
}
const PLAN_LIMITS: Record<string, QuotaLimits> = {
free: { api_calls: 10_000, storage_mb: 100, jobs_per_day: 50 },
pro: { api_calls: 1_000_000, storage_mb: 10_000, jobs_per_day: 5_000 },
enterprise: { api_calls: Number.MAX_SAFE_INTEGER, storage_mb: Number.MAX_SAFE_INTEGER, jobs_per_day: Number.MAX_SAFE_INTEGER },
};
@Injectable()
export class QuotaService {
private readonly redis = new Redis(process.env.REDIS_URL!);
async consume(resource: keyof QuotaLimits, amount = 1): Promise<void> {
const tenant = TenantContext.current();
const month = new Date().toISOString().slice(0, 7);
const key = `quota:${tenant.id}:${month}:${resource}`;
const current = await this.redis.incrby(key, amount);
await this.redis.expire(key, 60 * 60 * 24 * 35);
const limit = PLAN_LIMITS[tenant.plan][resource];
if (current > limit) throw new Error(`Quota exceeded: ${resource} (${current}/${limit})`);
if (current > limit * 0.8) {
// emit metric or alert
}
}
async usage(resource: keyof QuotaLimits): Promise<{ used: number; limit: number; percent: number }> {
const tenant = TenantContext.current();
const month = new Date().toISOString().slice(0, 7);
const used = Number((await this.redis.get(`quota:${tenant.id}:${month}:${resource}`)) ?? 0);
const limit = PLAN_LIMITS[tenant.plan][resource];
return { used, limit, percent: limit ? (used / limit) * 100 : 0 };
}
}Tests d'isolation. Le test le plus critique d'un système multi-tenant : « tenant A peut-il lire une donnée de tenant B ? ». Tests automatisés obligatoires : créer tenant A et B, injecter des données pour B, faire une requête authentifiée A, assert vide. À mettre dans le pipeline CI, non skippable. Cf section Testing.
Onboarding d'un nouveau tenant. Workflow type : créer le tenant en DB → si schema/DB per tenant, créer le schéma + appliquer toutes les migrations → seed des données initiales (rôles, settings par défaut) → créer le premier admin → envoyer email. Tout dans une transaction si possible, ou avec un mécanisme de rollback orchestré (saga). Idempotent : si l'onboarding échoue à mi-chemin, on doit pouvoir reprendre.
// src/onboarding/onboarding.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(execFile);
@Injectable()
export class OnboardingService {
constructor(private readonly prisma: PrismaClient) {}
async provisionTenant(input: { name: string; ownerEmail: string }): Promise<{ tenantId: string }> {
const tenant = await this.prisma.tenant.create({
data: { name: input.name, slug: this.slugify(input.name), plan: 'free' },
});
try {
await this.createSchema(tenant.id);
await this.runMigrations(tenant.id);
await this.seedDefaults(tenant.id);
await this.createOwner(tenant.id, input.ownerEmail);
return { tenantId: tenant.id };
} catch (err) {
await this.rollback(tenant.id);
throw err;
}
}
private async createSchema(tenantId: string): Promise<void> {
// Un nom de schéma ne peut PAS être paramétré (c'est un identifiant, pas une valeur).
// → on construit un identifiant SÛR à partir d'un UUID validé, jamais du nom brut du tenant.
await this.prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS ${schemaIdent(tenantId)}`);
}
private async runMigrations(tenantId: string): Promise<void> {
await exec('prisma', ['migrate', 'deploy', `--schema=prisma/tenants/${tenantId}/schema.prisma`]);
}
private async seedDefaults(tenantId: string): Promise<void> {
await this.prisma.role.createMany({
data: [
{ tenantId, name: 'admin' },
{ tenantId, name: 'member' },
],
});
}
private async createOwner(tenantId: string, email: string): Promise<void> {
await this.prisma.user.create({ data: { tenantId, email, role: 'admin' } });
}
private async rollback(tenantId: string): Promise<void> {
await this.prisma.tenant.delete({ where: { id: tenantId } }).catch(() => {});
await this.prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS ${schemaIdent(tenantId)} CASCADE`).catch(() => {});
}
private slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
}
// src/db/schema-ident.ts — la SEULE façon de fabriquer un identifiant de schéma.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function schemaIdent(tenantId: string): string {
if (!UUID_RE.test(tenantId)) {
throw new Error(`Refusing to build a schema identifier from non-UUID input: ${tenantId}`);
}
// Un UUID validé ne contient que [0-9a-f-]; on remplace `-` par `_` et on quote.
return `"tenant_${tenantId.replace(/-/g, '_')}"`;
}Pourquoi cette ceremonie ? Une chaîne de provisioning est typiquement appelée par un endpoint admin. Si le
name/slugarrivait jusqu'à unCREATE SCHEMA "...${input}...", unnamedu typex"; DROP SCHEMA public CASCADE; --détruirait la base. Les identifiants Postgres ne se paramètrent pas ($1ne marche que pour des valeurs), donc la seule défense est une allow-list stricte du format (ici : UUID), validée avant toute concaténation.
RLS comme défense en profondeur. L'app peut avoir un bug. Un développeur peut oublier un where tenant_id. RLS Postgres garantit qu'au niveau DB, aucune query ne retournera des lignes d'un autre tenant. Coût : ~5 % overhead query, parfois 10 %. Ça vaut largement la sécurité. Toujours activer RLS en production sur les tables sensibles.
🔄 Versions — Nest 7 → 11 + libs
| Année | Nest | ALS | Notes multi-tenant |
|---|---|---|---|
| 2020 | 7 | Node 12+ via cls-hooked | REQUEST-scope DI dominant, lent. |
| 2021 | 8 | node:async_hooks natif | Adoption de Node AsyncLocalStorage. |
| 2022 | 9 | ALS recommandé | @nestjs/cls package émerge. |
| 2023 | 10 | ALS standard | Prisma 5 $extends simplifie le tenant filter. |
| 2024 | 11 | ALS natif optimisé | Drizzle 0.30+ middleware pattern arrive. |
| 2026 | 11/12 | ALS + V8 isolates compat | Postgres 17 RLS performance améliorée. |
Libs clés en 2026 : @nestjs/cls ≥ 4.x (sucre syntaxique sur ALS, utile mais pas obligatoire), prisma ≥ 5.20 avec $extends, drizzle-orm ≥ 0.32 avec custom logger pour injection where, pg ≥ 8.13, pg-boss ou BullMQ ≥ 5.20 pour les jobs tenant-aware. Pour la RLS, Postgres 16 ou 17.
⚠️ Pitfalls — 10 à connaître par cœur
REQUEST-scope qui contamine. Injecter un REQUEST-scoped provider dans un service singleton transforme ce service en REQUEST-scoped, et tout son graph descendant. La perf s'effondre. Symptôme : un endpoint passe de 20 ms à 200 ms après l'ajout d'un nouveau provider. Solution : ALS.
tenantIdoublié dans une query custom raw SQL. L'extension Prisma protège les operations standard.prisma.$queryRaw\SELECT * FROM tasks WHERE due_date < ${date}`` n'a PAS de tenant filter. Catastrophe : on retourne les tâches de tous les tenants. Solution : RLS Postgres comme filet de sécurité.ALS perdu après
setImmediatemal placé. ALS propage à traversawait,setTimeout,setImmediate,Promise.then. Mais certaines libs anciennes utilisentprocess.nextTickmal câblé qui casse le contexte. Symptôme :TenantContext.current()jette parfois. Diagnostic :AsyncResource.bind()autour du callback fautif.Job BullMQ sans tenant. Un job ajouté dans la queue depuis une requête tenant doit porter le
tenantIddans son payload, et le worker doit ouvrir ALS avant d'exécuter le handler. Sans ça, le job tape la DB sans tenant filter → fuite. Pattern :queue.add({ ...data, tenantId: TenantContext.current().id })puisworker.process(async (job) => TenantContext.run(registry.byId(job.data.tenantId), () => handler(job))).Migration partielle sur certains tenants. Schema per tenant : la migration foire sur le tenant #347 (FK contrainte violée à cause de données legacy). On ne se rend compte que 2 semaines plus tard quand le tenant ouvre un ticket. Solution : tracking explicite via table
tenant_migrations(tenant_id, migration, applied_at, status), dashboard de monitoring, rejet automatique de release si un tenant est en retard.Index manquant pour
tenant_id. Sans index sur(tenant_id, ...), chaque query fait un seq scan filtré. Tenant gros (>1M rows) = latence x100. Audit régulier :EXPLAIN ANALYZEsur les top queries.RLS qui bloque les migrations. Si l'utilisateur DB qui exécute les migrations n'a pas
BYPASSRLS, lesINSERTde seed ou lesALTER TABLEpeuvent échouer mystérieusement. Créer un rôle dédiémigrator BYPASSRLSet séparer du rôle applicatifapp_user.Tenant Slug réutilisé. Tenant
acmeest supprimé, 6 mois plus tard un nouveau client veutacme. Si on réutilise le mêmeslug(sous-domaine), des liens externes versacme.app.com/projects/42peuvent atterrir sur le nouveau tenant avec un ancien project ID — IDOR potentiel. Soit interdire la réutilisation (tablereserved_slugs), soit invalider toutes les URLs avec un préfixe versionné.Cache Redis cross-tenant. Un cache
users:42partagé sanstenant_idmélange les utilisateurs. Préfixer TOUS les caches :t:<tenantId>:users:42. Vérifier l'invalidation : flush par tenant avecSCAN MATCH t:<id>:*(lent sur gros caches, préférer un namespace Redis Cluster).Logs sans tenant_id. Un incident en prod, on regarde les logs : 50 000 lignes, impossible de filtrer par tenant. Configurer le logger structuré (pino, winston) pour ajouter automatiquement
tenantIddepuis ALS à chaque log line via unmixinou un transport. Idem pour les traces OpenTelemetry :span.setAttribute('tenant.id', ...).
🧪 Testing
Le test d'isolation est l'épine dorsale. Il doit être impossible à oublier ou skipper.
// test/tenant-isolation.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '../src/app.module';
import * as request from 'supertest';
import { TENANT_PRISMA, TenantPrisma } from '../src/db/tenant-prisma.provider';
import { TenantContext } from '../src/tenant/tenant-context';
describe('Tenant isolation', () => {
let app: INestApplication;
let prisma: TenantPrisma;
let tenantA = { id: 'aaaa-aaaa', slug: 'a', plan: 'pro' as const, features: new Set<string>() };
let tenantB = { id: 'bbbb-bbbb', slug: 'b', plan: 'pro' as const, features: new Set<string>() };
beforeAll(async () => {
const mod = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = mod.createNestApplication();
await app.init();
prisma = app.get(TENANT_PRISMA);
await TenantContext.run(tenantA, () => prisma.project.create({ data: { name: 'A1' } }));
await TenantContext.run(tenantB, () => prisma.project.create({ data: { name: 'B1' } }));
});
afterAll(() => app.close());
it('tenant A cannot see tenant B projects (via API)', async () => {
const tokenA = signJwt({ sub: 'user-a', tid: tenantA.id });
const res = await request(app.getHttpServer())
.get('/projects')
.set('Authorization', `Bearer ${tokenA}`);
expect(res.status).toBe(200);
expect(res.body.map((p: any) => p.name)).toEqual(['A1']);
});
it('tenant A cannot fetch a specific tenant B project by ID', async () => {
const tokenA = signJwt({ sub: 'user-a', tid: tenantA.id });
const bProjects = await TenantContext.run(tenantB, () => prisma.project.findMany());
const res = await request(app.getHttpServer())
.get(`/projects/${bProjects[0].id}`)
.set('Authorization', `Bearer ${tokenA}`);
expect(res.status).toBe(404); // not 403, to avoid existence disclosure
});
it('raw query through extension is auto-filtered', async () => {
const projects = await TenantContext.run(tenantA, () => prisma.project.findMany());
expect(projects).toHaveLength(1);
expect(projects[0].name).toBe('A1');
});
it('RLS blocks unfiltered raw SQL', async () => {
// Even if ALS is empty, RLS should not leak. Setting paramétré, jamais d'interpolation.
const session = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`;
return tx.$queryRaw<any[]>`SELECT * FROM projects`;
});
expect(session).toHaveLength(1);
expect(session[0].name).toBe('A1');
});
});
function signJwt(_payload: object): string { return 'mock-jwt'; /* use real signer */ }Pour les tests unitaires, mocker TenantContext.run autour de chaque test pour simuler un contexte tenant. Pour les tests de charge multi-tenant, k6 avec un script qui boucle sur 100 tenants en parallèle vérifie qu'aucun tenant ne ralentit les autres (noisy neighbor detection).
// test/quota.spec.ts
import { TenantContext } from '../src/tenant/tenant-context';
import { QuotaService } from '../src/billing/quota.service';
it('enforces api_calls quota per tenant', async () => {
const tenant = { id: 'aaaa', slug: 'a', plan: 'free' as const, features: new Set<string>() };
const quota = new QuotaService(/* fake redis */);
await TenantContext.run(tenant, async () => {
for (let i = 0; i < 1000; i++) await quota.consume('api_calls');
await expect(quota.consume('api_calls')).rejects.toThrow(/quota exceeded/);
});
});🎬 Cas d'usage concrets
SaaS cabinets juridiques — schema per tenant
Qui : éditeur de plateforme de gestion de cabinets, 400 cabinets clients. Chaque cabinet exige une isolation forte pour des raisons déontologiques (secret professionnel). Chaque cabinet a 5 à 80 utilisateurs.
Problème : un shared schema simple est inacceptable pour des raisons de conformité Ordre. Une DB par cabinet exploserait les coûts. Le schema-per-tenant Postgres est le bon compromis : 400 schemas dans une même instance RDS, isolation logique forte, migrations différentielles possibles.
@Injectable({ scope: Scope.REQUEST })
export class TenantDataSourceProvider {
constructor(@Inject(REQUEST) private readonly req: Request, private readonly registry: TenantRegistryService) {}
async getDataSource(): Promise<DataSource> {
const tenantSlug = (this.req.headers.host ?? '').split('.')[0];
const tenant = await this.registry.findBySlug(tenantSlug);
return this.connectionPool.getOrCreate(tenant.id, {
type: 'postgres',
url: process.env.DB_URL,
schema: `tenant_${tenant.id.replace(/-/g, '_')}`,
synchronize: false,
});
}
}
@Injectable()
export class CaseRepository {
constructor(private readonly tenantDs: TenantDataSourceProvider) {}
async findOne(id: string): Promise<Case> {
const ds = await this.tenantDs.getDataSource();
return ds.getRepository(Case).findOneOrFail({ where: { id } });
}
}Gains : zéro fuite inter-cabinets en 3 ans (auditée par un cabinet indépendant). Migrations différentielles permettent d'opt-in les beta-testeurs sur les nouvelles features. Coût RDS unique (~600$/mois) au lieu de 400 RDS séparés (~80 k$/mois théoriques).
SaaS RH multi-entreprises — shared schema avec discriminator
Qui : éditeur SaaS RH pour PME, 3 200 entreprises clientes (2 à 200 salariés). Sensibilité moyenne, conformité RGPD classique mais pas d'exigence d'isolation physique.
Problème : trop de tenants pour faire schema-per-tenant (Postgres tient mal 3 000 schemas). Il faut une discriminator column companyId partout + RLS Postgres + un guard NestJS qui force l'inclusion.
@Injectable()
export class TenantInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
const companyId = req.user?.companyId;
if (!companyId) throw new UnauthorizedException();
return new Observable((sub) => {
this.als.run({ companyId }, () => {
next.handle().subscribe({
next: (v) => sub.next(v), error: (e) => sub.error(e), complete: () => sub.complete(),
});
});
});
}
}
@EventSubscriber()
export class TenantSubscriber implements EntitySubscriberInterface {
beforeInsert(event: InsertEvent<any>) {
const ctx = AsyncLocalStorageService.get();
if (event.entity.companyId === undefined && ctx?.companyId) {
event.entity.companyId = ctx.companyId;
}
}
}Côté Postgres, RLS strict :
ALTER TABLE employees ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON employees
USING (company_id = current_setting('app.current_company_id')::uuid);Gains : 3 200 tenants servis par une seule DB avec instance RDS m5.xlarge. Tests d'isolation automatisés (un test e2e par requête CRUD vérifie qu'aucune autre tenant n'est leakée). RLS = filet de sécurité côté DB même si une requête NestJS oublie le filter.
E-commerce SaaS multi-marques — DB per tenant pour les gros comptes
Qui : plateforme e-commerce SaaS B2B servant 30 grandes marques retail (chiffre d'affaires 50M+). Chaque marque demande une SLA dédié, un export complet annuel, un audit personnalisé.
Problème : ces clients exigent contractuellement leur propre infrastructure DB pour des raisons de gouvernance interne et de portabilité. Le coût est compensé par les contrats 6 chiffres.
@Injectable()
export class TenantConnectionManager {
private readonly pools = new Map<string, DataSource>();
async getConnection(tenantId: string): Promise<DataSource> {
const cached = this.pools.get(tenantId);
if (cached?.isInitialized) return cached;
const config = await this.registry.findConnectionConfig(tenantId);
const ds = new DataSource({
type: 'postgres',
host: config.host,
port: config.port,
database: config.database,
username: config.username,
password: await this.secrets.fetchPassword(config.passwordRef),
entities: [Product, Order, Customer, /* ... */],
poolSize: 5,
});
await ds.initialize();
this.pools.set(tenantId, ds);
return ds;
}
async closeConnection(tenantId: string) {
const ds = this.pools.get(tenantId);
if (ds?.isInitialized) await ds.destroy();
this.pools.delete(tenantId);
}
}
@Injectable()
export class TenantMigrationOrchestrator {
async migrateAll() {
const tenants = await this.registry.findActive();
for (const tenant of tenants) {
const ds = await this.manager.getConnection(tenant.id);
await ds.runMigrations({ transaction: 'each' });
}
}
}Gains : SLA contractuel respecté (99,99% pour 5 clients gold), backup et restore par client en quelques minutes. Migration phasée possible (canary sur tenant Beta avant les autres). Export annuel = pg_dump direct, livré sur disque chiffré.
🛠️ Exemple end-to-end
Contexte : SaaS de gestion de centres médicaux, 250 cliniques clientes. Données médicales = sensibilité forte mais pas exigeance de DB séparée. Stratégie hybride : schema-per-tenant pour les données de production, shared schema pour les données analytiques (anonymisées). Pipeline complet de provisioning d'un nouveau tenant, isolation runtime, audit cross-tenant, et purge RGPD.
// src/tenancy/tenant.module.ts
import { Module, Global } from '@nestjs/common';
import { TenantContextService } from './tenant-context.service';
import { TenantDataSourceFactory } from './tenant-datasource.factory';
import { TenantResolverMiddleware } from './tenant-resolver.middleware';
import { TenantProvisioningService } from './tenant-provisioning.service';
@Global()
@Module({
providers: [TenantContextService, TenantDataSourceFactory, TenantResolverMiddleware, TenantProvisioningService],
exports: [TenantContextService, TenantDataSourceFactory, TenantProvisioningService],
})
export class TenantModule {}
// src/tenancy/tenant-context.service.ts
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
interface TenantContext {
tenantId: string;
tenantSlug: string;
schemaName: string;
features: Record<string, boolean>;
}
@Injectable()
export class TenantContextService {
private readonly als = new AsyncLocalStorage<TenantContext>();
run<T>(ctx: TenantContext, fn: () => T): T { return this.als.run(ctx, fn); }
get(): TenantContext { const c = this.als.getStore(); if (!c) throw new Error('No tenant context'); return c; }
schema(): string { return this.get().schemaName; }
features(): Record<string, boolean> { return this.get().features; }
}
// src/tenancy/tenant-datasource.factory.ts
@Injectable()
export class TenantDataSourceFactory {
private readonly cache = new Map<string, DataSource>();
async forTenant(schemaName: string): Promise<DataSource> {
const cached = this.cache.get(schemaName);
if (cached?.isInitialized) return cached;
const ds = new DataSource({
type: 'postgres',
url: process.env.DB_URL,
schema: schemaName,
entities: [Patient, Appointment, Prescription, Practitioner],
poolSize: 5,
synchronize: false,
});
await ds.initialize();
this.cache.set(schemaName, ds);
return ds;
}
}
// src/tenancy/tenant-resolver.middleware.ts
@Injectable()
export class TenantResolverMiddleware implements NestMiddleware {
constructor(
private readonly registry: TenantRegistryService,
private readonly context: TenantContextService,
) {}
async use(req: Request, _res: Response, next: NextFunction) {
const host = req.headers.host ?? '';
const slug = host.split('.')[0];
const tenant = await this.registry.findBySlug(slug);
if (!tenant) throw new NotFoundException(`Tenant ${slug} not found`);
if (tenant.status !== 'ACTIVE') throw new ForbiddenException(`Tenant ${slug} is ${tenant.status}`);
this.context.run({
tenantId: tenant.id,
tenantSlug: tenant.slug,
schemaName: `t_${tenant.id.replace(/-/g, '_')}`,
features: tenant.features,
}, () => next());
}
}
// src/patients/patient.repository.ts
@Injectable()
export class PatientRepository {
constructor(
private readonly factory: TenantDataSourceFactory,
private readonly context: TenantContextService,
) {}
private async repo() {
const ds = await this.factory.forTenant(this.context.schema());
return ds.getRepository(Patient);
}
async findOne(id: string): Promise<Patient> {
return (await this.repo()).findOneOrFail({ where: { id } });
}
async create(input: CreatePatientInput): Promise<Patient> {
const repo = await this.repo();
const patient = repo.create({ ...input, createdAt: new Date() });
return repo.save(patient);
}
}
// src/tenancy/tenant-provisioning.service.ts
@Injectable()
export class TenantProvisioningService {
constructor(
@InjectDataSource() private readonly admin: DataSource,
private readonly registry: TenantRegistryService,
private readonly migrator: MigrationRunner,
) {}
async provision(input: ProvisionTenantInput): Promise<Tenant> {
const tenant = await this.registry.create({
slug: input.slug, name: input.name, status: 'PROVISIONING',
features: input.features ?? {},
});
// schemaIdent() valide le format UUID et retourne un identifiant quoté sûr.
const schemaName = `t_${tenant.id.replace(/-/g, '_')}`;
const ident = schemaIdent(tenant.id); // -> "tenant_<uuid_underscored>"
try {
await this.admin.query(`CREATE SCHEMA ${ident}`);
await this.migrator.runForSchema(schemaName);
await this.seedInitialData(schemaName, input);
await this.registry.activate(tenant.id);
return this.registry.findById(tenant.id);
} catch (e) {
await this.admin.query(`DROP SCHEMA IF EXISTS ${ident} CASCADE`);
await this.registry.markFailed(tenant.id, (e as Error).message);
throw e;
}
}
async purge(tenantId: string): Promise<void> {
const tenant = await this.registry.findById(tenantId);
await this.exporter.exportForRgpd(tenant); // archive avant suppression
await this.admin.query(`DROP SCHEMA ${schemaIdent(tenant.id)} CASCADE`);
await this.registry.markPurged(tenantId);
}
}
// src/audit/cross-tenant-audit.service.ts (shared schema analytics)
@Injectable()
export class CrossTenantAuditService {
constructor(@InjectRepository(AuditEvent, 'analytics') private readonly repo: Repository<AuditEvent>) {}
async logAction(action: string, resourceKind: string, resourceId: string) {
const ctx = this.context.get();
await this.repo.insert({
tenantId: ctx.tenantId,
action, resourceKind, resourceId,
timestamp: new Date(),
});
}
}Provisioning idempotent (rollback du schema en cas d'échec migration), résolveur middleware basé sur le sous-domaine, repositories qui résolvent dynamiquement leur DataSource via le contexte ALS, isolation forte par schema Postgres, audit cross-tenant dans une connexion analytics séparée pour les besoins de business intelligence sans exposer les schemas tenant. Le pipeline complet (création tenant, migration, seed, activation) prend moins de 8 secondes pour un nouveau client. Le système gère 250 schemas sur une instance Aurora avec une latence p99 sous 25 ms grâce au pool de DataSources par tenant.
🤖 Servir des agents IA en multi-tenant depuis NestJS
C'est le cas que tu vas vivre : une plateforme SaaS qui expose un assistant IA par tenant. Le multi-tenant change radicalement la donne pour l'IA, parce que le LLM est la ressource la plus chère et la plus abusable du système. Chaque token coûte de l'argent réel, et un tenant peut faire exploser ta facture ou ta latence pour les autres (noisy neighbor, version $$$). Voici comment un staff engineer câble ça.
Le client LLM est un provider DI, jamais un new Anthropic() dans un champ
Anti-pattern absolu : private anthropic = new Anthropic() dans un service. On perd la testabilité, la config par environnement, les retries SDK centralisés, et on duplique les connexions. Le client se fournit via forRootAsync, avec retries SDK activés.
// src/ai/anthropic.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC');
@Module({})
export class AnthropicModule {
static forRootAsync(): DynamicModule {
return {
module: AnthropicModule,
global: true,
providers: [
{
provide: ANTHROPIC,
useFactory: () =>
new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
maxRetries: 4, // backoff exponentiel géré par le SDK (429/5xx)
timeout: 60_000,
}),
},
],
exports: [ANTHROPIC],
};
}
}Modèles 2026 à connaître : flagship claude-opus-4-8, le workhorse équilibré claude-sonnet-4-6, le rapide/économique claude-haiku-4-5. En multi-tenant, le choix du modèle est une dimension du plan : free → haiku, pro → sonnet, enterprise → opus. Ça se résout depuis TenantContext.current().plan.
Cost-guard et rate-limit AU NIVEAU DU TENANT, à l'edge
Le quota générique (QuotaService plus haut) ne suffit pas pour l'IA : il faut compter en tokens (donc en argent), pas en requêtes. Un cost-guard pré-flight estime, post-flight réconcilie sur l'usage réel renvoyé par l'API.
// src/ai/tenant-llm.service.ts
import { Inject, Injectable, ForbiddenException } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from './anthropic.module';
import { TenantContext } from '../tenant/tenant-context';
import { QuotaService } from '../billing/quota.service';
const MODEL_BY_PLAN = {
free: 'claude-haiku-4-5',
pro: 'claude-sonnet-4-6',
enterprise: 'claude-opus-4-8',
} as const;
@Injectable()
export class TenantLlmService {
constructor(
@Inject(ANTHROPIC) private readonly anthropic: Anthropic,
private readonly quota: QuotaService,
) {}
/** Stream de tokens SSE, scopé tenant, avec cost-guard et annulation. */
async *streamChat(
messages: Anthropic.MessageParam[],
signal: AbortSignal,
): AsyncGenerator<string> {
const tenant = TenantContext.current();
// Cost-guard pré-flight : refuse AVANT de dépenser si le budget mensuel est crevé.
await this.quota.consume('llm_input_tokens', estimateTokens(messages));
const stream = this.anthropic.messages.stream(
{
model: MODEL_BY_PLAN[tenant.plan],
max_tokens: 2048,
messages,
},
{ signal }, // ⬅ propage l'annulation client jusqu'au SDK → coupe l'appel upstream
);
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield event.delta.text;
}
}
// Réconciliation post-flight sur l'usage RÉEL (et non l'estimation).
const final = await stream.finalMessage();
await this.quota.consume('llm_output_tokens', final.usage.output_tokens);
}
}
function estimateTokens(messages: Anthropic.MessageParam[]): number {
return Math.ceil(JSON.stringify(messages).length / 4); // heuristique grossière
}Pourquoi
ForbiddenExceptionAVANT l'appel ? Un tenantfreequi pousse 10 000 prompts/h ne doit jamais atteindre l'API : tu paierais pour son abus. Le cost-guard à l'edge transforme une perte sèche en429côté tenant.
Endpoint SSE de streaming, isolé par tenant + Stop côté serveur
L'AbortController est branché sur la déconnexion client (req.on('close')) : si l'utilisateur ferme l'onglet ou clique « Stop », on coupe l'appel LLM upstream — sinon tu continues de payer des tokens dans le vide.
// src/ai/chat.controller.ts
import { Controller, Post, Req, Res, Body } from '@nestjs/common';
import { Request, Response } from 'express';
import { TenantLlmService } from './tenant-llm.service';
@Controller('ai')
export class ChatController {
constructor(private readonly llm: TenantLlmService) {}
@Post('chat/stream')
async stream(@Body() body: { messages: any[] }, @Req() req: Request, @Res() res: Response) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const ac = new AbortController();
req.on('close', () => ac.abort()); // déconnexion client → annulation upstream
try {
// Le contrôleur tourne déjà dans l'ALS tenant (middleware/interceptor) :
// TenantLlmService.streamChat() lira TenantContext.current() correctement.
for await (const chunk of this.llm.streamChat(body.messages, ac.signal)) {
res.write(`data: ${JSON.stringify({ text: chunk })}\n\n`);
}
res.write('event: done\ndata: {}\n\n');
} catch (e) {
if (!ac.signal.aborted) res.write(`event: error\ndata: ${JSON.stringify({ message: (e as Error).message })}\n\n`);
} finally {
res.end();
}
}
}Piège ALS + streaming : l'
async generatorest consommé après que le handler a rendu la main au framework. Si ton ALS est ouvert par un middleware Express, il survit ici parce que lefor awaitreste dans la même chaîne d'await. Mais si tu refactores vers un worker ou unsetImmediate, tu perds le contexte tenant — re-runexplicitement.
Jobs IA asynchrones (BullMQ) tenant-aware : idempotence, coût, sortie partielle
Pour les générations longues (rapport, batch d'embeddings), on passe par une queue. Trois exigences non négociables en multi-tenant :
// src/ai/ai-jobs.worker.ts
import { Worker, Job } from 'bullmq';
import { TenantContext } from '../tenant/tenant-context';
import { TenantRegistry } from '../tenant/tenant-registry.service';
interface AiJob {
generationId: string; // clé d'idempotence STABLE (pas le jobId BullMQ)
tenantId: string;
prompt: string;
}
export function createAiWorker(registry: TenantRegistry, deps: { llm: any; store: any }) {
return new Worker<AiJob>(
'ai-generations',
async (job: Job<AiJob>) => {
const tenant = await registry.byId(job.data.tenantId);
return TenantContext.run(tenant, async () => {
// 1. IDEMPOTENCE : si la génération est déjà finie, on NE refacture PAS.
const existing = await deps.store.get(job.data.generationId);
if (existing?.status === 'done') return existing.result;
// 2. SORTIE PARTIELLE : on persiste le texte au fil de l'eau. Un retry reprend
// sans re-générer ce qui est déjà écrit (et déjà payé).
let acc = existing?.partial ?? '';
for await (const chunk of deps.llm.streamChat([{ role: 'user', content: job.data.prompt }], new AbortController().signal)) {
acc += chunk;
await deps.store.savePartial(job.data.generationId, acc);
}
await deps.store.complete(job.data.generationId, acc);
return acc;
});
},
{ connection: { host: process.env.REDIS_HOST, port: 6379 } },
);
}3. Cost-aware retry : ne configure PAS
attempts: 5aveugle sur un job LLM. Un retry après une génération partielle re-paie tout depuis zéro si tu n'as pas de sortie partielle persistée. La clé d'idempotence est legenerationId(métier, stable), jamais lejobIdBullMQ (qui change au requeue). Retry uniquement sur erreurs transitoires (429/5xx/timeout), jamais sur un refus de contenu ou un400.
Exposer un endpoint MCP par tenant
Si tu exposes tes outils à un agent (MCP), chaque tool doit s'exécuter dans l'ALS du tenant et vérifier que les features du plan autorisent l'outil. Un search_patients MCP appelé par l'agent d'un tenant free ne doit jamais voir les données d'un autre tenant — c'est exactement le même invariant que le reste de l'app, et RLS reste le filet de sécurité ultime même côté agent.
// pseudo-tool MCP côté serveur
async function handleToolCall(toolName: string, input: unknown) {
const tenant = TenantContext.current(); // contexte propagé par l'auth de la session MCP
if (!tenant.features.has(`mcp:${toolName}`)) {
throw new Error(`Tool ${toolName} not enabled for plan ${tenant.plan}`);
}
// Toute requête DB ici est auto-scopée tenant (extension Prisma) + RLS.
return runTool(toolName, input);
}Récap mental : en multi-tenant IA, le tenant est une dimension de (1) choix de modèle, (2) budget/cost-guard en tokens, (3) isolation des données vues par l'agent, (4) rate-limit anti-noisy-neighbor. Les quatre se résolvent depuis TenantContext.current() + RLS.
🔁 Quand utiliser / éviter
Shared schema quand : SaaS B2B classique avec milliers de tenants petits (TPE, indépendants), pas de contrainte conformité forte, budget infra serré. Exemples : Notion, Linear, Slack (free tier). Risque acceptable si RLS + tests d'isolation rigoureux.
Schema per tenant quand : SaaS B2B mid-market, tenants 10 à 500, besoin de migrations différentielles par tenant (feature flags DB), conformité moyenne, exports/backups par tenant exigés. Exemples : Salesforce historique, plateformes ERP.
DB per tenant quand : clients régulés (santé, finance, défense), SLA différencié contractuel (gold/silver/bronze), grandes entreprises (chacune avec sa DB dédiée), audit trail isolé par client. Coût plus élevé compensé par la marge contractuelle.
À éviter : mélanger les stratégies dans la même app (chaos de migrations). Démarrer en DB per tenant pour un MVP — overkill, on ne sait pas si on aura 10 ou 10 000 tenants. Faire du multi-tenant pour 2-3 tenants — un simple WHERE customer_id direct suffit, pas besoin de toute l'infra.
Migration de stratégie. Aller de shared → schema per tenant est faisable mais douloureux (recopie de chaque tenant). Aller de schema → DB per tenant est plus simple. Aller dans l'autre sens (consolidation) demande de l'ingénierie sérieuse mais reste réalisable. Choisir tôt et bien.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans l'ordre, chacun s'appuie sur le précédent.
1. ALS context + auto-scope Prisma (implémenter)
Objectif : un TenantContext (ALS) + une extension Prisma qui force tenantId sur findMany/create/update/delete, et un test prouvant que le tenant A ne voit pas les lignes de B.
Indice/Solution : reprends tenant-context.ts + extendWithTenant. Le piège qui fait échouer 80 % des candidats : assigner this.$extends(...) au lieu d'utiliser la valeur retournée par la factory. Le test : TenantContext.run(A, () => create), TenantContext.run(B, () => create), puis TenantContext.run(A, () => findMany) doit retourner 1 ligne.
2. RLS comme filet de sécurité (production-grade)
Objectif : activer ENABLE ROW LEVEL SECURITY + FORCE sur deux tables, rôle app_user (pas BYPASSRLS) vs app_migrator (BYPASSRLS), set_config('app.tenant_id', $1, true) paramétré par transaction. Prouve qu'un $queryRaw SANS filtre applicatif ne fuite quand même pas.
Indice/Solution : la policy USING (tenant_id::text = current_setting('app.tenant_id', true)). Le , true du current_setting évite l'exception si la variable n'est pas posée (retourne NULL → 0 ligne, fail-safe). Vérifie avec SET ROLE app_user dans le test que même un raw SQL est filtré.
3. Casser l'isolation, puis la réparer (break-then-fix)
Objectif : introduis volontairement 3 fuites — (a) un findUnique({ where: { id } }) sans tenantId composite, (b) un cache Redis user:42 sans préfixe tenant, (c) un job BullMQ qui oublie de re-run l'ALS. Écris un test e2e qui attrape chacune. Puis répare : PK composite/findFirst, préfixe t:<id>:, wrapper createTenantAwareWorker.
Indice/Solution : pour (a), la seule vraie défense est RLS — montre que le test passe grâce à RLS même quand le code applicatif fuite. C'est le point clé : défense en profondeur, pas confiance dans le code app.
4. Cost-guard IA par tenant (production-grade)
Objectif : un TenantLlmService qui (1) choisit le modèle selon plan (haiku/sonnet/opus), (2) refuse via ForbiddenException si le budget tokens mensuel est crevé AVANT l'appel, (3) réconcilie sur usage.output_tokens réel après le stream.
Indice/Solution : compteur Redis quota:<tenant>:<month>:llm_output_tokens avec INCRBY. Pré-flight = estimation len/4 ; post-flight = stream.finalMessage().usage. Test : 2 tenants en parallèle, l'un crève son quota, l'autre continue de répondre (pas de noisy neighbor sur le budget).
5. Stop = annulation client + serveur (break-then-fix)
Objectif : un endpoint SSE de chat. Démontre d'abord que fermer l'onglet client laisse l'appel LLM tourner (tu paies les tokens). Puis branche req.on('close', () => ac.abort()) et propage { signal } au SDK ; prouve via un mock que messages.stream reçoit bien l'AbortSignal et s'arrête.
Indice/Solution : sans signal, le for await continue de consommer le stream upstream après déconnexion. Le fix : un seul AbortController, signal passé au SDK ET vérifié dans le catch pour ne pas écrire un event: error sur une annulation volontaire.
6. Migrations multi-tenant résilientes (architecte)
Objectif : un orchestrateur qui applique une migration sur N schémas, avec table de tracking tenant_migrations(tenant_id, migration, status, applied_at), parallélisme borné (p-limit à 10), reprise après échec partiel, et refus de la release si un tenant est en retard.
Indice/Solution : Promise.allSettled sur des batches de 10, log par tenant, status IN ('pending','applied','failed'). Le test casse la migration sur le tenant #347 (FK legacy) et vérifie que les 346 autres réussissent ET que le déploiement global est marqué degraded, pas success.
🎤 En entretien
Q : Pourquoi ALS plutôt qu'un provider Scope.REQUEST pour porter le tenant ? R : Le REQUEST-scope recrée tout le sous-graphe DI à chaque requête (~30 % plus lent), contamine en cascade tout provider qui l'injecte, et n'existe PAS dans les workers BullMQ / schedulers / event handlers qui n'ont pas de requête HTTP. L'ALS a un overhead quasi nul, traverse les await, et fonctionne partout — d'où le pattern TenantContext.run jusque dans les workers.
Q : Si l'extension Prisma force déjà tenantId, pourquoi s'embêter avec RLS Postgres ? R : Parce que l'extension ne couvre pas tout : $queryRaw, findUnique sur une clé non-composite, ou simplement un where tenant_id oublié par un dev. RLS est la défense en profondeur au niveau DB — aucune requête ne peut retourner les lignes d'un autre tenant, même si le code applicatif a un bug. Coût ~5-10 % de latence, largement justifié. C'est la différence entre « probablement isolé » et « prouvablement isolé ».
Q : Comment SET LOCAL app.tenant_id se comporte avec PgBouncer en mode transaction ? R : Parfaitement, justement parce que SET LOCAL (ou set_config(..., true)) est scopé à la transaction et meurt à son COMMIT/ROLLBACK. La connexion retourne au pool sans setting résiduel → pas de fuite vers la requête suivante. Le piège mortel serait un SET sans LOCAL (session-level) : avec un pooler transaction-mode, la connexion réattribuée garderait le tenant_id du tenant précédent.
Q : Tu sers un assistant IA multi-tenant. Quelles dimensions le tenant pilote-t-il ? R : Quatre. (1) Le choix du modèle (haiku/sonnet/opus selon le plan), (2) le budget en tokens avec cost-guard pré-flight qui refuse à l'edge avant de dépenser, (3) l'isolation des données que l'agent/MCP peut voir — auto-scope Prisma + RLS y compris pour les tool-calls, (4) le rate-limit anti-noisy-neighbor pour qu'un tenant n'épuise pas la capacité des autres. Tout se dérive de TenantContext.current().
🔗 Liens
- Node
AsyncLocalStorage:https://nodejs.org/api/async_context.html#class-asynclocalstorage @nestjs/cls:https://papooch.github.io/nestjs-cls/- Postgres Row-Level Security :
https://www.postgresql.org/docs/current/ddl-rowsecurity.html - Prisma
$extends:https://www.prisma.io/docs/orm/prisma-client/client-extensions - Drizzle middleware :
https://orm.drizzle.team/docs/select#with-clause - Multi-tenant SaaS architectures (AWS) :
https://aws.amazon.com/builders-library/silo-pool-and-bridge-tenant-isolation/ - pgcat :
https://github.com/postgresml/pgcat - Neon database branching :
https://neon.tech/docs/introduction/branching - BullMQ :
https://docs.bullmq.io/ - Article fondateur (Force.com multitenant) :
https://developer.salesforce.com/page/Multi_Tenant_Architecture - Anthropic SDK (streaming, retries) :
https://github.com/anthropics/anthropic-sdk-typescript - Postgres
set_config/current_setting:https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADMIN-SET - Model Context Protocol (MCP) :
https://modelcontextprotocol.io/