0

I am trying to use security rules in order to limit what information about other users is able to see one authenticated user, depending on if these users are in the same department or not. I have about 50 users in Firebase Auth whose ids are correlated with the document id in the users collection (usuarios) in firestore. IN the collection, each document have a parameter called departments which is an array of strings, and it represents the departments the user belongs to. Then my goal is to limit the read access into the users which have any department in common. For this I created that security rule:

match /usuarios/{userId} { 
  allow read: 
    if request.auth != null 
    && "departments" in get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.keys() 
    && "departments" in resource.data.keys() 
    && get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.departments.hasAny(resource.data.departments); 
}

I took two users (for simplicity in the explanation userA and userB, but on the example DLuQJrAy... and DoF7SKfp...) of the collection and I add them the departments field with an array of one value ("department1" in the example) in each, all other document users remain with no departments field. So if I am authenticated with userA and try to read userB I should be allowed. If I use the test rules area inside Firestore it work successfully, but if I do the query from flutter web client I receive "Missing or insufficient permissions".

enter image description here

enter image description here

That code below is the flutter code in the above example. First I query the same user as I am authenticated so I can get the information on my departments and then I do a query to the whole collection with a where clause to filter those who has any value in the array of my departments.

DocumentSnapshot usuarioSnapshot = await firestore.collection('usuarios').doc(FirebaseAuth.instance.currentUser?.uid).get();
Map<String, dynamic> usuarioMap = usuarioSnapshot.data() as Map<String, dynamic>;
QuerySnapshot? usuariosSnapshot;
try { 
  usuariosSnapshot = await firestore.collection('usuarios').where("departments", arrayContainsAny: usuarioMap["departments"]).get(); 
} catch (e) {     
  debugPrint("Exception: $e");   
}

Surprisingly I am receiving well the first call and I can see the departments field with the value ["department1"] but then I got into the catch with the "Missing or insufficient permissions".

What is even more strange is that if I change my security rule to use directly the value ["department1"] instead of resource.data.departments in the parameter of the array.hasAny() function then the query from flutter client works well and I get only the two users that has the department1 (userA and userB).

match /usuarios/{userId} { 
  allow read: 
    if request.auth != null 
    && "departments" in get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.keys() 
    && "departments" in resource.data.keys() 
    && get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.departments.hasAny(["department1"]); 
}

enter image description here

enter image description here

Seems to me it is not related to the flutter query but to how the security rule is behaving if the query comes from the client or is executed directly from the testing area. Since just changing resource.data.departments to ["department1"] (which is the same, also from the view of the test area) makes it work.

EDIT (new test) I have just edited the security rule to be like this and worked! But I don't understand why it is behaving like that: for me if arrayA.hasAny(arrayC) && arrayB.hasAny(arrayC) is true, arrayA.hasAny(arrayB) is true also.

match /usuarios/{userId} { 
  allow read: 
    if request.auth != null 
    && "departments" in get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.keys() 
    && "departments" in resource.data.keys() 
    && get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.departments.hasAny(["department1"])
    && resource.data.departments.hasAny(["department1"]); 
}
10
  • Your code here is not very well formatted and is difficult to read. I suggest taking some time to learn how to format code to make it easier for others to understand what you're doing. Commented Jul 8 at 12:41
  • Can you have a look at this thread. hasAny() method accepts list parameters and resource.data returns a map of all of the fields and values stored in the document. This could be the reason for different behavior you are experiencing.
    – Roopa M
    Commented Jul 8 at 14:55
  • @RoopaM That is a really good point, then you are suggesting that resource.data.departments is not equal as ["department1"] even the emulator shows it is. I have checked the thread you just shared and I am "compliant" with everything it states so I keep thinking that the problem is that resource.data.departments is not ["department1"] even it should be. Sure there is some small piece I am not seeing... but I don't know what it can be.
    – Aleix Rué
    Commented Jul 8 at 17:14
  • @DougStevenson I am not used to it, I'll check the link to format properly in the future. I have just read this medium.com/firebase-developers/… which you wrote. My issue is related with the last case (array membership) but instead of checking a single value into an array, I seek for any value present in both arrays with hasAny(). But arrayA.hasAny(arrayB) got a different result if arrayB is hardcoded ["department1"] or resource.data.departments (value is ["department1"] as you can see on the first image)
    – Aleix Rué
    Commented Jul 8 at 17:25
  • When you pass ["department1"] to hasAny() field name which is String and method will search for If there are any fields with that name. resource.data.departments contains a map of document data. For more understanding check this document.
    – Roopa M
    Commented Jul 8 at 18:36

1 Answer 1

1

I have just found a workaround but I don't understand why the other way is not working. I am not sure if I am missing something or here we have a tiny bug.

The thing is that if I invert the factors of the .hasAny() method I get the expected behaviour. That is instead of arrayA.hasAny(arrayB) I did arrayB.hasAny(arrayA) and now I can query the desired documents from the client without getting the "Missing or insufficident permissions" error.

So instead of that security rule

match /usuarios/{userId} { 
  allow read: 
    if request.auth != null 
    && "departments" in get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.keys() 
    && "departments" in resource.data.keys() 
    && get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.departments.hasAny(resource.data.departments); 
}

I wrote this one

match /usuarios/{userId} { 
  allow read: 
    if request.auth != null 
    && "departments" in get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.keys() 
    && "departments" in resource.data.keys() 
    && resource.data.departments.hasAny(get(/databases/$(database)/documents/usuarios/$(request.auth.uid)).data.departments); 
}

And now the query with the where clause can be executed successfully, and the security rule is working fine because if I try to query the whole collection without filtering I get the permission error.

It seems to me that resource.data.departments is not read/processed as a list of string when passed as an argument to the hasAny() method but it is in the case it is the object calling the method (And it failed only when I queried the collection with the filter, when I queried a single document it worked well).

Again, if anyone know the reason of that behaviour I would be glad to hear their thoughts since I am quite curious about it.

FIREBASE SUPPORT ANSWER TO MY WORKAROUND AND A POTENTIAL REASON ON WHY IT IS WORKING ONLY ONE WAY

The behavior you're encountering with Firestore Security Rules and the .hasAny() method might be related to how the rule is evaluated at query time. Here's a breakdown of what could be happening:

Security Rule Evaluation:

Firestore Security Rules are evaluated at the time a query is executed. The rule accesses data from multiple documents using get calls within the rule itself.

Potential Issue:

The order of evaluation within the .hasAny() call might be affecting the outcome. Firestore might evaluate the arguments to .hasAny() from left to right.

Scenario 1 (Non-working rule):

  1. get(/databases/$(database)/documents/usuarios/$(request.auth.uid)) is called to retrieve the current user's departments. (Let's call this userDepartments)
  2. resource.data.departments is accessed (the requested document's departments). (Let's call this docDepartments)
  3. userDepartments.hasAny(docDepartments) is evaluated.

In this case, userDepartments might not be fully retrieved yet when the comparison happens. This could lead to an "Insufficient Permissions" error because the rule might not have all the data it needs to make a decision.

Scenario 2 (Working rule):

  1. resource.data.departments is accessed (docDepartments).
  2. get(/databases/$(database)/documents/usuarios/$(request.auth.uid)) is called to retrieve the current user's departments (userDepartments).
  3. docDepartments.hasAny(userDepartments) is evaluated.

Here, docDepartments is accessed first. Since it's part of the document being queried, it's likely available before the rule tries to access the user's data. Then, userDepartments is retrieved. Even if there's a slight delay, the comparison might still work because docDepartments is already available. While the behavior you're experiencing is unexpected in how Firestore security rules are evaluated, it's difficult to definitively say it's a bug.

Here's why:

  • Security Rule Evaluation: Firestore security rules prioritize data security and might have specific evaluation mechanisms to ensure data consistency during rule execution. The order of evaluation within a rule might be a design choice to prevent unauthorized access due to potential data inconsistencies.
  • Limited Information: Without access to the internal workings of Firestore security rule evaluation, it's challenging to confirm if it's a bug or an intentional design choice.

Not the answer you're looking for? Browse other questions tagged or ask your own question.