
Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.
Access control multi-tenant dans Payload : notre architecture pour 12 clients sur la même instance
- payload
Faire tourner 12 clients sur une seule instance Payload sans que chacun voie les données des autres. L'architecture qu'on a construite, les décisions qu'on a prises.
Access control multi-tenant dans Payload : notre architecture pour 12 clients sur la même instance
On fait tourner 12 organisations clientes sur la même instance Payload. Même base de données, même instance Next.js, même admin. Chaque client ne voit que ses propres données. Voici comment c'est construit, et ce qu'on a dû réviser en cours de route.
Le problème du multi-tenant dans un CMS
Un CMS classique est pensé pour une seule organisation. Payload ne fait pas exception : il n'existe pas de concept natif de "tenant". L'isolation entre clients doit être construite entièrement dans les access control functions.
Deux approches existent :
- Une instance Payload par tenant, simple à isoler, coûteux à opérer. À 12 clients, on multiplie les déploiements, les bases de données, les mises à jour.
- Une instance partagée avec isolation au niveau des données, plus complexe à construire, mais un seul plan de déploiement.
On a choisi la deuxième. Le risque principal : une faille dans un access control expose les données d'un autre tenant. Ce risque se gère, mais il demande de la rigueur.
La collection organizations
Tout part d'une collection centrale qui représente les tenants :
// src/collections/Organizations.ts
export const Organizations: CollectionConfig = {
slug: 'organizations',
admin: { useAsTitle: 'name' },
access: {
read: ({ req: { user } }) => {
if (!user) return false;
if (user.role === 'superAdmin') return true;
// Un admin standard ne voit que sa propre organisation
return { id: { equals: user.organization } };
},
create: isSuperAdmin,
update: isSuperAdmin,
delete: isSuperAdmin,
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true },
{ name: 'plan', type: 'select', options: ['starter', 'pro', 'enterprise'] },
],
};Le superAdmin est le seul qui peut créer ou supprimer des organisations. Les admins tenant ne peuvent pas.
Le champ organization sur chaque collection
Chaque collection de données porte un champ organization qui référence le tenant propriétaire :
// Champ partagé, importé dans chaque collection
export const tenantField: Field = {
name: 'organization',
type: 'relationship',
relationTo: 'organizations',
required: true,
// Le champ est masqué dans l'admin pour les non-superAdmin
// et pré-rempli automatiquement via un beforeChange hook
admin: {
condition: (_, siblingData, { user }) => user?.role === 'superAdmin',
},
};L'injection automatique de l'organisation au moment de la création :
// Hook beforeChange sur chaque collection tenant-aware
export const injectOrganization: BeforeChangeHook = ({ req, data }) => {
if (req.user && !data.organization) {
return { ...data, organization: req.user.organization };
}
return data;
};Ce hook garantit qu'un document créé par un utilisateur appartient toujours à son organisation, même si l'API est appelée sans passer le champ organization.
Les access control functions
C'est le coeur du système. Chaque collection expose des fonctions d'accès qui filtrent par organisation :
// src/access/byOrganization.ts
import type { Access } from 'payload';
export const readByOrganization: Access = ({ req: { user } }) => {
if (!user) return false;
if (user.role === 'superAdmin') return true;
return {
organization: { equals: user.organization },
};
};
export const writeByOrganization: Access = ({ req: { user } }) => {
if (!user) return false;
if (user.role === 'superAdmin') return true;
// L'utilisateur doit appartenir à l'organisation du document
return {
organization: { equals: user.organization },
};
};Ces fonctions retournent un objet where de Payload, pas un booléen. Payload construit la requête SQL avec cette contrainte. C'est du row-level filtering, pas du filtrage applicatif post-requête.
// Application dans une collection
export const Projects: CollectionConfig = {
slug: 'projects',
access: {
read: readByOrganization,
create: writeByOrganization,
update: writeByOrganization,
delete: writeByOrganization,
},
hooks: {
beforeChange: [injectOrganization],
},
fields: [tenantField, /* ... autres champs */],
};La gestion des rôles dans les utilisateurs
On a trois niveaux de rôles :
| Rôle | Périmètre |
|---|---|
superAdmin | Toutes les organisations, tous les documents |
orgAdmin | Tous les documents de son organisation |
orgMember | Sous-ensemble de collections selon les permissions de l'organisation |
// src/collections/Users.ts (extrait)
fields: [
{ name: 'name', type: 'text' },
{
name: 'role',
type: 'select',
options: ['superAdmin', 'orgAdmin', 'orgMember'],
defaultValue: 'orgMember',
// Seul un superAdmin peut attribuer le rôle superAdmin
access: {
update: ({ req: { user } }) => user?.role === 'superAdmin',
},
},
{
name: 'organization',
type: 'relationship',
relationTo: 'organizations',
// Champ obligatoire pour tout utilisateur non-superAdmin
required: true,
admin: {
condition: (_, __, { user }) => user?.role === 'superAdmin',
},
},
],Le champ organization sur l'utilisateur est la source de vérité. Il est injecté dans le JWT au login via generateAccessToken et disponible sur chaque requête via req.user.
Ce qu'on a dû réviser
Les relations entre collections de tenants différents
Au départ, on avait des relations Payload standard entre collections. Le problème : Payload ne filtre pas automatiquement les relations par organisation dans l'interface admin. Un orgAdmin pouvait voir les documents d'un autre tenant dans un champ de relation, même s'il ne pouvait pas y accéder directement.
La correction : on filtre explicitement les relations dans les champs relationship :
{
name: 'assignedTo',
type: 'relationship',
relationTo: 'users',
filterOptions: ({ user }) => {
if (!user) return false;
if (user.role === 'superAdmin') return true;
return { organization: { equals: user.organization } };
},
},L'API publique (Local API sans utilisateur)
Les appels depuis les Server Components Next.js via la Local API ne portent pas de contexte utilisateur. On passe le contexte manuellement :
// Dans un Server Component
const payload = await getPayload({ config: configPromise });
const projects = await payload.find({
collection: 'projects',
where: { organization: { equals: orgId } },
// Pas de user dans le contexte, filtrage explicite
});C'est une zone à risque : si on oublie le filtre where, on récupère tous les documents de la collection. On a ajouté des tests d'intégration qui vérifient qu'aucune route public ne retourne des données cross-tenant.
Les uploads de médias
Les médias partagent le même bucket S3. On préfixe les chemins par {orgSlug}/ pour isoler les fichiers, mais l'accès à l'URL directe du fichier n'est pas protégé par les access controls Payload.
Pour les médias sensibles, on passe par une route Next.js proxy qui vérifie l'organisation avant de servir le fichier. Pour les médias publics (logos, images marketing), le préfixe seul suffit.
La question qui reste ouverte
Cette architecture fonctionne pour 12 clients. Elle est testée, monitored, et on n'a pas eu de fuite de données cross-tenant depuis six mois.
La vraie limite : le scaling vertical. Tous les tenants partagent la même base PostgreSQL. À un volume élevé de données ou de requêtes, le goulot d'étranglement sera là. On a pas encore de réponse sur le seuil exact à partir duquel il faudrait envisager une séparation par base de données ou par schéma PostgreSQL.