La multi-tenancy a la réputation d'être difficile. Elle ne l'est pas, à condition de s'engager sur quatre règles dès le début.
Cette réputation vient de systèmes qui ajoutent la multi-tenancy après coup — en ajoutant des colonnes tenant à des tables existantes, en faisant transiter le contexte tenant par des couches qui n'ont pas été conçues pour cela, et en découvrant les cas limites un client mécontent à la fois. L'approche de Quotery est différente : chaque business model a été conçu avec la multi-tenancy dès le premier jour. La colonne tenant n'est pas une réflexion après coup ; elle est aussi fondamentale que la clé primaire.
Règle un : chaque ligne porte un tenant
Chaque business model non-auth possède tenant = ForeignKey('tenants.Tenant', ...). Aucune exception en dehors de la table auth. Si une ligne métier existe sans connaître son tenant, vous avez un bug.
Cette règle est appliquée par convention et par revue de code, pas par la base de données — mais l'application est stricte. Chaque model dans api/<app>/models/ qui hérite de BaseModel possède une FK tenant ou une raison explicite et documentée pour ne pas en avoir (models auth, configuration à l'échelle du système). Les nouveaux contributeurs apprennent cette règle lors de leur première revue de PR. C'est l'invariant structurel le plus important du codebase.
Règle deux : les services filtrent, les views non
Chaque fonction de service accepte tenant= et applique un filtre. Les views sont un transport simple : elles résolvent request.user.tenant, le transmettent à l'appel de service et font confiance à la couche.
Cette séparation est ce qui maintient le codebase maintenable. Si les views filtraient, vous devriez auditer chaque view pour vérifier l'isolation des tenants. Si les models filtraient (via un manager personnalisé), vous perdriez la capacité d'écrire des requêtes cross-tenant à des fins administratives. En centralisant le filtrage des tenants dans la couche de service, il n'y a qu'un seul endroit où vérifier que chaque requête est scopée : la fonction de service propriétaire de la requête.
Règle trois : cross-tenant renvoie 404, pas 403 Si un utilisateur demande une ressource qui existe dans le tenant d'un autre, l'API renvoie 404, pas 403. Renvoyer 403 révèle que la ressource existe. Le 404 la traite comme inexistante, ce qui correspond à ce que l'utilisateur doit voir.
C'est un argument de sécurité par l'obscurité qui est en réalité correct dans ce contexte. Un 403 dit « vous n'avez pas la permission de voir ceci, mais cela existe ». Un attaquant déterminé peut utiliser les réponses 403 pour cartographier quels tenants possèdent quelles ressources. Un 404 dit « cela n'existe pas dans votre univers ». Il est impossible de le distinguer d'une ressource réellement inexistante. La couche de service y parvient en filtrant les requêtes par tenant et en levant DoesNotExist lorsque la requête filtrée renvoie un résultat vide — jamais en vérifiant la propriété après la récupération.
Règle quatre : le tenant ne figure pas dans l'URL
Il n'existe pas de préfixe /api/t/<slug>/.... Le tenant est dérivé du cookie de session. Les URLs restent propres, les signets restent portables, et il n'y a aucune possibilité d'attaque par substitution d'URL.
Le routage des tenants par URL est courant (Slack, par exemple) mais introduit une classe de bugs où un utilisateur met une URL en signet, la partage, et révèle accidentellement son contexte tenant — ou pire, où une URL préparée permet à un utilisateur de tenter d'accéder à un autre tenant en modifiant le slug. La résolution des tenants par session élimine entièrement cette surface d'attaque. L'utilisateur s'authentifie, la session porte le tenant, et chaque requête ultérieure est automatiquement scopée. L'utilisateur ne voit jamais ni ne pense à son identifiant de tenant.
