fluttergoogle-cloud-firestorefirebase-security

Firestore security rule unexpected behaviour of hasAny() method depending on the order


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"]); 
}

Solution

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