Concevoir des Systèmes Multi-Tenant Qui Ne Cassent Pas en Charge
Comment concevoir des architectures multi-tenant avec une vraie isolation. Trois couches d'enforcement, scoping hiérarchique, patterns RBAC et leçons tirées de trois systèmes multi-tenant différents.
Le Multi-Tenancy N'est Pas une Décision de Base de Données
La plupart des articles sur le multi-tenancy commencent par la question de la base de données : base partagée, schéma partagé ou base dédiée par tenant ? C'est le mauvais point de départ. Le modèle de base de données est un détail d'implémentation. La vraie question d'architecture, c'est : comment tu enforces l'isolation à chaque couche du système pour que le tenant A ne puisse jamais voir, modifier ou affecter les données du tenant B ?
On a construit trois systèmes multi-tenant différents. Chacun a résolu l'isolation différemment parce que chacun avait des contraintes différentes. L'un utilise un scoping hiérarchique avec cinq niveaux d'identité. Un autre utilise un RBAC plat avec quatre rôles. Le troisième utilise un scoping par organisation avec isolation par channel. Le modèle de base de données était la décision la moins intéressante dans les trois cas.
Cet article couvre les patterns architecturaux qui rendent le multi-tenancy sûr à grande échelle. Pour un contexte plus large sur notre approche de l'architecture système, ce guide explique notre méthodologie. Pour des exemples concrets de systèmes multi-tenant IA, consulte nos guides sur le commerce agentique et la gouvernance IA.
Le Modèle d'Enforcement à Trois Couches
L'isolation des tenants doit être enforced à trois couches. Si une seule manque, les données fuient.
┌─────────────────────────────────────────────────┐
│ Couche 1 : MIDDLEWARE API │
│ Chaque requête authentifiée et scopée │
│ tenant_id extrait du JWT/clé API │
│ Injecté dans le contexte de requête │
│ │
├─────────────────────────────────────────────────┤
│ Couche 2 : FILTRES DE REQUÊTES │
│ Chaque requête DB inclut le tenant_id │
│ Chaque requête de recherche scopée par tenant │
│ Aucune requête ne tourne sans contexte tenant │
│ │
├─────────────────────────────────────────────────┤
│ Couche 3 : ENFORCEMENT DES POLICIES │
│ Les appels d'outils vérifiés contre les policies│
│ La mémoire de l'agent scopée par tenant+session │
│ L'output filtré par les règles de visibilité │
│ │
└─────────────────────────────────────────────────┘
Couche 1 : Middleware API
Chaque requête entrante doit être authentifiée et scopée à un tenant avant d'atteindre la moindre logique métier.
// Le middleware extrait le contexte tenant de chaque requête
async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'No token provided' });
const decoded = await verifyJwt(token);
const tenant = await tenantStore.getById(decoded.tenant_id);
if (!tenant || tenant.status !== 'active') {
return res.status(403).json({ error: 'Tenant not found or suspended' });
}
// Injecter le contexte tenant dans la requête
req.tenantContext = {
tenantId: tenant.id,
channelId: decoded.channel_id,
role: decoded.role,
permissions: decoded.permissions,
};
next();
}
Le contexte tenant n'est pas optionnel. Chaque route handler, chaque méthode de service, chaque requête de base de données le reçoit. Si une fonction n'a pas de contexte tenant, elle ne peut pas accéder aux données scopées par tenant.
Couche 2 : Filtres de Requêtes
Chaque requête de base de données doit inclure le scope tenant. Ce n'est pas enforced par convention ("n'oublie pas d'ajouter la clause WHERE"). C'est enforced par l'architecture.
// Classe de base du repository qui enforce le scoping par tenant
class TenantScopedRepository<T> {
async findMany(tenantId: string, filters: Partial<T>): Promise<T[]> {
return this.db.query({
TableName: this.tableName,
KeyConditionExpression: 'tenant_id = :tid',
FilterExpression: this.buildFilterExpression(filters),
ExpressionAttributeValues: {
':tid': tenantId,
...this.buildFilterValues(filters),
},
});
}
async findById(tenantId: string, id: string): Promise<T | null> {
const result = await this.db.get({
TableName: this.tableName,
Key: { tenant_id: tenantId, id },
});
return result.Item || null;
}
// Aucune méthode n'existe pour requêter sans tenant_id
// Les requêtes cross-tenant sont architecturalement impossibles
}
Pour les moteurs de recherche (OpenSearch, MeiliSearch, Elasticsearch), chaque requête inclut un filtre tenant :
async function searchProducts(tenantId: string, channelId: string, query: string) {
return opensearch.search({
index: 'products',
body: {
query: {
bool: {
must: [{ match: { searchText: query } }],
filter: [
{ term: { tenant_id: tenantId } },
{ term: { channel_ids: channelId } },
],
},
},
},
});
}
Couche 3 : Enforcement des Policies
Au-delà de l'accès aux données, les tenants ont des permissions différentes pour les actions qu'ils peuvent effectuer. La couche policy vérifie tout ça avant l'exécution de toute action.
interface TenantPolicy {
tenant_id: string;
rules: PolicyRule[];
}
interface PolicyRule {
action: string; // "create_order", "export_data", "use_ai_agent"
effect: "allow" | "deny";
conditions?: {
max_value?: number;
allowed_channels?: string[];
require_approval?: boolean;
};
}
// Vérification de la policy avant toute action
async function checkPolicy(tenantId: string, action: string, params: any): Promise<boolean> {
const policy = await policyStore.getForTenant(tenantId);
const matchingRules = policy.rules.filter(r => r.action === action);
// Les règles deny ont la priorité
if (matchingRules.some(r => r.effect === 'deny')) return false;
// Pas de règle allow correspondante = refusé (default-deny)
const allowRule = matchingRules.find(r => r.effect === 'allow');
if (!allowRule) return false;
// Vérifier les conditions
if (allowRule.conditions?.max_value && params.value > allowRule.conditions.max_value) {
return false;
}
return true;
}
Scoping Hiérarchique vs Plat
Scoping Plat (SaaS Simple)
Chaque ressource appartient à exactement un tenant. Pas de sous-niveaux.
Tenant A
├── Utilisateurs (owner, admin, member, viewer)
├── Produits
├── Commandes
└── Paramètres
Tenant B
├── Utilisateurs
├── Produits
├── Commandes
└── Paramètres
Adapté pour : les produits SaaS où chaque client est un workspace isolé. Pense aux outils de gestion de projet, systèmes CRM, dashboards internes.
Le scoping plat nécessite quatre niveaux de rôles :
| Rôle | Permissions |
|---|---|
| Owner | Accès complet, facturation, suppression du tenant |
| Admin | Gestion des utilisateurs, paramètres, toutes les données |
| Member | Créer et modifier ses propres données, voir les données partagées |
| Viewer | Accès en lecture seule aux données partagées |
Scoping Hiérarchique (Plateformes Enterprise)
Les ressources sont scopées à travers plusieurs niveaux. Chaque niveau réduit la visibilité.
Tenant (organisation marchande)
└── Channel (storefront, API, widget)
└── Supplier Binding (quels fournisseurs visibles par channel)
└── Customer (utilisateur final dans un channel)
└── Session (session navigateur/appareil)
└── Agent Thread (conversation IA unique)
Adapté pour : les plateformes marketplace, le commerce multi-marques, les systèmes enterprise où une organisation a plusieurs storefronts, canaux de vente ou marques subsidiaires.
Chaque niveau ajoute un filtre. Un produit visible dans le Channel A n'est pas forcément visible dans le Channel B, même au sein du même tenant. Un client dans le Channel A n'a pas accès aux données du Channel B. Un thread d'agent dans une session ne peut pas voir les conversations d'une autre session.
// Contexte hiérarchique passé à travers chaque opération
interface TenantContext {
tenantId: string; // organisation
channelId: string; // storefront ou canal de vente
customerId?: string; // utilisateur final (si authentifié)
sessionId?: string; // session navigateur
threadId?: string; // thread de conversation IA
}
// Requête scopée à toute la hiérarchie
async function getVisibleProducts(ctx: TenantContext) {
const channel = await channelStore.get(ctx.tenantId, ctx.channelId);
return productStore.findMany({
tenant_id: ctx.tenantId,
supplier_id: { $in: channel.visibleSupplierIds },
status: 'active',
});
}
Scoping Hybride
Certains systèmes ont besoin d'un scoping plat pour la plupart des ressources mais d'un scoping hiérarchique pour des fonctionnalités spécifiques. Par exemple, une installation Vendure commerce pourrait utiliser un scoping plat (un tenant par boutique) mais un scoping par channel pour la visibilité des produits et les prix.
// Scoping par channel dans Vendure
async findByCustomer(ctx: RequestContext, customerId: number) {
return this.connection.getRepository(ctx, CiWishlist).find({
where: {
customerId,
channelId: ctx.channelId, // Scoping par channel au sein du tenant
},
});
}
Pour en savoir plus sur notre implémentation du scoping par channel dans Vendure, consulte notre guide d'architecture Vendure en production.
Patterns Auth et RBAC
Scoping Tenant par JWT
Le token JWT porte l'identité du tenant. Chaque requête API l'inclut.
// Structure du payload JWT
interface TenantJwtPayload {
sub: string; // ID utilisateur
tenant_id: string; // quel tenant
channel_id?: string; // quel channel (si applicable)
role: string; // owner | admin | member | viewer
permissions: string[]; // permissions granulaires
iat: number;
exp: number;
}
Le tenant_id dans le JWT est le mécanisme de scoping principal. Il est défini au moment du login et ne peut pas être changé sans ré-authentification. Le backend l'extrait de chaque requête et l'utilise pour scoper tous les accès aux données.
Authentification par Clé API
Pour la communication machine-to-machine (intégrations ERP, services externes, webhooks), les clés API sont mappées aux tenants :
async function apiKeyMiddleware(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) return next(); // passer à l'auth JWT
const keyRecord = await apiKeyStore.findByKey(apiKey);
if (!keyRecord || keyRecord.status !== 'active') {
return res.status(401).json({ error: 'Invalid API key' });
}
req.tenantContext = {
tenantId: keyRecord.tenantId,
channelId: keyRecord.channelId,
role: keyRecord.role,
permissions: keyRecord.permissions,
};
next();
}
Les clés API sont scopées par tenant. La rotation des clés ne change pas le binding tenant. Les rate limits et les scopes de permissions sont par clé, pas par tenant.
Granularité des Permissions
Les rôles définissent des niveaux d'accès larges. Les permissions définissent des capacités spécifiques :
const PERMISSIONS = {
// Gestion des produits
PRODUCT_READ: 'product:read',
PRODUCT_CREATE: 'product:create',
PRODUCT_UPDATE: 'product:update',
PRODUCT_DELETE: 'product:delete',
// Gestion des commandes
ORDER_READ: 'order:read',
ORDER_CREATE: 'order:create',
ORDER_CANCEL: 'order:cancel',
ORDER_REFUND: 'order:refund',
// Fonctionnalités IA
AI_AGENT_USE: 'ai:agent:use',
AI_AGENT_CONFIGURE: 'ai:agent:configure',
AI_EXPORT: 'ai:export',
// Administration
USER_MANAGE: 'user:manage',
SETTINGS_MANAGE: 'settings:manage',
BILLING_MANAGE: 'billing:manage',
};
// Mapping rôle-permissions
const ROLE_PERMISSIONS = {
owner: Object.values(PERMISSIONS),
admin: Object.values(PERMISSIONS).filter(p => p !== 'billing:manage'),
member: ['product:read', 'product:create', 'product:update', 'order:read', 'order:create', 'ai:agent:use'],
viewer: ['product:read', 'order:read'],
};
Ce Qui Se Passe Quand le Scoping Échoue
La façon la plus instructive de comprendre pourquoi l'enforcement à trois couches est essentiel, c'est de voir ce qui casse quand une couche manque.
| Couche manquante | Ce qui se passe | Exemple concret |
|---|---|---|
| Pas de middleware API | N'importe quelle requête avec un JWT valide peut accéder aux données de n'importe quel tenant en devinant les tenant IDs | Un concurrent scrape le catalogue produits de ton client |
| Pas de filtres de requêtes | Un développeur oublie la clause WHERE dans un nouvel endpoint, les données fuient entre tenants | Le dashboard admin affiche tous les clients de tous les tenants |
| Pas d'enforcement de policy | Un tenant avec un plan "starter" accède aux fonctionnalités "enterprise" via des appels API directs | Un tenant gratuit exporte des données illimitées, contournant les limites du plan |
Le scénario le plus flippant : les trois couches fonctionnent pour les lectures mais pas pour les écritures. Le tenant A ne peut pas voir les données du tenant B, mais un bug dans l'endpoint de mise à jour permet au tenant A d'écraser les prix des produits du tenant B. On a attrapé ça en test. En production, ça aurait été catastrophique.
Tester l'Isolation Multi-Tenant
Tester le multi-tenancy nécessite des patterns de test spécifiques que la plupart des suites de tests ne couvrent pas.
Le Test d'Accès Cross-Tenant
Pour chaque endpoint, vérifie que le tenant A ne peut pas accéder aux données du tenant B :
describe('Tenant isolation', () => {
it('tenant A cannot read tenant B products', async () => {
// Créer un produit en tant que tenant B
const product = await createProduct(tenantB.token, { name: 'Secret Product' });
// Essayer de le lire en tant que tenant A
const response = await api.get(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
expect(response.status).toBe(404); // Pas 403, pas 200 avec des données vides
});
it('tenant A cannot update tenant B products', async () => {
const product = await createProduct(tenantB.token, { name: 'Original' });
const response = await api.patch(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantA.token}` },
body: { name: 'Hacked' },
});
expect(response.status).toBe(404);
// Vérifier que le produit n'a pas été modifié
const check = await api.get(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantB.token}` },
});
expect(check.body.name).toBe('Original');
});
});
Renvoie 404 (pas 403) pour les tentatives d'accès cross-tenant. Un 403 confirme que la ressource existe, ce qui est en soi une fuite d'information.
Le Test d'Isolation de Recherche
Vérifie que les résultats de recherche sont scopés par tenant :
it('search results are tenant-scoped', async () => {
await createProduct(tenantA.token, { name: 'Widget Alpha' });
await createProduct(tenantB.token, { name: 'Widget Beta' });
await waitForSearchIndex();
const results = await api.get('/search?q=Widget', {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
expect(results.body.items).toHaveLength(1);
expect(results.body.items[0].name).toBe('Widget Alpha');
// Widget Beta ne doit pas apparaître
});
Le Test d'Opérations en Masse
Vérifie que les opérations en masse (exports, imports, mises à jour batch) respectent les frontières tenant :
it('export only includes own tenant data', async () => {
const exportResult = await api.post('/export/products', {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
const exportedIds = exportResult.body.products.map(p => p.id);
const tenantBProducts = await getAllProducts(tenantB.token);
const tenantBIds = tenantBProducts.map(p => p.id);
// Aucun ID produit du tenant B dans l'export du tenant A
const overlap = exportedIds.filter(id => tenantBIds.includes(id));
expect(overlap).toHaveLength(0);
});
Pour en savoir plus sur notre approche des tests de manière plus large, consulte notre guide d'ingénierie logicielle.
Infrastructure Partagée vs Dédiée
| Modèle | Quand l'utiliser | Compromis |
|---|---|---|
| Tout partagé (une DB, un schéma) | SaaS avec beaucoup de petits tenants | Le moins cher. Le plus difficile à isoler. Risque de noisy neighbor. |
| DB partagée, schémas séparés | Tenants moyens nécessitant une isolation logique | Bonne isolation. Plus de complexité de migration. |
| Bases de données dédiées | Tenants enterprise avec des exigences de conformité | Meilleure isolation. Le plus cher. Le plus difficile à gérer. |
| Clusters dédiés | Industries régulées (santé, finance) | Isolation complète. Coût le plus élevé. Ops séparées par tenant. |
Pour la plupart des applications SaaS, tout partagé avec enforcement au niveau applicatif (le modèle à trois couches ci-dessus) est le bon choix. C'est plus simple à opérer, moins cher à faire tourner, et si les couches d'enforcement sont correctes, tout aussi sécurisé.
L'infrastructure dédiée devient nécessaire quand les tenants ont des exigences réglementaires qui imposent une isolation physique (par ex., les données doivent résider dans un pays spécifique), ou quand la charge de travail d'un tenant est si importante qu'elle affecte les autres (le problème du noisy neighbor).
Pièges Courants
-
Traiter le multi-tenancy comme un problème de base de données. Le modèle de base de données (partagé vs dédié) est la décision la moins importante. Le modèle d'enforcement (trois couches) est la plus importante.
-
Enforcer l'isolation par convention. "Les développeurs doivent toujours inclure tenant_id dans les requêtes" n'est pas une stratégie. Rends architecturalement impossible de requêter sans contexte tenant.
-
Retourner 403 pour un accès cross-tenant. Retourne 404. Un 403 confirme que la ressource existe, ce qui fait fuiter de l'information entre tenants.
-
Pas de tests d'accès cross-tenant. Chaque endpoint a besoin d'un test qui vérifie que le tenant A ne peut pas accéder aux données du tenant B. Pour les lectures et les écritures.
-
Oublier l'isolation de l'index de recherche. Les requêtes de base de données sont peut-être scopées, mais si l'index de recherche n'est pas filtré par tenant, les résultats de recherche fuient entre tenants.
-
Caches partagés sans préfixe de clé tenant. Si ta clé de cache Redis est
product:123, elle est partagée entre les tenants. Utilisetenant_abc:product:123. -
Jobs en arrière-plan sans contexte tenant. Un job planifié qui traite "toutes les commandes en attente" sans scoping tenant traite les commandes de tous les tenants d'un coup. Passe le contexte tenant dans les payloads des jobs.
-
Pas de rate limiting par tenant. L'import en masse d'un tenant ne devrait pas dégrader les performances pour tous les autres tenants. Rate limit par tenant, pas juste par IP.
Points Clés à Retenir
-
L'enforcement à trois couches est non-négociable. Middleware API, filtres de requêtes et enforcement des policies. Les trois. Chaque requête, chaque query, chaque action.
-
Le scoping hiérarchique gère la complexité enterprise. Le scoping plat fonctionne pour du SaaS simple. Les plateformes enterprise ont besoin de scoping au niveau tenant, channel, customer, session et thread.
-
Rends l'accès cross-tenant architecturalement impossible. Ne compte pas sur les développeurs pour se souvenir des clauses WHERE. Des classes de base de repository qui exigent tenant_id comme paramètre obligatoire.
-
Teste l'isolation explicitement. Chaque endpoint a besoin d'un test d'accès cross-tenant. Pour les lectures, les écritures, les recherches, les exports et les opérations en masse.
-
Retourne 404, pas 403. Les tentatives d'accès cross-tenant doivent donner l'impression que la ressource n'existe pas, pas que l'utilisateur n'a pas la permission.
-
L'infrastructure partagée avec isolation au niveau applicatif fonctionne dans la plupart des cas. L'infrastructure dédiée est pour les exigences réglementaires ou les problèmes de noisy neighbor, pas pour la sécurité.
On applique ces patterns dans nos services IA, nos projets de développement sur mesure et nos plateformes commerce. Si tu conçois un système multi-tenant, parle à notre équipe ou demande un devis. Tu peux aussi explorer notre page solutions et notre approche confiance et conformité pour voir comment on gère les garanties d'isolation des tenants.
Sujets couverts
Guides connexes
Guide Entreprise des Systèmes d'IA Agentiques
Guide technique des systemes d'IA agentiques en entreprise. Decouvre l'architecture, les capacites et les applications des agents IA autonomes.
Lire le guideCommerce Agentique : Comment laisser les agents IA acheter en toute securite
Comment concevoir un commerce agentique gouverne. Moteurs de politiques, portes d'approbation HITL, reçus HMAC, idempotence, isolation multi-tenant et le protocole Agentic Checkout complet.
Lire le guideLes 9 endroits où ton système IA laisse fuir des données (et comment colmater chacun)
Cartographie systématique de chaque point de fuite de données dans les systèmes IA. Prompts, embeddings, logs, appels d'outils, mémoire d'agent, messages d'erreur, cache, données de fine-tuning et transferts entre agents.
Lire le guidePrêt à construire des systèmes IA prêts pour la production ?
Notre équipe est spécialisée dans les systèmes IA prêts pour la production. Discutons de comment nous pouvons aider.
Démarrer une conversation