Retour aux textes

Les permissions en cache vieillissent de trois façons

Version de l'utilisateur, version du catalogue et l'horloge. Et aucune ne peut dépendre d'un cron.

Garder les permissions effectives dans la session est une optimisation raisonnable. Recalculer les rôles, les grants et les partitions à chaque requête coûte cher, et presque toujours le résultat est le même. Le problème, c'est le « presque ». Une session matérialisée est un cache, et un cache sans stratégie d'invalidation, c'est un privilège collant qui attend son heure.

Le payload contient généralement quelque chose comme ça :

user_id
roles
permissions
org_units
permission_version
authz_catalog_version
permissions_valid_until

Les trois derniers champs existent parce que la session peut devenir obsolète pour trois raisons différentes, et chacune a besoin de son propre mécanisme.

1. L'utilisateur a changé

Il a gagné ou perdu un rôle, changé d'unité, reçu ou perdu un grant direct. Un compteur par utilisateur suffit : chaque mutation d'autorisation incrémente permission_version dans la même transaction que le changement. La session garde la version avec laquelle elle a été résolue, et l'écart entre les deux trahit le cache périmé.

2. Le catalogue a changé

C'est le cas qui passe souvent inaperçu. Quelqu'un ajoute une permission à un rôle, ou en retire une, ou change la sémantique d'un rôle joker. Aucun utilisateur n'a subi de mutation directe, donc aucun permission_version n'a changé, et toutes les sessions ouvertes gardent l'ancien ensemble. Un authz_catalog_version global, incrémenté à chaque changement de catalogue, bouche exactement ce trou.

3. L'horloge a tourné

Un grant temporaire expire à 22:00. L'horloge qui passe de 21:59 à 22:00 ne modifie aucune ligne dans la base. Si la session n'est invalidée que quand une version change, le privilège expiré reste actif jusqu'à ce que quelqu'un touche à l'utilisateur.

La tentation, c'est de régler ça avec un job qui tourne toutes les cinq minutes et révoque ce qui a expiré. Ça marche jusqu'à ce que le job prenne du retard, échoue ou soit coupé pendant une maintenance, et là la fenêtre de privilège expiré devient l'intervalle entre deux exécutions, voire infinie. La session doit porter le prochain instant où l'ensemble matérialisé cesse d'être fiable :

permissions_valid_until = min(expires_at des grants directs actifs)

L'expiration doit s'appliquer même si aucun job ne tourne.

Le prédicat de re-résolution

Avec les trois champs, la décision de recalculer est une comparaison peu coûteuse, faite à chaque requête :

needs_reresolve(session, db) :=
      session.permission_version    != db.user_permission_version
   OR session.authz_catalog_version != db.catalog_version
   OR now() >= session.permissions_valid_until

Quand c'est vrai, le serveur recalcule les rôles, les permissions, les partitions et la prochaine expiration, met à jour la session et poursuit la requête. Personne n'a besoin d'être déconnecté juste parce que l'autorisation a changé. Les deux versions tiennent dans un lookup indexé, ou dans un cache invalidé par événement, et l'horloge est gratuite.

Invalider l'autorisation n'est pas tuer la session

Ce sont des problèmes proches avec des solutions différentes. Désactiver un utilisateur, réinitialiser un identifiant ou répondre à un incident exige de faire tomber les sessions actives, pas seulement de recalculer ce qu'elles peuvent faire. C'est pour ça qu'un index des sessions par utilisateur, dans Redis ou équivalent, reste utile. Le versionnage répond à « que peut faire cette session maintenant » ; le session kill répond à « cette session ne devrait pas exister ».

Le bootstrap du bypass

Une permission qui traverse le RLS crée un problème de l'œuf et de la poule. Pour savoir si la connexion peut recevoir un bypass de tenant, il faut lire les permissions de l'utilisateur avant d'ouvrir le scope de tenant. Donc le minimum nécessaire pour résoudre cette autorité est lu dans un scope système étroit et contrôlé, et seulement ensuite l'application décide si la connexion reçoit un contexte normal ou un bypass explicite.

Contexte de connexion et pool

Quand le RLS lit le tenant dans une variable de session de Postgres, le cycle de vie de cette variable fait partie de la sécurité. Avec le connection pooling, un SET ordinaire survit au retour de la connexion dans le pool, et la requête suivante, d'un autre tenant, hérite du contexte précédent. Utilisez une configuration locale à la transaction :

SET LOCAL app.tenant_id = '...';
SET LOCAL app.is_platform_bypass = 'false';

-- ou, depuis une fonction :
SELECT set_config('app.tenant_id', '...', true);

Et testez de la façon qui casse : un test d'intégration qui réutilise exprès la même connexion entre deux tenants et prouve que le tenant et le bypass ont été réinitialisés. C'est le genre de bug qu'aucun test unitaire n'attrape et qui n'apparaît en production que sous charge.