Search code examples
firebaseindexinggoogle-cloud-firestorefirebase-securityuser-roles

Firestore: How to query a map of roles inside a subcollection of documents?


I have the following subcollection structure:

/projects/{projectId}/documents/{documentId}

Where each document has an access map used for checking security rules for each document:

{
  title: "A Great Story",
  content: "Once upon a time ...",
  access: {
    alice: true,
    bob: false,
    david: true,
    jane: false
    // ...
  }
}

How the security rules are set:

match /{path=**}/documents/{documentId} {
      allow list, get: if resource.data.access[request.auth.uid] == true;
}

When I want to query all documents alice can access across all projects:

db.collectionGroup("documents").where(`access.${uid}`, "==", true);

I get an error telling me that I need to create a collection group index for access.alice, access.david, etc.

How can I overcome this indexing issue with collection group queries?


Solution

  • As a quick overview of indexes, there are two types - a composite index (used for connecting multiple fields together) or a single-field index. By default, single-field indexes are automatically created for each field of a document for ordinary collection queries and disabled for collection group queries. If a field's value is an array, the values of that array will be added to an Array Contains index. If a field's value is primitive (string, number, Timestamp, etc), the document will be added to the Ascending and Descending indexes for that field.

    To enable querying of the access field for a collection group query, you must create the index. While you could click the link in the error message you get when making the query, this would only add an index for the user you were querying (e.g. access.alice, access.bob, and so on). Instead, you should use the Firebase Console to tell Cloud Firestore to create the index for access (which will create indexes for each of its children). Once you've got the console open, use the "Add Exemption" button (consider this use of "exemption" to mean "override default settings") to define a new indexing rule:

    In the first dialog, use these settings:

    Collection ID:      "documents"
    Field Path:         "access"
    
    Collection:         Checked ✔
    Collection group:   Checked ✔
    

    In the second dialog, use these settings:

    Collection Scope Collection Group Scope
    Ascending Enabled Enabled
    Descending Enabled Enabled
    Array Contains Enabled Enabled

    In your security rules, you should also check if the target user is in the access map before doing the equality. While accessing a missing property throws a "Property is undefined on object." error which denies access, this will become a problem if you later combine statements together with ||.

    To fix this, you can either use:

    match /{path=**}/documents/{documentId} {
      allow list, get: if request.auth.uid in resource.data.access
                       && resource.data.access[request.auth.uid] == true;
    }
    

    or provide a fallback value when the desired key is not found:

    match /{path=**}/documents/{documentId} {
      allow list, get: if resource.data.access.get(request.auth.uid, false) == true;
    }
    

    As an example of the rules breaking, let's say you wanted a "staff" user to be able to read a document, even if they aren't in that document's access map.

    These rules would always error-out and fail (if the staff member's user ID wasn't in the access map):

    match /{path=**}/documents/{documentId} {
      allow list, get: if resource.data.access[request.auth.uid] == true
                       || get(/databases/$(database)/documents/users/$(request.auth.uid)).data.get("staff", false) == true;
    }
    

    whereas, these rules would work:

    match /{path=**}/documents/{documentId} {
      allow list, get: if resource.data.access.get(request.auth.uid, false) == true
                       || get(/databases/$(database)/documents/users/$(request.auth.uid)).data.get("staff", false) == true;
    }
    

    As a side note, unless you need to query for documents where a user does not have access to it, it is simpler to simply omit that user from the access map.

    {
      title: "A Great Story",
      content: "Once upon a time ...",
      access: {
        alice: true,
        david: true,
        // ...
      }
    }