Protéger vos données Firestore avec les règles de sécurité Firebase

1. Avant de commencer

Cloud Firestore, Cloud Storage for Firebase et Realtime Database s'appuient sur des fichiers de configuration que vous écrivez pour accorder l'accès en lecture et en écriture. Cette configuration, appelée "Règles de sécurité", peut également servir de schéma pour votre application. Il s'agit de l'un des aspects les plus importants du développement de votre application. Cet atelier de programmation vous guidera tout au long du processus.

Conditions préalables

  • Un éditeur simple tel que Visual Studio Code, Atom ou Sublime Text
  • Node.js 8.6.0 ou version ultérieure (pour installer Node.js, utilisez nvm ; pour vérifier votre version, exécutez node --version)
  • Java 7 ou version ultérieure (pour installer Java, suivez ces instructions ; pour vérifier votre version, exécutez java -version)

Objectifs de l'atelier

Dans cet atelier de programmation, vous allez sécuriser une plate-forme de blog simple basée sur Firestore. Vous utiliserez l'émulateur Firestore pour exécuter des tests unitaires sur les règles de sécurité et vous assurer qu'elles autorisent et refusent l'accès comme prévu.

Vous allez apprendre à effectuer les opérations suivantes :

  • Accorder des autorisations précises
  • Appliquer des validations de données et de types
  • Implémenter le contrôle des accès basé sur les attributs
  • Accorder l'accès en fonction de la méthode d'authentification
  • Créer des fonctions personnalisées
  • Créer des règles de sécurité basées sur le temps
  • Implémenter une liste de refus et des suppressions réversibles
  • Comprendre quand dénormaliser les données pour répondre à plusieurs modèles d'accès

2. Configurer

Il s'agit d'une application de blogging. Voici un résumé général des fonctionnalités de l'application :

Rédiger des articles de blog :

  • Les utilisateurs peuvent créer des brouillons d'articles de blog, qui sont stockés dans la collection drafts.
  • L'auteur peut continuer à modifier un brouillon jusqu'à ce qu'il soit prêt à être publié.
  • Lorsqu'il est prêt à être publié, une fonction Firebase est déclenchée pour créer un document dans la collection published.
  • Les brouillons peuvent être supprimés par l'auteur ou par les modérateurs du site.

Articles de blog publiés :

  • Les utilisateurs ne peuvent pas créer de posts publiés, uniquement via une fonction.
  • Ils ne peuvent être supprimés de façon réversible, ce qui met à jour un attribut visible sur "false".

Commentaires

  • Les posts publiés autorisent les commentaires, qui sont une sous-collection de chaque post publié.
  • Pour limiter les utilisations abusives, les utilisateurs doivent disposer d'une adresse e-mail validée et ne pas figurer sur une liste de refus pour pouvoir laisser un commentaire.
  • Vous ne pouvez modifier un commentaire qu'une heure après sa publication.
  • Les commentaires peuvent être supprimés par leur auteur, par l'auteur du post d'origine ou par les modérateurs.

En plus des règles d'accès, vous allez créer des règles de sécurité qui appliquent les champs obligatoires et les validations de données.

Tout se passera en local, à l'aide de la suite d'émulateurs Firebase.

Obtenir le code source

Dans cet atelier de programmation, vous allez commencer par tester les règles de sécurité, mais avec des règles de sécurité minimales. La première chose à faire est donc de cloner la source pour exécuter les tests :

$ git clone https://github.com/FirebaseExtended/codelab-rules.git

Accédez ensuite au répertoire initial-state, dans lequel vous travaillerez pour le reste de cet atelier de programmation :

$ cd codelab-rules/initial-state

Installez maintenant les dépendances pour pouvoir exécuter les tests. Si votre connexion Internet est lente, cette opération peut prendre une ou deux minutes :

# Move into the functions directory, install dependencies, jump out.
$ cd functions && npm install && cd -

Obtenir la CLI Firebase

La suite d'émulateurs que vous utiliserez pour exécuter les tests fait partie de la CLI (interface de ligne de commande) Firebase, qui peut être installée sur votre machine à l'aide de la commande suivante :

$ npm install -g firebase-tools

Ensuite, vérifiez que vous disposez de la dernière version de la CLI. Cet atelier de programmation devrait fonctionner avec la version 8.4.0 ou ultérieure, mais les versions ultérieures incluent davantage de corrections de bugs.

$ firebase --version
9.10.2

3. Exécuter les tests

Dans cette section, vous allez exécuter les tests en local. Il est donc temps de démarrer l'Emulator Suite.

Démarrer les émulateurs

L'application avec laquelle vous allez travailler comporte trois collections Firestore principales : drafts contient les articles de blog en cours, la collection published contient les articles de blog publiés et comments est une sous-collection des articles publiés. Le dépôt est fourni avec des tests unitaires pour les règles de sécurité qui définissent les attributs utilisateur et les autres conditions requises pour qu'un utilisateur puisse créer, lire, mettre à jour et supprimer des documents dans les collections drafts, published et comments. Vous allez écrire les règles de sécurité pour que ces tests réussissent.

Pour commencer, votre base de données est verrouillée : les accès en lecture et en écriture sont refusés à tous les utilisateurs, et tous les tests échouent. À mesure que vous écrivez des règles de sécurité, les tests réussissent. Pour afficher les tests, ouvrez functions/test.js dans votre éditeur.

Sur la ligne de commande, démarrez les émulateurs à l'aide de emulators:exec et exécutez les tests :

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"

Faites défiler la page jusqu'en haut :

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
⚠  functions: Unable to fetch project Admin SDK configuration, Admin SDK behavior in Cloud Functions emulator may be incorrect.
i  firestore: Importing data from /Users/user/src/firebase/rules-codelab/initial-state/.seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
⚠  hosting: Authentication error when trying to fetch your current web app configuration, have you run firebase login?
⚠  hosting: Could not fetch web app configuration and there is no cached configuration on this machine. Check your internet connection and make sure you are authenticated. To continue, you must call firebase.initializeApp({...}) in your code before using Firebase.
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/user/src/firebase/rules-codelab/initial-state/functions" for Cloud Functions...
✔  functions[publishPost]: http function initialized (http://localhost:5001/codelab/us-central1/publishPost).
✔  functions[softDelete]: http function initialized (http://localhost:5001/codelab/us-central1/softDelete).
i  Running script: pushd functions; npm test
~/src/firebase/rules-codelab/initial-state/functions ~/src/firebase/rules-codelab/initial-state

> functions@ test /Users/user/src/firebase/rules-codelab/initial-state/functions
> mocha

(node:76619) ExperimentalWarning: Conditional exports is an experimental feature. This feature could change at any time


  Draft blog posts
    1) can be created with required fields by the author
    2) can be updated by author if immutable fields are unchanged
    3) can be read by the author and moderator

  Published blog posts
    4) can be read by everyone; created or deleted by no one
    5) can be updated by author or moderator

  Comments on published blog posts
    6) can be read by anyone with a permanent account
    7) can be created if email is verfied and not blocked
    8) can be updated by author for 1 hour after creation
    9) can be deleted by an author or moderator


  0 passing (848ms)
  9 failing

...

Pour le moment, il y a neuf échecs. À mesure que vous créez le fichier de règles, vous pouvez mesurer votre progression en observant le nombre de tests réussis.

4. créer des brouillons d'articles de blog ;

Étant donné que l'accès aux articles de blog en brouillon est très différent de celui aux articles de blog publiés, cette application de blog stocke les articles de blog en brouillon dans une collection distincte, /drafts. Seuls l'auteur ou un modérateur peuvent accéder aux brouillons, qui sont soumis à des validations pour les champs obligatoires et immuables.

En ouvrant le fichier firestore.rules, vous trouverez un fichier de règles par défaut :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

L'instruction de correspondance match /{document=**} utilise la syntaxe ** pour s'appliquer de manière récursive à tous les documents des sous-collections. Comme il s'agit d'une règle de premier niveau, la même règle générale s'applique actuellement à toutes les requêtes, quels que soient l'auteur de la requête et les données qu'il tente de lire ou d'écrire.

Commencez par supprimer l'instruction de correspondance la plus intérieure et remplacez-la par match /drafts/{draftID}. (Les commentaires sur la structure des documents peuvent être utiles dans les règles et seront inclus dans cet atelier de programmation. Ils sont toujours facultatifs.)

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional
    }
  }
}

La première règle que vous allez écrire pour les brouillons contrôlera qui peut créer les documents. Dans cette application, les brouillons ne peuvent être créés que par la personne indiquée comme auteur. Vérifiez que l'UID de la personne qui envoie la demande est identique à celui indiqué dans le document.

La première condition de la création sera la suivante :

request.resource.data.authorUID == request.auth.uid

Ensuite, les documents ne peuvent être créés que s'ils incluent les trois champs obligatoires : authorUID, createdAt et title. (L'utilisateur ne fournit pas le champ createdAt. Cela permet de s'assurer que l'application doit l'ajouter avant de tenter de créer un document.) Comme vous n'avez besoin que de vérifier que les attributs sont créés, vous pouvez vérifier que request.resource possède toutes ces clés :

request.resource.data.keys().hasAll([
  "authorUID",
  "createdAt",
  "title"
])

Enfin, le titre de l'article de blog ne doit pas comporter plus de 50 caractères :

request.resource.data.title.size() < 50

Comme toutes ces conditions doivent être vraies, concaténez-les avec l'opérateur logique AND, &&. La première règle devient :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

Dans le terminal, réexécutez les tests et vérifiez que le premier test réussit.

5. Modifier des brouillons d'articles de blog

Ensuite, les auteurs affinent leurs brouillons d'articles de blog en les modifiant. Créez une règle pour les conditions dans lesquelles un post peut être modifié. Tout d'abord, seul l'auteur peut modifier ses brouillons. Notez que vous devez vérifier l'UID déjà écrit,resource.data.authorUID :

resource.data.authorUID == request.auth.uid

La deuxième exigence pour une mise à jour est que deux attributs, authorUID et createdAt, ne doivent pas changer :

request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
]);

Enfin, le titre ne doit pas comporter plus de 50 caractères :

request.resource.data.title.size() < 50;

Comme toutes ces conditions doivent être remplies, concaténez-les avec && :

allow update: if
  // User is the author, and
  resource.data.authorUID == request.auth.uid &&
  // `authorUID` and `createdAt` are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
  ]) &&
  // Title must be < 50 characters long
  request.resource.data.title.size() < 50;

Les règles complètes deviennent :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

Réexécutez vos tests et vérifiez qu'un autre test réussit.

6. Supprimer et lire les brouillons : contrôle des accès basé sur les attributs

Tout comme les auteurs peuvent créer et modifier des brouillons, ils peuvent également les supprimer.

resource.data.authorUID == request.auth.uid

De plus, les auteurs dont le jeton d'authentification comporte un attribut isModerator sont autorisés à supprimer des brouillons :

request.auth.token.isModerator == true

Étant donné que l'une ou l'autre de ces conditions suffit pour une suppression, concaténez-les avec un opérateur logique OR, || :

allow delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

Les mêmes conditions s'appliquent aux lectures. Vous pouvez donc ajouter cette autorisation à la règle :

allow read, delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

Les règles complètes sont désormais les suivantes :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }
  }
}

Réexécutez vos tests et vérifiez qu'un autre test réussit désormais.

7. Lecture, création et suppression de posts publiés : dénormalisation pour différents modèles d'accès

Étant donné que les modèles d'accès pour les posts publiés et les brouillons sont très différents, cette application dénormalise les posts dans des collections draft et published distinctes. Par exemple, les posts publiés peuvent être lus par tous les utilisateurs, mais ne peuvent pas être supprimés définitivement. Les brouillons, quant à eux, peuvent être supprimés, mais ne peuvent être lus que par l'auteur et les modérateurs. Dans cette application, lorsqu'un utilisateur souhaite publier un brouillon d'article de blog, une fonction est déclenchée pour créer le nouvel article publié.

Vous allez ensuite écrire les règles pour les posts publiés. Les règles les plus simples à écrire sont que les posts publiés peuvent être lus par n'importe qui, mais ne peuvent être ni créés ni supprimés par n'importe qui. Ajoutez les règles suivantes :

match /published/{postID} {
  // `authorUID`: string, required
  // `content`: string, required
  // `publishedAt`: timestamp, required
  // `title`: string, < 50 characters, required
  // `url`: string, required
  // `visible`: boolean, required

  // Can be read by everyone
  allow read: if true;

  // Published posts are created only via functions, never by users
  // No hard deletes; soft deletes update `visible` field.
  allow create, delete: if false;
}

En ajoutant ces règles à celles existantes, le fichier de règles complet devient :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }

    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;
    }
  }
}

Réexécutez les tests et vérifiez qu'un autre test réussit.

8. Mettre à jour les posts publiés : fonctions personnalisées et variables locales

Voici les conditions à remplir pour modifier un post publié :

  • seule l'auteur ou le modérateur peuvent le faire ;
  • il doit contenir tous les champs obligatoires.

Comme vous avez déjà écrit des conditions pour être auteur ou modérateur, vous pouvez les copier et les coller. Toutefois, au fil du temps, cela peut devenir difficile à lire et à gérer. Au lieu de cela, vous allez créer une fonction personnalisée qui encapsule la logique pour être un auteur ou un modérateur. Vous l'appellerez ensuite à partir de plusieurs conditions.

Créer une fonction personnalisée

Au-dessus de l'instruction de correspondance pour les brouillons, créez une fonction appelée isAuthorOrModerator qui prend comme arguments un document de post (cela fonctionnera pour les brouillons ou les posts publiés) et l'objet d'authentification de l'utilisateur :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {

    }

    match /drafts/{postID} {
      allow create: ...
      allow update: ...
      ...
    }

    match /published/{postID} {
      allow read: ...
      allow create, delete: ...
    }
  }
}

Utiliser des variables locales

Dans la fonction, utilisez le mot clé let pour définir les variables isAuthor et isModerator. Toutes les fonctions doivent se terminer par une instruction de retour. La nôtre renverra une valeur booléenne indiquant si l'une des variables est définie sur "true" :

function isAuthorOrModerator(post, auth) {
  let isAuthor = auth.uid == post.authorUID;
  let isModerator = auth.token.isModerator == true;
  return isAuthor || isModerator;
}

Appeler la fonction

Vous allez maintenant mettre à jour la règle pour les brouillons afin d'appeler cette fonction, en veillant à transmettre resource.data comme premier argument :

  // Draft blog posts
  match /drafts/{draftID} {
    ...
    // Can be deleted by author or moderator
    allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
  }

Vous pouvez désormais écrire une condition pour mettre à jour les posts publiés qui utilise également la nouvelle fonction :

allow update: if isAuthorOrModerator(resource.data, request.auth);

Ajouter des validations

Certains champs d'un post publié ne doivent pas être modifiés. Plus précisément, les champs url, authorUID et publishedAt sont immuables. Les deux autres champs, title et content, ainsi que visible doivent toujours être présents après une mise à jour. Ajoutez des conditions pour appliquer ces exigences aux modifications apportées aux posts publiés :

// Immutable fields are unchanged
request.resource.data.diff(resource.data).unchangedKeys().hasAll([
  "authorUID",
  "publishedAt",
  "url"
]) &&
// Required fields are present
request.resource.data.keys().hasAll([
  "content",
  "title",
  "visible"
])

Créer vous-même une fonction personnalisée

Enfin, ajoutez une condition pour que le titre comporte moins de 50 caractères. Comme il s'agit d'une logique réutilisée, vous pouvez le faire en créant une fonction, titleIsUnder50Chars. Avec la nouvelle fonction, la condition pour mettre à jour un post publié devient :

allow update: if
  isAuthorOrModerator(resource.data, request.auth) &&
  // Immutable fields are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "publishedAt",
    "url"
  ]) &&
  // Required fields are present
  request.resource.data.keys().hasAll([
    "content",
    "title",
    "visible"
  ]) &&
  titleIsUnder50Chars(request.resource.data);

Le fichier de règles complet est le suivant :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }
  }
}

Relancez les tests. À ce stade, vous devriez avoir cinq tests réussis et quatre tests échoués.

9. Commentaires : sous-collections et autorisations du fournisseur de connexion

Les posts publiés autorisent les commentaires, qui sont stockés dans une sous-collection du post publié (/published/{postID}/comments/{commentID}). Par défaut, les règles d'une collection ne s'appliquent pas aux sous-collections. Vous ne souhaitez pas que les mêmes règles s'appliquent aux commentaires qu'au document parent du post publié. Vous allez donc en créer d'autres.

Pour écrire des règles d'accès aux commentaires, commencez par l'instruction de correspondance :

match /published/{postID}/comments/{commentID} {
  // `authorUID`: string, required
  // `comment`: string, < 500 characters, required
  // `createdAt`: timestamp, required
  // `editedAt`: timestamp, optional

Lecture des commentaires : impossible d'être anonyme

Pour cette application, seuls les utilisateurs qui ont créé un compte permanent (et non un compte anonyme) peuvent lire les commentaires. Pour appliquer cette règle, recherchez l'attribut sign_in_provider qui se trouve sur chaque objet auth.token :

allow read: if request.auth.token.firebase.sign_in_provider != "anonymous";

Réexécutez vos tests et vérifiez qu'un test supplémentaire réussit.

Créer des commentaires : vérifier une liste de refus

Pour pouvoir créer un commentaire, vous devez remplir trois conditions :

  • un utilisateur doit disposer d'une adresse e-mail validée.
  • le commentaire doit comporter moins de 500 caractères ;
  • ils ne doivent pas figurer sur une liste d'utilisateurs bannis, stockée dans Firestore dans la collection bannedUsers. Examinons ces conditions une par une :
request.auth.token.email_verified == true
request.resource.data.comment.size() < 500
!exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

La règle finale pour créer des commentaires est la suivante :

allow create: if
  // User has verified email
  (request.auth.token.email_verified == true) &&
  // UID is not on bannedUsers list
  !(exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

Le fichier de règles complet est désormais le suivant :

For bottom of step 9
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));
    }
  }
}

Réexécutez les tests et assurez-vous qu'un test supplémentaire réussit.

10. Modifier les commentaires : règles basées sur le temps

La logique métier des commentaires est qu'ils peuvent être modifiés par leur auteur pendant une heure après leur création. Pour ce faire, utilisez le code temporel createdAt.

Tout d'abord, pour établir que l'utilisateur est l'auteur :

request.auth.uid == resource.data.authorUID

Ensuite, que le commentaire a été créé au cours de la dernière heure :

(request.time - resource.data.createdAt) < duration.value(1, 'h');

En combinant ces éléments avec l'opérateur logique AND, la règle de mise à jour des commentaires devient :

allow update: if
  // is author
  request.auth.uid == resource.data.authorUID &&
  // within an hour of comment creation
  (request.time - resource.data.createdAt) < duration.value(1, 'h');

Réexécutez les tests et assurez-vous qu'un test supplémentaire réussit.

11. Suppression de commentaires : vérification de la propriété parentale

Les commentaires peuvent être supprimés par leur auteur, par un modérateur ou par l'auteur de l'article de blog.

Tout d'abord, étant donné que la fonction d'assistance que vous avez ajoutée précédemment recherche un champ authorUID qui peut exister sur un post ou un commentaire, vous pouvez réutiliser la fonction d'assistance pour vérifier si l'utilisateur est l'auteur ou le modérateur :

isAuthorOrModerator(resource.data, request.auth)

Pour vérifier si l'utilisateur est l'auteur de l'article de blog, utilisez un get pour rechercher l'article dans Firestore :

request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID

Comme l'une ou l'autre de ces conditions suffit, utilisez un opérateur logique OR entre elles :

allow delete: if
  // is comment author or moderator
  isAuthorOrModerator(resource.data, request.auth) ||
  // is blog post author
  request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;

Réexécutez les tests et assurez-vous qu'un test supplémentaire réussit.

L'intégralité du fichier de règles est la suivante :

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

      allow update: if
        // is author
        request.auth.uid == resource.data.authorUID &&
        // within an hour of comment creation
        (request.time - resource.data.createdAt) < duration.value(1, 'h');

      allow delete: if
        // is comment author or moderator
        isAuthorOrModerator(resource.data, request.auth) ||
        // is blog post author
        request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;
    }
  }
}

12. Étapes suivantes

Félicitations ! Vous avez écrit les règles de sécurité qui ont permis de réussir tous les tests et de sécuriser l'application.

Voici quelques sujets connexes à explorer :

  • Article de blog : "Comment réviser le code des règles de sécurité"
  • Atelier de programmation : découvrez le développement "local first" avec les émulateurs
  • Vidéo : Configurer l'intégration continue pour les tests basés sur l'émulateur à l'aide de GitHub Actions