Implement Firestore security rules for a many to many relationship - firebase

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!

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:
Get all users of a group (given the current user is a member of this group and has the correct permission).
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(groupId, "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!

Related

Find document ID if the query know two of his fields, firestore rules

I'm trying my best to set up a functionality in my application that will allow people who know a group's login and password to join it.
Each group has a document in the "Groups" collection, and each user has a document in the "Users" collection.
To keep the id and the password information, I have another collection named "AuthGroups", containing as many documents as there are groups, with two fields: "login" and "password". Each auth document has the same ID as the corresponding document the Groups collection.
So, here is my strategy:
When the user valid the login and password, a first query is sent to the database, to find a document with theses credentials in the "AuthGroups" collection.
If a document is found, its ID is used to do another query in the "Groups" collection to retrieve the group's data.
Queries could look like this:
var ID = await firestore.collection('AuthGroups')
.where('login', isEqualTo: login)
.where('password', isEqualTo: password)
.get()
.then((value) {
return value.docs.first.id;
});
var groupName = await firestore.collection('Groups')
.doc(id)
.get()
.then((value) {
return value.get('name');
});
Now, let's speak about firestore rules to make it secure...
To prevent someone malicious from seeing all documents in my "AuthGroup" collection. I told myself that my rules need to only allow queries containing both "login" and "password" fields. But I don't know how to do it right, and if it's even possible...
Same thing for the documents in the "Groups" collection: users can only get a document if they know its ID.
A solution could be to name my documents in my "AuthGroup" collection like "login + password", and store the group's ID in it. And in my rules, allow only list requests like that:
service cloud.firestore {
match /databases/{database}/documents {
match /AuthGroup/{organization} {
allow list: if request.auth != null;
}
}
}
I told myself that my rules need to only allow queries containing both "login" and "password" fields. But I don't know how to do it right, and if it's even possible.
It's not possible. You can't check for specific query parameters in security rules.
A solution could be to name my documents in my "AuthGroup" collection like "login + password", and store the group's ID in it. And in my rules, allow only list requests like that
Yes, that is a possible solution. Or you can hash a concatenation of login and password strings so you don't expose them in the document id or exceed the max length of that ID.

Firestore Security Rules for Query with Array Contains

I have a Flutter app in which users can make posts and tag the post as belonging to a group. Posts are stored in a global collection and each has a Post.groupId field:
/posts/{postId}
Based on my Firestore security rules and queries, users are only allow to read posts if they are in the group for which the post is tagged (i.e the posts's groupId field). Approved group users are stored in:
/groups/{groupId}/users/{userId}
I could query the posts from a particular user's group like:
_firestore.collection('posts').where('groupId', isEqualTo: 'groupA')...
This above was all working properly.
I am attempting to make an improvement in which a post can be tagged in multiple groups instead of just one, so I am replacing the single Post.groupId field with a Post.groupIds array. A user should be able to read a post if he/she is a member of ANY of the groups from Post.groupIds. I attempt to read all posts tagged with a particular group with the following query from my Flutter app:
_firestore.collection('posts').where('groupIds', arrayContains: 'groupA')...
I keep receiving the following exception Missing or insufficient permissions with these security rules:
match /posts/{postId} {
allow read: if canActiveUserReadAnyGroupId(resource.data.groupIds);
}
function isSignedIn() {
return request.auth != null;
}
function getActiveUserId() {
return request.auth.uid;
}
function isActiveUserGroupMember(groupId) {
return isSignedIn() &&
exists(/databases/$(database)/documents/groups/$(groupId)/users/$(getActiveUserId()));
}
function canActiveUserReadAnyGroupId(groupIds) {
return groupIds != null && (
(groupIds.size() >= 1 && isActiveUserGroupMember(groupIds[0])) ||
(groupIds.size() >= 2 && isActiveUserGroupMember(groupIds[1])) ||
(groupIds.size() >= 3 && isActiveUserGroupMember(groupIds[2])) ||
(groupIds.size() >= 4 && isActiveUserGroupMember(groupIds[3])) ||
(groupIds.size() >= 5 && isActiveUserGroupMember(groupIds[4]))
);
}
With these security rules I can read a single post but I cannot make the above query. Is it possible to have security rules which allow me to make this query?
UPDATE 1
Added isSignedIn() and getActiveUserId() security rules functions for completeness.
UPDATE 2
Here is the error I am receiving when I attempt to execute this query with the Firestore Emulator locally:
FirebaseError:
Function not found error: Name: [size]. for 'list' # L215
Line 215 corresponds to the allow read line within this rule:
match /posts/{postId} {
allow read: if canActiveUserReadAnyGroupId(resource.data.groupIds);
}
It appears Firestore does not currently support security rules for this scenario at the moment (thanks for your help tracking this down Doug Stevenson). I have come up with a mechanism to work around the limitation and wanted to share in case someone else is dealing with this issue. It requires an extra query but keeps me from having to create a Web API using the Admin SDK just to get around the security rules.
Posts are stored as follows (simplified):
/posts/{postId}
- userId
- timestamp
- groupIds[]
- message
- photo
Now I am adding an additional post references collection which just stores pointer information:
/postRefs/{postId}
- userId
- timestamp
- groupIds[]
The posts collection will have security rules which does all the validation to ensure the user is in at least one of the groups in which the post is tagged. Firestore is able to handle this properly for simple get requests, just not list requests at the moment.
Since the postRefs collection stores only ID's, and not sensitive information which may be in the post, its security rules can be relaxed such that I only verify a user is logged in. So, the user will perform post queries on the postRefs collection to retrieve a list of ordered postId's to be lazily loaded from the posts collection.
Clients add/delete posts to/from the normal posts collection and then there is a Cloud Function which copies the ID information over to the postRefs collection.
As per this blog post, if you can maintain an index of member IDs for a given post (based on group assignments), then you can secure post read access storing member IDs in an array data type and matching against the member IDs with the "array-contains" clause in your ruleset. It looks like this in your Firebase rules:
service cloud.firestore {
match /databases/{database}/documents {
match /posts/{postId} {
allow read: if request.auth.uid in resource.data.members
allow write: if request.auth.uid == resource.data.owner
}
}
}
If I had to guess, I'd say that groupIds isn't actually a List type object, which means that the field from the document is also not an array. If it's a string, this code won't work, since strings don't have a method called size() in the rules language.
If you aren't 100% certain what the type of field is going to be, you will need to check the type in the rule and determine what to do with it. You can use the is operator to check the type. For example, groupIds is list will be boolean true if you're actually working with one.
In your rules, you can use the debug() function to dump the value of some expression to the log. It will return the same value. So, you can say debug(groupIds) != null to both print the value and check it for null.

Firestore Match Rules for looking up data in a document that is in another collection

I am having an issue with Firestore rules when the permission is stored in another document in another collection. I haven't been able to find any examples of this, but I have read that it can be done.
I want to do it this way to avoid having to do a lot of writes when a student shares his homework list with many other students. Yes, I know this counts as another read.
I have 3 collections, users, permissions, and homework along with some sample data.
users
{
id: fK3ddutEpD2qQqRMXNW,
name: "Steven Smith"
},
{
id: YI2Fx656kkkk25,
name: "Becky Kinsley"
},
{
id: CAFDSDFR244bb,
name: "Tonya Benz"
}
permissions
{
id: fK3ddutEpD2qQqRMXNW,
followers: [YI2Fx656kkkk25,CAFDSDFR244bb]
}
homework
{
id: adfsajkfsk4444,
owner: fK3ddutEpD2qQqRMXNW,
name: "Math Homework",
isDone: false
}
The start of my firestore rules:
service cloud.firestore {
//lock down the entire firestore then open rules up.
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
match /homework/{ } {
allow get: if isSignedIn()
}
// helper functions
function isSignedIn() {
return request.auth != null;
}
function isUser(userId) {
return request.auth.uid == userId;
}
function isOwner(userId) {
return request.auth.uid == resource.data.uid;
}
}
}
Use case:
Steven Smith Shared his homework list with Tonya Benz.
Tonya Benz is logged into the app to view her friend Steven's homework. The app runs this query to get the homework list of Steven Smith.
var homeworkRef = db.collection("homework");
var query = homeworkRef.where("owner", "==", "fK3ddutEpD2qQqRMXNW");
Question:
What is the proper Firestore match rule that takes the "owner" field from the homework collection to look up it up as the id in the permissions collection when the user Tonya Benz is signed in so this query can run.
With your current query and database structure, you won't be able to achieve your goal using security rules.
Firstly, it sounds like you're expecting to be able to filter the results of the query based on the contents of another document. Security rules can't act as query filters. All the documents matched by the query must be granted read access by security rules, or the entire query is denied. You will need to come up with a query that is specific about which documents should be allowed access. Unfortunately, there is no single query that can do this with your current structure, because that would require a sort of "join" between permissions and homework. But Firestore (like all NoSQL databases), do not support joins.
You will need to model your data in such a way that is compatible with rules. You have one option that I can think of.
You could store the list users who should have read have access to a particular document in homework, within that same document, represented as a list field. The query could specify a filter based on the user's uid presence in that list field. And the rule could specify that read access only be granted to users whose IDs are present in that list.
{
id: adfsajkfsk4444,
owner: fK3ddutEpD2qQqRMXNW,
name: "Math Homework",
isDone: false,
readers: [ 'list', 'of', 'userids' ] // filter against this list field
}
The bottom line here is that you'll need to satisfy these two requirements:
Your query needs to be specific about exactly which documents that it expects to be readable. You can't use a rule to filter the results.
Your rule needs a way to determine, using nothing more complicated than the fields of the document itself, or a get() on other known documents, what the access should be for the current uid.

Firestore: Query a collection for all documents user has access to

Running into a situation that I'm unclear has a clean solution.
In Firestore, I have a collection in which user are only allowed to access certain documents. Users can be assigned to one or more accounts, and Accounts can have one or more user. The general models and rules work as expected:
USER: {
id : abc123,
accounts : [ xyz789, ... ]
}
ACCOUNT: {
id : xyz789,
users : [ abc123, ... ]
}
service cloud.firestore {
match /databases/{database}/documents {
match /accounts/{accountID} {
allow read, write: if accountID in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.accounts;
}
}
}
From what I can tell with the Firebase Rule Simulator, the above rule is working correctly (I can read/update the accounts that list my userID, but not the ones that don't).
The issue is that if I want to get those same accounts via a Query operator, I get an error. The error does go away when I relax the ruleset, but that's not ideal.
firestore.collection('accounts').where('users', 'array-contains', userID)
ERROR: Missing or insufficient permissions
Given that the ruleset and the query seem to refer to the same records, is there a way to get them to work in conjunction or am I forced to relax the rules in order to get this to work?
I had a similar problem before and I found that Firebase doesn't check the fetched data to validate the rules, but it compare the query code with the rules, and depending on that it throws the exception
So what I found is that the if condition should have a where filter in the code
This if condition is missing a where
allow read, write: if accountID in ...
To make your code work, it would need to add a where filter that refers to accountID
firestore().collection('accounts')
.where(firestore.FieldPath.documentId(), 'in', accounts) //accounts: ['xyz789']
.where('users', 'array-contains', userID)

Custom security rules for Cloud Firestore

I want to create a Cloud Firestore realtime database containing groups which users can join and share information to other members of their group. I want to preserve users anonymity so the way I see it to be implemented is:
Group creator generates a group key in format XXXX-XXXX-XXXX-XXXX
Those who want to join must have the group key which they enter in the app, after that they should be able to read, create and update data in that group
So basically the data structure is something like this:
/groups/ : [
//groups as documents
"ABCD-0000-0000-0001" : { /*group data model*/ }
"ABCD-0000-0000-0002" : { /*group data model*/ }
"ABCD-0000-0000-0003" : { /*group data model*/ }
]
The question is, what security rules should I write to permit users to read, create and update data ONLY in the group they belong to (have its group key)? At the same time, how to forbid users from finding out other groups' keys?
Your group structure can remain as is-
groups (Collection) : [
//groups as documents
"ABCD-0000-0000-0001" : { /*group data model*/ }
"ABCD-0000-0000-0002" : { /*group data model*/ }
"ABCD-0000-0000-0003" : { /*group data model*/ } ]
And to maintain the access, you can have another separate collection named group_users as-
group_users(Collection)/ <group_id>(Document)/ members(Collection)/ :
uid_1 (document)
uid_2 (document)
...
Now the rule to allow can be like-
service cloud.firestore {
match /databases/{database}/documents {
match /groups/{group_id} {
allow read, create, update: if exists(/databases/$(database)/documents/group_users/$(group_id)/members/$(request.auth.uid));
}
}
}
When a member joins the group, you will have to add the user's uid to that new collection-
group_users/<group_id>/members
For admins, you can have a similar collection, and uid will be added when the admin creates the group-
group_users/<group_id>/admins
Instead of having a separate collection outside of groups collection, you could have a collection within group itself as an alternative solition, then you will have to modify the group data model to some more extent.
Also FYI, each exists () call in security rules is billed (probably as one read operation).
And here is a detailed documentation explaining almost every possible aspect of firestore security rules.
You can save the group ID in the user's profile. Then create a rule which only allows CRU permissions if that group ID exists.
db.collection.('users').doc({userId}.update({
ABCD-0000-0000-0001: true
})
match /groups/{groupId} {
allow read, create, update: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.$(groupId) == true;
}

Resources