Un scope multi-tenant doit échouer fermé
La fuite de données entre clients n'est jamais une décision : c'est un oubli. Voici pourquoi je fais renvoyer zéro ligne à mes scopes quand le tenant est absent, plutôt que tout.
Dans un SaaS multi-tenant, personne ne décide un jour de laisser un client voir les données d'un autre. Ça arrive parce qu'une requête a été écrite sans le filtre tenant_id — dans un export ajouté six mois plus tard, dans un rapport écrit un vendredi soir, dans un job de fond que personne n'a relu.
C'est pour ça que « il faut penser à filtrer par tenant » n'est pas une stratégie de sécurité. C'est une consigne, et les consignes s'oublient.
Le piège de l'implémentation naïve
L'approche habituelle consiste à ajouter un scope global sur les modèles concernés :
public function apply(Builder $builder, Model $model): void
{
$tenant = app(TenantManager::class)->current();
if ($tenant) {
$builder->where('tenant_id', $tenant->id);
}
}
Ce code paraît raisonnable. Il est dangereux.
Relisez la condition : quand il n'y a pas de tenant, aucun filtre n'est appliqué. La requête part sans clause where, et renvoie donc les lignes de tous les clients de la plateforme. Le cas « je ne sais pas qui vous êtes » se comporte exactement comme le cas « vous avez le droit de tout voir ».
Et le tenant peut être absent pour des raisons parfaitement banales : un job en file d'attente qui ne porte pas le contexte de la requête, une commande artisan, un webhook entrant, un test mal isolé, un middleware qui n'a pas tourné sur cette route.
Inverser la valeur par défaut
La correction tient en une ligne :
public function apply(Builder $builder, Model $model): void
{
$tenant = app(TenantManager::class)->current();
if (! $tenant) {
$builder->whereRaw('1 = 0'); // aucun tenant, aucune ligne
return;
}
$builder->where('tenant_id', $tenant->id);
}
Sans tenant résoluble, la requête ne renvoie rien.
Le bénéfice n'est pas théorique, il est opérationnel : un bug de résolution produit désormais une page vide. Une page vide, ça se voit, ça se signale, ça se corrige dans la journée. Un déversement silencieux de données inter-clients, ça ne se voit pas — jusqu'au jour où c'est un client qui vous l'apprend.
L'exception qu'il faut assumer
Il y a un modèle qui ne peut pas suivre cette règle : celui des utilisateurs.
L'authentification doit pouvoir retrouver un utilisateur avant de savoir à quel tenant il appartient — c'est justement l'utilisateur qui porte l'information. Si le modèle User est scopé fermé par défaut, plus personne ne peut se connecter.
Cette exception doit être explicite et unique. Une exception documentée est une décision ; trois exceptions ajoutées au fil des sprints, c'est une passoire.
Rendre le contournement bruyant
Il reste des cas légitimes de lecture inter-tenants : les statistiques de la plateforme, le back-office, la facturation. Ces cas doivent exister — mais ils doivent être visibles dans le code :
// Analytics plateforme : volontairement inter-tenant.
Order::withoutGlobalScopes()
->whereIn('tenant_id', $tenantIds)
->sum('total');
Personne n'écrit withoutGlobalScopes() par accident. En relecture, cette ligne saute aux yeux — et c'est exactement l'effet recherché.
Le principe général tient en une phrase, et il dépasse largement le multi-tenant :
Rendez le cas sûr automatique, et le cas dangereux verbeux.
Tout ce qui repose sur la vigilance d'un développeur à trois heures du matin finira par échouer. Ce qui repose sur la structure du code, non.