Fiche 11.08 — Règles de sécurité backend : Firestore Rules et Postgres RLS
Objectif
Écrire de vraies règles de sécurité côté backend (Firestore Security Rules et Supabase/Postgres RLS), pas juste du conceptuel. Comprendre pourquoi le client n'est jamais de confiance, sécuriser l'accès "owner-only", gérer les rôles, et faire une suppression de compte conforme RGPD / App Store 5.1.1(v).
1. Le principe : le client n'est JAMAIS de confiance
Ton app iOS tourne sur l'appareil de l'utilisateur. N'importe qui peut la décompiler, intercepter le trafic (proxy type Charles/Proxyman), forger une requête. Toute vérification faite uniquement côté Swift est cosmétique.
Réflexe UIKit/iOS -> moderne : tu validais déjà un formulaire côté app pour l'UX (désactiver un bouton). Garde-le pour l'UX, mais rejoue toujours la validation critique côté serveur. Le contrôle d'accès (qui peut lire/écrire quoi) ne vit QUE côté backend.
Ce qui se valide obligatoirement côté serveur :
- l'identité (qui es-tu ? ->
request.auth/auth.uid()) ; - l'autorisation (as-tu le droit sur CE document ? -> ownership, rôle) ;
- l'intégrité des données (champs obligatoires, types, valeurs interdites comme
isAdmin: true).
2. Firestore Security Rules — structure de base
Les règles vivent dans firestore.rules, déployées via Firebase CLI (firebase deploy --only firestore:rules). Elles s'exécutent sur les serveurs Google à chaque lecture/écriture du SDK client. Elles ne s'appliquent pas à l'Admin SDK (backend de confiance).
Distinction cruciale :
resource.data= le document tel qu'il existe déjà (avant l'écriture). Utilisé pour read/update/delete.request.resource.data= le document tel qu'il sera après l'écriture (ce que le client envoie). Utilisé pour create/update.
Sur un create, resource n'existe pas encore -> ne teste que request.resource.data.
3. Firestore — validation de champs
Le contrôle d'accès ne suffit pas : il faut aussi empêcher d'écrire des données invalides ou de l'escalade de privilège.
hasOnly([...]) bloque un client malveillant qui ajouterait isPremium: true ou role: "admin". C'est l'équivalent serveur d'une whitelist de champs.
Pas idéal : allow read, write: if request.auth != null; -> n'importe quel utilisateur connecté lit/écrit TOUS les documents de tout le monde.
Préférable : filtrer par ownerId comme ci-dessus.
4. Firestore — rôles via custom claims
Pour un rôle admin/modérateur, n'écris pas le rôle dans un document Firestore que le client pourrait modifier. Utilise les custom claims du token Firebase Auth, posés côté backend (Admin SDK / Cloud Function) :
Le claim est signé dans le JWT : le client ne peut pas le falsifier. Après setCustomUserClaims, l'app doit rafraîchir son token (Auth.auth().currentUser?.getIDTokenResult(forcingRefresh: true)).
5. Supabase / Postgres RLS — activer et écrire une policy
Avec Supabase (Postgres), la sécurité passe par Row Level Security. Point clé : tant que RLS n'est pas activée sur une table, soit elle est ouverte, soit (selon l'API) inaccessible. Activer RLS = refus par défaut, puis tu ouvres avec des policies.
Distinction clé (équivalent du couple resource / request.resource Firestore) :
using= filtre les lignes existantes que l'opération a le droit de voir/toucher (SELECT, UPDATE, DELETE).with check= valide les lignes après écriture (INSERT, UPDATE). Empêche d'insérer/modifier une ligne avecowner_id≠ soi.
auth.uid() renvoie l'UUID de l'utilisateur extrait du JWT vérifié par Postgres. Infalsifiable côté client.
6. Supabase — rôles via colonne + policy
Pour un rôle, ajoute une colonne role sur une table profiles (1 ligne par user, id = auth.uid()), puis lis-la dans tes policies. Ne laisse jamais l'utilisateur écrire sa propre colonne role.
Pourquoi revoke update (role) plutôt qu'un test dans le with check ? Une policy RLS n'a pas accès à l'ancienne valeur de la ligne (pas de OLD comme dans un trigger) : tenter de comparer le nouveau role à un select role from profiles where id = auth.uid() est fragile et trompeur. Le privilège au niveau colonne, lui, bloque proprement toute écriture de role par le rôle authenticated. Seul le backend (service role) peut alors poser un rôle.
Côté Swift, l'appel reste trivial : le SDK envoie le JWT, Postgres applique la policy. Tu n'écris jamais de WHERE owner_id = ... côté app pour la sécurité — la base le fait, et ne ment pas.
7. Suppression de compte conforme (RGPD / App Store 5.1.1(v))
Depuis juin 2022, toute app qui permet de créer un compte doit permettre de le supprimer depuis l'app (guideline App Store 5.1.1(v)). Masquer un bouton ou rediriger vers un email = rejet. RGPD ajoute le droit à l'effacement.
Séquence correcte :
- Réauthentifier (opération sensible -> Firebase exige un token récent, sinon
requiresRecentLogin). - Supprimer les données en cascade (documents Firestore/Storage ou lignes Postgres).
- Supprimer le compte Auth en dernier (une fois Auth supprimé, tu n'as plus l'
uidpour nettoyer).
L'ordre est important : supprime les données AVANT le compte. En pratique, la suppression en cascade et fiable se fait côté backend (Cloud Function deleteUser, ou ON DELETE CASCADE + Edge Function), car l'app peut être tuée en plein milieu.
Côté Supabase : suppression de l'utilisateur Auth via une Edge Function avec la service role key (jamais dans l'app), les tables liées partant en ON DELETE CASCADE sur auth.users(id).
Points à connaître
- Laisser une collection/table ouverte. Le piège n°1 :
allow read, write: if true;"le temps du dev", ou oublierenable row level security. Tout est exposé publiquement, y compris les données d'autres users. Vérifie chaque table/collection avant prod. - Valider uniquement côté app. Désactiver un bouton ou un
guarden Swift n'empêche personne de forger la requête. Si ce n'est pas dans les Rules / RLS, ce n'est pas sécurisé. - Confondre
resourceetrequest.resource(Firestore) ouusingetwith check(RLS).resource/using= état actuel ;request.resource/with check= état après écriture. Mauvais choix = soit ça bloque tout, soit ça laisse passer l'escalade de privilège (changer sonownerId/role). - Supprimer le compte Auth avant les données. Tu perds l'
uid/UUID nécessaire au nettoyage -> données orphelines, non-conformité RGPD. Données d'abord, Auth en dernier, idéalement côté backend.
Exercice (10-20 min)
Pour une collection/table notes où chaque note a un propriétaire, écris :
-
Firestore : une règle qui n'autorise lecture, création, modification et suppression QUE par le propriétaire, où la note stocke
ownerId, et où on ne peut pas réassignerownerIdlors d'un update. -
Supabase RLS : les 4 policies (select/insert/update/delete) équivalentes sur une table
notes(owner_id uuid, ...), après avoir activé RLS.
Bonus : ajoute dans la règle Firestore une validation title (string, 1 à 100 caractères) et un hasOnly([...]) pour bloquer tout champ non prévu.
Solution de référence (à comparer après) :
Question d'entretien
Q : Comment sécurises-tu l'accès aux données dans une app iOS connectée à un backend ? Que valides-tu côté serveur ?
R : Le client n'est jamais de confiance : la frontière de sécurité est le backend. Toute validation critique (identité, autorisation, intégrité) est rejouée côté serveur. Avec Firebase, j'écris des Firestore Security Rules : request.auth != null pour exiger l'authentification, et je compare l'ownerId du document à request.auth.uid pour du owner-only. Je valide aussi les champs (types, tailles, hasOnly pour interdire des clés non prévues comme un faux role: admin). Avec Supabase, j'active Row Level Security et j'écris des policies SQL avec auth.uid() = owner_id, en distinguant using (lignes existantes) et with check (état après écriture). Les rôles passent par les custom claims Firebase ou une colonne role lue dans les policies, jamais par une donnée que le client peut modifier. Côté app, je valide aussi mais uniquement pour l'UX.
Q : Pourquoi ne pas juste filtrer les données côté app avec un WHERE ownerId == currentUser ?
R : Parce que ce filtre est dans du code que l'attaquant contrôle. Il peut forger une requête sans ce filtre et lire toutes les données. La RLS / les Rules s'exécutent côté serveur et ne peuvent pas être contournées : la base elle-même ne renvoie que les lignes autorisées.
Résumé
- Le client n'est jamais de confiance ; la frontière de sécurité est le backend.
- Firestore :
rules_version = '2',match,allow read/write: if request.auth != null && resource.data.ownerId == request.auth.uid. resource.data= document actuel ;request.resource.data= document après écriture (à valider en create/update).- Valide les champs : types, tailles,
hasOnly([...])pour bloquer l'injection de clés et l'escalade de privilège. - Supabase :
enable row level security(refus par défaut) puis policies SQL select/insert/update/delete avecauth.uid() = owner_id. using= lignes existantes ;with check= état après écriture.- Rôles : custom claims Firebase (token signé) ou colonne
role+ policy ; jamais une donnée modifiable par le client. - Suppression de compte (RGPD / App Store 5.1.1(v)) : réauth -> supprimer les données en cascade -> supprimer le compte Auth en dernier, idéalement côté backend.
- Pièges majeurs : laisser une collection/table ouverte, et valider uniquement côté app.