23

For the life of me, I cannot understand why the following is resulting in a false for allowing writes. Assume my users collection is empty to start, and I am writing a document of the following form from my Angular frontend:

{
  displayName: 'FooBar',
  email: 'foo.bar@example.com'
}

My current security rules:

service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      function isAdmin() {
        return resource.data.role == 'ADMIN';
      }

      function isEditingRole() {
        return request.resource.data.role != null;
      }

      function isEditingOwnRole() {
        return isOwnDocument() && isEditingRole();
      }

      function isOwnDocument() {
        return request.auth.uid == userId;
      }

      allow read: if isOwnDocument() || isAdmin();
      allow write: if !isEditingOwnRole() && (isOwnDocument() || isAdmin());
    }
  }
}

In general, I want no users to be able to edit their own role. Regular users can edit their own document otherwise, and admins can edit anyone's.

Stubbing isEditingRole() for false gives the expected result, so I've narrowed it down to that expression.

The write keeps coming back false, and I cannot determine why. Any ideas or fixes would be helpful!

Edit 1

Things I've tried:

function isEditingRole() {
  return request.resource.data.keys().hasAny(['role']);
}

and

function isEditingRole() {
  return 'role' in request.resource.data;
}

and

function isEditingRole() {
  return 'role' in request.resource.data.keys();
}

Edit 2

Note that eventually, admins will set a role for users, so a role could eventually exist on a document. This means that, according to the Firestore docs below, the request will have a role key, even if wasn't in the original request.

Fields not provided in the request which exist in the resource are added to request.resource.data. Rules can test whether a field is modified by comparing request.resource.data.foo to resource.data.foo knowing that every field in the resource will also be present in request.resource even if it was not submitted in the write request.

According to that, I think the three options from "Edit 1" are ruled out. I did try the suggestion of request.resource.data.role != resource.data.role and that's not working either... I'm at a loss and am beginning to wonder if there's actually a bug in Firestore.

Andrew M.
  • 832
  • 1
  • 8
  • 18

11 Answers11

10

Your rules will be a lot more readable and maintainable if you create a custom function to check for updates. For example:

service cloud.firestore {
  match /databases/{database}/documents {
    function isUpdatingField(fieldName) {
      return (!(fieldName in resource.data) && fieldName in request.resource.data) || resource.data[fieldName] != request.resource.data[fieldName];
    }

    match /users/{userId} {
      // Read rules here ...
      allow write: if !isUpdatingField("role") && !isUpdatingField("adminOnlyAttribute");
    }
  }
}
nicoqh
  • 1,213
  • 12
  • 21
Tom Bailey
  • 682
  • 5
  • 12
  • Unfortunately, I've tried all flavors of what "should" work based on the documentation, and that's one of them. I'll update my post to include things I've tried. – Andrew M. Jan 10 '18 at 00:05
  • @menehune23 I thought keys().hasAny() was working. If you are saying it isn't we should definitely raise it on the Google group as it should be! – Tom Bailey Jan 10 '18 at 18:45
  • Yeah I'm still really puzzled. I'm actually finding that both using `in` and `hasAny()` seem to behave as expected, but for some reason the `role` field is being added to the request when I don't expect it to (i.e. even when `role` does not exist in the resource). Caching issue maybe? As I have had it added to documents in the past, but cleared the collection to start fresh. – Andrew M. Jan 11 '18 at 04:16
  • I'll post on update when I have more clarity, but am starting to make some progress. – Andrew M. Jan 11 '18 at 04:29
  • It is not working and it is a shame that google misleads us – ZuzEL Jun 04 '18 at 07:53
  • Just to be clear, this rule WILL BREAK once the field "role" or "adminOnlyAttribute" are added to the user document, since the request.resource.data contains the final result of the document, meaning all fields with the fields being updated. Simply put, if you add "role" field via Cloud Functions, then the next time the user tries to update their profile then this rule will block it since "role" does exist! – Gerardlamo May 07 '19 at 07:08
  • @GerardLamusse I am not quite sure I understand the scenario where this breaks for you. "request.resource" is the future state of the document, if the write suceeds. "resource.data" is the current state. You have to validate the future state is what you desire because you would be too late to prevent the write if you were validating the current state. I don't think the future state, "request.resource", has any of the current state, "resource.data", values unless they are being updated too. Hence, only updates where the user tries to add a role, regardless of other fields, will be blocked – Tom Bailey May 08 '19 at 06:17
  • @TomBailey request.resource.data contains the future state of the entire document, including fields that are existing on the current state (unless the update is to delete a field). Since writeFields have been removed, which btw, only contained the fields the user was updating, there isn't a way to test the same. [AFAIK] – Gerardlamo May 09 '19 at 09:51
  • @GerardLamusse I believe this worked differently so I have updated my answer according to the apparent change in behaviour – Tom Bailey May 10 '19 at 18:29
7

So in the end, it seems I was assuming that resource.data.nonExistentField == null would return false, when it actually returns an Error (according to this and my tests). So my original solution may have been running into that. This is puzzling because the opposite should work according to the docs, but maybe the docs are referring to a value being "non-existent", rather than the key -- a subtle distinction.

I still don't have 100% clarity, but this is what I ended up with that worked:

function isAddingRole() {
  return !('role' in resource.data) && 'role' in request.resource.data;
}

function isChangingRole() {
  return 'role' in resource.data && 'role' in request.resource.data && resource.data.role != request.resource.data.role;
}

function isEditingRole() {
  return isAddingRole() || isChangingRole();
}

Another thing that still puzzles me is that, according to the docs, I shouldn't need the && 'role' in request.resource.data part in isChangingRole(), because it should be inserted automatically by Firestore. Though this didn't seem to be the case, as removing it causes my write to fail for permissions issues.

It could likely be clarified/improved by breaking the write out into the create, update, and delete parts, instead of just allow write: if !isEditingOwnRole() && (isOwnDocument() || isAdmin());.

Andrew M.
  • 832
  • 1
  • 8
  • 18
6

I solved it by using writeFields. Please try this rule.

allow write: if !('role' in request.writeFields);

In my case, I use list to restrict updating fields. It works, too.

allow update: if !(['leader', '_created'] in request.writeFields);
Pang
  • 9,564
  • 146
  • 81
  • 122
gekijin
  • 77
  • 2
  • Your `in` rule seems to always evaluate to true. The `in` operator only checks if a given list has a particular value (not for lists). In order to do what you want, try this: `!(request.writeFields.hasAny(['leader', '_created']));` – DarkNeuron Jun 18 '18 at 15:31
  • the request.resource.keys.hasAny() approach is best for people not using the SDK and stick to the simulator. – Jem Aug 09 '18 at 14:33
  • 6
    writeFields are no longer in the documentation and apparently deprecated. https://stackoverflow.com/a/52192476/4458849 – Brendan McGill Mar 15 '19 at 18:22
4

The solution of Tom Bailey (https://stackoverflow.com/a/48177722/5727205) did look promising.

But in my case I needed to prevent a field from being edited, and could have the case, that the field simply does not exist on the existing data. Thereby I added a check if the field exists.

This solution does check two checks:

  1. If the field is not in request and non in the existing data (equals field not modified)
  2. or request and existing data are the same (equals field not modified)
 function isNotUpdatingField(fieldName) {
   return
     ( !(fieldName in request.resource.data) && !(fieldName in resource.data) ) || 
     request.resource.data[fieldName] == resource.data[fieldName];
}
Laszlo Schürg
  • 521
  • 3
  • 6
3

With this single function you can check if a fields are/aren't being created/modified.

function incomingDataHasFields(fields) {
    return ((
        request.writeFields == null
        && request.resource.data.keys().hasAll(fields)
    ) || (
        request.writeFields != null
        && request.writeFields.hasAll(fields)
  ));
}

Usage:

match /xxx/{xxx} {    
    allow create:
        if incomingDataHasFields(['foo'])              // allow creating a document that contains 'foo' field
           && !incomingDataHasFields(['bar', 'baz']);  // but don't allow 'bar' and 'baz' fields to be created
Metu
  • 1,033
  • 1
  • 13
  • 20
3

Here's a function that takes all of this into account:

  • Doesn't trigger security rule errors like "Property name is undefined on object".
  • Works with set() and update()
  • Works when explicitly trying to delete the field using firebase.firestore.FieldValue.delete()
  • Can be used both when creating and updating a document (update, create).
function fieldNotWrittenByUser(field) {
  return (
    (
      (field in request.resource.data)
      && (resource != null && field in resource.data)
      && request.resource.data[field] == resource.data[field]
    )
    || (
      !(field in request.resource.data) && (resource == null || !(field in resource.data))
    )
  )
}

Explanation

Remember that request.resource.data represents the resource after a successful write operation, i.e. the "future" document.

If the field is present on the future document, it must be identical to its value on the existing document.

Alternatively, if the field isn't present on the future document, the operation is only allowed if the resource doesn't yet exist, or if the existing document also lacks the field.

Source: https://www.sentinelstand.com/article/firestore-security-rules-examples

nicoqh
  • 1,213
  • 12
  • 21
  • 2
    "(Remember that request.resource.data represents the resource after a successful write operation)" is worth some upvotes for me :) – rupps Mar 21 '21 at 21:27
2

Since reference to writeFields in documentation has disappeared, I had to come up new way to do what we could do with writeFields.

function isSameProperty(request, resource, key) {
    return request.resource.data[key] == resource.data[key]
}

match /myCollection/{id} {
    // before version !request.writeFields.hasAny(['property1','property2','property3', 'property4']);
  allow update: isSameProperty(request, resource, 'property1')
    && isSameProperty(request, resource, 'property2')
    && isSameProperty(request, resource, 'property3')
    && isSameProperty(request, resource, 'property4')
  }
  • 1
    I had to do something similar. Would be nice to have a function like "[Resource not modified except this field] then OK", instead of repeating the "isSameProperty" for all the fields manually... Not nice also if we add more fields in the document later, we need to update this rule as well... If such function exist that I missed, would love to know ! Or if there is a better way to deal with this. – schankam Apr 11 '19 at 04:18
2

This might seem like over kill but for updating documents where you might have other fields that are not user generated, eg. roles, created etc. you need functions that can test that those fields don't change. Hence meet these three FNs.

function hasOnlyFields(fields) {
  if request.resource.data.keys().hasOnly(fields) 
}
function hasNotChanged(fields) {
  return (fields.size() < 1 || equals(fields[0]))
    && (fields.size() < 2 || equals(fields[1]))
    && (fields.size() < 3 || equals(fields[2]))
    && (fields.size() < 4 || equals(fields[3]))
    && (fields.size() < 5 || equals(fields[4]))
    && (fields.size() < 6 || equals(fields[5]))
    && (fields.size() < 7 || equals(fields[6]))
    && (fields.size() < 8 || equals(fields[7]))
    && (fields.size() < 9 || equals(fields[8]))
}
function equals(field) {
  return field in request.resource.data && field in resource.data && request.resource.data[field] == request.resource.data[field]
}

So on update of say user document, where the user may only update their name, age, and address, but not roles and email you can do:

allow update: if hasOnlyFields(['name', 'age', 'address']) && hasNotChanged(['email', 'roles'])

Note the hasNotChanged can check up to 9 fields. Also these aren't the only checks you would want to do. You'd need to check the the types and ownership of the document as well.

Gerardlamo
  • 1,505
  • 15
  • 21
1

Found this rule to work quite well:

function propChanged(key) {
  // Prop changed if key in req but not res, or if key req and res have same value
  return (
    (key in request.resource.data) && !(key in resource.data)
  ) || (
    (key in request.resource.data) && request.resource.data[key] != resource.data[key]
  );
}
dennis-zinzi
  • 9
  • 1
  • 1
1

I wrote these functions based on official documentation (as of 2022):

Verifying that specified fields are not changed

Firebase documentation

By using the hasAny() method on the set generated by affectedKeys() and then negating the result, you can reject any client request that attempts to change fields that you don't want changed.

function verifyUnchangedFields(fields) {
    return !request.resource.data.diff(resource.data).affectedKeys().hasAny(fields)
}

Explicitly listing allowed and required fields

Firebase documentation

You can combine hasAll and hasOnly operations together in your security rules to require some fields and allow others. For instance, this example requires that all new documents contain the name, location, and city fields, and optionally allows the address, hours, and cuisine fields.

function verifyFields(required, optional) {
  let allAllowedFields = required.concat(optional);
  return request.resource.data.keys().hasAll(required) &&
    request.resource.data.keys().hasOnly(allAllowedFields);
}

Usage example

Use those functions as part of your rules, eg:

      allow update: if isOwner() && isCorrectModeratedFlag()
      && verifyFields(['id', 'isModerated', 'nickname', 'updatedAt', 'status'], ['photoURL'])
      && verifyUnchangedFields(['id']);
Be Kind
  • 4,712
  • 1
  • 38
  • 45
0

Here's my function that covers all states:

function isUpdating(fieldName) {
      return fieldName in request.resource.data
        && (!(fieldName in resource.data) || resource.data[fieldName] != request.resource.data[fieldName]);
    }

This checks whether fieldName will be present in the document after the update. If true, it will check whether fieldName is being added or if its value will change.

Lukas Nevosad
  • 196
  • 2
  • 9