2
votes

Consider this simple Firestore database structure:

1: Firestore Structure

cities/
    city1/
        name:Beijing
        likes: 0
        dislikes: 0
        ... more fields 
    city2/
        name: New York
        likes: 21
        dislikes: 1
        ... more fields 

To protect the database from unintended operations, I added the following Firestore Security Rules

2 Firestore Security Rules:

// Allow read/write access to all users under any conditions
service cloud.firestore {
  match /databases/{database}/documents {    
    match /cities/{cityId} {
      allow read: if request.auth.uid != null;
      allow update: if request.auth.uid != null && 
request.resource.data.keys().hasOnly(["likes", "dislikes"]); 
    }
  }
}

Which basically allows me to enforce these requirements:

  • Authenticated users can READ a city's data.
  • Authenticated users can UPDATE the 'likes' and 'dislikes' fields of a city.

Question

How come, the above setup gives me a Exception: PERMISSION_DENIED: Missing or insufficient permissions., when I try to use batch writes, but succeeds if I write changes individually?

E.g. below code (using Batch write) fails: PERMISSION_DENIED: Missing or insufficient permissions.

fun rate(likes: List<String>, dislikes: List<String>, done: (Boolean) -> Unit) {

    db.runBatch { batch ->
        likes.map { cityCollection.document(it) }.forEach { doc ->
            batch.update(doc,"likes", FieldValue.increment(1))
        }
        dislikes.map { cityCollection.document(it) }.forEach { doc ->
            batch.update(doc,"dislikes", FieldValue.increment(1))
        }
    }.addOnCompleteListener { task ->
        task.exception() // Exception: PERMISSION_DENIED: Missing or insufficient permissions.
        done(task.isSuccessful) // false
    }
}

Whereas without Batching, the code works fine. e.g. this works perfectly:

override fun rate(likes: List<String>, dislikes: List<String>, done: (Boolean) -> Unit) {

    val tasks = likes.map { cityCollection.document(it) }.map { doc ->
        doc.update("likes", FieldValue.increment(1))
    }.union(dislikes.map { cityCollection.document(it) }.map { doc ->
        doc.update("dislikes", FieldValue.increment(1))
    })

    Tasks.whenAllComplete(tasks).addOnCompleteListener { task ->
        done(task.isSuccessful) // true
    }
}

Is there something special about batch writes/transactions that I need to know with respect to my security rules? I would like these updates to execute atomically, hence I tried to use batch writes initially. However, i cannot seem to get them working in combination with my security rules.

2

2 Answers

3
votes

You should know that request.resource.data.keys() contains all the keys in the existing document, not just the ones that are being updated. request.resource.data represents the final state of the document if you allow the write to finish. So, if you're updating a document that already contains other fields, your rule will always reject access, as hasOnly will return false.

Since you're not showing the contents of the existing documents in both cases, it's not really possible to say exactly where this is tripping up your code. But this is almost certainly what's going on here. It's irrelevant whether or not the update came from a single document write, a batch update, or a transaction. They are all gated by the same rules.

(You used to be able to use a property called writeFields to find out only which fields were being updated, but that is deprecated - don't use it.)

If you want to perform per-field restrictions, it's actually much more involved than what you've written now. You have to check if (and only if) a particular field has been modified, while also checking that no other fields have been changed. See this question for more details:

Cloud Firestore Security Rules - only allow write to specific key in document

0
votes

Your document request.resource.data object can (and probably will) have more than just 'likes' and 'dislikes' which will cause rules to fail. The new canonical way to check what fields have changed is by diffing the document against the existing document (this replaces 'writeFields'). I've edited your rules to show this:

service cloud.firestore {
  match /databases/{database}/documents {    
    match /cities/{cityId} {
      function onlyLikedFieldUpdated() {
        return request.resource.data.diff(resource.data).affectedKeys().hasOnly(["likes", "dislikes"]);
      }
      allow read: if request.auth.uid != null;
      allow update: if request.auth.uid != null && onlyLikedFieldUpdated();
    }
  }
}