Skip to Content
SécuritéPartie 5 — Autorisation & contrôle d'accès5.3 — Vérifier côté serveur & multi-tenant

Chapitre 5.3 — Vérifier côté serveur & multi-tenant

⏱️ TL;DR — La parade universelle au broken access control tient en une phrase : l’autorisation se vérifie côté serveur, à chaque requête, en liant chaque objet au demandeur (et à son tenant) — jamais dans l’UI, jamais en se fiant à un champ client. Concrètement : centraliser la décision (voters, policies) plutôt que l’éparpiller, porter les requêtes par le propriétaire/tenant (WHERE owner_id = :moi), refuser par défaut (deny by default). En multi-tenant (plusieurs organisations sur la même appli), l’enjeu monte d’un cran : une fuite entre tenants expose les données d’un client à un autre. On isole en propageant le tenant_id dans toutes les requêtes, idéalement de façon automatique et impossible à oublier.

🎯 Objectifs

  • Formuler et appliquer la règle d’or de l’autorisation (serveur, par requête, par objet).
  • Centraliser la logique d’autorisation (voters/policies) au lieu de la disperser.
  • Adopter le deny by default et les requêtes portées par le propriétaire.
  • Sécuriser un contexte multi-tenant : propagation du tenant_id, isolation, patterns anti-oubli.

La règle d’or

Tout le chapitre 5.2 se referme avec un principe unique, à graver :

Vérifie l’autorisation côté serveur, sur chaque requête, en liant l’objet demandé à l’utilisateur (et à son organisation). En cas de doute, refuse.

Décomposons ses quatre exigences :

  1. Côté serveur : jamais dans l’UI, jamais via un champ que le client contrôle. L’UI qui masque un bouton est du confort, pas de la sécurité (l’endpoint doit refuser tout seul).
  2. Sur chaque requête : l’autorisation n’est pas un état acquis à la connexion — elle se re-vérifie à chaque accès. Une requête = une décision.
  3. En liant l’objet au demandeur : la vérification porte sur l’objet précis visé (appartenance, tenant), pas seulement sur le type d’action (rôle). C’est ce qui ferme l’IDOR.
  4. Refuser par défaut (deny by default, fail secure) : l’absence de décision explicite d’autorisation = refus. On n’autorise que ce qui est explicitement permis.

Centraliser la décision

Éparpiller des if (user.role === 'admin' || user.id === obj.ownerId) dans chaque contrôleur est une garantie d’oubli : tôt ou tard, un endpoint est écrit sans le if, et c’est la faille. La bonne pratique est de centraliser la logique d’autorisation dans des composants dédiés, testés une fois et réutilisés partout :

  • Symfony : les voters — une classe qui répond « cet utilisateur peut-il faire EDIT sur cet objet ? », appelée via #[IsGranted] ou denyAccessUnlessGranted() (Partie 9).
  • Moodle : les capabilities + require_capability() (Partie 11).
  • Node/Next : des policies/guards/ability (ex. une fonction can(user, 'edit', ressource)) appelées dans chaque route/Server Action (Partie 10).

L’objectif : un seul endroit décide, on peut le tester exhaustivement, et les appels sont repérables (on peut auditer « quels endpoints n’appellent pas l’autorisation ? »).

💡 Réflexe — Si tu vois la même logique d’autorisation copiée dans plusieurs contrôleurs, c’est un signal : extrais-la dans un voter/policy central. Non seulement tu supprimes les divergences (un endroit corrigé, tous corrigés), mais tu rends l’absence de contrôle visible (un endpoint qui n’appelle jamais l’autorisation saute aux yeux en revue). La dispersion cache les trous ; la centralisation les révèle.

Porter les requêtes par le propriétaire

Le pattern le plus robuste contre l’IDOR consiste à ne jamais pouvoir récupérer un objet qui n’est pas au demandeur — en incluant la contrainte dans la requête elle-même :

-- ✅ La requete NE PEUT PAS renvoyer l'objet d'un autre SELECT * FROM factures WHERE id = :id AND owner_id = :userId;

Ainsi, même si un dev oublie un contrôle applicatif, la requête ne remonte rien pour un objet étranger. C’est plus sûr que « récupérer puis vérifier », car il n’y a pas de fenêtre où l’objet d’autrui est en mémoire (et risque d’être renvoyé par erreur). On répond 404 (plutôt que 403) pour ne même pas confirmer l’existence de l’objet à un non-ayant droit.

Le multi-tenant : l’isolation entre clients

Une appli multi-tenant sert plusieurs organisations (tenants) sur la même infrastructure et la même base : FormaCampus héberge l’établissement A et l’établissement B. Ici, le broken access control prend une dimension critique : une fuite ne concerne plus « un utilisateur voit les données d’un autre », mais « un client voit les données d’un autre client » — un incident majeur, contractuel et RGPD.

La règle : chaque requête sur des données de tenant doit être filtrée par le tenant_id de l’utilisateur courant. Un utilisateur de l’organisation A ne doit jamais pouvoir atteindre une ligne de l’organisation B, quel que soit l’id qu’il fournit.

-- ✅ Toute requete multi-tenant porte le tenant courant SELECT * FROM cours WHERE id = :id AND tenant_id = :tenantCourant;

Le danger : il suffit d’une seule requête où l’on oublie le tenant_id pour ouvrir une brèche transversale. Comme l’oubli est humain, on cherche à rendre le filtrage automatique et non-contournable :

  • Filtre global au niveau de l’ORM : Doctrine (filtres SQL), une global scope/policy qui injecte systématiquement tenant_id = :courant dans toutes les requêtes de l’entité. L’oubli devient impossible car c’est le défaut.
  • Séparation plus forte selon le niveau d’exigence : un schéma par tenant, voire une base par tenant (isolation physique, plus lourde mais plus étanche). Le choix dépend du modèle de risque.
  • Le tenant_id vient de la session serveur, jamais d’un paramètre client : un ?tenant=B fourni par l’utilisateur ne doit rien décider.

⚠️ Piège — En multi-tenant, le tenant_id ne doit JAMAIS venir du client (paramètre d’URL, corps, en-tête, champ caché). S’il est fourni par l’utilisateur, un attaquant de l’org A passe simplement tenant=B et lit les données du client B. Le tenant_id se déduit exclusivement de la session/identité côté serveur. Et on ne compte pas sur chaque dev pour l’ajouter à la main : on l’impose via un filtre global que personne ne peut oublier.

🎯 Côté attaquant — Contre une appli multi-tenant, l’attaquant crée un compte dans sa petite organisation, puis cherche la requête où le tenant_id a été oublié : un endpoint de reporting, un export, une recherche « globale », une jointure. Il teste des ids qui ne sont pas dans son tenant. Une seule requête non filtrée suffit pour franchir la frontière entre clients — le graal, car il aspire alors les données d’organisations entières. D’où l’obsession du filtrage systématique et automatique.

🧭 Sur FormaCampus — FormaCampus est multi-tenant (un tenant par établissement). L’isolation repose sur un filtre Doctrine global qui injecte tenant_id = :courant dans toutes les requêtes sur les entités partagées ; le tenant_id est toujours pris dans la session serveur (jamais d’un paramètre). Les requêtes sensibles sont en plus portées par le propriétaire (AND owner_id = :userId), les décisions passent par des voters centralisés, et le principe est deny by default. Des tests d’isolation vérifient qu’un utilisateur de l’établissement A reçoit 404 sur toute ressource de l’établissement B, y compris via les exports et la recherche. Une fuite inter-tenant serait, pour une EdTech, un incident RGPD et contractuel majeur — l’isolation est traitée comme prioritaire.

✏️ Exercices

Exercice 1 — Récupérer-puis-vérifier vs requête portée. Ces deux approches ferment l’IDOR. Laquelle est la plus robuste et pourquoi ?

// Approche A const doc = await db.docs.findById(id) if (doc.ownerId !== user.id) return res.sendStatus(403) res.json(doc) // Approche B const doc = await db.docs.findOne({ id, ownerId: user.id }) if (!doc) return res.sendStatus(404) res.json(doc)

✅ Solution

Les deux sont correctes, mais B est plus robuste. En A, l’objet d’autrui est récupéré en mémoire avant le contrôle : un refactor maladroit (retour anticipé, log qui expose doc, oubli du if) peut le laisser fuiter, et il y a une fenêtre où la donnée sensible est manipulée. En B, la contrainte d’appartenance est dans la requête : l’objet d’un autre n’est jamais remonté, il n’y a rien à laisser fuiter, et l’oubli est structurellement plus difficile. B répond aussi 404 (ne confirme pas l’existence). En multi-tenant, on ajoute tenantId à la même clause. Préfère B (porter la requête) quand c’est possible.

Exercice 2 — Le paramètre tenant. Un endpoint multi-tenant fait : SELECT * FROM cours WHERE tenant_id = :t avec :t = req.query.tenant. Explique la faille et corrige.

✅ Solution

Le tenant_id provient d’un paramètre client (req.query.tenant) : un attaquant de l’organisation A passe simplement ?tenant=B et lit toutes les données de l’organisation B — franchissement complet de l’isolation entre clients. Correctif : le tenant_id doit venir exclusivement de la session/identité côté serveur (const t = req.user.tenantId), jamais d’une entrée client. Idéalement, imposer ce filtre via un mécanisme global (filtre ORM) pour qu’aucune requête ne puisse l’oublier ou le contourner. Le client ne doit rien pouvoir décider sur le périmètre tenant.

🧠 Quiz de révision

1. Énonce la règle d’or de l’autorisation.

Vérifier l’autorisation côté serveur, à chaque requête, en liant l’objet demandé à l’utilisateur (et son tenant), et refuser par défaut. Côté serveur (pas l’UI), par requête (pas un état acquis), par objet (pas juste le rôle), deny by default.

2. Pourquoi centraliser la logique d’autorisation ?

Parce que la disperser en if un peu partout garantit l’oubli (un endpoint finira sans contrôle). Centraliser (voters/policies) permet de tester la décision une fois, de la corriger partout d’un coup, et de repérer les endpoints qui n’appellent pas l’autorisation.

3. Pourquoi « porter la requête par le propriétaire » est-il plus sûr que « récupérer puis vérifier » ?

Parce que la contrainte d’appartenance est dans la requête (WHERE id = :id AND owner_id = :moi) : l’objet d’un autre n’est jamais remonté, donc rien à laisser fuiter par erreur, et l’oubli est structurellement plus dur. « Récupérer puis vérifier » laisse l’objet d’autrui en mémoire avant le contrôle.

4. Quel est l’enjeu spécifique du multi-tenant ?

Une fuite ne concerne plus deux utilisateurs mais deux clients/organisations : un tenant voit les données d’un autre — incident majeur (contractuel, RGPD). Il faut filtrer chaque requête par tenant_id, de façon idéalement automatique et non contournable.

5. D’où doit venir le tenant_id, et pourquoi jamais du client ?

De la session/identité côté serveur, jamais d’un paramètre client. Si le client le fournit (?tenant=B), un attaquant change simplement la valeur pour lire les données d’un autre tenant. On l’impose aussi via un filtre global (ORM) pour qu’aucune requête ne l’oublie.


Fin de la Partie 5. Le contrôle d’accès — le risque n°1 — est verrouillé : authn/authz distingués, IDOR fermés, vérification serveur systématique, tenants isolés. On descend maintenant vers les données elles-mêmes avec la Partie 6 — Injections : quand une entrée non fiable se fait passer pour du code.

Last updated on