Multi-tenancy en SaaS IA — Isolation des orgs, quotas et facturation
La plupart des SaaS ratent le multi-tenant. Les patterns qui tiennent : isolation des orgs, quotas, RBAC, et quand migrer vers schema-per-tenant.

Pourquoi le multi-tenancy est plus difficile qu'il n'y paraît
Le modèle mental de départ de la plupart des équipes : "construire le produit pour un utilisateur, puis ajouter org_id à chaque table." Ça tient jusqu'à un certain point. Le jour où un client voit les données d'un autre client, c'est le pipeline de vente enterprise qui s'effondre.
Le multi-tenancy en SaaS — et particulièrement en SaaS IA, où les artefacts (documents uploadés, sorties générées, embeddings vectoriels, logs de prompts) sont volumineux et sensibles — a trois modes d'échec :
- Lectures fuitantes — une requête oublie le filtre
org_idet retourne des données d'un autre tenant. - Écritures fuitantes — une opération d'écriture place l'artefact sous la ressource du mauvais tenant.
- Couplage cross-tenant caché — un modèle de "ressource partagée" qui a commencé innocemment (une table de tags globale, un flux d'opportunités partagé) finit par être la surface où deux tenants peuvent se marcher dessus.
Nous avons livré plusieurs produits IA multi-tenant. Les patterns ci-dessous sont ceux qui ont survécu, plus les erreurs que nous ne répéterons pas.
Les trois modes de multi-tenancy
Il faut en choisir un, et ce choix façonne des années de code. Chacun a un équilibre différent :
| Mode | Isolation | Coût | Coût de migration | Quand choisir |
|---|---|---|---|---|
Base partagée + org_id | Logique | Le moins cher | Le plus difficile ensuite | Défaut pour SaaS jusqu'à ~quelques centaines d'orgs |
| Schema-par-tenant | Forte | Modéré | Modéré | Compliance-driven, beaucoup de données par tenant |
| Base-par-tenant | La plus forte | Le plus élevé | Trivial (le tenant a sa propre base) | Industries régulées, self-hosting enterprise |
Nous démarrons chaque nouveau produit sur base partagée + org_id, sauf si nous savons dès le départ que nous aurons besoin d'une isolation plus forte (finance régulée, santé, marchés publics). La migration vers schema-par-tenant représente un vrai chantier, mais c'est un problème qu'on préfère avoir plutôt que son alternative sur-ingéniérée.
L'invariant d'isolation des orgs
La règle la plus importante : chaque lecture et chaque écriture touchant des données appartenant à un tenant doit être filtrée par org_id. Sans exception, sans "c'est un endpoint interne", sans "le controller a déjà vérifié".
La raison : un seul filtre oublié est une vulnérabilité. Et les humains oublient des filtres. La code review en attrape certains ; le reste, on les attrape avec un pattern structurel.
Trois niveaux de défense :
Niveau 1 — Un helper d'accès typé
On n'interroge pas directement le modèle depuis les controllers. On passe par un helper qui exige le contexte utilisateur/org :
// ❌ non sécurisé — facile d'oublier le filtre
const job = await Job.findById(jobId)
// ✅ sécurisé — contexte requis, filtre appliqué en un seul endroit
const job = await assertCanAccessJob(jobId, currentUser)
assertCanAccessJob récupère le job, vérifie que l'utilisateur est membre de l'org du job (ou administrateur global), puis retourne le job ou lève une ForbiddenError. Chaque point d'appel qui veut accéder à un job passe par cette fonction.
Le compromis : on ne peut plus faire facilement Job.find() depuis un controller — il faut réfléchir à quelle org on appartient. C'est exactement la friction souhaitée.
Niveau 2 — Fonctions ACL par ressource
Chaque type de ressource (Job, Document, Embedding, Conversation) a sa propre fonction assertCanAccess<Ressource>. Toutes partagent la même forme :
async function assertCanAccessDocument(
documentId: string,
user: AuthenticatedUser,
): Promise<Document> {
const doc = await Document.findById(documentId).lean()
if (!doc) throw new NotFoundError('document', documentId)
// ADMIN/OWNER global peut accéder à tout pour le support
if (isGlobalAdmin(user)) return doc
// Sinon doit être membre de l'org du document
const isMember = user.organizations.some(o => o.organizationId === doc.orgId)
if (!isMember) throw new ForbiddenError('document', documentId)
return doc
}
Trois propriétés à garantir dans chaque helper :
- 404 vs 403 — quand l'utilisateur n'est pas membre, retourner 404 (pas 403). Un 403 révèle l'existence de la ressource. Nous utilisons 404, sauf si la ressource est intentionnellement publique.
- Bypass admin global pour le support — l'équipe de support doit parfois accéder aux données des tenants. Construire le bypass explicitement, logger chaque accès, l'auditer.
- Vérification du rôle dans le tenant, pas seulement l'appartenance — parfois le helper doit aussi vérifier que le membre possède au moins un certain rôle dans l'org (ex. : seuls les ADMINs peuvent accéder aux données de facturation).
Niveau 3 — Ne pas partager les artefacts sur des modèles de ressources partagées
Le mode d'échec le plus profond : un artefact placé sur une ressource qui semble partagée. Nous avons livré ce bug. Il ressemblait à ceci :
// Flux d'opportunités quasi-public
const Opportunity = new Schema({
publicReference: String,
title: String,
// ...
// ⚠️ ajouté plus tard, scopé par erreur au "run de l'utilisateur courant"
uploadedDocuments: [{ ... }],
generatedDraft: { ... },
qualityScore: Number,
})
L'opportunité elle-même est publique (un avis d'appel d'offres, une annonce publiée). Mais dès qu'un utilisateur uploade des documents pour cette opportunité, ces documents deviennent per-tenant. Les placer sur le document Opportunity partagé signifie que le brouillon du tenant A peut être retourné au tenant B si un chemin de code lit Opportunity.generatedDraft sans re-scoper.
La correction : toujours modéliser les artefacts per-tenant sur une ressource per-tenant :
const Opportunity = new Schema({
publicReference: String,
title: String,
// ... uniquement les champs publics
})
const Job = new Schema({
orgId: { type: ObjectId, required: true, index: true },
opportunityId: ObjectId,
// les artefacts per-tenant vivent ici, scopés à une seule org
uploadedDocuments: [{ ... }],
generatedDraft: { ... },
qualityScore: Number,
})
Le chemin d'accès devient alors Opportunity (lecture publique) → Job (par org, contrôlé ACL). La récupération d'artefact passe toujours par le job, qui a un orgId, qui est vérifié.
Ce refactoring est douloureux quand on le découvre trois mois après le lancement. Construire le modèle per-tenant dès le départ, même si ça semble sur-modélisé.
Quotas par org
Le SaaS IA génère rapidement des coûts proportionnels à l'usage (appels LLM, minutes de voix, stockage). Les quotas par org rendent les coûts prévisibles et empêchent un tenant incontrôlé de faire exploser le budget.
Deux patterns que nous utilisons :
Quotas souples — vérification avant l'appel, décrémentation après
Pour les appels LLM et les minutes de voix :
async function consumeCredits(orgId: string, amount: number) {
const result = await Org.findOneAndUpdate(
{ _id: orgId, credits: { $gte: amount } },
{ $inc: { credits: -amount } },
{ new: true },
)
if (!result) throw new InsufficientCreditsError({ orgId, amount })
return result.credits
}
La vérification atomique $gte + $inc est la clé. Sans elle, deux requêtes concurrentes peuvent toutes deux passer la vérification et toutes deux décrémenter, aboutissant à un solde négatif. Avec elle, le pattern est thread-safe.
Pour les compteurs à fort débit (limites de débit par minute, bursts de requêtes), Redis avec INCR + EXPIRE est plus rapide et moins coûteux. Nous utilisons Redis pour la couche de rate-limiting et MongoDB pour le solde de crédits persisté.
Quotas durs — fail fast à la périphérie
Certains quotas doivent tuer la requête avant que le moindre travail soit effectué :
- Limites de taille de fichier sur les uploads (rejet au niveau du parser multipart)
- Longueur maximale des prompts (rejet au niveau du validateur)
- Nombre maximum de jobs concurrents par org (rejet à la soumission dans la queue)
Les quotas durs vivent dans un middleware qui s'exécute avant le controller. Les quotas souples vivent dans la couche service, autour de l'action qui génère réellement un coût.
Rôles au sein d'une org
Au-delà de "est-ce que cet utilisateur est membre de cette org ?", un vrai SaaS a besoin d'un contrôle d'accès basé sur les rôles :
- OWNER — facturation, peut promouvoir d'autres membres au rang d'OWNER, peut supprimer l'org
- ADMIN — peut inviter/retirer des membres, changer les rôles (mais pas promouvoir au rang d'OWNER), accès complet aux données
- MEMBER — accès complet aux données, aucun pouvoir d'administration
- (Optionnel) VIEWER — lecture seule, pour l'accès des parties prenantes
Plus un axe orthogonal : rôles globaux (ADMIN, OWNER sur l'ensemble du SaaS, utilisés par l'équipe de support). Ne pas les confondre. Nous utilisons :
User.userRole— rôle global sur le SaaS (USER / ADMIN / OWNER)User.organizations[].role— rôle au niveau de l'org (OWNER / ADMIN / MEMBER)
La plupart des vérifications utilisent le rôle au niveau de l'org. Le rôle global n'est utilisé que pour le bypass support et la console d'administration.
La garde du dernier OWNER
Une contrainte facile à manquer : quand un utilisateur change le rôle d'un autre utilisateur ou le supprime, il faut empêcher l'opération qui laisserait l'org sans aucun OWNER.
async function changeMemberRole(orgId, targetUserId, newRole, actor) {
if (newRole !== 'OWNER') {
// Vérifier qu'on ne supprime pas le dernier OWNER
const ownerCount = await User.countDocuments({
'organizations.organizationId': orgId,
'organizations.role': 'OWNER',
})
const target = await User.findById(targetUserId).lean()
const targetIsOwner = target.organizations.find(o =>
o.organizationId.equals(orgId) && o.role === 'OWNER'
)
if (targetIsOwner && ownerCount <= 1) {
throw new LastOwnerError()
}
}
// ... appliquer le changement
}
La même garde s'applique à la suppression de membres. Ça semble trivial, mais le bug — une org verrouillée sur elle-même sans aucun admin — a touché chaque équipe qui n'a pas écrit cette garde dès le départ.
Flux d'invitation
Inviter par email est le point d'entrée canonique. Le flux qui fonctionne en production :
- L'admin clique sur "inviter", saisit l'email + le rôle
- Le backend crée une
OrgInvitation { orgId, email, role, token (24 octets aléatoires), invitedBy, expiresAt: now + 7 jours, status: 'pending' } - Email envoyé avec le lien
/signup?invite=<token> - Le nouvel utilisateur s'inscrit ; le flux d'inscription réclame l'invitation de manière atomique avec
findOneAndUpdate({ token, status: 'pending' }, { status: 'accepted' })et ajoute l'utilisateur dansorg.members[] - L'utilisateur existant clique sur le lien ; même réclamation atomique, il suffit d'attacher l'utilisateur existant à la nouvelle org
Trois détails qui finissent par revenir vous hanter :
- TTL sur les invitations en attente — index TTL Mongo avec
partialFilterExpression: { status: 'pending' }pour que les invitations acceptées soient conservées pour l'audit, mais les invitations en attente expirent automatiquement. - Endpoint de consultation publique — la page d'inscription doit récupérer le nom de l'org + le rôle depuis le token sans authentification. Rendre cet endpoint explicitement public, le protéger derrière le token (qui est un secret aléatoire de 24 octets), et ne pas divulguer d'autres informations sur l'org.
- Fiabilité des webhooks Resend — si Resend échoue à envoyer l'email, annuler l'invitation. Ne pas laisser l'utilisateur avec un token d'invitation qu'il n'a jamais reçu et l'interface admin affichant "invité".
Facturation — trois modèles qui fonctionnent
Pour le SaaS IA en 2026, trois modèles de facturation couvrent presque tout :
1. Par siège (SaaS classique)
X € / mois / utilisateur. Simple, prévisible pour le client, facile à facturer via Stripe Subscription. Fonctionne quand la valeur par utilisateur est à peu près constante. Se dégrade quand l'usage varie fortement entre utilisateurs — les gros utilisateurs subventionnés par les petits jusqu'à ce que les gros utilisateurs partent.
2. Usage-based (par appel, par minute, par token)
On paye ce qu'on consomme. Approprié quand l'usage et le coût sont très corrélés (minutes de voix, appels LLM). Complexe à facturer (Stripe Metered Billing ou cycles de facturation maison). Les clients détestent l'incertitude sur leur facture mensuelle — contrer ça avec des plafonds mensuels et des tableaux de bord clairs.
3. Par action (par job, par dossier, par artefact généré)
X € par job terminé, max N en parallèle, remboursement en cas d'échec. Simple à communiquer ("vous ne payez que pour les résultats réussis"), aligne les incitations du vendeur et du client, facile à expliquer à l'acheteur. Le bon modèle pour les produits IA où chaque "job" est une unité de valeur discrète (une proposition générée, un document traduit, un lot d'images traité).
Le pattern qui porte ses fruits : remboursements idempotents en cas d'échec.
async function refundJobIfFailed(jobId: string) {
const job = await Job.findOneAndUpdate(
{ _id: jobId, status: 'failed', refundedAt: { $exists: false } },
{ $set: { refundedAt: new Date() } },
)
if (!job) return // déjà remboursé ou pas en échec
await consumeCredits(job.orgId, -job.cost) // négatif = remboursement
await audit('refund_processed', { jobId, amount: job.cost })
}
La mise à jour atomique sur refundedAt rend la fonction idempotente : les rejeux ne génèrent pas de double remboursement. C'est le genre de code qui doit être juste du premier coup — récupérer après "on a accidentellement remboursé en double 200 clients" représente un après-midi entier qu'on préfère ne pas vivre.
Logs d'audit et d'activité
Dès que plusieurs utilisateurs collaborent sur la même ressource, "qui a fait quoi" devient important. Nous construisons un ActivityLog per-tenant dès le premier jour :
const ActivityLog = new Schema({
orgId: { type: ObjectId, required: true, index: true },
resourceType: String, // 'job', 'document', 'member', 'invitation'
resourceId: ObjectId,
actorUserId: ObjectId,
action: String, // 'created', 'updated', 'role_changed', 'deleted'
metadata: Schema.Types.Mixed, // avant/après, IP, user-agent
createdAt: { type: Date, default: Date.now, index: true },
})
Deux choix de conception qui perdurent :
- Append-only. Ne jamais mettre à jour ou supprimer une entrée de log d'activité. Si on fait évoluer la forme du log, écrire une migration vers une collection v2 ; ne pas muter la v1.
- TTL per-tenant est parfois requis (règles de conservation légales), mais par défaut conserver indéfiniment. Le stockage est peu coûteux. Le contexte d'audit perdu est cher.
Quand la base partagée atteint ses limites
Les patterns ci-dessus tiennent jusqu'à quelques centaines d'orgs et des millions d'enregistrements par type de ressource. Au-delà, on commence à rencontrer :
- Des tailles d'index qui ne tiennent plus en RAM
- Des durées de backup/restore mesurées en heures
- Des effets de voisin bruyant cross-tenant (un gros tenant ralentit tout le monde)
- Des exigences de conformité : "Montrez-moi mes données, uniquement mes données, prouvez qu'elles sont séparées"
Les échappatoires, par ordre de coût :
1. Base partagée shardée. Même modèle logique, shardé par orgId. MongoDB Atlas, Postgres Citus. Gagne encore un ordre de grandeur en volume.
2. Schema-par-tenant. Chaque tenant obtient sa propre base de données MongoDB (toujours sur le même cluster). Les migrations s'exécutent tenant par tenant. Isolation logique forte. Charge d'administration plus lourde — chaque changement de schéma devient N migrations.
3. Base-par-tenant. Le tenant obtient son propre cluster. Souvent requis pour les contrats enterprise en self-hosting. Coûteux opérationnellement, mais la seule façon de livrer "vos données sont dans votre VPC" de manière crédible.
La décision suit généralement le modèle économique. Si vous ciblez les PME avec des centaines d'orgs faisant chacun 10 Mo, restez sur une base partagée. Si vous êtes enterprise avec cinq clients gérant chacun des dizaines de Go de données régulées, le schema- ou la base-par-tenant est ce qu'ils veulent acheter.
Pour conclure
Le multi-tenancy est un problème qu'il faut résoudre tôt, parce que le coût de le corriger plus tard se cumule. Les patterns ici sont ceux que nous implémenterions dès le premier jour de tout nouveau produit SaaS, IA ou non :
- Base partagée +
org_idpour démarrer - Les helpers ACL comme seul chemin vers les ressources per-tenant
- Les artefacts per-tenant sur des modèles per-tenant, jamais greffés sur des ressources partagées
- Rôles sur deux axes : global SaaS + par org
- Garde du dernier OWNER dès le premier jour
- Log d'activité dès le premier jour
- Application des quotas au bon niveau (durs à la périphérie, souples dans le service)
Si vous avez livré un SaaS qui dérive vers un multi-tenancy fuitant et que vous n'êtes pas sûr de où sont les failles, contactez-nous. Un audit de quatre heures fait généralement remonter les trois risques principaux avant qu'ils ne mordent.
Travailler avec Ikki
Vous montez en multi-tenant B2B ?
Nous concevons un SaaS IA multi-tenant avec isolation par org, quotas, facturation et audit logs — production-ready dès le jour 1.
Autres articles
La semaine où Anthropic a pris le contrôle de la pile complète
Project Glasswing en bêta publique, acquisition de Stainless, sept releases SDK en quatre jours. La question n'est plus 'quel modèle' — c'est 'quelle plateforme'.
AgentsSix releases en onze jours : ce que le sprint pré-I/O de Google annonce
@google/genai a livré les API Agent et Environment aujourd'hui — à quelques jours de Google I/O. La cadence du SDK vous dit ce qui arrive avant le keynote.