Isoler les données de plusieurs entreprises dans une seule base
Row Level Security dans PostgreSQL : le piège du rôle superutilisateur, et pourquoi le filtre applicatif ne suffit pas.
Un ERP en SaaS pose une question avant toutes les autres : comment garantir que l'entreprise A ne verra jamais une ligne de l'entreprise B.
Trois réponses existent. Une base par client, un schéma par client, ou une base unique avec une colonne tenant_id. La troisième est la moins chère à opérer et la plus dangereuse à écrire.
Le filtre applicatif ne suffit pas
La tentation est d'ajouter WHERE tenant_id = :current partout. Ça fonctionne — jusqu'au premier oubli.
Et il y aura un oubli. Pas dans la requête principale, écrite avec attention : dans le script d'export lancé un vendredi soir, dans la jointure ajoutée six mois plus tard, dans la requête de statistiques d'un développeur qui vient d'arriver.
Un seul oubli suffit. Le coût n'est pas un bug, c'est une fuite de données entre clients concurrents.
Row Level Security déplace la garantie
PostgreSQL sait appliquer le filtre lui-même, au niveau de la table :
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);À partir de là, toute requête sur invoices est filtrée, quelle que soit la manière dont elle est écrite. Un développeur qui oublie la clause obtient zéro ligne, pas les lignes du voisin.
L'application n'a plus qu'à positionner la variable au début de chaque transaction :
SET LOCAL app.tenant_id = '…';SET LOCAL et non SET : la valeur meurt avec la transaction. Dans un pool de connexions, une variable qui survit à la transaction est exactement le bug qu'on cherchait à éviter.
Le piège qui annule tout
RLS ne s'applique pas au propriétaire de la table ni à un superutilisateur. Par défaut, l'application se connecte souvent avec le rôle qui a créé le schéma — donc avec le rôle qui contourne silencieusement toutes les politiques.
Le test passe. L'isolation ne fonctionne pas.
Il faut deux rôles distincts :
-- Rôle de migration : propriétaire, contourne RLS.
CREATE ROLE erp_owner LOGIN PASSWORD '…';
-- Rôle applicatif : soumis aux politiques.
CREATE ROLE erp_app LOGIN PASSWORD '…';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO erp_app;L'application tourne avec erp_app. Les migrations tournent avec erp_owner. Ce n'est pas une précaution : c'est ce qui fait que RLS existe réellement.
Et pour être certain de ne pas se tromper, on force la politique même pour le propriétaire :
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;Le test qui vaut la peine d'être écrit
Un seul test automatisé suffit à garantir toute la mécanique :
def test_tenant_isolation(db):
with db.tenant("entreprise-a"):
db.execute("INSERT INTO invoices (…) VALUES (…)")
with db.tenant("entreprise-b"):
rows = db.execute("SELECT * FROM invoices").fetchall()
assert rows == []S'il passe avec le bon rôle, l'isolation tient. S'il passe avec le rôle propriétaire, il ne prouve rien — d'où l'importance de le faire tourner avec exactement la connexion de production.
Ce que ça coûte
RLS ajoute un prédicat à chaque requête. Sur des tables correctement indexées — index composite commençant par tenant_id — le surcoût est de l'ordre de quelques pour cent.
C'est le prix d'une garantie que le code applicatif ne peut pas offrir : celle qui tient même le jour où quelqu'un oublie.
À lire ensuite
Un système de paiement commence par un ledger, pas par une API
Pourquoi la comptabilité à double entrée n'est pas un détail d'implémentation quand on fait circuler l'argent des autres.
Une seule dépendance dure : une chaîne de connexion PostgreSQL
Comment on a conçu une plateforme ambitieuse pour qu'elle démarre sur un serveur modeste, sans Docker, sans Redis, sans file d'attente.
FastAPI en production : ce qui compte vraiment
Cinq réglages qu'on ne voit dans aucun tutoriel et qui font la différence entre une API qui tourne et une API qu'on peut exploiter.