Search code examples
javascriptfirebasegoogle-cloud-firestorefirebase-security

Firebase Firestore security rules - MapDiff for nested objects


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


Solution

  • 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 :)