learn.chetana.fr

RLS multi-tenant : l'isolation par la base

16 min readAdvanced

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_id dans 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 :

  1. 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 DEFINER qui ne renvoient que des IDs (surface minimale), au lieu de compter sur le BYPASSRLS.
  2. Exclure ces SECURITY DEFINER de 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 du REASSIGN OWNED.
  3. Déployer le CODE avant de flipper le login : le code qui pose correctement role=app_user partout 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.
  4. 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 ROLE sec : 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).

🧩 Quiz1/5

Pourquoi SET LOCAL (transaction) plutôt qu'une variable de session ?

🃏 Flashcards1/6