Search code examples
firebasesecuritygoogle-cloud-firestorefirebase-security

Firestore won't let me add one more map to they array


In my Firestore, I have a collection of documents named "items". Each document in my collection has a random ID and some properties. One of these properties might be "reviews", which is added by my client-side. I have a function that adds "reviews" array with one map (which would be one review). Each user should be able to add only one review on each of the items. Here's the function itself:

const handleSubmit = async () => {
    const itemRef = doc(db, "items", itemId);
    await updateDoc(itemRef, {
      reviews: arrayUnion({
        userId,
        rating,
        review,
        userName,
      }),
    });
  };

And here are the rules of the Firestore:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /items/{itemId} {
      allow read;
      allow create, update: if request.auth != null
      && request.resource.data.reviews.size() == 1
      && 'reviews' in request.resource.data
      && request.resource.data.reviews[0].keys().hasAll(['userId', 'rating', 'review', 'userName'])
      && request.resource.data.reviews[0].size() == 4
      && request.resource.data.reviews[0].userId == request.auth.uid
      && !(exists(/databases/$(database)/documents/items/$(itemId)/reviews/$(request.resource.data.reviews[0].userId)))
    }
  }
}

For some odd reason, this ruleset would only let me add one review (along with creating the array itself).

Why wouldn't it let me add one more map (which is one review) to my "reviews" array? I've been struggling with this for couple of day and I couldn't find the answer in the documentation, so I have no idea what am I doing wrong.

EDIT: As @l1b3rty suggested, I've changed my data structure from array to map as it is impossible to do what I need with array. I still struggle with one thing: checking if user has already left a review and deny doing so if user did already left a review.

My handleSubmit():

const handleSubmit = async () => {
    const itemRef = doc(db, "items", itemId);
    const reviewData = {
      rating,
      review,
      userName,
    };
    const reviewPath = `reviews.${userId}`;
    await updateDoc(itemRef, {
      [reviewPath]: reviewData,
    });
  };

My rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /items/{itemId} {
      allow read: if true;
      allow write: if request.auth != null
      && request.resource.data.reviews.keys().hasAll([request.auth.uid])
      && request.resource.data.reviews[request.auth.uid].rating is int
      && request.resource.data.reviews[request.auth.uid].rating >=1
      && request.resource.data.reviews[request.auth.uid].rating <= 5
      && request.resource.data.reviews[request.auth.uid].review is string
            && request.resource.data.reviews[request.auth.uid].userName is string
    }
  }
}

Solution

  • You are valdiating for the review array to be of size 1:

    request.resource.data.reviews.size() == 1
    

    So once created, you wont be able to add any reviews, logical.

    If you want to be able to add a new review, split the create and update methods:

    rules_version = '2';
    service cloud.firestore {
      match /databases/{database}/documents {
        match /items/{itemId} {
          allow read;
          allow create: if request.auth != null
            && request.resource.data.reviews.size() == 1
            && 'reviews' in request.resource.data
            && request.resource.data.reviews[0].keys().hasAll(['userId', 'rating', 'review', 'userName'])
            && request.resource.data.reviews[0].size() == 4
            && request.resource.data.reviews[0].userId == request.auth.uid
            && !(exists(/databases/$(database)/documents/items/$(itemId)/reviews/$(request.resource.data.reviews[0].userId)))
          allow update: if request.auth != null
            && 'reviews' in request.resource.data
        }
      }
    }
    

    Note that this will not validate the new review, I am pretty sure you will have to change your data model to do that: using an array will not let you access the review being added or removed by the current user. Use a map instead:

    reviews
      uid1
        rating
        review
        userName
      uid2
        rating
        review
        userName
    

    That way you can:

    1. Ensure a user is only adding/removing/updating his own review
    2. Validate the added/removed/updated review

    Use the map.diff to identify what's being changed.

    Your requirements are unclear to me but here is an untested example that should be a good start:

    rules_version = '2';
    service cloud.firestore {
      match /databases/{database}/documents {
        match /items/{itemId} {
          allow read;
          allow create: if request.auth != null
            && request.resource.data.reviews is map
            && request.resource.data.reviews.size() == 1
            && request.auth.uid in request.resource.data.reviews
            && validatereview(request.resource.data.reviews[request.auth.uid])
          allow update: if request.auth != null
            && request.resource.data.diff(resource.data).affectedKeys().hasOnly(["reviews"])    // Ensures only reviews are modified
            && !(request.auth.uid in resource.data.reviews)                                     // Ensures a review does not already exist for the current user
            && request.resource.data.reviews.diff(resource.data.reviews).affectedKeys().hasOnly([request.auth.uid]) // Ensures only the review for the current user is modified
            && validatereview(request.resource.data.reviews[request.auth.uid]);
        
          function validatereview(review) {
            return review.keys().hasAll(['rating', 'review', 'userName'])
              && review.size() == 3
              && review.rating is int
              && review.rating >=1
              && review.rating <= 5
              && review.review is string        // You may want to check for the string max length here
              && review.userName is string      // Same
          }
        }
      }
    }
    

    Here a user can only add reviews, no delete or update are allowed. Tweak as you wish.

    Note: to make the solution scalable and easier to handle with rules you should create one review document per review. This will cause more reads (1 per doc) if you want to get all reviews but is the way Firestore is designed to be used for.