I'm trying to set Firestore rules, allowing user to update only certain fields inside of a nested object. I know if it's a top level key, we can simply use something like this:
allow update: if request.auth.uid == userID &&
request.resource.data.diff(resource.data).affectedKeys().hasOnly(["key1", "key2"]);
However, I'm stuck with a problem restricting updates for keys inside an object, lets call it "someData". This is what I have tried, it seems logical, but it doesn't work:
allow update: if request.auth.uid == userID &&
request.resource.data.diff(resource.data).affectedKeys().hasOnly("someData", "otherData") &&
(!("someData" in request.resource.data.keys()) ||
(request.resource.data["someData"].diff(resource.data["someData"])
.affectedKeys().hasOnly(["key1", "key2"]));
Or like this:
allow update: if request.auth.uid == userID &&
request.resource.data.diff(resource.data).affectedKeys().hasOnly("someData", "otherData") &&
(!("someData" in request.resource.data.keys()) ||
(request.resource.data.someData.diff(resource.data.someData)
.affectedKeys().hasOnly(["key1", "key2"]));
Neither works.
Is there a way protecting nested fields?
Thanks!
I tried to test in test environment using "mocha", tried with real Firestore, tried to put this logic in separate function. Nothing works.
Firebase Docs only say about top level keys of the document. I found one solution here, similar to my first example, but in real life it doesn't seem to be working.
Am I doing anything wrong?
Update with more specific details
These are my original rules (working on a taxi booking app):
allow update: if request.auth.uid == userID &&
editOnlyFields(["name", "phoneNumber", "driversData", "waitingDriverApproval", "lastUpdate"]) &&
request.resource.data.lastUpdate == request.time &&
(!("name" in request.resource.data.keys()) ||
request.resource.data.name is string) &&
(!("phoneNumber" in request.resource.data.keys()) ||
request.resource.data.phoneNumber is string) &&
(!("waitingDriverApproval" in request.resource.data.keys()) ||
(request.resource.data.waitingDriverApproval is bool && request.resource.data.waitingDriverApproval == true)) &&
//nested driversData (pain in the butt)
(!("driversData" in request.resource.data.keys()) ||
(request.resource.data.driversData.keys().hasOnly(["licensingAuthority", "amountOfVehicles", "DVLACheckCode", "DVLALicence", "PHBadge", "PHDriversLicence"])) &&
(!("licensingAuthority" in request.resource.data.driversData.keys()) ||
request.resource.data.driversData.licensingAuthority is string) &&
(!("amountOfVehicles" in request.resource.data.driversData.keys()) ||
request.resource.data.driversData.amountOfVehicles is int) &&
(!("DVLACheckCode" in request.resource.data.driversData.keys()) ||
(request.resource.data.driversData.DVLACheckCode.keys().hasOnly(["code", "pending"]) &&
isValidDVLACheckCode(request.resource.data.driversData.DVLACheckCode))) &&
(!("DVLALicence" in request.resource.data.driversData.keys()) ||
(request.resource.data.driversData.DVLALicence.keys().hasOnly(["downloadUrl", "pending"]) &&
isValidDriversPaperwork(request.resource.data.driversData.DVLALicence))) &&
(!("PHBadge" in request.resource.data.driversData.keys()) ||
(request.resource.data.driversData.PHBadge.keys().hasOnly(["downloadUrl", "pending"]) &&
isValidDriversPaperwork(request.resource.data.driversData.PHBadge))) &&
(!("PHDriversLicence" in request.resource.data.driversData.keys()) ||
(request.resource.data.driversData.PHDriversLicence.keys().hasOnly(["downloadUrl", "pending"]) &&
isValidDriversPaperwork(request.resource.data.driversData.PHDriversLicence))));
}
function editOnlyFields(allowedFields) {
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(allowedFields);
}
It all went well, till I started working on admin part. the admin has auth token custom claim isBoss: true
Rules allow read and write if isBoss()
match /{document=**} {
allow read, write: if isBoss();
}
function isBoss() {
return request.auth.token.isBoss == true;
}
Now with original rules, I am facing a problem - when admin changes anything inside user's driversData
, something that user wouldn't be authorized to do, like approvedDriver: true
, then user is not able to update their driversData
because request object contains all fields that would be present after update.
So, I started rules update with replacing this line:
request.resource.data.driversData.keys().hasOnly(allowedFields)
With this one:
(editOnlyFieldsNested("driversData", ["licensingAuthority", "amountOfVehicles", "DVLACheckCode", "DVLALicence", "PHBadge", "PHDriversLicence"]))
Where
function editOnlyFieldsNested(nestedObj, allowedFields) {
return request.resource.data[nestedObj].diff(resource.data[nestedObj]).affectedKeys().hasOnly(allowedFields);
}
When I run my Firestore emulator, this is the function that fails:
it("Can update a user document only with allowed fields", async () => {
await testEnv.withSecurityRulesDisabled((context) => {
return context.firestore().collection("users").doc(myId).set({
name: "User Abc",
email: "[email protected]",
createdAt: serverTimestamp(),
});
});
const testDoc = myUser.firestore().collection("users").doc(myId);
await assertSucceeds(
testDoc.update({
name: "New Name",
phoneNumber: "after",
driversData: { amountOfVehicles: 1, licensingAuthority: "Bristol" },
lastUpdate: serverTimestamp(),
})
);
});
If I run this with old rules (without MapDiff), it passes.
If I use new rules with editOnlyFieldsNested()
it only passes if I comment out driversData
property, otherwise it gives me an error:
Can update a user document only with allowed fields:
FirebaseError: 7 PERMISSION_DENIED:
Property isBoss is undefined on object. for 'update' @ L8, evaluation error at L17:24 for 'update' @ L17, Property isBoss is undefined on object. for 'update' @ L8, Property driversData is undefined on object. for 'update' @ L17
Perhaps it can be something to do on driversData undefined on object... Why? I'm stuck on this for a couple days, my laptop is covered with tears..
Any advise would be gratefully appreciated
I think, the main problem lies in the fact that .diff()
method tries to compare 2 objects - request
and resource
. If resource doesn't have driversData
object, test (and real request) will fail.
I see 2 ways of solving. Either create all users with empty driversData
, that also has nested objects, or rewrite rules that also work if driversData
doesn't exist.
UPDATE + SOLUTION
Finally got it all sorted. My solution was to create a function that firstly checks if driversData
exists in a resource document, then creates a MapDiff by using .diff()
function, but if the update is coming for a resource that doesn't have driversData
yet, it uses
.keys().hasOnly(allowedFields);
on a request object.
And because we can't have any "if statements" in Firestore rules helper functions, I used a combination of "&&" and "||", which appeared to be not too clunky:
function editOnlyFieldsNested(nestedObj, allowedFields) {
return (nestedObj in resource.data && request.resource.data[nestedObj].diff(resource.data[nestedObj]).affectedKeys().hasOnly(allowedFields))
|| request.resource.data[nestedObj].keys().hasOnly(allowedFields);
}
In addition, I think it's worth pointing out that the function above doesn't seem to work correctly if we use DOT notation (like "driversData.DVLALicence"
) as an argument for nestedObj
.
Therefore, because my "DVLALicence"
is also an object/map, I had to create another function that can be re-used for any other object sitting inside driversData
:
function editOnlyFieldsNestedInDriversData(nestedObj, allowedFields) {
return (nestedObj in resource.data.driversData && request.resource.data.driversData[nestedObj].diff(resource.data.driversData[nestedObj]).affectedKeys().hasOnly(allowedFields))
|| request.resource.data.driversData[nestedObj].keys().hasOnly(allowedFields);
}
And, of course, big thanks to Frank van Puffelen for the advice about DOT notation, if we don't want to replace whole nested object during the update. I hope it will help anyone who bumps into this or similar problem.
...and another update
Please don't use my original rules (in my question) as reference. Something like this bit:
!("approved" in request.resource.data.keys())
//do something
This check is not good enough and may lead to problems.
For example, if an admin changes approved
field to true
, which is not allowed for ordinary user, and then our ordinary user wants to update something that they ARE allowed to update - it will be rejected by rules.
We have this weird Firestore feature that request.resource.data
contains all keys as they will be after the update, therefore even if our user didn't send the request containing approved
field, it will be still included and the request rejected.
Instead I'm using functions like this:
function isAddingField(field) {
return !(field in resource.data) && (field in request.resource.data);
}
function isUpdatingExistingField(field) {
return (field in resource.data && field in request.resource.data && resource.data[field] != request.resource.data[field]);
}
function isUpdatingField(field) {
return isAddingField(field) || isUpdatingExistingField(field);
}
Hope it helps :)