Accès sécurisé aux données pour les utilisateurs et les groupes

De nombreuses applications collaboratives permettent aux utilisateurs de lire et d'écrire différents éléments de données en fonction d'un ensemble d'autorisations. Dans une application d'édition de documents, par exemple, les utilisateurs peuvent souhaiter autoriser quelques utilisateurs à lire et à écrire leurs documents tout en bloquant les accès indésirables.

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

Vous pouvez profiter du modèle de données de Cloud Firestore ainsi que des 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 construisiez une application d'écriture collaborative dans laquelle les utilisateurs peuvent créer des « histoires » et des « commentaires » avec les exigences de sécurité suivantes :

  • Chaque histoire a un propriétaire et peut être partagée avec des « écrivains », des « commentateurs » et des « lecteurs ».
  • Les lecteurs ne peuvent voir que les histoires et les commentaires. Ils ne peuvent rien modifier.
  • Les commentateurs ont tous les accès des lecteurs et peuvent également ajouter des commentaires à une histoire.
  • Les écrivains ont tous les accès des commentateurs et peuvent également modifier le contenu de l'histoire.
  • Les propriétaires peuvent modifier n'importe quelle partie d'une histoire et contrôler l'accès des autres utilisateurs.

Structure de données

Supposons que votre application dispose d’une collection stories dans laquelle chaque document représente une histoire. Chaque histoire possède également une sous-collection comments dans laquelle chaque document est un commentaire sur cette histoire.

Pour garder une trace des rôles d'accès, ajoutez un champ roles qui est une carte des ID utilisateur aux rôles :

/histoires/{identifiant de l'histoire}

{
  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 de l'auteur et du 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 supposent que l'application utilise Firebase Auth afin que la variable request.auth.uid soit l'ID de l'utilisateur.

Étape 1 : Commencez avec un fichier de règles de base, qui comprend des règles vides pour les histoires 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 donne aux propriétaires un contrôle total sur les histoires. Les fonctions définies aident à déterminer les rôles d'un utilisateur et si les 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 && isOneOfRoles(request.resource, ['owner']);
        }

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

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

Étape 3 : Écrivez des règles qui permettent à un utilisateur de n'importe quel rôle de lire des histoires et des commentaires. L'utilisation des fonctions définies à l'étape précédente permet de conserver 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 auteurs d'histoires, les commentateurs et les propriétaires à publier des commentaires. Notez que cette règle valide également que le owner du commentaire correspond à l'utilisateur demandeur, ce qui empêche les utilisateurs d'écraser 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'histoire, mais pas de modifier les rôles de l'histoire ou de modifier toute autre propriété du document. Cela nécessite de diviser la règle write des histoires en règles distinctes pour create , update et delete puisque les rédacteurs ne peuvent mettre à jour que les histoires :

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

Limites

La solution présentée ci-dessus illustre la sécurisation des données utilisateur à l'aide de règles de sécurité, mais vous devez être conscient des limitations suivantes :

  • Granularité : Dans l'exemple ci-dessus, plusieurs rôles (écrivain et propriétaire) ont un accès en écriture au même document mais avec des limitations différentes. Cela peut devenir difficile à gérer avec des documents plus complexes et il peut être préférable de diviser des documents uniques en plusieurs documents appartenant chacun à un seul rôle.
  • Grands groupes : si vous avez besoin de partager avec des groupes très grands ou complexes, envisagez un système dans lequel les rôles sont stockés dans leur propre collection plutôt que sous forme de champ sur le document cible.