0
votes

I'm struggling use Firestore security rules to secure a many to many relationship. I have the following collections:

Key:
documentId: [field: value, ...]

groups {
    group1: [name: Group1]
    group2: [name: Group2]
}

users {
    bobUser:   [name: Bob]
    aliceUser: [name: Alice]
    fredUser:  [name: Fred]
}

// Contains data specific to a user in a particular group.
// Specifically the user's role
userGroups {
    userGroup1: [userId: bobUser,   groupId: group1, role: "admin"]
    userGroup2: [userId: aliceUser, groupId: group1, role: "member"]
    userGroup3: [userId: fredUser,  groupId: group2, role: "admin"]
}

How can I construct a firestore security rule so that:

  • A user with role:"admin" can read another user's document if they both are found in the same group

So in the example above, Bob can read Alice's user document as he has an "admin" role but Fred can't as he is an admin for another group. Or to put it another way:

If bobUser makes the below request, then it should pass security rules:

db.collection("users").doc("aliceUser");

as bob is has an admin role in the same group as Alice

In contrast, if fredUser was logged in, the below request would fail:

db.collection("users").doc("aliceUser");

Fred is an admin user, but not in the same group and so the rule would block the request.

In the security rule I think I need to split into a few stages:

  • Query userGroups to find all groupIds where requesting userId is role: "admin"
  • Query userGroups to find all groupIds where requested user exists
  • Allow write if there is a match of groupIds in both groups

But I'm having trouble getting this logic into the rule. Security rules don't seem be able to filter like this. Any help would be great!

1
Please edit the question to show specific examples of client code that performs queries that the rules should allow or reject. Bear in mind that security rules can't themselves perform queries. They can only get() specific documents.Doug Stevenson
@DougStevenson updated now. Does that make more sense?Adam
I don't think this is going to work with the way you have userGroups set up now, since security rules can only get() a single document at a time. You're going to have to put the admin data in individual documents identified by the user IDs. See this to understand your options: firebase.google.com/docs/firestore/security/…Doug Stevenson
@DougStevenson thanks. Had been thinking I would have to restructure somehow to make it work with security rules. Will give that a shot.Adam

1 Answers

2
votes

In order to solve this problem, you need to keep in mind that you cannot transfer relational database patterns to a non-relational database. When working with Firestore, you should start by asking "What queries should be possible?" and then structure your data based on that. Building up Security Rules will follow naturally.

I wrote a blogpost about exactly your use case: "How to build a team-based user management system with Firebase", so if anything from the following answer is unclear, go there first to see if it helps.

In your case, you'd probably want the following queries:

  1. Get all users of a group (given the current user is a member of this group and has the correct permission).
  2. Get all groups of a user (given you are only querying groups of the currently authenticated user).

As you noticed, many-to-many relationships are hard to work with through Firestore and Security Rules, because you would need to make additional requests to join the datasets. To avoid that, I recommend renaming the userGroups collection to memberships and turning it into a subcollection of each doc in the groups collection. So your new structure would look like

- collection "groups", a doc contains:
  - field "name"
  - collection "memberships", a doc contains:
    - field "name"
    - field "role"
    - field "user" → references doc from "users"
- collection "users", a doc contains:
  - field "name"

This way you can easily solve the first query "Get all users of a group" by querying the subcollection "memberships" like collection("groups").doc("your-group-id").collection("memberships").get().

Now, to secure that, you can write a helper functions in Security Rules:

function hasAccessToGroup(groupId, role) {
  return get(/databases/$(database)/documents/groups/$(groupId)/memberships/$(request.auth.uid)).data.role == role
}

Given a groupId and a role, you can use it allow only users who are a member and have a specific role access to data and subcollections within the group. In order to protect the memberships collection on a group this might look like this:

rules_version = '2'
service cloud.firestore {
  match /databases/{database}/documents {
    function hasAccessToGroup(groupId, role) {
      return get(/databases/$(database)/documents/groups/$(groupId)/memberships/$(request.auth.uid)).data.role == role
    }
    match /groups/{groupId} {
      // Allow users with the role "user" access to read the group doc.
      allow get: if
        request.auth != null &&
        hasAccessToGroup(groupId, "user")

      // Allow users with the role "admin" access to read all subcollections, including "memberships".
      match /{subcollection}/{document=**} {
        allow read: if
          request.auth != null &&
          hasAccessToGroup(teamId, "admin")
      }
    }
  }
}

Now there is only the second query "Get all groups of a user" left. This can be achieved through a Collection Group Index, which allows to query all subcollections with the same name. You want to create one for the memberships collection. Given a specific user, you can then easily query all of his groups with collectionGroup("memberships").where("user", "==", currentUserRef).get().

In order to secure that, you need to setup a Rule that allows such requests only if the queried user reference equals the currently authenticated user:

function isReferenceTo(field, path) {
  return path('/databases/(default)/documents' + path) == field
}

match /{document=**}/memberships/{userId} {
  allow read: if
    request.auth != null &&
    isReferenceTo(resource.data.user, "/users/" + request.auth.uid)
}

One last thing to talk about is how you keep the data in the memberships collection up-to-date with the data in the users doc that it references. The answer are Cloud Functions. Every time a users doc changes, you query all of its memberships and update the data.

As you can see, answering your original question how you can construct a Firestore Rule so that a user with the correct permission can read another user's document if they both are found in the same group, takes a different approach. But after restructuring your data, your Security Rules will be easier to read.

I hope this helped. Cheers!