Firestore Rule function to check role OR company - firebase

I need to implement role based authorization in my app before we launch for production. I have a function that checks whether the user is accessing data from only their company. I need to also make sure that a user with the role "SuperUser", "Tester", or "Manager" can also access the data even though they aren't part of the company. I've looked at other role based implementations but they don't seem to address this scenario of having to check either the company or the role.
I have the rule that has the OR statement here:
allow read: if exists(/databases/$(database)/documents/companies/$(company)/users/$(request.auth.uid))
|| hasRole(['SuperUser', 'Manager', 'Tester']);
And I have this as the function for hasRoles:
function hasRole(roles) {
return get("/databases/$(database)/documents/companies/Seva Development/users/$(request.auth.uid)").data.roles.hasAny(roles);
}
When I add this function the user is denied access, even when the left side of the OR statement is true. I can only assume something is wrong with the function, but I've been unable to find where from firebase documentation.
The document I'm trying to access looks like this:
How can I modify this function to allow me to check if the user is part of our organization and has one of the specified roles?
This works if you have the role but aren't part of the company:
allow read: if isUserCompany(company)
|| hasRole(['SuperUser', 'Manager', 'Tester']);
But it will fail if you are part of the company and don't have the role. What I can't figure out, is that this works if you are part of the company but don't have the role:
allow read: if isUserCompany(company)
So the left side works, the right side works. But they don't work together.
It also works if I write:
allow read: if isUserCompany(company) || false;
Which makes me assume the problem must be with the hasRole function if the role doesn't exist. I tried writing a function to check for the role first, but it still failed.

The problem is that you're passing a string to get() instead of a path. Note the reference documentation for get() says the argument is a Path. According to the docs for Path:
Paths can be created in two ways. The first is in the "raw" form
beginning with a forward slash /:
/path/to/resource
The second is by converting from a string using the path() function:
path("path/to/resource")
If you want to compose a string to become a path (because you have a space in there somewhere), you'll have to use the path() function. Or, you can use the bind() function to insert the values that have spaces in them. Using path():
function hasRole(roles) {
return get(path("databases/" + database + "/documents/companies/Seva Development/users/" + request.auth.uid)).data.roles.hasAny(roles);
}
Note that the leading slash is missing from the string form of the path, and all variables need to be added by concatenation.
Or you can just remove the space from "Seva Development" and just use the raw form of the path.

Related

Firestore Security: How to create rules based on a changed property which identifiers a document

Imagine a User Document which holds a property "restaurants" witch again holds keys for all restaurant he is responsible for.
I want to implement a security rule which only allows updating the property "restaurants" if the changed restaurant-id in the update, references a restaurant owned by the user.
Therefore in security rules i want to detect the changed-restaurant-id, load corresponding restaurant-doc and check if the field "owner" inside the restaurant-doc is equal the user-id.
I was already able to implement a rule to check only 1 Id was modified by implmenting something like this:
function hasSingleChange(){
let changedKeys = request.resource.data.diff(resource.data).affectedKeys()
return changedKeys.size() == 0;
}
Now I wanted to get the the specific Id that changed and use it for building the query for the restaurant:
function getChangedKeys() {
let changedKeys = request.resource.data.diff(resource.data).affectedKeys()
return changedKeys[0]
}
But unfortunately "return changedKeys[0]" will not work as affectedKeys returns a Set with limited operations available.
Is there an other way around to load a document in security-rules, based on changed property to be able to apply checks on this changed, referenced document?
Edit
Let me explain you what i actually want to solve with this.
I have a Domain consisting of graphs, nodes and users as shown here:
What I want to accomplish is that users can access read/update/delete a node if the node is in a at least one graph which the user is the owner for. In the example above the user should have acess to node "n1" but not to node "n2".
My idea was to check the permission on the node as following:
match /nodes/{nodeId} {
allow read: if resource.data.graphs.keys().hasAny(get(/databases/$(database)/documents/users/$(request.auth.uid)).data.graphs.keys());
I guess this would work (?). But I need somehow to protect the user-Document to allow only writes to the "graphs" field if the user is listed as owner in the graph.
Thats why I want to add another security-rule, something like this:
match /users/{userId} {
allow update: if request.auth.uid == userId && hasSingleChange() && get(/databases/$(database)/documents/graphs/getChangedKey()).data.owner).keys().hasAny($(userId))
}
function hasSingleChange(){
let changedKeys = request.resource.data.diff(resource.data).affectedKeys()
return changedKeys.size() == 0;
}
//To get the id of the changed graph
function getChangedKey() {
let changedKeys = request.resource.data.diff(resource.data).affectedKeys()
return changedKeys[0];
}
But as mentioned this is not working as I am unable to extract the changed graph-Id to lookup the document (getChangedKey not working).
Maybe there is another way arround to fullfill my requirements? What I try to avoid is to entitle the userId directly on the node, as I would have to update a lot of nodes if someone entitles a new user to a specific graph.
As you want an end-user to be able to modify only a document's field. You can check the section “Preventing some fields from being changed
” by referencing the documentation. You can try with the permission below.
allow update: if (!request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['A', 'B']));
You check the discussion on stackoverflow link & Stackoverflow url.

Firestore security rule that only allows empty documents

I'm basically trying to use a firestore collection as a an email list. Anyone can create a document that has their email as the id and nothing more. The tricky part is the "and nothing more" bit. When no data is provided in the request, request.resource is undefined which you can't check for in security rules to my knowledge. Is this possible? Or is it necessary to have something like one mandatory field for this use case?
Having empty documents regularly leads to issues down the line. Why not require a single marker field, and validate that in rules?
request.resource.data.keys.hasOnly("marker")
For the benefit of others looking to make an email list in firestore, this is the full rule I ended up using:
match /email-list/{email} {
allow get: if true;
allow list: if false;
allow create: if request.resource.data.keys().hasOnly(["marker"])
&& request.resource.data.marker == true
}

Request.auth.metadata in security rules?

I have a Firebase project where I'd like for users to be able to see when other users created their profiles. My initial hope was that I could use "user.metadata.creationTime" on the frontend to pass the date into the user's extra info document and verify that it is correct by having "request.resource.data.datecreated == request.auth.metadata.creationTime" as a Database Rule, but it looks like it is not possible according to the documentation.
Is there any way I can verify that the creation date is correct on the backend?
More info edit: Below is the code that is being triggered when a user creates a new account on my profile. The three values are displayed publicly. I'm creating a niche gear for sale page so being able to see when a user first created their account could be helpful when deciding if a seller is sketchy. I don't want someone to be able to make it seem like they have been around for longer than they have been.
db.collection('users').doc(user.uid).set({
username: "Username-156135",
bio: "Add a bio",
created: user.metadata.creationTime
});
Firestore rules:
match /users/{id} {
allow get;
allow create, update: if request.resource.data.username is string &&
request.resource.data.bio is string &&
request.resource.data.created == request.auth.metadata.creationTime;
}
user.metadata.creationTime, according to the API documentation is a string with no documented format. I suggest not using it. In fact, what you're trying to do seems impossible since that value isn't available in the API documentation for request.auth.
What I suggest you do instead is use a Firebase Auth onCreate trigger with Cloud Functions to automatically create that document with the current time as a proper timestamp. Then, in security rules, I wouldn't even give the user the ability to change that field, so you can be sure it was only ever set accurately by the trigger. You might be interested in this solution overall.

Firestore Rules break if the document path has a comma

So, I am building a multi tenant application which involves multitude of collections and user invitation. Since I don't know which user ID the signup is going to use and I want the companies to start making changes and updates to their user data I am pre setting the user ID and I use it in several paths as:
/companies/{company}/customers/{userId}
/users/{userId}
Now, this userId is pretty much the email with the dot replaced by a comma. The dot is a character not allowed in firebase but the comma is, and viceversa with emails so it makes sense and there is no problem there.
The issue is when I need to make some rules. For example I have this in the configuration since I want admins to access every company:
function cleanEmail(){
return request.auth.token.email.split('.').join(',') //Here I also tried %2C
}
function isSuperadmin() {
return exists(/databases/$(database)/documents/admins/$(cleanEmail()))
}
match /companies/{company} {
allow write: if isSuperadmin();
allow read: if isSuperadmin() || belongsToCompany(company)
}
The simulator was breaking but I could not understand why, so I even sent a bug report. Eventually I tried making the path hard coded and then I found the problem:
function isSuperadmin() {
return exists(/databases/$(database)/documents/admins/test,account#gmail,com) //Same issue with get()
}
I tried with exists() because I thought it could be a bug with get() but the issue remains. I believe this should be a bug since that is a valid Firestore path and I have seen some people already using this "clean email" strategy.
Funny thing is that this was not happening during first testing and I realized that this bug only happens if the comma is BEFORE the #. If you remove the one before that, and leave the second one, seems to work:
I might add a new step to the clean email that turns that into base64 and that might work tho. If someone has a solution great.
I do something similar that looks like this in outline:
/invitation
/fb-generated-doc-name
email: somecustomer#gmail.com
trip: db-ref-to-trip
/usertrips
/doc-name-is-UID
trip: db-ref-to-trip
/trips
/fb-generated-doc-name
{ ... describes the trip ... }
The outline for onCreate auth'd user is:
exports.authDidCreateUser = functions.auth.user().onCreate((authUser, context) => {
/*
find invitation where email == authUser.email
let docref = collection('usertrips').doc(authUser.uid)
docref.set({ trip: invitation.trip })
*/
});

How to use firebase rule to check is user group array and record group array intersect

I have a list of records in firebase which will have a group property with zero or more groups on it. I also have the firebase auth object which will also have zero or more groups on it as well. I would like to set up a .read firebase rule for my records that will check if the two have at lease one group that exists in both lists.
Put another way I have a user that has an array of groups that have been assigned to it. I have some records that also has some list of groups on them that specify what groups the user must have to access them. If the logged in user tries to access the record, I want to make sure that the user has at least one group that the record requires.
On the client I would do something like _.intersect(userGroups, recordGroups).length > 0
I'm not sure how I would do this in a firebase rule expression. It would be cool if it worked something like this.
Record:
{
someData: "test"
groups: ['foo', 'bar']
}
Firebase Auth Object:
{
userName: "Bob",
groups: ['foo', 'bar']
}
Rule Data:
{
"rules": {
"records": {
"$recordId": {
".read": "data.child('groups').intersectsWith(auth.groups)"
}
}
}
}
Thanks.
Update:
I think that if hasChildren() used || instead of && I could put the group names in they key position and check for their existence this way. Something like "data.child('groups').hasChildren(auth.groups, 'or')"
Where Record:
{
someData: "test"
groups: {
'foo': '',
'bar': ''
}
}
Update2:
Based off Kato's comment & link I realize that even if hasChildren could do OR it still wouldn't work quite right. Requests for individual records would work but requests for all records would error if the current user didn't have access to every record.
It is still not clear how you would structure data to make this work. If a record could belong to many groups how would that work? This is a very common scenario(basically how linux group permissions work) so I can't be the only one trying to do this. Anyone have any ideas/examples of how to accomplish this in firebase?
At the current moment, I believe it's impossible. There's a limited number of variables, methods, and operators allowed, listed here:
Firebase Security Rules API
Since function definitions are not allowed in the rules, you can't do anything fancy like call array.some(callback) on an array to do the matching yourself.
You have three options that I know of:
1) Copy data so you don't need to do the check. This is what I did in my project: I wanted some user data (names) available to users that shared a network in their network lists. Originally I wanted to check both member's network lists to see if there was at least one match. Eventually I realized it would be easier to just save each user's name as part of the network data so there wouldn't have to be a user look up requiring this odd permissions. I don't know enough about your data to suggest what you need to copy.
2) Use strings instead of arrays. You can turn one string into a regex (or just save it in regex format) and use it to search the other string for a match.Firebase DB Regex Docs
3) If you have enough weird cases like this, actually run a server that validates the request in a custom fashion. In the DB, just allow permissions to your server. You could use Firebase Cloud Functions or roll your own server that uses the Firebase Admin SDK
Nowadays, there's another possibility: to use Firestore to deliver your content, possibly in sync with the Realtime Database.
In Firestore, you can create rules like this:
function hasAccessTo(permissionList) {
return get(/databases/$(database)/documents/permissions/$(request.auth.uid))
.data.userPermissions.keys().hasAny(permissionList)
}
match /content/{itemId} {
allow read: if hasAccessTo(resource.data.permissions.keys());
}
The following data would allow a read of $CONTENTID by $UID, because the user permissions set intersects with the possible permissions required to access the content (with access123). My scenario is that a piece of content can be unlocked by multiple In-App Purchases.
{
permissions: {
$UID: { userPermissions: { access123:true, access456:true } },
...
},
content: {
$CONTENTID: { ..., permissions: { access123, access789 } },
...
}
}
For a progressive migration, you can keep data in sync between the Realtime Database and Firestore by using a one-way Cloud Function like this for example:
exports.fsyncContent = functions.database
.ref("/content/{itemId}")
.onWrite((snapshot, context) => {
const item = snapshot.after.val();
return admin
.firestore()
.collection("content")
.doc(context.params.itemId)
.set(item);
});

Resources