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 la base de données en temps réel s'appuient sur des fichiers de configuration que vous écrivez pour accorder un 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'une des parties les plus importantes du développement de votre application. Cet atelier de programmation vous guidera tout au long de cette procédure.

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 ; vérifiez votre version en exécutant 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 interdisent l'accès que vous attendez.

Vous allez apprendre à effectuer les opérations suivantes :

  • Accorder des autorisations précises
  • Appliquer des validations de données et de type
  • 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é temporelles
  • Implémenter une liste de refus et des suppressions réversibles
  • Savoir quand dénormaliser les données pour répondre à plusieurs modèles d'accès

2. Configurer

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

Brouillons d'articles de blog:

  • Les utilisateurs peuvent créer des brouillons d'articles de blog, qui se trouvent dans la collection drafts.
  • L'auteur peut continuer à mettre à jour 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 et crée 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, mais uniquement via une fonction.
  • Ils ne peuvent être supprimés que de façon réversible, ce qui met à jour un attribut visible sur "false".

Commentaires

  • Les posts publiés autorisent les commentaires, qui constituent une sous-collection de chaque article publié.
  • Afin de limiter les abus, les utilisateurs doivent disposer d'une adresse e-mail validée et ne pas être bloqués pour pouvoir laisser un commentaire.
  • Les commentaires ne peuvent être modifiés que dans l'heure qui suit leur 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 des champs obligatoires et des validations de données.

Tout se passe localement, à l'aide de la suite d'émulateurs Firebase.

Obtenir le code source

Dans cet atelier de programmation, vous commencerez par des tests pour les règles de sécurité, mais avec des règles de sécurité semblables. 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 allez travailler 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 vous utilisez une connexion Internet lente, l'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 Firebase (interface de ligne de commande), que vous pouvez installer sur votre machine à l'aide de la commande suivante :

$ npm install -g firebase-tools

Vérifiez ensuite 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 la suite d'émulateurs.

Démarrer les émulateurs

L'application avec laquelle vous allez travailler comporte trois collections Firestore principales: drafts contient des articles de blog en cours, la collection published contient les articles de blog qui ont été publiés et comments est une sous-collection sur les 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 rédigerez les règles de sécurité pour que ces tests réussissent.

Pour commencer, votre base de données est verrouillée : les lectures et les écritures dans la base de données sont universellement refusées, et tous les tests échouent. Lorsque 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 de la sortie:

$ 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

...

Il y a actuellement neuf échecs. Lorsque vous créez le fichier de règles, vous pouvez mesurer la progression en observant davantage de tests réussis.

4. Créez 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 bloggage stocke les articles de blog en brouillon dans une collection distincte, /drafts. Seuls l'auteur ou un modérateur peuvent accéder aux brouillons, et les champs obligatoires et immuables sont validés.

Si vous ouvrez 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. Et comme il se trouve au niveau supérieur, la même règle s'applique actuellement à toutes les requêtes, quel que soit l'utilisateur qui les envoie ou les données qu'il tente de lire ou d'écrire.

Commencez par supprimer l'instruction de correspondance la plus interne et remplacez-la par match /drafts/{draftID}. (Les commentaires de 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 écrirez pour les brouillons contrôlera les utilisateurs autorisés à créer des 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 à l'origine de la demande est le même que celui indiqué dans le document.

La première condition pour la création sera:

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. L'application doit donc l'ajouter avant de tenter de créer un document.) Comme vous n'avez besoin de vérifier que les attributs sont créés, vous pouvez vérifier que request.resource dispose de toutes ces clés :

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

La dernière exigence pour créer un article de blog est que le titre ne doit pas comporter plus de 50 caractères :

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

Étant donné que toutes ces conditions doivent être vraies, concaténez-les à l'aide de 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 les brouillons d'articles de blog ;

Ensuite, à mesure que les auteurs affinent leurs brouillons d'articles de blog, ils modifient les brouillons de documents. Créez une règle définissant les conditions dans lesquelles un post peut être mis à jour. Tout d'abord, seul l'auteur peut mettre à jour ses brouillons. Notez que vous vérifiez ici l'UID déjà écrit,resource.data.authorUID :

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

La deuxième condition pour une mise à jour est que les 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 dépasser 50 caractères:

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

Étant donné que toutes ces conditions doivent être remplies, concatenatez-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;
    }
  }
}

Relancez vos tests et vérifiez qu'un autre test réussit.

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

Tout comme les auteurs peuvent créer et mettre à jour des brouillons, ils peuvent également en supprimer.

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

De plus, les auteurs disposant d'un attribut isModerator sur leur jeton d'authentification sont autorisés à supprimer les brouillons :

request.auth.token.isModerator == true

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

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

Le règlement complet est désormais le suivant:

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;
    }
  }
}

Relancez vos tests et vérifiez qu'un autre test réussit maintenant.

7. Lecture, création et suppression des posts publiés : dénormalisation pour différents schémas d'accès

Les modèles d'accès aux articles publiés et aux brouillons étant très différents, cette application dénormalise les articles en collections draft et published distinctes. Par exemple, les posts publiés peuvent être lus par tout le monde, mais ne peuvent pas être supprimés définitivement. Les brouillons, quant à eux, peuvent être supprimés, mais seuls l'auteur et les modérateurs peuvent les lire. Dans cette application, lorsqu'un utilisateur souhaite publier un brouillon d'article de blog, une fonction est déclenchée pour créer l'article publié.

Vous allez ensuite rédiger les règles concernant les posts publiés. Les règles les plus simples à rédiger sont les suivantes : les posts publiés peuvent être lus par n'importe qui, et ne peuvent être créés ni supprimés par quiconque. 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 aux règles existantes, l'intégralité du fichier de règles 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;
    }
  }
}

Exécutez à nouveau les tests et vérifiez qu'un autre test réussit.

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

Les conditions pour mettre à jour un article publié sont les suivantes:

  • seul l'auteur ou le modérateur peut le faire.
  • il doit contenir tous les champs obligatoires.

Étant donné que vous avez déjà rédigé les conditions pour être auteur ou modérateur, vous pouvez les copier et les coller, mais avec le temps, cela peut devenir difficile à lire et à gérer. Vous allez plutôt créer une fonction personnalisée qui encapsule la logique d'un auteur ou d'un modérateur. Ensuite, vous l'appellerez à 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 utilise comme arguments un document de publication (fonctionne aussi bien pour les brouillons que pour les articles publiés) et l'objet auth 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 vraie :

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 modifier 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 maintenant écrire une condition de mise à jour des articles 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. En particulier, les champs url, authorUID et publishedAt sont immuables. Les deux autres champs, title et content, et visible doivent toujours être présents après une mise à jour. Ajoutez des conditions afin d'appliquer les conditions suivantes pour la mise à jour des 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 une fonction personnalisée par vous-même

Enfin, ajoutez une condition selon laquelle le titre doit comporter 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 cette nouvelle fonction, la condition de mise à jour d'un article 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);
    }
  }
}

Réexécutez les tests. À ce stade, vous devriez avoir cinq tests réussis et quatre échecs.

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

Les commentaires publiés autorisent les commentaires, qui sont stockés dans une sous-collection de l'article 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 que celles appliquées au document parent du post publié s'appliquent aux commentaires. Vous allez en créer d'autres.

Pour définir des règles permettant d'accéder 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: anonyme

Pour cette application, seuls les utilisateurs ayant créé un compte permanent (et non les utilisateurs anonymes) peuvent lire les commentaires. Pour appliquer cette règle, recherchez l'attribut sign_in_provider figurant 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 autre test réussit.

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

Pour 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 peuvent pas figurer sur la liste des utilisateurs bannis, qui est stockée dans Firestore dans la collection bannedUsers. Prenez 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 dernière règle 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));
    }
  }
}

Exécutez à nouveau les tests et assurez-vous qu'un autre test réussit.

10. Mettre à jour les commentaires : règles basées sur le temps

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

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

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

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

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

Si l'on les combine avec l'opérateur logique AND, la règle de mise à jour des commentaires devient la suivante:

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');

Exécutez à nouveau les tests et assurez-vous qu'un autre test réussit.

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

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

Tout d'abord, comme la fonction d'assistance que vous avez ajoutée précédemment recherche un champ authorUID qui peut exister dans un post ou un commentaire, vous pouvez la réutiliser 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, recherchez l'article dans Firestore à l'aide d'un get:

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

Chacune de ces conditions étant suffisante, utilisez un opérateur logique OU 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 autre test 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 associés à étudier:

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