Workaround approach: Custom functions
You can't use match with internal fields, instead you must make use of the rules.List, rules.Map and rules.Set objects.
It's important to note that rules are static and do not have the ability to iterate over lists (such as using forEach, map, etc). This can be overcome by using someList.size() <= position to check if the list is long enough before performing the element comparison. Unfortunately, this must be hard-coded as you'll see below.
One goal of these rules is that they should be able to be combined with other rules on the same document. i.e. the "cars" map should be restricted but you should still be able to update the "name" and "address" fields.
Throughout this section, the variables are going to be pretty verbose for ease-of-understanding (such as including type information). Rename them to suit your style.
Disclaimer: While this first set of rules works, it's janky and overly specific - not recommended for production use.
service cloud.firestore {
match /databases/{database}/documents {
// assert no changes or that only "salesComment" was changed
function isCarEditAllowed(afterCarMap, beforeCarMap) {
return afterCarMap.diff(beforeCarMap).affectedKeys().size() == 0
|| afterCarMap.diff(beforeCarMap).affectedKeys().hasOnly(["salesComment"]);
}
// assert that if this car exists that it has allowed changes
function isCarAtPosValid(afterCarsList, beforeCarsList, position) {
return afterCarsList.size() <= position // returns true when car doesn't exist
|| isCarEditAllowed(afterCarsList[position], beforeCarsList[position])
}
function areCarEditsAllowed(afterDataMap, beforeDataMap) {
return afterDataMap.get("cars", false) != false // cars field exists after
&& beforeDataMap.get("cars", false) != false // cars field exists before
&& afterDataMap.cars.size() == beforeDataMap.cars.size() // cars field is same length
&& isCarAtPosValid(afterDataMap.cars, beforeDataMap.cars, 0)
&& isCarAtPosValid(afterDataMap.cars, beforeDataMap.cars, 1)
&& isCarAtPosValid(afterDataMap.cars, beforeDataMap.cars, 2)
&& isCarAtPosValid(afterDataMap.cars, beforeDataMap.cars, 3)
&& isCarAtPosValid(afterDataMap.cars, beforeDataMap.cars, 4)
}
match /carUsers/{userId} {
allow read: if request.auth.uid == userId;
allow write: if request.auth.uid == userId
&& areCarEditsAllowed(request.resource.data, resource.data)
}
}
}
Now that the above rules work, they can be improved by abstracting the steps into a set of reusable custom functions.
service cloud.firestore {
match /databases/{database}/documents {
/* Custom Functions: Restrict map changes */
function mapHasAllowedChanges(afterMap, beforeMap, setOfWhitelistedKeys) {
return afterMap.diff(beforeMap).affectedKeys().size() == 0 // no changes
|| setOfWhitelistedKeys.hasAll(afterMap.diff(beforeMap).affectedKeys()) // only named keys may be changed
}
function mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, position) {
return afterList.size() <= position // returns true when element doesn't exist
|| mapHasAllowedChanges(afterList[position], beforeList[position], setOfWhitelistedKeys)
}
function listOfMapsHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys) {
return mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 0)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 1)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 2)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 3)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 4)
}
function largeListOfMapsHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys) {
return mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 0)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 1)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 2)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 3)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 4)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 5)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 6)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 7)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 8)
&& mapInListHasAllowedChanges(afterList, beforeList, setOfWhitelistedKeys, 9)
}
function namedListWithSameSizeExists(listPath) {
return request.resource.data.get(listPath, false) != false
&& resource.data.get(listPath, false) != false
&& request.resource.data.get(listPath, {}).size() == resource.data.get(listPath, {}).size()
}
function namedListOfMapsWithSameSizeExistsWithAllowedChanges(listPath, setOfWhitelistedKeys) {
return namedListWithSameSizeExists(listPath)
&& listOfMapsHasAllowedChanges(request.resource.data.get(listPath, {}), resource.data.get(listPath, {}), setOfWhitelistedKeys)
}
/* Rules */
match /carUsers/{userId} {
allow read: if request.auth.uid == userId;
allow write: if request.auth.uid == userId
&& namedListOfMapsWithSameSizeExistsWithAllowedChanges("cars", ["salesComment"].toSet())
}
}
}
Note: The above rules do not assert that the whitelisted keys were not deleted. To ensure that the listed keys are present after the change, you will need to replace the mapHasAllowedChanges function with:
function mapHasAllowedChanges(afterMap, beforeMap, setOfWhitelistedKeys) {
return afterMap.diff(beforeMap).affectedKeys().size() == 0 // no changes
|| (setOfWhitelistedKeys.hasAll(afterMap.diff(beforeMap).affectedKeys()) // only named keys may be changed
&& afterMap.keys().toSet().hasAll(setOfWhitelistedKeys)) // all named keys must exist
}
Recommended approach: Move to cars to subcollection
The above rules are quite complex, and can be simplified if you move the cars to their own collection and make use of rules.Map#diff.
The below code will only allow a write to take place if the user owns that car document and is also modifying only the salesComment key (modify = add/change/remove).
service cloud.firestore {
match /databases/{database}/documents {
match /user/{userId} {
allow read, write: if request.auth.uid == userId;
match /cars/{carId} {
allow read: if request.auth.uid == userId; // Firestore rules don't cascade to subcollections, so this is also needed
allow write: if request.auth.uid == userId
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(["salesComment"]);
}
}
}
}
If you require that salesComment must be present after a write (add/change allowed - but not remove), you can also ensure that is still present using k in x.
service cloud.firestore {
match /databases/{database}/documents {
match /user/{userId} {
allow read, write: if request.auth.uid == userId;
match /cars/{carId} {
allow read: if request.auth.uid == userId; // Firestore rules don't cascade to subcollections, so this is also needed
allow write: if request.auth.uid == userId
&& "salesComment" in request.resource.data
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(["salesComment"]);
}
}
}
}