RLS multi-tenant : l'isolation par la base
Le problème et le parti pris
Tous les clients (tenants) partagent les mêmes tables. L'approche naïve — un WHERE tenant_id = :tid dans chaque requête — a un défaut fatal : un seul oubli = fuite de données entre clients. Le parti pris du projet (ADR-0002) : l'isolation descend dans PostgreSQL avec Row-Level Security — la base refuse structurellement de montrer les lignes d'un autre tenant, même si le code oublie.
ALTER TABLE variant ENABLE ROW LEVEL SECURITY;
ALTER TABLE variant FORCE ROW LEVEL SECURITY; -- même le propriétaire est filtré
CREATE POLICY tenant_isolation ON variant
USING (tenant_id = current_setting('app.tenant_id', true)::uuid) -- lecture
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid); -- écriture
L'app se connecte avec un rôle app_user NOBYPASSRLS. Chaque requête ne voit que les lignes dont le tenant_id égale la variable de session app.tenant_id.
Le pattern clé : transaction-scoped, pas session-scoped
Comment poser app.tenant_id ? Pas avec un SET de session : en async, la connexion retourne au pool à chaque commit — une variable de session fuirait vers la requête (et le tenant !) suivant. La solution :
@event.listens_for(Session, "after_begin") # à CHAQUE début de transaction
def set_tenant(session, transaction, connection):
tid = session.info["tenant_id"] # posé par session_with_tenant()
connection.execute(text(
"SELECT set_config('app.tenant_id', :tid, true)" # true = SET LOCAL
), {"tid": tid})
set_config(..., true) = SET LOCAL : la variable meurt avec la transaction. Peu importe quelle connexion du pool exécute la transaction suivante, le listener ré-applique le bon tenant. Compatible PgBouncer en mode transaction. En ceinture-bretelles : RESET ALL au retour au pool.
La règle de code la plus contre-intuitive du projet
Interdiction d'écrire
WHERE Model.tenant_id == tenant_iddans les requêtes.
Redondant avec la policy — et surtout dangereux : ce filtre manuel masquerait une régression RLS (si la policy saute, les tests passent quand même grâce au WHERE… jusqu'à la requête qui ne l'a pas). Un hook pre-commit et un test d'isolation dédié le traquent. Seule exception : Model(tenant_id=...) à la création d'une ligne. Défense en profondeur jusque dans les vues : toute vue SQL doit être security_invoker = true, sinon elle s'exécuterait avec les droits de son créateur… en contournant la RLS.
Migrer un substrat de fail-open à fail-closed (sans coupure)
Nuance de production souvent ignorée : d'où vient la garantie ? Si le rôle de login de l'app est un superuser (BYPASSRLS), la RLS ne s'applique pas à lui — l'isolation ne tient QUE par le SET LOCAL role=app_user posé à chaque transaction. C'est un substrat fail-open : un chemin qui oublie de poser le rôle tourne en BYPASSRLS → fuite cross-tenant totale. La cible durcie : un rôle de login NOBYPASSRLS → même sans SET LOCAL, rien ne fuit (fail-closed).
Basculer ce substrat en prod, sans coupure, est une manœuvre en ordre précis :
- Recenser les traversées légitimes : certains acteurs système énumèrent tous les tenants (poll, reconciler). Ces requêtes cross-tenant deviennent des fonctions
SECURITY DEFINERqui ne renvoient que des IDs (surface minimale), au lieu de compter sur le BYPASSRLS. - Exclure ces
SECURITY DEFINERde la réassignation d'ownership : quand tu renommes/réassignes le rôle propriétaire, ces fonctions doivent garder leur owner privilégié (sinon elles perdent leur pouvoir voulu). Piège classique duREASSIGN OWNED. - Déployer le CODE avant de flipper le login : le code qui pose correctement
role=app_userpartout part en prod d'abord ; le changement du rôle de login (→ NOBYPASSRLS) vient après, quand plus aucun chemin ne dépend du bypass. - Rouler dev → preprod → prod : chaque étage prouve l'absence de régression (les tests d'isolation tournent, l'ingestion n'est pas bloquée) avant le suivant.
Le fail-open→fail-closed ne se fait pas d'un
ALTER ROLEsec : c'est code d'abord, switch ensuite, avec les traversées légitimes explicitées (SECURITY DEFINER, IDs only) plutôt que subies (BYPASSRLS global).
Pourquoi SET LOCAL (transaction) plutôt qu'une variable de session ?