Isolation multi‑tenant dans Strapi 5 : middleware, RLS et service de documents
Découvrez comment garantir une isolation multi‑tenant réelle dans Strapi 5 en ajoutant un champ tenant, un middleware dédié, la sécurité au niveau des lignes PostgreSQL et le service de documents pour des requêtes sécurisées.
Par l’équipe Revisionary · · 4 min de lecture
Strapi 5 ne fournit pas d’isolation multi‑tenant native, mais vous pouvez obtenir une séparation réelle des données en combinant un identifiant de tenant sur chaque enregistrement, un middleware conscient du tenant qui injecte cet identifiant dans chaque appel au service de documents, et la sécurité au niveau des lignes (RLS) de PostgreSQL pour appliquer la règle au niveau de la base.
1. Prérequis
- Strapi 5 (Node 18‑24) avec une base de données PostgreSQL.
- Bonne connaissance des plugins Strapi, des politiques et de l’API du service de documents.
- Accès à la base avec un rôle super‑utilisateur pour créer les politiques RLS.
2. Ajouter un champ tenant aux utilisateurs et aux types de contenu
La première étape consiste à faire du tenant un élément du modèle de données. Dans un scénario SaaS classique, on stocke habituellement l’ID du tenant sur l’utilisateur authentifié et on le propage à chaque contenu créé.
2.1 Étendre le modèle User
Créer un nouveau composant tenant (type integer) ou une relation vers une collection tenant si vous avez besoin de métadonnées plus riches. Ici nous restons simples et ajoutons une colonne numérique.
// src/api/user/content-types/user/schema.json
{
"kind": "collectionType",
"collectionName": "users-permissions_user",
"info": { "singularName": "user", "pluralName": "users", "displayName": "User" },
"options": { "draftAndPublish": false },
"attributes": {
"username": { "type": "string", "unique": true },
"email": { "type": "email", "unique": true },
"password": { "type": "password" },
"tenant": { "type": "integer", "required": true }
}
}2.2 Ajouter tenant à chaque type de contenu à isoler
Répétez la même addition dans chaque collection que vous souhaitez restreindre. Exemple pour un type project :
// src/api/project/content-types/project/schema.json
{
"kind": "collectionType",
"collectionName": "projects",
"info": { "singularName": "project", "pluralName": "projects", "displayName": "Project" },
"options": { "draftAndPublish": true },
"attributes": {
"title": { "type": "string" },
"description": { "type": "text" },
"tenant": { "type": "integer", "required": true }
}
}3. Créer une politique consciente du tenant
Les politiques s’exécutent après l’authentification et peuvent rejeter une requête si l’utilisateur tente d’accéder à des données appartenant à un autre tenant.
// src/policies/is-tenant-member.js
module.exports = async (ctx, config, { strapi }) => {
const user = ctx.state.user;
if (!user) return ctx.unauthorized('No authenticated user');
// L'ID du tenant attendu – peut provenir de la requête, du corps ou du paramètre d'URL
const tenantId =
ctx.request.body?.tenant ||
ctx.request.query?.tenant ||
ctx.params?.tenantId;
// Si la requête ne fournit pas explicitement de tenant, on utilise celui de l'utilisateur
const effectiveTenant = tenantId ?? user.tenant;
if (Number(effectiveTenant) !== Number(user.tenant)) {
return ctx.forbidden('You are not a member of this tenant');
}
// Attache le tenant résolu au contexte pour une utilisation ultérieure du middleware
ctx.state.tenant = effectiveTenant;
return true;
};Enregistrez la politique dans config/policies.js et attachez‑la aux routes qui nécessitent une protection.
4. Middleware global qui injecte le filtre tenant
Le Document Service de Strapi 5 (strapi.documents) est la méthode recommandée pour interagir avec le contenu. Un middleware global peut ajouter automatiquement un filtre { tenant: ctx.state.tenant } à chaque opération de lecture et renseigner le champ lors des créations / modifications.
// src/middlewares/tenant-filter/index.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
// S'assurer que la politique tenant a été exécutée en premier
if (!ctx.state.tenant) {
return ctx.unauthorized('Tenant not resolved');
}
const tenantId = ctx.state.tenant;
// Envelopper les méthodes du Service de documents qui nous intéressent
const originalUse = strapi.documents.use.bind(strapi.documents);
strapi.documents.use = (serviceName) => {
const service = originalUse(serviceName);
const wrap = (method) => async (params = {}) => {
// Injecter le filtre tenant pour les méthodes en lecture seule
if (['findMany', 'findOne', 'count'].includes(method)) {
params.filters = params.filters || {};
params.filters.tenant = tenantId;
}
// Appliquer le tenant sur les méthodes d'écriture
if (['create', 'update', 'delete'].includes(method)) {
params.data = params.data || {};
params.data.tenant = tenantId;
}
return servicemethod;
};
return {
...service,
findMany: wrap('findMany'),
findOne: wrap('findOne'),
count: wrap('count'),
create: wrap('create'),
update: wrap('update'),
delete: wrap('delete'),
// laisser les autres méthodes inchangées (publish, unpublish, etc.)
publish: service.publish,
unpublish: service.unpublish,
discardDraft: service.discardDraft,
};
};
await next();
};
};Ajoutez le middleware à config/middlewares.js après la politique :
module.exports = [
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
// Middlewares personnalisés
'./src/middlewares/tenant-filter',
];Désormais chaque appel au Document Service respecte automatiquement le contexte tenant.
5. Appliquer l’isolation au niveau de la base avec le RLS de PostgreSQL
Les contrôles côté application sont utiles, mais un attaquant déterminé pourrait les contourner en exécutant directement du SQL. Le RLS de PostgreSQL garantit que, même si la requête est construite manuellement, les lignes hors du tenant restent invisibles.
5.1 Créer une variable de session par requête
Nous devons définir app.current_tenant pour chaque connexion. Étendez le même middleware pour exécuter une requête brute avant le traitement de la requête :
// src/middlewares/set-rls-tenant/index.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
const tenantId = ctx.state.tenant;
if (!tenantId) return ctx.unauthorized('Tenant missing');
// Utiliser une transaction afin que le paramètre ne vive que pour cette requête
await strapi.db.connection.transaction(async (trx) => {
await trx.raw('SET LOCAL app.current_tenant = ?', [tenantId]);
// Stocker la transaction dans le contexte pour qu’elle soit utilisée par les services ultérieurs
ctx.state.trx = trx;
await next();
});
};
};Enregistrez ce middleware avant le middleware tenant-filter afin que la variable soit disponible lorsque le Document Service s’exécute.
5.2 Créer des politiques RLS pour chaque table portée par le tenant
Connectez‑vous à votre instance PostgreSQL en tant que super‑utilisateur et exécutez :
-- Activer le RLS sur la table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Politique qui autorise une ligne uniquement lorsque son tenant correspond à la variable de session
CREATE POLICY tenant_isolation ON projects
USING (tenant = current_setting('app.current_tenant')::int);Répétez les instructions ALTER TABLE … ENABLE ROW LEVEL SECURITY et CREATE POLICY pour chaque table contenant une colonne tenant.
5.3 Vérifier la politique
Depuis psql, définissez la variable de session et lancez une requête :
SET app.current_tenant = 42;
SELECT id, title FROM projects; -- Retourne uniquement les lignes où tenant = 42Si la variable n’est pas définie, PostgreSQL renvoie une erreur, empêchant les scans complets de table.
6. Utiliser le service de documents avec filtrage tenant
Comme le middleware ajoute déjà le filtre, la plupart du code des contrôleurs peut rester inchangé. Voici un contrôleur typique qui liste les projets du tenant courant :
// src/api/project/controllers/project.js
module.exports = {
async find(ctx) {
// Pas besoin de fournir le filtre tenant manuellement – le middleware l’ajoute
const projects = await strapi.documents('api::project.project').findMany();
ctx.body = projects;
},
async create(ctx) {
const data = ctx.request.body;
const project = await strapi.documents('api::project.project').create({ data });
ctx.body = project;
},
};Si vous avez besoin d’un filtre supplémentaire en plus du tenant, ajoutez‑le simplement :
const projects = await strapi.documents('api::project.project').findMany({
filters: { status: 'active' } // le filtre tenant est ajouté automatiquement
});6.1 Publication avec Draft & Publish
Lorsque Draft & Publish est activé, le Document Service expose également publish, unpublish et discardDraft. Le middleware n’interfère pas ; le tenant reste attaché car le chemin update est utilisé en interne.
await strapi.documents('api::project.project').publish({
documentId: '640c5f1e2f8b4a001c8f2d3a'
});7. Tester l’isolation
- Créer deux utilisateurs appartenant respectivement aux tenants 1 et 2.
- Se connecter avec chaque utilisateur et appeler l’endpoint
/projects. - Vérifier que chaque utilisateur ne voit que ses propres enregistrements.
- Faire une requête directe vers
/projects/:idavec un ID appartenant à l’autre tenant – la politique doit renvoyer403 Forbidden. - Se connecter à la base en tant que super‑utilisateur, supprimer la variable de session et exécuter un
SELECTbrut. PostgreSQL doit rejeter la requête parce que le paramètreapp.current_tenantest absent.
8. Optionnel : schémas séparés par tenant
Si vous privilégiez une isolation physique absolue, vous pouvez créer un schéma PostgreSQL distinct pour chaque tenant et définir search_path dans le même middleware. L’approche RLS est généralement suffisante et garde le nombre de tables maîtrisable, mais la stratégie de schémas peut être combinée avec elle dans des environnements soumis à des exigences de conformité.
9. Quand utiliser Revisionary
Si vous avez aussi besoin de versionnage, de journaux d’audit ou d’une UI pour comparer les changements entre tenants, le plugin Revisionary ajoute un historique au niveau des champs, une corbeille et un journal d’audit. Il fonctionne sur les mêmes modèles conscients du tenant, vous pouvez donc l’activer après avoir mis en place la couche d’isolation.
Conclusion
Strapi 5 ne fournit pas d’isolation multi‑tenant native, mais une combinaison d’une colonne tenant, d’une politique consciente du tenant, d’un middleware global qui injecte le tenant dans chaque appel du Document Service, et du RLS de PostgreSQL vous offre une solution robuste en profondeur. Cette approche maintient une surface API propre, exploite le nouveau Document Service de Strapi, et garantit que même le SQL brut ne pourra pas divulguer de données entre tenants.
Points clés
- Ajoutez un champ
tenantentier obligatoire aux utilisateurs et à chaque type de contenu à restreindre. - Utilisez une politique pour rejeter les requêtes inter‑tenant et exposez
ctx.state.tenant. - Un middleware global enveloppe le Document Service, filtrant automatiquement les lectures et renseignant les écritures.
- Activez le RLS de PostgreSQL et définissez une variable de session par requête pour imposer l’isolation au niveau de la base.
- Le même pattern fonctionne avec Draft & Publish, des filtres personnalisés, et peut être combiné avec le plugin Revisionary pour le versionnage.
Avec ces étapes, vous disposez d’une installation Strapi 5 multi‑tenant prête pour la production, capable de s’étendre en toute sécurité sur de nombreux clients.
Sources
L’équipe Revisionary
Les développeurs de Revisionary, un plugin d’historique des versions pour Strapi 5. Nous écrivons sur Strapi, le développement web, l’IA et la sécurité des applications.
Cet article a été rédigé avec l'aide d'une IA.