implémenter l'isolation des données multitenant avec PostgreSQL dans un SaaS : guide technique
24/08/2026
implémenter l'isolation des données multitenant avec PostgreSQL dans un SaaS : guide technique
Public : CTO, lead dev, ingénieur backend d’un SaaS/ERP/CRM multitenant. Objectif : connaître les architectures d'isolation, choisir la bonne stratégie et implémenter une solution sécurisée et performante avec PostgreSQL (exemples, commandes, pièges courants).
Pourquoi l’isolation des données est critique pour un SaaS
Une mauvaise isolation conduit à des fuites de données entre clients, à des erreurs métier et à des risques légaux. Techniquement, il faut garantir qu’aucune requête d’un tenant A ne lise ou modifie les lignes du tenant B, tout en gardant latence, coût et complexité sous contrôle.
options d’architecture (comparatif rapide)
- Base par tenant (database-per-tenant) : isolation maximale, sauvegardes/restaurations simples par client, mais coûts et gestion des connexions montent vite. Bon pour très peu de gros clients.
- Schéma par tenant (schema-per-tenant) : isolation logique, moins coûteuse qu’une DB par client, migrations plus lourdes (nombre de schémas élevé).
- Schéma partagé + colonne tenant_id : simple à développer et scale horizontalement ; nécessite discipline pour filtrer par tenant.
- Schéma partagé + Row-Level Security (RLS) : met la règle d’isolation directement dans le SGBD — très pratique pour éviter les oublis côté application. C’est souvent le bon compromis pour un SaaS multi-tenant.
Choix recommandé pour la plupart des SaaS : Shared schema + RLS
RLS (Row-Level Security) permet d’exprimer les politiques d’accès au niveau de la table, ce qui réduit le risque d’erreur côté application. La documentation officielle PostgreSQL explique le mécanisme et la nécessité d’activer RLS sur les tables concernées. ([postgresql.org](https://www.postgresql.org/docs/17/ddl-rowsecurity.html?utm_source=openai))
Exemple concret : modèle de données et politique RLS
Schéma simplifié : une table tenants, une table invoices (partagée).
-- table tenants
CREATE TABLE tenants (
id uuid PRIMARY KEY,
name text NOT NULL
);
-- table partagée (exemple)
CREATE TABLE invoices (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
amount numeric,
created_at timestamptz DEFAULT now()
);
Activer RLS et créer une policy qui n’autorise l’accès que si tenant_id == valeur en session :
-- activer RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- policy pour SELECT/UPDATE/DELETE (USING) et pour INSERT (WITH CHECK)
CREATE POLICY invoices_tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::uuid);
Notes :
- current_setting('app.current_tenant', true) lit un paramètre de session (retourne NULL si absent) ; pour la doc sur ces fonctions, voir la doc fonctions-admin de PostgreSQL. ([postgrespro.com](https://postgrespro.com/docs/postgresql/current/functions-admin.html?utm_source=openai))
- WITH CHECK garantit qu’un INSERT/UPDATE ne peut assigner une ligne à un tenant différent.
- Si RLS est activé sans policy applicable, l’accès est refusé — attention lors des migrations. ([postgresql.org](https://www.postgresql.org/docs/17/sql-createpolicy.html?utm_source=openai))
Comment fournir la valeur app.current_tenant depuis l’application
Deux approches courantes :
- Définir une variable de session dans la transaction avant d’exécuter les requêtes (idéal si vous avez un pool de connexions en mode session ou si vous gérez la transaction vous-même) :
-- SQL direct
SET LOCAL app.current_tenant = '8a3f...-uuid';
-- ou depuis l'application (Node.js + pg) :
await client.query('BEGIN');
await client.query("SET LOCAL app.current_tenant = $1", [tenantId]);
/* exécuter SELECT/INSERT/UPDATE ... */
await client.query('COMMIT');
- Si vous ne pouvez pas compter sur des variables de session (ex. pooled transaction mode), envoyez le tenant_id explicitement dans chaque requête ou utilisez des fonctions stockées prenant tenant_id en paramètre.
Important : si vous utilisez un pooler comme PgBouncer en transaction pooling, les variables de session ne survivent pas entre transactions — cela casse la méthode basée sur SET. En pratique, soit configurez PgBouncer en session pooling pour votre application, soit réappliquez la variable à chaque transaction, soit utilisez des requêtes paramétrées. Voir la doc et recommandations sur PgBouncer pool modes. ([pgbouncer.org](https://www.pgbouncer.org/features.html?utm_source=openai))
Exemple Node.js (pg) — pattern transactionnel sûr
const { Pool } = require('pg');
const pool = new Pool(/* config */);
async function withTenant(tenantId, work) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL app.current_tenant = $1", [tenantId]);
const result = await work(client); // ex : SELECT FROM invoices
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
Performance et bonnes pratiques
- Indexer tenant_id (et colonnes souvent filtrées) : indispensable pour éviter les scans globaux.
- Sur tables massives, envisager la partition par tenant ou par plage temporelle ; la partition peut améliorer le pruning et les opérations de purge. La doc PostgreSQL sur la partition explique quand c’est utile. ([postgresql.org](https://www.postgresql.org/docs/current/ddl-partitioning.html?utm_source=openai))
- Testez les policies RLS avec des plans d’exécution : RLS est évalué pendant le plan d’exécution, ce qui peut introduire overhead si la policy appelle des fonctions lourdes. Évitez les fonctions coûteuses dans les expressions de policy. ([postgresql.org](https://www.postgresql.org/docs/17/ddl-rowsecurity.html?utm_source=openai))
- Surveillez les requêtes lentes et mettez en place des règles d’archivage pour limiter la taille des tables (et donc le coût des scans/partitions).
Pièges fréquents et comment les éviter
- Oublier d’activer RLS : ALTER TABLE ... ENABLE ROW LEVEL SECURITY. Sans activation, les policies ne s’appliquent pas. ([postgresql.org](https://www.postgresql.org/docs/17/sql-createpolicy.html?utm_source=openai))
- Policy incomplète : ne créer que USING sans WITH CHECK permettra certains INSERT malveillants. Toujours définir les deux quand pertinent.
- Connection pooler en transaction mode : casse les variables de session — voir section précédente. ([pgbouncer.org](https://www.pgbouncer.org/features.html?utm_source=openai))
- Test insuffisant : écrire des tests d’intégration (tenant A ne voit jamais tenant B) et des tests de montée en charge.
Opérations d’exploitation : sauvegarde, purge, migrations
- Sauvegardes/restauration : avec shared schema, restaurer pour un seul tenant nécessite scripts d’extraction/filtrage.
- Purge : utiliser partitionnement pour supprimer ancien data en DROP PARTITION (beaucoup plus rapide que DELETE massifs). ([postgresql.org](https://www.postgresql.org/docs/current/ddl-partitioning.html?utm_source=openai))
- Migrations : prévoir scripts idempotents et migration par lot si vous avez schema-per-tenant (ou hundreds of tenants).
Quand préférer schema-per-tenant ou database-per-tenant
Choisir database- or schema-per-tenant si vos clients exigent iso complète (SLA, conformité), si le nombre de tenants est limité, ou si les tailles de données sont très disparates. Pour la majorité des SaaS ciblant many tenants, shared schema + RLS offre le meilleur compromis.
Ressources utiles
- Doc technique PostgreSQL (Novane) — pour intégrations et accompagnement.
- Services SaaS (Novane) — architecture et migration SaaS.
- Services ERP/CRM (Novane) — cas métier multi-tenant.
Pour aller plus loin : implémentez d’abord un POC (1 table stratégique + RLS + tests). Mesurez latence avant/après, testez avec votre pooler et simulez tenants concurrents. Si vous voulez, nous pouvons vous aider à auditer la solution et prototyper la stratégie la plus adaptée à votre contexte.
Besoin d’un audit rapide de votre architecture multitenant ? contactez-nous.

