Dans mon schéma de profils, il y avait une politique comme celle-ci, écrite avec le helper de Drizzle pour Neon :
crudPolicy({
role: authenticatedRole,
read: authUid(table.userId),
modify: false,
})
Trois lignes. Elles disent qu'un utilisateur authentifié ne voit que ses lignes et ne peut pas les modifier. Difficile de discuter ça. Un an et demi plus tard, j'ai supprimé ces trois lignes, et celles de la table de feedback, dans le même commit. Des mois après, j'ai répété la suppression dans un autre projet qui avait hérité du même schéma.
Ce n'était pas un caprice. C'est que la politique et le code disaient la même chose, et quand deux endroits disent la même chose, l'un des deux finit par mentir.
La promesse de RLS et pourquoi je l'ai achetée
La promesse est bonne : la permission vit dans la base de données, donc peu importe qui interroge. Si demain j'écris un script de maintenance, ou que j'ouvre un nouvel endpoint, ou que quelqu'un oublie un where, la ligne ne sort pas. C'est de la vraie défense en profondeur, pas un ornement.
J'ai tout monté autour de ça. Deux connexions :
export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
const authToken = (await user?.getAuthJson())?.accessToken;
const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
const sql = neon(url, { authToken });
return drizzle(sql);
};
Une connexion « authentifiée » qui porte le JWT de l'utilisateur et respecte les politiques, et une autre d'admin avec la DATABASE_URL normale qui les contourne. Je suis même allé jusqu'à mettre en cache une map de connexions RLS par identifiant d'utilisateur, jusqu'à ce que je réalise que je stockais des connexions liées à un token qui expire, et je me suis rabattu sur la création d'une nouvelle à chaque fois.
Là où ça a commencé à grincer avec Drizzle et les server actions
Le problème n'est pas que RLS échoue. C'est que dans une application avec Next et des server actions, ce n'est presque jamais la couche qui décide.
Chaque action serveur commence déjà par résoudre la session. Vous avez déjà l'utilisateur avant de toucher à la base de données. Et là vous écrivez ceci :
const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));
Ce where est redondant avec la politique. Mais vous ne pouvez pas le retirer, parce que si vous le retirez le code devient mensonger : on dirait qu'il ramène tout le feedback, alors qu'il dépend d'une politique invisible située dans un autre fichier. Vous finissez donc avec le filtre dans le code et dans la base, et le doute sur celui des deux qui fait effectivement le travail.
Ensuite vient la friction au quotidien :
- Deux connexions. Chaque nouvelle fonction commence par la question de savoir si elle passe par RLS ou par l'admin. Et tout ce qui touche à l'administration, aux webhooks de paiement, aux crons ou aux migrations passe par l'admin. C'est-à-dire par le chemin qui ne vérifie rien.
- Les erreurs n'expliquent rien. Quand la politique vous bloque, elle ne vous dit pas « il vous manque le claim » ; elle vous renvoie zéro ligne. Déboguer un
selectqui renvoie un tableau vide alors que la ligne existe coûte plus cher qu'il n'y paraît. - Le token voyage là où vous ne voudriez pas. L'URL authentifiée finit dans une variable préfixée
NEXT_PUBLIC_, et cela oblige déjà à réfléchir à deux fois à ce que vous exposez. - Les politiques vivent dans le snapshot. Changer une politique, c'est générer une migration et l'appliquer. Changer une vérification dans le code, c'est changer une ligne.
D'ailleurs, à un moment j'ai contourné Drizzle entièrement et fait passer le feedback et les profils par PostgREST, justement pour m'appuyer vraiment sur RLS. Là, le prix est apparu clairement : j'ai perdu le typage, j'ai dû écrire les noms de colonnes à la main en snake_case (user_id, created_at) et chaque appel se terminait par le même rituel.
const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];
Un as Feedback[] à la fin. Autrement dit, faire confiance au fait que la base renvoie ce que je dis. Ça a fait revenir Drizzle deux semaines plus tard.
Le même dénouement dans le second projet
Quand j'ai démarré le projet suivant à partir du même boilerplate, les politiques étaient dedans. Elles ont duré le temps que je touche sérieusement à la table de feedback. Le commit dans lequel je les ai retirées faisait autre chose en même temps : renommer feedback.description en feedback.content. C'est un détail bête mais il raconte toute l'histoire : j'étais en train de donner forme au modèle de données, et les politiques étaient ce qui me gênait pour le déplacer. Dans ce commit ont disparu l'import de drizzle-orm/neon et les quatre appels à crudPolicy. Les tables ne sont restées qu'avec la seule chose qui sert vraiment tous les jours :
(table) => [index("idx_feedback_user_id").on(table.userId)]
Un index sur user_id. Ça, Postgres le consulte à chaque requête.
Où vit aujourd'hui ma logique de permissions
Dans le middleware et dans la couche des actions serveur. Quand j'ai quitté l'auth managée et que je me suis écrit mon propre middleware de session, l'endroit où se décide qui voit quoi s'est retrouvé en un point unique : on résout la session, on vérifie le rôle, et à partir de là toutes les requêtes s'écrivent avec le userId déjà en main. Une seule connexion, celle d'admin, et aucun chemin privilégié parallèle.
C'est moins sûr sur le papier. Une requête qui oublie son where renvoie les lignes de quelqu'un d'autre. Je compense par le fait que ces requêtes vivent toutes dans un répertoire d'actions, et non éparpillées dans l'application, et par le fait que la signature de la fonction vous oblige à passer l'utilisateur.
Quand je réutiliserais RLS
Quand la base de données a plus d'un client. Si, en plus de mon application, il y a un PostgREST public, un client qui se connecte directement, ou des analystes en lecture seule, la politique est le seul endroit où la règle s'applique à tous. Là, le coût de maintenir deux vérités se rentabilise tout seul.
Également en multi-tenant dur, où l'erreur n'est pas « vous voyez un feedback qui n'est pas le vôtre » mais « vous voyez les données d'une autre entreprise ». Là, je veux le filet en dessous même si le code filtre déjà.
Pour une application avec un seul client, avec des server actions et l'utilisateur résolu avant la première requête, RLS est une seconde copie d'une règle que vous avez déjà écrite. Et une règle écrite deux fois n'est pas deux fois plus sûre : elle est deux fois plus facile à laisser périmer.
