La sécurité multi-tenant ne doit pas dépendre de ton code Go. Un seul
ifoublié, et un client voit les données d'un autre. Postgres sait poser la barrière à ta place, avec la Row-Level Security. Tu actives une policy par table, tu remplis le tenant à chaque connexion, et la base filtre toute seule. Voici comment je l'ai câblé en Go avecpgx. Plus les deux pièges qui m'ont coûté du temps. Et comment je le prouve avec des tests.
Pour qui : les devs Go qui construisent un SaaS multi-tenant sur Postgres. Ceux qui veulent une isolation qui tient, même quand le code a un bug.
Le contexte
Un tenant, c'est un client de ton application. Dans un SaaS multi-tenant, plusieurs clients partagent la même base de données. Leurs données vivent dans les mêmes tables, séparées par une colonne tenant_id.
Le risque est simple. Si une requête oublie son filtre WHERE tenant_id = ..., un client lit les données d'un autre. En santé, en finance, ou avec des données perso, c'est une fuite grave.
J'ai construit ce pattern sur mon propre produit, un outil de gestion locative avec un conseiller juridique IA. J'avais effleuré la RLS dans mon article sur la sécurité des agents LLM. Ici, c'est le traitement complet.
La RLS en une minute
La Row-Level Security, ou RLS, est une fonction de Postgres. Elle décide, ligne par ligne, ce qu'une requête a le droit de lire et d'écrire. Tu l'actives par table, avec une policy.
Une policy a deux parties. USING filtre les lignes lues. WITH CHECK bloque les écritures qui cassent la règle. Voici la table conversations avec son isolation par tenant :
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON conversations
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
current_setting('app.current_tenant_id') lit une variable de session. Ton code la remplit avec le tenant de l'utilisateur. Ensuite, Postgres compare chaque ligne à cette valeur. Une requête ne voit que ses lignes. Les autres n'existent pas pour elle.
FORCE ROW LEVEL SECURITY ajoute une garantie. Sans lui, le propriétaire de la table ignore la RLS. Avec lui, même le propriétaire obéit à la policy.
Rien de ce SQL n'est spécifique à Go. Les policies et la variable de session sont les mêmes que ton backend soit en Python, Node, Rails ou Java. Ce qui change d'un langage à l'autre, c'est la façon de remplir le tenant à chaque requête. Et c'est là que le pooling de connexions se complique. Je te montre cette partie en Go, avec pgx.
Deux rôles Postgres, pas un
La RLS ne sert à rien si ton application peut la désactiver. Alors je crée deux rôles Postgres. Un rôle, c'est un utilisateur de la base.
Le premier, app_role, sert à toutes les requêtes des utilisateurs. Il est soumis à la RLS.
Le second, system_role, a l'attribut BYPASSRLS. Il ignore la RLS. Je le garde pour les migrations, les tâches d'admin, et les rares requêtes qui doivent voir plusieurs tenants.
CREATE ROLE app_role WITH LOGIN PASSWORD '...';
CREATE ROLE system_role WITH LOGIN PASSWORD '...' BYPASSRLS;
La règle est simple. Le code qui répond aux utilisateurs passe toujours par app_role. system_role reste pour le travail interne, jamais pour une requête déclenchée par un utilisateur. Deux pools de connexions séparés, un par rôle.
Remplis le tenant à chaque connexion
La variable app.current_tenant_id doit être posée avant chaque requête utilisateur. La poser à la main dans chaque fonction, c'est reproduire le problème du if oublié.
Alors je le fais une seule fois, dans le pool de connexions. pgx est le driver Postgres pour Go. Son pool sait exécuter une fonction sur chaque connexion avant de te la prêter.
poolCfg.PrepareConn = func(ctx context.Context, conn *pgx.Conn) (bool, error) {
tenantID := middleware.GetTenantID(ctx)
if tenantID == "" {
return true, nil
}
_, err := conn.Exec(ctx,
"SELECT set_config('app.current_tenant_id', $1, false)", tenantID)
if err != nil {
return false, err
}
return true, nil
}
poolCfg.AfterRelease = func(conn *pgx.Conn) bool {
_, _ = conn.Exec(context.Background(), "RESET app.current_tenant_id")
return true
}
PrepareConn s'exécute quand le pool sort une connexion. Il lit le tenant depuis le contexte de la requête, puis remplit la variable. Dans les vieilles versions de pgx, le hook s'appelle BeforeAcquire.
AfterRelease s'exécute quand la connexion revient. Il fait RESET. Ce détail est vital. Une connexion est réutilisée par d'autres requêtes. Sans RESET, elle garde le tenant du dernier utilisateur. La requête suivante en hériterait. Ce serait une fuite.
Le tenant vient du contexte. Moi, je le mets là depuis le JWT de l'utilisateur. Un JWT est un jeton signé qui prouve qui est l'utilisateur. Un middleware vérifie ce jeton, lit le tenant, et le range dans le contexte.
Premier piège : la RLS échoue en silence
La RLS ne lève pas d'erreur quand elle bloque. Elle rend les lignes invisibles. C'est le comportement voulu, mais il surprend.
Un exemple. Le tenant B tente de modifier une ligne du tenant A. La RLS ne refuse pas. L'UPDATE touche zéro ligne. Aucune erreur. Ton code Go croit que la mise à jour a réussi.
Pareil pour un DELETE cross-tenant : zéro ligne, aucune erreur. Et lire la ligne d'un autre tenant renvoie « rien trouvé », pas « interdit ».
Deux conséquences pour ton code. Un, ne prends jamais l'absence d'erreur pour une réussite sur une écriture. Vérifie le nombre de lignes touchées quand l'opération doit changer quelque chose. Deux, traite « zéro ligne » comme un « not found » propre, pas comme un bug.
Second piège : oublier le tenant
Que se passe-t-il si la variable n'est jamais posée ? La policy ne peut pas s'évaluer. La requête échoue.
C'est un bon défaut. Pas de tenant, pas de données. La RLS échoue fermée, jamais ouverte. Une variable manquante bloque tout, au lieu de tout montrer.
Je double cette garantie côté application. Un middleware refuse toute requête sans tenant, avec un code 403. La base est la barrière finale. Le middleware est la première.
La récursion infinie, erreur 42P17
Voici le bug qui m'a coûté le plus de temps. Il arrive quand deux tables se protègent l'une l'autre.
Ma table conversations avait une policy qui regardait conversation_participants. Et conversation_participants avait une policy qui regardait conversations. Chaque policy déclenchait l'autre. Postgres bouclait, puis rendait l'erreur SQLSTATE 42P17 : récursion infinie dans une policy.
La solution tient en deux mots : SECURITY DEFINER. Je sors la logique d'accès dans une fonction. Cette fonction vérifie l'accès une seule fois, et s'exécute avec les droits de son créateur. Elle ignore donc la RLS à l'intérieur. Plus de boucle.
CREATE FUNCTION user_can_access_conversation(conv_id uuid, uid uuid)
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM conversations
WHERE id = conv_id AND owner_id = uid
) OR EXISTS (
SELECT 1 FROM conversation_participants
WHERE conversation_id = conv_id AND user_id = uid
);
$$ LANGUAGE sql SECURITY DEFINER STABLE;
CREATE POLICY conversations_access ON conversations
USING (user_can_access_conversation(id, current_setting('app.current_tenant_id')::uuid));
Une règle est née de ce bug. Une policy ne doit pas interroger une autre table dont la policy pointe, elle aussi, vers la première. Si tu as besoin de cette logique croisée, passe par une fonction SECURITY DEFINER.
Prouve l'isolation avec des tests
La RLS est une règle de sécurité. Une règle de sécurité non testée n'existe pas. Alors j'écris des tests d'intégration. Ils lancent un vrai Postgres dans un conteneur, avec les vraies policies.
Le test pose le tenant A, puis tente de lire les données du tenant B. Il doit recevoir zéro ligne. Il tente une écriture cross-tenant. Elle doit être bloquée.
// Le tenant A ne voit que ses propres lignes.
require.NoError(t, db.SetTenant(ctx, tenantA))
repo := NewConversationRepo(db.TenantPool)
got, err := repo.List(ctx)
require.NoError(t, err)
require.Len(t, got, 1) // seulement les lignes du tenant A
// Le tenant A ne peut pas lire une conversation du tenant B.
_, err = repo.Get(ctx, tenantBConversationID)
require.Error(t, err) // not found
// Une écriture avec le mauvais tenant est bloquée par WITH CHECK.
_, err = repo.Create(ctx, &Conversation{TenantID: tenantB})
require.Error(t, err)
Je teste les quatre opérations : lecture, création, mise à jour, suppression. Et je vérifie au niveau SQL brut, avec un count(*), que chaque tenant ne voit que ses lignes. Ces tests tournent à chaque commit, dans la CI.
La checklist avant la prod
Avant de mettre un SaaS multi-tenant en production sur Postgres, coche toutes ces cases.
- Chaque table multi-tenant a
ENABLEetFORCE ROW LEVEL SECURITY - Chaque policy a un
USINGpour la lecture et unWITH CHECKpour l'écriture - Deux rôles : un rôle applicatif sous RLS, un rôle système en
BYPASSRLS - Le code qui répond aux utilisateurs passe toujours par le rôle applicatif
- Le tenant est posé dans le hook du pool, et remis à zéro au retour de la connexion
- Le tenant vient du JWT, jamais d'un paramètre de la requête
- Un middleware refuse toute requête sans tenant, avec un 403
- Ton code traite « zéro ligne » comme un « not found », pas comme un succès d'écriture
- Aucune policy n'interroge une autre table dont la policy pointe vers elle
- La logique d'accès croisée passe par une fonction
SECURITY DEFINER - Des tests d'intégration prouvent l'isolation en lecture et en écriture
Ce qu'il faut retenir
Le filtrage par tenant dans le code Go marche, jusqu'au jour où un if manque. La RLS déplace la barrière dans la base. Là, aucun bug de code ne peut la contourner.
Le coût, c'est deux rôles, un hook de pool, et quelques pièges à connaître : l'échec silencieux, la variable oubliée, la récursion 42P17. Une fois ces pièges passés, tu as une isolation qui tient toute seule.
Tu construis un SaaS multi-tenant en Go et tu veux un second regard sur ton isolation ? C'est exactement ce que je fais. Écris-moi.