In Cloud Firestore Security Rules, an array is called a List
and an array of unique values is a Set
.
Keeping this terminology, you want to check if the set of values for likes
had request.auth.uid
added to/removed from it, or was left unchanged. You also want to assert that the only changes were related to that value.
We can do this with the aid of Custom Functions in our rules.
First, let's write a function to assert that a list is unique:
// Checks whether the given list is unique
function isListUnique(list) {
return list.toSet().size() == list.size();
}
// Examples:
isListUnique(['a', 'b']) == true
isListUnique(['a', 'a']) == false
Next, we want to make sure that the initial list of unique values and the final list of unique values differ only by the given list of values.
// Checks whether the two sets differ by only the given changes set
function doesSetDifferBy(setA, setB, setChanges) {
return setA.difference(setB).union(setB.difference(setA)) == setChanges;
}
// Examples:
doesSetDifferBy(['a', 'b'].toSet(), ['a', 'c'].toSet(), ['b'].toSet()) == false // differs by ['b','c'].toSet() not ['b'].toSet()
doesSetDifferBy(['a', 'b'].toSet(), ['a'].toSet(), ['b'].toSet()) == true
doesSetDifferBy(['a', 'b'].toSet(), ['a', 'b'].toSet(), ['b'].toSet()) == false // sets are equal, not different
doesSetDifferBy(['a'].toSet(), ['a', 'b'].toSet(), ['b'].toSet()) == true
While we could call the above function using this next line, we should first make sure that the input lists are unique:
doesSetDifferBy(resource.data.likes.toSet(), request.resource.data.likes.toSet(), [request.auth.uid].toSet())
To assert that two lists are unique and differ by only the given list of changes, you would use this function:
// Checks whether the two lists are both unique and differ by only the given changes list
function doesUniqueListDifferBy(listA, listB, listChanges) {
return isListUnique(listA) && isListUnique(listB)
&& doesSetDifferBy(listA.toSet(), listB.toSet(), listChanges.toSet())
}
// Examples:
doesUniqueListDifferBy(['a', 'b'], ['a', 'c'], ['b']) == false // differs by ['b','c'] not ['b']
doesUniqueListDifferBy(['a', 'b'], ['a'], ['b']) == true
doesUniqueListDifferBy(['a', 'b'], ['a', 'b'], ['b']) == false // lists are equal, not different
doesUniqueListDifferBy(['a'], ['a', 'b'], ['b']) == true
doesUniqueListDifferBy(['a', 'a'], ['a', 'b'], ['b']) == false // first list isn't unique
For your application, you also want to make sure that if the likes
list was unchanged, that it is allowed through. We can achieve this by tweaking the doesUniqueListDifferBy()
function from above to:
// Checks whether the two lists are both unique and either are
// equal or differ by only the given changes list
function isUniqueListUnchangedOrDiffersBy(listA, listB, listChanges) {
return isListUnique(listA) && isListUnique(listB)
&& (listA.toSet() == listB.toSet()
|| doesSetDifferBy(listA.toSet(), listB.toSet(), listChanges.toSet()))
}
// Examples:
isUniqueListUnchangedOrDiffersBy(['a', 'b'], ['a', 'c'], ['b']) == false // differs by ['b','c'] not ['b']
isUniqueListUnchangedOrDiffersBy(['a', 'b'], ['a', 'b'], ['b']) == true
isUniqueListUnchangedOrDiffersBy(['a', 'b'], ['a'], ['b']) == true
isUniqueListUnchangedOrDiffersBy(['a'], ['a', 'b'], ['b']) == true
isUniqueListUnchangedOrDiffersBy(['a', 'a'], ['a', 'b'], ['b']) == false // first list isn't unique
Rolling the above functions together, your rules will look like:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Checks whether the given list is unique
function isListUnique(list) {
return list.toSet().size() == list.size();
}
// Checks whether the two sets differ by only the given changes set
function doesSetDifferBy(setA, setB, setChanges) {
return setA.difference(setB).union(setB.difference(setA)) == setChanges;
}
// Checks whether the two lists are both unique and either are
// equal or differ by only the given changes list
function isUniqueListUnchangedOrDiffersBy(listA, listB, listChanges) {
return isListUnique(listA) && isListUnique(listB)
&& (listA.toSet() == listB.toSet()
|| doesSetDifferBy(listA.toSet(), listB.toSet(), listChanges.toSet()))
}
match /requests/{requestID} {
allow update: if isUniqueListUnchangedOrDiffersBy(resource.data.likes, request.resource.data.likes, [request.auth.uid]);
}
}
}
You also need to update your client's Dart code to:
FirebaseFirestore.instance.collection("requests").doc(id)
.update({
"likes": FieldValue.arrayRemove(FirebaseAuth.instance.currentUser.uid)
});
If you are a fan of syntactic sugar, you could also tweak the function to take just the property name, but this won't support nested properties.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Checks whether the given list is unique
function isListUnique(list) {
return list.toSet().size() == list.size();
}
// Checks whether the two sets differ by only the given changes set
function doesSetDifferBy(setA, setB, setChanges) {
return setA.difference(setB).union(setB.difference(setA)) == setChanges;
}
// Checks whether the two lists are both unique and either are
// equal or differ by only the given changes list
function isUniqueListUnchangedOrDiffersBy(listA, listB, listChanges) {
return isListUnique(listA) && isListUnique(listB)
&& (listA.toSet() == listB.toSet()
|| doesSetDifferBy(listA.toSet(), listB.toSet(), listChanges.toSet()))
}
// Checks whether the property contains a unique list that was either
// unchanged or differs by only the given changes list
function isUniqueListDataPropUnchangedOrDiffersBy(propName, listChanges) {
return isUniqueListUnchangedOrDiffersBy(resource.data[propName], request.resource.data[propName], listChanges)
}
match /requests/{requestID} {
allow update: if isUniqueListDataPropUnchangedOrDiffersBy("likes", [request.auth.uid]);
}
}
}