Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

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 :

  1. 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.
  2. 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ôlePérimètre
superAdminToutes les organisations, tous les documents
orgAdminTous les documents de son organisation
orgMemberSous-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.

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Payload multi-tenant : architecture access control production | Apogée Consult