La Row Level Security (RLS) de PostgreSQL permet de partager une seule base entre tous les clients d’un SaaS B2B tout en garantissant qu’une requête ne lit et n’écrit que les lignes de son tenant, même quand le code applicatif oublie un WHERE tenant_id = …. Elle ne protège réellement qu’à quatre conditions : RLS activée et forcée sur chaque table, un rôle applicatif qui n’est ni propriétaire des tables ni BYPASSRLS, un contexte tenant posé par transaction, et un test qui fait échouer la CI dès qu’une table y échappe.
Pourquoi isoler les tenants dans la base plutôt que dans le code ?
Parce qu’un filtre applicatif repose sur la discipline de chaque requête, alors qu’une policy RLS s’applique à toutes. Dans un modèle « base partagée, schéma partagé », chaque table métier porte une colonne tenant_id ; une seule requête sans WHERE, ou une jointure mal bornée, suffit à exposer un autre client. Avec RLS, PostgreSQL ajoute lui-même la condition d’isolation et vérifie chaque écriture : l’oubli devient une requête vide plutôt qu’une fuite.
Comment écrire une policy RLS pour un tenant ?
Une policy compare la colonne tenant_id de la ligne à une variable de session lue avec current_setting. Voici le modèle appliqué à la table users dans la première migration de saas-core :
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_users ON users
FOR ALL
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
USING filtre les lignes visibles ; WITH CHECK refuse d’écrire une ligne dont le tenant_id n’est pas celui du contexte. Un INSERT vers un autre tenant échoue, et un UPDATE qui vise l’autre tenant ne touche aucune ligne.
Pourquoi current_setting(..., true) et NULLIF ?
Le second argument true de current_setting rend NULL au lieu de lever une erreur quand la variable n’existe pas. NULLIF(…, '') traite de la même façon une variable connue de la session mais vide. Dans les deux cas, la comparaison tenant_id = NULL n’est jamais vraie : sans contexte tenant, la requête ne voit aucune ligne, sans erreur. C’est un refus par défaut, pas une ouverture par défaut.
Pourquoi ENABLE ne suffit-il pas sans FORCE ?
Parce que le propriétaire d’une table échappe normalement à ses propres policies, sauf si la table est marquée FORCE ROW LEVEL SECURITY. Sans FORCE, un service connecté avec le rôle propriétaire, par commodité ou par erreur de configuration, fait disparaître l’isolation en silence. FORCE ne change rien en revanche pour les superutilisateurs et les rôles BYPASSRLS, qui contournent toujours RLS. D’où la règle suivante.
Pourquoi le rôle applicatif ne doit-il ni posséder les tables ni avoir BYPASSRLS ?
Le rôle avec lequel l’application se connecte doit être soumis aux policies, sans aucun moyen de s’en extraire. Trois propriétés sont nécessaires :
| Propriété du rôle applicatif | Ce qu’elle empêche |
|---|---|
NOBYPASSRLS, non superutilisateur | contourner toutes les policies d’un coup |
| non propriétaire des tables | désactiver RLS ou supprimer une policy par ALTER TABLE ou DROP POLICY, réservés au propriétaire |
droits accordés table par table (GRANT) | écrire là où rien n’a été prévu |
Les migrations tournent sous un rôle propriétaire distinct. Un traitement qui doit légitimement traverser les tenants, comme un relais d’événements, reçoit son propre rôle et des policies nommées, bornées par une clause TO, plutôt qu’un BYPASSRLS.
Comment poser le tenant sans fuite avec un pool de connexions ?
En posant la variable par transaction, jamais par connexion. Un pool réutilise les connexions d’une requête HTTP à l’autre : un SET app.tenant_id = … au niveau session survivrait à la requête et serait hérité par la suivante, qui lirait alors les données du tenant précédent.
set_config('app.tenant_id', $1, true) est l’équivalent fonctionnel de SET LOCAL : avec le troisième argument à true, le réglage meurt avec la transaction, au COMMIT comme au ROLLBACK. L’appel de fonction a un avantage pratique sur SET LOCAL : il accepte un paramètre lié, alors que la commande SET ne prend pas de paramètre de requête. Le helper de saas-core ouvre la transaction et pose le contexte en premier :
export async function runInTenantTx<T>(
db: Db,
tenantId: string,
fn: (tx: Db) => Promise<T>,
): Promise<T> {
return db.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`);
return fn(tx as unknown as Db);
});
}
Hors transaction explicite, un set_config(…, true) isolé s’efface aussitôt : le contexte et les requêtes doivent vivre dans la même transaction.
Comment empêcher qu’une table échappe à RLS ?
Avec un méta-test qui interroge le catalogue au lieu de tester un cas. Dans saas-core, apps/api/test/rls.spec.ts liste toutes les tables du schéma public qui ont une colonne tenant_id et signale celles qui n’ont pas RLS activée, pas FORCE, ou aucune policy :
SELECT c.relname AS table_name,
CASE
WHEN NOT c.relrowsecurity THEN 'RLS not enabled'
WHEN NOT c.relforcerowsecurity THEN 'FORCE ROW LEVEL SECURITY missing'
ELSE 'no policy'
END AS problem
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind = 'r'
AND EXISTS (
SELECT 1 FROM information_schema.columns col
WHERE col.table_schema = 'public'
AND col.table_name = c.relname
AND col.column_name = 'tenant_id'
)
AND (
NOT c.relrowsecurity
OR NOT c.relforcerowsecurity
OR NOT EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)
)
Le test attend zéro ligne : une table métier ajoutée sans policy fait rougir la suite, par construction. Le même fichier vérifie que saas_app n’a ni BYPASSRLS ni le statut de superutilisateur, et ne possède aucune table.
Ce méta-test a une limite, documentée dans le dépôt : il vérifie qu’une policy existe, pas ce qu’elle dit. Une policy USING (true) sans clause TO coche toutes les cases et n’isole rien. Un second test, apps/api/test/rls-policies-bornees.spec.ts, refuse donc toute policy permissive qui atteint le rôle applicatif sans être bornée à un rôle inter-tenants assumé. Il est né de deux policies de ce type, corrigées par la migration 0039_policies_bornees.sql.
Quel piège avec INSERT … ON CONFLICT sous RLS ?
Une clause ON CONFLICT fait intervenir les policies de lecture, en plus de celles d’insertion : pour traiter un conflit, PostgreSQL examine la ligne existante. La documentation de CREATE POLICY détaille quelles policies s’appliquent à chaque forme de commande.
Le cas s’est présenté dans saas-core au provisioning d’un tenant : la ligne tenants est insérée hors contexte grâce à une policy d’insertion dédiée, alors que la policy de lecture ne montre que le tenant courant. Avec un arbitre de conflit, la policy SELECT s’appliquerait aux lignes existantes, que saas_app ne voit pas hors contexte. L’idempotence passe donc par un INSERT simple, l’erreur 23505 (violation d’unicité) signifiant « déjà provisionné ».
Comment saas-core l’intègre
- Les migrations SQL (
apps/api/migrations/*.sql) sont la source de vérité du DDL, policies comprises.0000_init.sqlpose le modèleENABLE+FORCE+ policyUSING/WITH CHECKdécrit plus haut. - Le rôle applicatif
saas_appest crééNOBYPASSRLSet non propriétaire (docker/initdb/01-roles.sql) ; seul le migrateur utilise la connexion propriétaire. - Le contexte tenant est posé par
runInTenantTxdansapps/api/src/db/client.ts, avecset_config(..., true). apps/api/test/rls.spec.tscontient le méta-test et des vérifications réelles sous le rôle applicatif : un tenant ne lit que ses lignes, aucune ligne sans contexte, unINSERTvers un autre tenant refusé parWITH CHECK, unUPDATEciblant l’autre tenant sans effet.apps/api/test/rls-policies-bornees.spec.tscomplète le méta-test sur le contenu des policies.- Le worker lit l’outbox de tous les tenants avec un rôle dédié,
saas_relay, au travers de policies nommées et bornéesTO saas_relay(0003_audit_outbox.sql), sansBYPASSRLS. - Le provisioning d’un tenant tolère
23505au lieu d’utiliserON CONFLICT(apps/api/src/auth/auth.ts).
Ces tests tournent dans le workflow ci sur un vrai PostgreSQL. Un ci rouge bloque la livraison d’une version, pas la fusion sur main, dont la protection de branche est aujourd’hui absente. La manière de tester ces invariants est détaillée dans l’article sur Testcontainers et les tests sur un vrai PostgreSQL, et le même modèle de droits sert au journal d’audit en ajout seul.
Pour aller plus loin
L’isolation par RLS, le rôle applicatif, le méta-test et leurs tests sous le vrai rôle PostgreSQL font partie du socle livré avec le kit. Le détail de ce que comprend chaque formule est présenté sur la page tarifs.