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

1. Avant de commencer

Cloud Firestore, Cloud Storage pour Firebase et la base de données en temps réel s'appuient sur les 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 agir comme une sorte de schéma pour votre application. C'est l'une des parties les plus importantes du développement de votre application. Et ce codelab vous guidera à travers cela.

Conditions préalables

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

Ce que tu vas faire

Dans cet atelier de programmation, vous sécuriserez une plate-forme de blog simple basée sur Firestore. Vous utiliserez l'émulateur Firestore pour exécuter des tests unitaires par rapport aux règles de sécurité et vous assurer que les règles autorisent et interdisent l'accès que vous attendez.

Vous apprendrez à :

  • Accorder des autorisations granulaires
  • Appliquer les validations de données et de type
  • Mettre en œuvre le contrôle d'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 blog. Voici un résumé de haut niveau des fonctionnalités de l'application :

Brouillons d'articles de blog :

  • Les utilisateurs peuvent créer des brouillons d'articles de blog, qui résident dans la collection de 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 pour créer un nouveau 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 articles publiés ne peuvent pas être créés par les utilisateurs, uniquement via une fonction.
  • Ils peuvent uniquement être supprimés de manière réversible, ce qui met à jour un attribut visible sur false.

commentaires

  • Les publications publiées autorisent les commentaires, qui constituent une sous-collection de chaque publication publiée.
  • Pour réduire les abus, les utilisateurs doivent avoir une adresse e-mail vérifiée et ne pas être sur un denyist afin de laisser un commentaire.
  • Les commentaires ne peuvent être mis à jour que dans l'heure qui suit leur publication.
  • Les commentaires peuvent être supprimés par l'auteur du commentaire, l'auteur du message original 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 localement, en utilisant Firebase Emulator Suite.

Obtenir le code source

Dans cet atelier de programmation, vous commencerez par tester les règles de sécurité, mais les règles de sécurité minimales elles-mêmes. 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

Déplacez-vous ensuite dans le répertoire d'état initial, où vous travaillerez pour le reste de cet atelier de programmation :

$ cd codelab-rules/initial-state

Maintenant, installez les dépendances pour pouvoir exécuter les tests. Si vous utilisez une connexion Internet plus lente, cela peut prendre une minute ou deux :

# 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) qui peut être installée sur votre machine avec la commande suivante :

$ npm install -g firebase-tools

Ensuite, confirmez que vous disposez de la dernière version de la CLI. Ce laboratoire de programmation devrait fonctionner avec la version 8.4.0 ou ultérieure, mais les versions ultérieures incluent davantage de corrections de bogues.

$ firebase --version
9.10.2

3. Exécutez les tests

Dans cette section, vous exécuterez les tests localement. Cela signifie qu'il est temps de démarrer Emulator Suite.

Démarrer les émulateurs

L'application avec laquelle vous allez travailler comporte trois collections Firestore principales : drafts contiennent les articles de blog en cours, la collection published contient les articles de blog qui ont été publiés et les comments sont une sous-collection des articles publiés. Le référentiel 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 crée, lise, mette à jour et supprime des documents dans des collections de drafts , published et de comments . Vous écrirez les règles de sécurité pour faire passer ces tests.

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. Au fur et à mesure que vous écrivez des règles de sécurité, les tests réussiront. Pour voir les tests, ouvrez functions/test.js dans votre éditeur.

Sur la ligne de commande, démarrez les émulateurs en utilisant emulators:exec et exécutez les tests :

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

Faites défiler vers le 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

...

En ce moment, il y a 9 échecs. Au fur et à mesure que vous créez le fichier de règles, vous pouvez mesurer les progrès en observant la réussite de plusieurs tests.

4. Créez des brouillons d'articles de blog.

Étant donné que l'accès aux brouillons d'articles de blog est si différent de l'accès aux articles de blog publiés, cette application de blog stocke les brouillons d'articles de blog dans une collection distincte, /drafts . Les brouillons ne sont accessibles que par l'auteur ou un modérateur, et ont 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. Et parce que c'est au niveau supérieur, la même règle générale s'applique actuellement à toutes les demandes, peu importe qui fait la demande ou quelles données ils essaient de lire ou d'écrire.

Commencez par supprimer l'instruction de correspondance la plus interne 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 rédigerez 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 fait la demande est le même UID 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 requis, authorUID , createdAt et title . (L'utilisateur ne fournit pas le champ createdAt ; cela oblige l'application à l'ajouter avant d'essayer de créer un document.) Étant donné que vous n'avez qu'à vérifier que les attributs sont en cours de création, vous pouvez vérifier que request.resource a tous 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 dépasser 50 caractères :

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

Étant donné que toutes ces conditions doivent être vraies, concaténez-les avec l'opérateur logique ET, && . 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, relancez les tests et confirmez que le premier test réussit.

5. Mettre à jour les brouillons d'articles de blog.

Ensuite, au fur et à mesure que les auteurs affinent leurs brouillons d'articles de blog, ils modifieront les brouillons de documents. Créez une règle pour les conditions dans lesquelles une publication peut être mise à jour. Tout d'abord, seul l'auteur peut mettre à jour ses brouillons. Notez qu'ici vous vérifiez 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"
]);

Et enfin, le titre doit être de 50 caractères ou moins :

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

Étant donné que ces conditions doivent toutes ê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 confirmez qu'un autre test réussit.

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

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

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

De plus, les auteurs avec un attribut isModerator sur leur jeton d'authentification sont autorisés à supprimer des 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, de sorte que l'autorisation peut être ajoutée à 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 :

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 confirmez qu'un autre test réussit maintenant.

7. Lit, crée et supprime des articles publiés : dénormalisation pour différents modèles d'accès

Étant donné que les modèles d'accès pour les articles publiés et les articles brouillons sont si différents, cette application dénormalise les articles dans des collections draft et published distinctes. Par exemple, les messages publiés peuvent être lus par n'importe qui mais ne peuvent pas être supprimés de manière définitive, tandis que les brouillons 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é.

Ensuite, vous rédigerez les règles pour les articles publiés. Les règles les plus simples à écrire sont que les messages publiés peuvent être lus par n'importe qui et ne peuvent être créés ou supprimés par personne. Ajoutez ces règles :

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 les ajoutant aux règles existantes, l'ensemble 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;
    }
  }
}

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

8. Mise à jour des articles publiés : fonctions personnalisées et variables locales

Les conditions pour mettre à jour une publication publiée sont :

  • cela ne peut être fait que par l'auteur ou le modérateur, et
  • il doit contenir tous les champs obligatoires.

Puisque vous avez déjà écrit les conditions pour être auteur ou modérateur, vous pouvez copier et coller les conditions, mais avec le temps, cela pourrait devenir difficile à lire et à maintenir. Au lieu de cela, vous allez créer une fonction personnalisée qui encapsule la logique pour être auteur ou modérateur. Ensuite, vous l'appelerez à partir de plusieurs conditions.

Créer une fonction personnalisée

Au-dessus de l'instruction de correspondance pour les brouillons, créez une nouvelle fonction appelée isAuthorOrModerator qui prend comme arguments un document de publication (cela fonctionnera pour les brouillons ou les publications publiées) 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, et la nôtre renverra un booléen indiquant si l'une ou l'autre des variables est vraie :

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

Appelez la fonction

Vous allez maintenant mettre à jour la règle pour que les brouillons appellent 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 des champs d'un article 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 pour appliquer ces exigences pour les mises à jour des articles 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éez vous-même une fonction personnalisée

Et enfin, ajoutez une condition que le titre soit inférieur à 50 caractères. Comme il s'agit d'une logique réutilisée, vous pouvez le faire en créant une nouvelle fonction, titleIsUnder50Chars . Avec la 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);

Et le fichier de règles complet est :

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

Refaire les tests. À ce stade, vous devriez avoir 5 tests réussis et 4 échecs.

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

Les articles publiés autorisent les commentaires, et les commentaires 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 voulez pas que les mêmes règles qui s'appliquent au document parent de la publication publiée s'appliquent aux commentaires ; vous en fabriquerez d'autres.

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

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

Lire les commentaires : ne peut pas être anonyme

Pour cette application, seuls les utilisateurs ayant 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 confirmez qu'un autre test réussit.

Création de commentaires : vérification d'une liste de refus

Il y a trois conditions pour créer un commentaire :

  • un utilisateur doit avoir un email vérifié
  • le commentaire doit comporter moins de 500 caractères, et
  • ils ne peuvent pas figurer sur une liste d'utilisateurs bannis, qui est stockée dans Firestore dans la collection bannedUsers . En prenant ces conditions une à la fois :
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 :

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

L'ensemble du fichier de règles est maintenant :

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 charachters
        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 autre test réussit.

10. Mise à jour des commentaires : règles temporelles

La logique métier des commentaires est qu'ils peuvent être modifiés par l'auteur du commentaire pendant une heure après leur création. Pour implémenter cela, utilisez l'horodatage 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 les combinant avec l'opérateur logique ET, 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 autre test réussit.

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

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

Tout d'abord, étant donné que la fonction d'assistance que vous avez ajoutée précédemment vérifie un champ authorUID qui pourrait exister sur une publication 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

Étant donné que l'une de ces conditions est 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.

Et l'ensemble du fichier de règles est :

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 charachters
        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. Prochaines étapes

Toutes nos félicitations! Vous avez écrit les règles de sécurité qui ont fait passer tous les tests et sécurisé l'application !

Voici quelques sujets connexes dans lesquels plonger ensuite :

  • Article de blog : comment réviser les règles de sécurité
  • Codelab : découverte du premier développement local avec les émulateurs
  • Vidéo : Comment utiliser la configuration CI pour les tests basés sur l'émulateur à l'aide des actions GitHub
,

1. Avant de commencer

Cloud Firestore, Cloud Storage pour Firebase et la base de données en temps réel s'appuient sur les 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 agir comme une sorte de schéma pour votre application. C'est l'une des parties les plus importantes du développement de votre application. Et ce codelab vous guidera à travers cela.

Conditions préalables

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

Ce que tu vas faire

Dans cet atelier de programmation, vous sécuriserez une plate-forme de blog simple basée sur Firestore. Vous utiliserez l'émulateur Firestore pour exécuter des tests unitaires par rapport aux règles de sécurité et vous assurer que les règles autorisent et interdisent l'accès que vous attendez.

Vous apprendrez à :

  • Accorder des autorisations granulaires
  • Appliquer les validations de données et de type
  • Mettre en œuvre le contrôle d'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 blog. Voici un résumé de haut niveau des fonctionnalités de l'application :

Brouillons d'articles de blog :

  • Les utilisateurs peuvent créer des brouillons d'articles de blog, qui résident dans la collection de 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 pour créer un nouveau 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 articles publiés ne peuvent pas être créés par les utilisateurs, uniquement via une fonction.
  • Ils peuvent uniquement être supprimés de manière réversible, ce qui met à jour un attribut visible sur false.

commentaires

  • Les publications publiées autorisent les commentaires, qui constituent une sous-collection de chaque publication publiée.
  • Pour réduire les abus, les utilisateurs doivent avoir une adresse e-mail vérifiée et ne pas être sur un denyist afin de laisser un commentaire.
  • Les commentaires ne peuvent être mis à jour que dans l'heure qui suit leur publication.
  • Les commentaires peuvent être supprimés par l'auteur du commentaire, l'auteur du message original 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 localement, en utilisant Firebase Emulator Suite.

Obtenir le code source

Dans cet atelier de programmation, vous commencerez par tester les règles de sécurité, mais les règles de sécurité minimales elles-mêmes. 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

Déplacez-vous ensuite dans le répertoire d'état initial, où vous travaillerez pour le reste de cet atelier de programmation :

$ cd codelab-rules/initial-state

Maintenant, installez les dépendances pour pouvoir exécuter les tests. Si vous utilisez une connexion Internet plus lente, cela peut prendre une minute ou deux :

# 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) qui peut être installée sur votre machine avec la commande suivante :

$ npm install -g firebase-tools

Ensuite, confirmez que vous disposez de la dernière version de la CLI. Ce laboratoire de programmation devrait fonctionner avec la version 8.4.0 ou ultérieure, mais les versions ultérieures incluent davantage de corrections de bogues.

$ firebase --version
9.10.2

3. Exécutez les tests

Dans cette section, vous exécuterez les tests localement. Cela signifie qu'il est temps de démarrer Emulator Suite.

Démarrer les émulateurs

L'application avec laquelle vous allez travailler comporte trois collections Firestore principales : drafts contiennent les articles de blog en cours, la collection published contient les articles de blog qui ont été publiés et les comments sont une sous-collection des articles publiés. Le référentiel 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 crée, lise, mette à jour et supprime des documents dans des collections de drafts , published et de comments . Vous écrirez les règles de sécurité pour faire passer ces tests.

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. Au fur et à mesure que vous écrivez des règles de sécurité, les tests réussiront. Pour voir les tests, ouvrez functions/test.js dans votre éditeur.

Sur la ligne de commande, démarrez les émulateurs en utilisant emulators:exec et exécutez les tests :

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

Faites défiler vers le 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

...

En ce moment, il y a 9 échecs. Au fur et à mesure que vous créez le fichier de règles, vous pouvez mesurer les progrès en observant la réussite de plusieurs tests.

4. Créez des brouillons d'articles de blog.

Étant donné que l'accès aux brouillons d'articles de blog est si différent de l'accès aux articles de blog publiés, cette application de blog stocke les brouillons d'articles de blog dans une collection distincte, /drafts . Les brouillons ne sont accessibles que par l'auteur ou un modérateur, et ont 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. Et parce que c'est au niveau supérieur, la même règle générale s'applique actuellement à toutes les demandes, peu importe qui fait la demande ou quelles données ils essaient de lire ou d'écrire.

Commencez par supprimer l'instruction de correspondance la plus interne 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 rédigerez 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 fait la demande est le même UID 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 requis, authorUID , createdAt et title . (L'utilisateur ne fournit pas le champ createdAt ; cela oblige l'application à l'ajouter avant d'essayer de créer un document.) Étant donné que vous n'avez qu'à vérifier que les attributs sont en cours de création, vous pouvez vérifier que request.resource a tous 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 dépasser 50 caractères :

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

Étant donné que toutes ces conditions doivent être vraies, concaténez-les avec l'opérateur logique ET, && . 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, relancez les tests et confirmez que le premier test réussit.

5. Mettre à jour les brouillons d'articles de blog.

Ensuite, au fur et à mesure que les auteurs affinent leurs brouillons d'articles de blog, ils modifieront les brouillons de documents. Créez une règle pour les conditions dans lesquelles une publication peut être mise à jour. Tout d'abord, seul l'auteur peut mettre à jour ses brouillons. Notez qu'ici vous vérifiez 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"
]);

Et enfin, le titre doit être de 50 caractères ou moins :

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

Étant donné que ces conditions doivent toutes ê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 confirmez qu'un autre test réussit.

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

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

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

De plus, les auteurs avec un attribut isModerator sur leur jeton d'authentification sont autorisés à supprimer des 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, de sorte que l'autorisation peut être ajoutée à 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 :

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 confirmez qu'un autre test réussit maintenant.

7. Lit, crée et supprime des articles publiés : dénormalisation pour différents modèles d'accès

Étant donné que les modèles d'accès pour les articles publiés et les articles brouillons sont si différents, cette application dénormalise les articles dans des collections draft et published distinctes. Par exemple, les messages publiés peuvent être lus par n'importe qui mais ne peuvent pas être supprimés de manière définitive, tandis que les brouillons 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é.

Ensuite, vous rédigerez les règles pour les messages publiés. Les règles les plus simples à écrire sont que les messages publiés peuvent être lus par n'importe qui et ne peuvent être créés ou supprimés par personne. Ajoutez ces règles :

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 les ajoutant aux règles existantes, l'ensemble 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;
    }
  }
}

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

8. Mise à jour des articles publiés : fonctions personnalisées et variables locales

Les conditions pour mettre à jour une publication publiée sont :

  • cela ne peut être fait que par l'auteur ou le modérateur, et
  • il doit contenir tous les champs obligatoires.

Puisque vous avez déjà écrit les conditions pour être auteur ou modérateur, vous pouvez copier et coller les conditions, mais avec le temps, cela pourrait devenir difficile à lire et à maintenir. Au lieu de cela, vous allez créer une fonction personnalisée qui encapsule la logique pour être auteur ou modérateur. Ensuite, vous l'appelerez à partir de plusieurs conditions.

Créer une fonction personnalisée

Au-dessus de l'instruction de correspondance pour les brouillons, créez une nouvelle fonction appelée isAuthorOrModerator qui prend comme arguments un document de publication (cela fonctionnera pour les brouillons ou les publications publiées) 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, et la nôtre renverra un booléen indiquant si l'une ou l'autre des variables est vraie :

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

Appelez la fonction

Vous allez maintenant mettre à jour la règle pour que les brouillons appellent 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 des champs d'un article 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 pour appliquer ces exigences pour les mises à jour des articles 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éez vous-même une fonction personnalisée

Et enfin, ajoutez une condition que le titre soit inférieur à 50 caractères. Comme il s'agit d'une logique réutilisée, vous pouvez le faire en créant une nouvelle fonction, titleIsUnder50Chars . Avec la 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);

Et le fichier de règles complet est :

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

Refaire les tests. À ce stade, vous devriez avoir 5 tests réussis et 4 échecs.

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

Les articles publiés autorisent les commentaires, et les commentaires 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 voulez pas que les mêmes règles qui s'appliquent au document parent de la publication publiée s'appliquent aux commentaires ; vous en fabriquerez d'autres.

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

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

Lire les commentaires : ne peut pas être anonyme

Pour cette application, seuls les utilisateurs ayant 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 confirmez qu'un autre test réussit.

Création de commentaires : vérification d'une liste de refus

Il y a trois conditions pour créer un commentaire :

  • un utilisateur doit avoir un email vérifié
  • le commentaire doit comporter moins de 500 caractères, et
  • ils ne peuvent pas figurer sur une liste d'utilisateurs bannis, qui est stockée dans Firestore dans la collection bannedUsers . En prenant ces conditions une à la fois :
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 :

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

L'ensemble du fichier de règles est maintenant :

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 charachters
        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 autre test réussit.

10. Mise à jour des commentaires : règles temporelles

La logique métier des commentaires est qu'ils peuvent être modifiés par l'auteur du commentaire pendant une heure après leur création. Pour implémenter cela, utilisez l'horodatage 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 les combinant avec l'opérateur logique ET, 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 autre test réussit.

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

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

Tout d'abord, étant donné que la fonction d'assistance que vous avez ajoutée précédemment vérifie un champ authorUID qui pourrait exister sur une publication 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

Étant donné que l'une de ces conditions est 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.

Et l'ensemble du fichier de règles est :

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 charachters
        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. Prochaines étapes

Toutes nos félicitations! Vous avez écrit les règles de sécurité qui ont fait passer tous les tests et sécurisé l'application !

Voici quelques sujets connexes dans lesquels plonger ensuite :

  • Article de blog : comment réviser les règles de sécurité
  • Codelab : découverte du premier développement local avec les émulateurs
  • Video : How to use set up CI for emulator-based tests using GitHub Actions