Acceso seguro a los datos para usuarios y grupos

Muchas apps colaborativas permiten que los usuarios lean y escriban diversos tipos de datos en función de un conjunto de permisos. Por ejemplo, en una app de edición de documentos, los usuarios tienen la opción de permitir que otros usuarios lean y escriban en sus documentos y, a la vez, bloquear el acceso a personas no deseadas.

Solución: Control de acceso según la función

Puedes aprovechar el modelo de datos de Cloud Firestore y las reglas de seguridad personalizadas para implementar el control de acceso según la función en tu app.

Supongamos que estás compilando una aplicación de escritura colaborativa en la que los usuarios pueden crear “historias” y “comentarios” con los siguientes requisitos de seguridad:

  • Cada historia tiene un propietario y se puede compartir con los “escritores”, “comentaristas” y “lectores”.
  • Los lectores solo pueden ver las historias y los comentarios. No pueden editar nada.
  • Los comentaristas tienen el mismo nivel de acceso que los lectores, pero pueden agregar comentarios a las historias.
  • Los escritores tienen el mismo nivel de acceso que los comentaristas, pero pueden editar el contenido de las historias.
  • Los propietarios pueden editar cualquier parte de la historia y controlar el nivel de acceso de los otros usuarios.

Estructura de datos

Supongamos que tu app tiene una colección de stories en la que cada documento representa una historia. A su vez, cada historia tiene una subcolección de comments en la que cada documento representa un comentario en ella.

Para realizar un seguimiento de las funciones de acceso, agrega un campo roles que actúe como mapa de los ID de usuario para las funciones:

/stories/{storyid}

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

Los comentarios solo contienen dos campos, el ID de usuario del autor y algo de contenido:

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

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

Reglas

Ahora que registraste las funciones de los usuarios en la base de datos, debes escribir las reglas de seguridad para validarlas. Estas suponen que la app usa Firebase Auth para que la variable request.auth.uid sea el ID de usuario.

Paso 1: Comienza con un archivo de reglas básicas que incluya reglas vacías para las historias y los comentarios.

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

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

Paso 2: Agrega una regla write sencilla que permita a los propietarios tener todo el control sobre las historias. Las funciones definidas permiten determinar las funciones de un usuario y la validez de los documentos nuevos:

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} {
            // ...
         }
     }
   }
}

Paso 3: Escribe una regla que permita a un usuario con cualquier función leer las historias y los comentarios. Usar las funciones que se definieron en los pasos anteriores generará reglas concisas y legibles:

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

Paso 4: Permite que los escritores, los comentaristas y los propietarios de historias publiquen comentarios. Ten en cuenta que esta regla también permite que el owner del comentario sea el usuario solicitante, lo que evita que otros usuarios sobrescriban los comentarios de los demás:

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

Paso 5: Permite que los escritores editen el contenido de las historias, pero no las funciones de estas ni otras propiedades del documento. Esta acción requiere separar la regla write de las historias en varias reglas independientes para create, update y delete, ya que los escritores solo pueden actualizar las historias:

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

Limitaciones

La solución anterior demuestra cómo proteger los datos del usuario con las reglas de seguridad, pero debes tener en cuenta los siguientes límites:

  • Nivel de detalle: En el ejemplo anterior, varias funciones (escritor y propietario) tienen acceso de escritura al mismo documento, pero con distintos límites. Esta característica puede ser difícil de administrar en documentos más complejos, por lo que se recomienda separarlos en varios documentos más pequeños y que cada uno pertenezca a una función en particular.
  • Grupos grandes: Si tienes que compartir con grupos muy grandes o complejos, recomendamos que uses un sistema en el que las funciones se almacenen en una colección propia, no como un campo en el documento de destino.