Search code examples
firebasegoogle-cloud-firestorefirebase-security

How do you force a Firestore client app to maintain a correct document count for a collection?


Firestore doesn't have a way to query for the size of a collection, so if a client app needs to know this value, it requires some maintenance of a document in another collection to hold this count. However, it requires that the client to correctly perform a transaction in order to keep this count up to date while adding and removing documents. A malicious or broken client could modify either the collection or the count independently, and create a situation where the count isn't accurate.

It's possible to force clients through a backend to enforce this, or use a Cloud Functions trigger to automatically maintain the count (which will take effect after some delay). However, I don't want to introduce a backend, and I'd rather use security rules. How can I do this?


Solution

  • Imagine you have a collection "messages" that holds messages that clients can add and remove. Also imagine a document in a different collection with the path "messages-stats/data" with a field called "count" that maintains an accurate count of the documents in messages. If the client app performs a transaction like this to add a document:

    async function addDocumentTransaction() {
        try {
            const ref = firestore.collection("messages").doc()
            const statsRef = firestore.collection("messages-stats").doc("data")
            await firestore.runTransaction(transaction => {
                transaction.set(ref, {
                    foo: "bar"
                })
                transaction.update(statsRef, {
                    count: firebase.firestore.FieldValue.increment(1),
                    messageId: ref.id
                })
                return Promise.resolve()
            })
            console.log(`Added message ${ref.id}`)
        }
        catch (error) {
            console.error(error)
        }
    }
    

    Or a batch like this:

    async function addDocumentBatch() {
        try {
            const batch = firestore.batch()
            const ref = firestore.collection("messages").doc()
            const statsRef = firestore.collection("messages-stats").doc("data")
            batch.set(ref, {
                foo: "bar"
            })
            batch.update(statsRef, {
                count: firebase.firestore.FieldValue.increment(1),
                messageId: ref.id
            })
            await batch.commit()
            console.log(`Added message ${ref.id}`)
        }
        catch (error) {
            console.error(error)
        }
    }
    

    And like this to delete a document using a transaction:

    async function deleteDocumentTransaction(id) {
        try {
            const ref = firestore.collection("messages").doc(id)
            const statsRef = firestore.collection("messages-stats").doc("data")
            await firestore.runTransaction(transaction => {
                transaction.delete(ref)
                transaction.update(statsRef, {
                    count: firebase.firestore.FieldValue.increment(-1),
                    messageId: ref.id
                })
                return Promise.resolve()
            })
            console.log(`Deleted message ${ref.id}`)
        }
        catch (error) {
            console.error(error)
        }
    }
    

    Or like this with a batch:

    async function deleteDocumentBatch(id) {
        try {
            const batch = firestore.batch()
            const ref = firestore.collection("messages").doc(id)
            const statsRef = firestore.collection("messages-stats").doc("data")
            batch.delete(ref)
            batch.update(statsRef, {
                count: firebase.firestore.FieldValue.increment(-1),
                messageId: ref.id
            })
            await batch.commit()
            console.log(`Deleted message ${ref.id}`)
        }
        catch (error) {
            console.error(error)
        }
    }
    

    Then you can use security rules to require that both the document being added or removed can only be changed at the same time as the document with the count field. Minimally:

    rules_version = '2';
    service cloud.firestore {
      match /databases/{database}/documents {
    
        match /messages/{id} {
          allow read;
          allow create: if
            getAfter(/databases/$(database)/documents/messages-stats/data).data.count ==
                 get(/databases/$(database)/documents/messages-stats/data).data.count + 1;
          allow delete: if
            getAfter(/databases/$(database)/documents/messages-stats/data).data.count ==
                 get(/databases/$(database)/documents/messages-stats/data).data.count - 1;
        }
    
        match /messages-stats/data {
          allow read;
          allow update: if (
            request.resource.data.count == resource.data.count + 1 &&
            existsAfter(/databases/$(database)/documents/messages/$(request.resource.data.messageId)) &&
               ! exists(/databases/$(database)/documents/messages/$(request.resource.data.messageId))
          ) || (
            request.resource.data.count == resource.data.count - 1 &&
            ! existsAfter(/databases/$(database)/documents/messages/$(request.resource.data.messageId)) &&
                   exists(/databases/$(database)/documents/messages/$(request.resource.data.messageId))
          );
        }
    
      }
    }
    

    Note that the client must:

    • Increment or decrement the count in /messages-stats/data while adding or removing a document.
    • Must provide the id of the document being added or removed in the "data" document in a field called messageId.
    • Incrementing the count requires that the new document identified in messageId must not exist before the batch/transaction commits, and exist after transaction.
    • Decrementing the count requires that the old document identified in messageId must exist before the batch/transaction commits, and must not exist after the transaction.

    Note that existsAfter() checks the state of the named document after the transaction would complete, while exists() checks it before. The difference between these two functions is important to how these rules work.

    Also note that this will not scale well under heavy load. If documents are being added and removed faster than 10 per second, the per-document write rate will be exceeded for the data document, and the transaction will fail.

    Once you have this in place, now you can actually write security rules to limit the size of a collection like this:

    match /messages/{id} {
      allow create: if
        get(/databases/$(database)/documents/messages-stats/data).data.count < 5;
    }