Multi-tenancy par ligne en Go : la règle, le middleware et le test qui la prouve
La multi-tenancy a trois implémentations courantes : une base de données par locataire, un schéma par locataire, et une colonne sur chaque ligne. Pour un SaaS qui démarre petit et doit rester exploitable par une seule personne, la colonne gagne — à condition que la règle soit appliquée à un seul endroit plutôt que mémorisée à cinquante.
Voici le design complet, tel qu'implémenté dans GoVueKit, et le raisonnement derrière chaque choix.
La règle
Chaque table métier a un
organization_id. Chaque requête filtre dessus. La valeur vient du contexte de la requête, jamais du client.
Trois phrases, et c'est tout le modèle de sécurité de la tenancy. L'implémentation existe pour rendre leur violation pénible.
Étape 1 : la route porte le locataire
Les routes à portée de locataire vivent sous /api/orgs/{orgID}/…. C'est
délibéré : le locataire fait partie de l'URL, donc il est visible dans les
journaux, dans les tests et dans le routeur lui-même. L'alternative — une
« organisation courante » implicite dans la session — fait dépendre chaque
requête d'un état caché, et transforme l'utilisateur qui change d'onglet en
ticket de bug.
Étape 2 : un middleware résout l'appartenance
func (o *Org) Context(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := UserFrom(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
orgID := chi.URLParam(r, "orgID")
m, err := o.Q.GetOrgMember(r.Context(), sqlcgen.GetOrgMemberParams{
OrganizationID: orgID, UserID: u.ID,
})
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "not found")
return
}
})
}
La recherche d'appartenance est le contrôle d'autorisation. Si l'appelant n'est pas membre, la requête meurt ici — avant tout handler, avant toute requête SQL, avant toute occasion d'oublier.
Étape 3 : 404, pas 403
Un 403 dit : cette organisation existe, et vous n'en faites pas partie. C'est une fuite d'information, et une fuite utile pour un attaquant qui énumère des identifiants. Un 404 ne dit rien du tout.
Le même raisonnement s'applique à l'intérieur d'un locataire : une ressource qui appartient à une autre organisation n'est pas « interdite », elle est introuvable, parce que du point de vue de l'appelant elle n'existe pas.
L'exception, ce sont les rôles à l'intérieur d'une organisation dont l'appelant est membre. Là, 403 est correct : le membre sait que l'organisation existe, il voit que le bouton est désactivé, et masquer la différence ne ferait que le dérouter.
Étape 4 : les requêtes ne peuvent pas être mal appelées
sqlc génère une fonction par instruction SQL. Écrivez l'instruction avec le
locataire dans la clause WHERE et la signature générée l'exige :
-- name: DeleteOrgInvitation :exec
DELETE FROM org_invitations WHERE id = $1 AND organization_id = $2;
err := q.DeleteOrgInvitation(ctx, sqlcgen.DeleteOrgInvitationParams{
ID: invID, OrganizationID: orgCtx.Org.ID,
})
Un développeur qui veut contourner la tenancy doit écrire une nouvelle
instruction SQL, qui apparaît en revue comme un fichier modifié dans
internal/db/queries/. C'est la propriété qu'on veut : le chemin dangereux
est visible, pas commode.
C'est aussi pour ça que le kit utilise sqlc plutôt qu'un ORM. Avec un query
builder, Invitation.find(id) compile, s'exécute, et renvoie la ligne d'un
autre locataire. Il n'y a pas d'équivalent de cette erreur ici, parce qu'il
n'y a pas de finder générique.
Étape 5 : les rôles sont vérifiés côté serveur
one.With(middleware.RequireOrgRole(org.RoleOwner)).Delete("/", orgsH.Delete)
Le front masque aussi le bouton de suppression, et cette vérification est explicitement cosmétique. N'importe qui peut ouvrir la console et appeler le endpoint ; c'est le middleware qui répond.
Une règle facile à manquer : le dernier propriétaire d'une organisation ne peut être ni rétrogradé, ni retiré, ni la quitter. Sans elle, les organisations deviennent inadministrables et les tickets de support de la chirurgie de base de données.
Étape 6 : le test qui garde tout ça vrai
Chacun des points ci-dessus peut être défait par un refactoring bien intentionné. La propriété a donc un test au niveau où elle compte vraiment — à travers HTTP, contre une vraie base :
1. Créer l'utilisateur A et l'organisation A.
2. Créer l'utilisateur B et l'organisation B.
3. En tant que B, GET /api/orgs/{orgA} → 404
4. En tant que B, GET /api/orgs/{orgA}/members → 404
5. En tant que membre (non propriétaire) de A,
PATCH /api/orgs/{orgA}/members/{userID} → 403
Les étapes 3 et 4 sont celles qui comptent. Elles coûtent peu à écrire une fois et elles échouent bruyamment le jour où quelqu'un ajoute une requête sans le locataire.
Dans GoVueKit, ce test existe deux fois : comme tests d'intégration exécutés contre les deux moteurs de base en CI, et comme parcours Playwright contre le binaire de production. Écrivez-le en même temps que la ressource, jamais après — un test qu'il faut penser à ajouter plus tard est un test qui s'ajoute après l'incident.
Ce que ce design ne vous donne pas
Soyez au clair sur les limites avant de l'adopter :
- Pas d'isolation physique. Un bug dans une requête peut, en principe, renvoyer la ligne d'un autre locataire. Les garde-fous sont structurels, pas physiques. Si votre exigence de conformité est littéralement des bases séparées, ce n'est pas le modèle qu'il vous faut.
- Pas de sauvegarde/restauration par locataire. Restaurer les données d'un client veut dire export sélectif, pas copie de fichier.
- Les voisins bruyants partagent les ressources. La requête lourde d'un locataire peut ralentir les autres ; on gère ça avec des index, de la pagination et des limites.
En échange, vous avez une base à exploiter, des migrations qui tournent une
fois, et la possibilité de répondre à « combien d'organisations sont sur
l'offre payante ? » avec un seul GROUP BY. Pour l'écrasante majorité des
produits SaaS, ce compromis est le bon — et si vous le dépassez, vous le
dépassez avec du chiffre d'affaires.
Le résumé en cinq lignes pour votre propre code
- Mettez
organization_idsur chaque table métier, avec une cascade et un index. - Résolvez l'appartenance dans un seul middleware, depuis l'URL, après l'authentification.
- Répondez 404 aux extérieurs, 403 seulement à l'intérieur d'une organisation dont l'appelant est membre.
- Écrivez des requêtes qui prennent le locataire, pour que le code généré refuse d'être appelé sans lui.
- Écrivez le test inter-locataires une fois par ressource, et faites-le tourner en CI.
Voyez-le dans les extraits de code de la page d'accueil, ou lisez comment la même discipline s'applique aux webhooks de facturation.