Aller au contenu
Sylvain Joffraud l4w
Retour

RLS Postgres : `INSERT … RETURNING` refusé en 403

En RLS Postgres, un INSERT … RETURNING peut être refusé en 403 alors que la policy d’INSERT est juste : le RETURNING soumet aussi la nouvelle ligne aux policies SELECT. Sur une app de signalements (Supabase self-hosted, PostgreSQL 15, PostgREST), un gestionnaire fraîchement inscrit ne pouvait pas créer sa première résidence. Il a fallu deux correctifs : le premier a réparé la policy d’INSERT, le second la policy SELECT déclenchée par le RETURNING. Les deux échouaient pour la même raison : un helper qui cherchait par son id une ligne pas encore visible.

Le symptôme : POST /rest/v1/residences 403

La création échouait côté client. Les logs de Kong, la passerelle API de Supabase, montraient la requête :

POST /rest/v1/residences 403

Le code client est banal, un insert suivi d’un select pour récupérer l’identifiant et le code de rattachement :

const { data, error } = await supabase
  .from("residences")
  .insert({ manager_id: input.managerId, name: input.name, address: input.address })
  .select("id, join_code")
  .single();

L’utilisateur était bien authentifié, et il avait bien sa ligne d’administrateur dans manager_members.

Correctif n° 1 : une policy d’INSERT qui cherche une ligne inexistante

La policy d’origine couvrait toutes les commandes d’un coup :

create policy residences_modify_member on public.residences
  for all
  using (public.is_manager_member(id))
  with check (public.is_manager_member(id));

Le helper is_manager_member(_residence) est une fonction security definer stable qui joint manager_members et residences sur residences.id = _residence. À l’INSERT, il cherche la résidence qu’on est en train de créer : il ne la trouve pas, renvoie faux, et le with check refuse.

Le correctif sépare les commandes. L’INSERT vérifie l’appartenance via manager_id, une colonne de la nouvelle ligne, avec un autre helper qui ne lit que manager_members :

drop policy if exists residences_modify_member on public.residences;

create policy residences_insert_member on public.residences
  for insert
  with check (public.is_member_of_manager(manager_id));

create policy residences_update_member on public.residences
  for update
  using (public.is_manager_member(id))
  with check (public.is_manager_member(id));

create policy residences_delete_member on public.residences
  for delete
  using (public.is_manager_member(id));

Appliqué en staging : toujours 403.

Pourquoi INSERT … RETURNING déclenche-t-il la policy SELECT ?

.select() après .insert() fait envoyer par supabase-js l’en-tête Prefer: return=representation, et PostgREST exécute un INSERT … RETURNING. Pour rendre la ligne, PostgreSQL exige qu’elle passe aussi les policies SELECT de la table (doc PostgreSQL 15, CREATE POLICY, tableau des policies appliquées par commande). Si elle échoue, c’est toute l’insertion qui est annulée.

La policy SELECT de residences était using (public.is_manager_member(id)) : le même helper, le même raisonnement par id. Et il est STABLE. Une fonction STABLE lit avec l’instantané (snapshot) pris au début de la requête appelante (doc PostgreSQL 15 sur la volatilité) : la ligne insérée par cette même requête n’y figure pas. Le helper ne voit pas la résidence, la policy SELECT refuse, PostgREST répond 403.

Avec RETURNING, une ligne neuve doit passer la policy d’INSERT et la policy SELECT : un helper qui la cherche par son id ne la trouve dans aucune des deux.

Correctif n° 2 : une seconde policy SELECT par la colonne de rattachement

Les policies permissives d’une même commande se combinent en OR. Il suffit d’en ajouter une qui raisonne sur manager_id, présent dans la nouvelle ligne :

create policy residences_select_member_via_manager on public.residences
  for select using (public.is_member_of_manager(manager_id));

Vérification directe en psql : INSERT … RETURNING id, join_code passe. Les SELECT classiques continuent de passer par la policy existante.

Deux mois plus tard, le linter de base de données de Supabase a remonté deux familles d’avertissements de performance : auth_rls_initplan (un auth.uid() nu, évalué à chaque ligne, à envelopper en (select auth.uid())) et multiple_permissive_policies. Les trois policies SELECT de residences ont alors été fusionnées en une seule, sémantique identique :

create policy residences_select on public.residences
  for select using (
    public.is_manager_member(id)
    or public.is_member_of_manager(manager_id)
    or public.is_residence_member(id)
  );

La branche manager_id reste indispensable : c’est elle qui laisse passer le RETURNING.

Pourquoi 87 tests RLS verts n’ont rien vu ?

Au moment du bug, pnpm test:rls affichait 87/87. Aucun test ne jouait l’INSERT d’une résidence depuis un compte fraîchement créé, c’est-à-dire le premier geste d’un nouveau client. Le trou a été couvert au sprint suivant. Un test d’INSERT RLS utile reproduit exactement l’appel du client : même rôle, même RETURNING.

Sur le même projet, deux autres pièges RLS de la même famille : un exists de policy filtré par la RLS d’une autre table, et permission denied for table, qui est un GRANT et pas une policy.

Lessons learned

Questions fréquentes

Pourquoi un INSERT via supabase-js renvoie-t-il 403 alors que la policy d’INSERT est correcte ?
Avec supabase-js, .insert(...).select() envoie l’en-tête Prefer: return=representation, et PostgREST exécute un INSERT … RETURNING. PostgreSQL vérifie alors la nouvelle ligne contre les policies SELECT en plus du WITH CHECK d’INSERT. Si la policy SELECT ne voit pas la ligne qu’on vient d’insérer, toute l’insertion est refusée et PostgREST répond 403.
Pourquoi une fonction STABLE ne voit-elle pas la ligne en cours d’insertion ?
Une fonction PostgreSQL STABLE ou IMMUTABLE lit les données avec l’instantané (snapshot) établi au début de la requête appelante. La ligne insérée par cette même requête n’en fait pas partie. Un helper RLS qui relit la table pour retrouver la ligne par son id renvoie donc faux pendant l’INSERT et pendant la vérification du RETURNING.
Comment écrire une policy RLS d’INSERT qui fonctionne pour une ligne neuve ?
La policy doit raisonner sur les colonnes de la nouvelle ligne, pas relire la table. Pour une résidence rattachée à un gestionnaire, with check (is_member_of_manager(manager_id)) interroge la table d’appartenance avec la colonne manager_id de la ligne insérée, qui existe déjà, au lieu de chercher la résidence par son id, qui n’existe pas encore.
Que signifient les avertissements auth_rls_initplan et multiple_permissive_policies du linter Supabase ?
auth_rls_initplan signale un auth.uid() appelé nu dans une policy : l’envelopper en (select auth.uid()) le fait évaluer une fois par requête au lieu d’une fois par ligne. multiple_permissive_policies signale plusieurs policies permissives pour la même action, que PostgreSQL combine en OR : les fusionner en une seule policy donne la même sémantique, plus lisible et plus rapide.

Partager cet article :

Article précédent
mlx_lm.server : les commentaires SSE cassent le client
Article suivant
Coolify webhook : un push, cinq déploiements