Search code examples
typescriptfirebasegoogle-cloud-firestoregoogle-cloud-functionsfirebase-security

Can a Firestore Rules wildcard match statement support a list query snapshot listener


I have set up my security rules, and verified that they operate correctly when using get requests, but fail with list requests. Below is the basic structure of my database:

/parent_collection
   /parent_doc
      ...data,
      [status]: Enum
      /sub_collection1
         /sub_doc1
            ...data
         /sub_doc2
            ...data
      /sub_collection2
         /sub_doc1
            ...data
         /sub_doc2
            ...data

I have the following rule as one of the match statements for the parent_collection

function isPublished(docId) {
   let data = get(/databases/$(database)/documents/parent_collection/$(docId)).data;
   return 'status' in data && data.status == "published";
}

match /parent_collection/{docId}/{document=**} {
   // Deny all requests to create, update, or delete the doc
   allow create, update, delete: if false

   // Allow the requestor to read the doc if published
   allow read: if isPublished(docId)
}

And finally the three client side queries I have written

// is accepted by firestore rules
const getDocById: GetDocById = (docId) => {
   return new Promise((resolve, reject) => {
      getDoc(doc(firestore, "parent_collection", docId))
         .then((doc) => {
            resolve(doc.data());
         })
         .catch((error) => reject(error));
   });
};

// is rejected by firestore rules
const getDocsWithCursor: GetDocsWithCursor = (cursor) => {
   const docs: Doc[] = [];
   return new Promise((resolve, reject) => {
      let q = query(
         collection(firestore, "parent_collection"),
         where("status", "==", "published"),
         orderBy("updated")
      );

   if (cursor) q = query(q, startAfter(cursor));

   getDocs(q)
      .then((snapshot) => {
         snapshot.forEach((doc) => {
            docs.push(doc.data());
         });

         return resolve([docs, snapshot.docs[snapshot.docs.length - 1]]);
      })
      .catch((error) => {
         return reject(error);
      });
   });
};

// is rejected by firestore rules
const attachCollectionListener: AttachCollectionListener = (docId, callback) => {
   return onSnapshot(
      query(
         collection(
            firestore,
            "parent_collection",
            docId,
            "sub_collection1"
         ),
         orderBy("order")
      ),
      (snapshot) => {
         snapshot.docChanges().forEach((change) => {
            callback(
               change.type,
               change.doc.data(),
               change.newIndex
            );
         });
      }
   );
};

I have spent a long time trying to figure out why the list query failed and I found out several interesting notes from other questions that explain why it fails.

  1. As it is implemented currently, the wildcard variable has an undefined value for list (query) type requests (but not for get type requests, because the document ID is of course coming directly from the client). Queries might match multiple documents, and your query doesn't specify a document ID, so it can't be used in the rule. (original question).
  2. A security rule "is only checked once per query", and not on a per document basis. (original question)

With all that laid out and explained, I have two problems I am trying to solve.

  1. Is it possible to use a Firestore Rule match statement that relies on a wildcard variable for a list query. If not, I can reject all list requests and move them to a Firebase Functions call and use the admin api. While not ideal, migrating to node functions would not bar a major rehaul.
  2. Is there a way I can structure the rules to support a snapshot listener for a list query. This second problem appears to be the bigger fish to fry as I can't migrate a listener to Firebase Functions (explained here). I use snapshot listeners to support live updates and state syncing between the local and server databases. If I couldn't use listeners a lot of functionality would get removed.

Solution

  • Firestore security rules are checked when the read operation starts and it do not evaluate against all your actual potential documents. Instead the rule merely checks whether the read operation is certain to only access data that it is allowed to access based on static analysis of the read operation/query against the conditions you set in the rules.

    The way you check whether the document is published in your security rules requires that it performs a get call for each document that it evaluates for a list call, which isn't possible according to the above. That's essentially require a join on each document, which wouldn't scale if it was possible.

    The short summary of this is that Firestore queries can only filter on data that is present in the documents they return.


    If you only want to read documents from the parent_collection you could actually get what you want, as you wouldn't need the get call in the rules and could simple do:

    match /parent_collection/{docId}/{document} {
       // Deny all requests to create, update, or delete the doc
       allow create, update, delete: if false
    
       // Allow the requestor to read the doc if published
       allow read: if 'status' in resource.data && resource.data.status == "published"
    }
    

    If you want to allow a read on subcollections under parent_collection too (as its name implies), then the data you want to filter on will have to be also included in each document in those subcollections. There's no workaround for that in this case.