Sécuriser l'accès aux données pour les utilisateurs et les groupes

Un grand nombre d'applications collaboratives permettent aux utilisateurs de lire et de modifier différentes données en fonction d'un ensemble d'autorisations. Dans une application d'édition de documents, par exemple, les utilisateurs peuvent vouloir autoriser quelques utilisateurs à lire et à écrire leurs documents tout en bloquant les accès non désirés.

Solution : contrôle d'accès basé sur les rôles

Vous pouvez exploiter le modèle de données de Cloud Firestore et les règles de sécurité personnalisées pour mettre en œuvre un contrôle d'accès basé sur les rôles dans votre application.

Supposons que vous développiez une application d'écriture collaborative dans laquelle les utilisateurs peuvent créer des "articles" et des "commentaires" avec les exigences de sécurité suivantes :

  • Chaque article a un propriétaire et peut être partagé avec des "rédacteurs", des "commentateurs" et des "lecteurs".
  • Les lecteurs peuvent uniquement consulter les articles et les commentaires. Ils ne peuvent rien modifier.
  • Les commentateurs ont tous les droits d'accès des lecteurs et peuvent également ajouter des commentaires à un article.
  • Les rédacteurs ont tous les droits d'accès des commentateurs et peuvent également modifier le contenu d'un article.
  • Les propriétaires peuvent modifier n'importe quelle partie d'un article et contrôler l'accès des autres utilisateurs.

Structure des données

Supposons que votre application dispose d'une collection stories où chaque document représente un article. Chaque article comporte également une sous-collection comments où chaque document est un commentaire sur cet article.

Pour effectuer le suivi des rôles d'accès, ajoutez un champ roles qui fait correspondre les ID utilisateur à des rôles :

/stories/{storyid}

{
  title: "A Great Story",
  content: "Once upon a time ...",
  roles: {
    alice: "owner",
    bob: "reader",
    david: "writer",
    jane: "commenter"
    // ...
  }
}

Les commentaires ne contiennent que deux champs : l'ID utilisateur du rédacteur et le contenu

/stories/{storyid}/comments/{commentid}

{
  user: "alice",
  content: "I think this is a great story!"
}

Règles

Maintenant que les rôles des utilisateurs sont enregistrés dans la base de données, vous devez rédiger des règles de sécurité pour les valider. Ces règles partent du principe que l'application utilise Firebase Auth de sorte que la variable request.auth.uid correspond à l'ID utilisateur.

Étape 1 : commencez par un fichier de règles de base, qui inclut des règles vides pour les articles et les commentaires :

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
         // TODO: Story rules go here...

         match /comments/{comment} {
            // TODO: Comment rules go here...
         }
     }
   }
}

Étape 2 : ajoutez une règle write simple qui permet aux propriétaires de contrôler entièrement les articles. Les fonctions définies permettent de déterminer les rôles d'un utilisateur et si de nouveaux documents sont valides :

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
        function isSignedIn() {
          return request.auth != null;
        }

        function getRole(rsc) {
          // Read from the "roles" map in the resource (rsc).
          return rsc.data.roles[request.auth.uid];
        }

        function isOneOfRoles(rsc, array) {
          // Determine if the user is one of an array of roles
          return isSignedIn() && (getRole(rsc) in array);
        }

        function isValidNewStory() {
          // Valid if story does not exist and the new story has the correct owner.
          return resource == null
            && request.resource.data.roles[request.auth.uid] == 'owner';
        }

        // Owners can read, write, and delete stories
        allow write: if isValidNewStory() || isOneOfRoles(resource, ['owner']);

         match /comments/{comment} {
            // ...
         }
     }
   }
}

Étape 3 : rédigez des règles permettant à un utilisateur de lire des articles et des commentaires, quel que soit son rôle. L'utilisation des fonctions définies à l'étape précédente permet de garder les règles concises et lisibles :

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
        function isSignedIn() {
          return request.auth != null;
        }

        function getRole(rsc) {
          return rsc.data.roles[request.auth.uid];
        }

        function isOneOfRoles(rsc, array) {
          return isSignedIn() && (getRole(rsc) in array);
        }

        function isValidNewStory() {
          return resource == null
            && request.resource.data.roles[request.auth.uid] == 'owner';
        }

        allow write: if isValidNewStory() || isOneOfRoles(resource, ['owner']);

        // Any role can read stories.
        allow read: if isOneOfRoles(resource, ['owner', 'writer', 'commenter', 'reader']);

        match /comments/{comment} {
          // Any role can read comments.
          allow read: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                      ['owner', 'writer', 'commenter', 'reader']);
        }
     }
   }
}

Étape 4 : autorisez les rédacteurs, les commentateurs et les propriétaires à publier des commentaires. Notez que cette règle vérifie également que le champ owner du commentaire correspond à celui de l'utilisateur ayant fait la demande, ce qui empêche les utilisateurs d'écrire par-dessus les commentaires des autres :

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
        function isSignedIn() {
          return request.auth != null;
        }

        function getRole(rsc) {
          return rsc.data.roles[request.auth.uid];
        }

        function isOneOfRoles(rsc, array) {
          return isSignedIn() && (getRole(rsc) in array);
        }

        function isValidNewStory() {
          return resource == null
            && request.resource.data.roles[request.auth.uid] == 'owner';
        }

        allow write: if isValidNewStory() || isOneOfRoles(resource, ['owner'])
        allow read: if isOneOfRoles(resource, ['owner', 'writer', 'commenter', 'reader']);

        match /comments/{comment} {
          allow read: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                      ['owner', 'writer', 'commenter', 'reader']);

          // Owners, writers, and commenters can create comments. The
          // user id in the comment document must match the requesting
          // user's id.
          //
          // Note: we have to use get() here to retrieve the story
          // document so that we can check the user's role.
          allow create: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                        ['owner', 'writer', 'commenter'])
                        && request.resource.data.user == request.auth.uid;
        }
     }
   }
}

Étape 5 : donnez aux rédacteurs la possibilité de modifier le contenu de l'article, mais pas de modifier les rôles de l'article ni les autres propriétés du document. Pour cela, vous devez diviser la règle write des articles en règles distinctes pour create, update et delete car les rédacteurs ne peuvent que mettre à jour les articles :

service cloud.firestore {
   match /databases/{database}/documents {
     match /stories/{story} {
        function isSignedIn() {
          return request.auth != null;
        }

        function getRole(rsc) {
          return rsc.data.roles[request.auth.uid];
        }

        function isOneOfRoles(rsc, array) {
          return isSignedIn() && (getRole(rsc) in array);
        }

        function isValidNewStory() {
          return request.resource.data.roles[request.auth.uid] == 'owner';
        }

        function onlyContentChanged() {
          // Ensure that title and roles are unchanged and that no new
          // fields are added to the document.
          return request.resource.data.title == resource.data.title
            && request.resource.data.roles == resource.data.roles
            && request.resource.data.keys() == resource.data.keys();
        }

        // Split writing into creation, deletion, and updating. Only an
        // owner can create or delete a story but a writer can update
        // story content.
        allow create: if isValidNewStory();
        allow delete: if isOneOfRoles(resource, ['owner']);
        allow update: if isOneOfRoles(resource, ['owner'])
                      || (isOneOfRoles(resource, ['writer']) && onlyContentChanged());
        allow read: if isOneOfRoles(resource, ['owner', 'writer', 'commenter', 'reader']);

        match /comments/{comment} {
          allow read: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                      ['owner', 'writer', 'commenter', 'reader']);
          allow create: if isOneOfRoles(get(/databases/$(database)/documents/stories/$(story)),
                                        ['owner', 'writer', 'commenter'])
                        && request.resource.data.user == request.auth.uid;
        }
     }
   }
}

Limitations

La solution ci-dessus illustre la sécurisation des données utilisateur à l'aide des règles de sécurité, mais vous devez tenir compte des limitations suivantes :

  • Granularité : dans l'exemple ci-dessus, plusieurs rôles (rédacteur et propriétaire) disposent d'un accès en écriture au même document, mais avec des limitations différentes. Cela peut s'avérer difficile à gérer avec des documents plus complexes, et il peut être préférable de scinder les documents en plusieurs documents appartenant chacun à un même rôle.
  • Grands groupes : si vous souhaitez partager vos documents avec des groupes très importants ou complexes, envisagez un système dans lequel les rôles sont stockés dans leur propre collection plutôt que comme un champ sur le document cible.