Visibilité par utilisateur : étendre la RLS
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
| Helper | Pose | Sé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ôle | contexte 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.
Pourquoi ne pas filtrer la visibilité par utilisateur avec un WHERE user_id dans les services ?