learn.chetana.fr

Visibilité par utilisateur : étendre la RLS

15 min de lectureAvancé

Le nouveau besoin

La RLS multi-tenant garantit qu'un client ne voit jamais les données d'un autre. Nouveau cran : à l'intérieur d'un tenant, un utilisateur « standard » ne doit voir que les dossiers de ses comptes clients ; un admin voit tout. Même exigence sur les 5 surfaces : l'inbox, les listes, le détail, le chat copilot et les exports.

Le réflexe naïf est le même piège que pour le tenant : un WHERE user_id = … dans chaque service. Un oubli sur une des 5 surfaces = exactement la fuite qu'on veut interdire. On refuse donc de filtrer dans le code, comme pour le tenant.

La bonne réponse : un second GUC

On étend la RLS avec deux variables de session de plus, posées dans le même listener after_begin que app.tenant_id :

  • app.user_id — l'identité de l'utilisateur de la requête ;
  • app.is_admin'on' s'il voit tout le tenant.

La table qui relie un utilisateur à ses comptes (affectation N-N) est jointe dans la policy, jamais dans le code applicatif :

CREATE POLICY visibilite ON account
USING (
  tenant_id = current_setting('app.tenant_id', true)::uuid   -- le cran tenant, d'abord
  AND (
       current_setting('app.is_admin', true) = 'on'          -- admin/système : tout
    OR NOT EXISTS (SELECT 1 FROM account_user_assignment a    -- compte non affecté → tous
                   WHERE a.account_id = account.id)
    OR EXISTS     (SELECT 1 FROM account_user_assignment a    -- affecté à moi
                   WHERE a.account_id = account.id
                     AND a.user_sub  = current_setting('app.user_id', true))
  )
);

Lis la clause comme la règle métier, mot pour mot : admin voit tout ; un compte sans affectation est visible par tous ; sinon, seulement ses affectataires.

Deux niveaux de session — le cœur du design

HelperPoseSémantique
session_with_tenant(tenant) (existant)app.tenant_id + app.is_admin='on'contexte privilégié / système : voit tout le tenant
session_with_operator(tenant, user, is_admin) (nouveau)app.tenant_id + app.user_id + app.is_admin selon le rôlecontexte HTTP porté par un utilisateur

Le trait de génie est le défaut : l'ancien helper pose is_admin='on'. Donc tout le code déjà écrit (le polling, les jobs, les imports, les dizaines d'appelants) continue de « voir tout » sans une ligne modifiée. Seules les surfaces utilisateur migrent vers session_with_operator. La rétro-compatibilité n'est pas promise par de la vigilance : elle est structurelle.

Fail-open ici, et pourquoi on l'assume

Subtilité de sécurité à connaître : ce cran-ci est fail-open, à l'inverse de l'isolation tenant (fail-closed). Si un chemin HTTP oublie de passer en session_with_operator et retombe sur le helper privilégié, l'utilisateur voit… tout (is_admin='on'). Le compromis inverse d'un fail-closed.

C'est un choix conscient : le fail-open préserve la rétro-compat (rien ne casse au déploiement), mais il déplace la charge de preuve sur les tests. La parade n'est pas « faire attention » — c'est un test d'isolation par utilisateur (un standard ne voit ni via liste, ni via chat, ni via export le portefeuille d'un autre) au même rang que le test d'isolation tenant, et un audit des points d'ouverture de session sur les 5 surfaces.

🧩 Quiz1/4

Pourquoi ne pas filtrer la visibilité par utilisateur avec un WHERE user_id dans les services ?

🃏 Flashcards1/5