Security Rules

Role-Based Access Control in Cloud Firestore

Aravind Chowdary
5 min readJun 8, 2021

Learn how to use Firebase Security Rules to implement role-based Firestore access

Credit: DNS stuff

This is an a little bit advanced guide with Firestore rules, if you’re totally unfamiliar you can read my Getting Started with Firestore Rules Guide first and come back to this.

I’ll try to explain this with an example so that you can easily understand when to use this method of authentication when building your application.

Role-Based User Authorization: Where users can have many roles and those roles provide different privileges that enable different operations on your (Firestore) database.

To better understand this, let’s take a look at the following data model.

Here in our data model, we have a users collection with a number of user documents. The user document has a field roles which is an array of strings, where each string represents which role(s) the specific user has , allowing them to perform actions according to the privilege(s) this role has.

There is a slight issue with how the data model is structured: users can edit their own roles, so we need to restructure the data model so only admin users can edit other users’ roles.

Let’s create a posts collection to separate users’ roles from their data:

Documents in the posts collection have four main fields, the content (also the image), published which determines whether or not the post is made public, a timestamp to give information about when it was published, and userId to associate the author of the post to that document.

Let’s understand the logic on how this works with rules :

Look at the match block which references the users collection on line 4. It has custom functions set to it and you’ll learn to implement it later after we understand the logic.

So basically, on the users collection, we want to allow read if a user is logged in to our app. Updating or deleting a user requires admin privileges. To check if it’s a request from an admin account we use hasAnyRole(list) which takes in a list of strings that we’ll match to the roles of that user and if a user has any of those roles it will allow the operation.

Now, regarding the posts collection, to read a post we check if a user is logged in and the post has been published. However, the admin can read any post, no matter it is published or not. So we added a or admin check, which checks if the requesting user is admin or not and allows admin to read unpublished posts.

Coming to creating a post we’ll first check if it has all the valid fields and format and the user is the author of the post.

For updating a post, it’s almost a similar process but we only allow certain fields of a document to be updated once after it’s posted. So we’ll use another custom function to validate an update post request, and also allow additional roles to make an update to a post.

Finally, deletion of the post can only be done by admin, so we check if the requesting user is an admin or not. You could optionally allow the author of the post to be able to delete a post but that depends on your use case.

Custom Functions

Let’s get to the unimplemented functions from the above logic :

function isLoggedIn(){
return request.auth != null;
}

This function is pretty straightforward, it just checks if the requesting user’s auth attribute is not null.

The next function is to check the roles


function hasAnyRole(roles) {
return isLoggedIn() && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles.hasAny(roles)
}

This function checks if the user is logged in and then gets the roles array that we created earlier in the user’s data model and checks if it contains any of the roles.

Checking the validity of the post is a bit complex than these as we need to check multiple attributes to approve the request.

function isValidNewPost() {
let post = request.resource.data;
let isOwner = post.uid request.auth.uid;
let isNotFromPastOrFuture = request.time == request.resource.data.timestamp;
let hasMandatoryFields = post.keys().hasAll(['caption', 'uid', 'timestamp', 'published']);

return isOwner && hasMandatoryFields && isNotFromPastorFuture;
}

The post is the variable that holds the data from the request.

The isOwner variable holds the boolean value of if a user is the owner of the post.

isNotFromPastOrFuture will check if the timestamp on the incoming data is not from the past or future. This will help you to server-side validate if the user is trying to post something back or forward in time.

The hasMandatoryFields variable will check if all the mandatory keys exist in the incoming data. This will help you filter incomplete data sent through unauthorized clients.

And finally returns the boolean of all these variables.

Now, validation of updating post is a bit different from this :

function isValidUpdatedPost(){
let post = request.resource.data;
let hasMandatoryFeilds = post.keys().hasAny(['caption','timestamp','published']);
let isValid = post.content is string && post.content.size() > 2000;

return hasMandatoryFeilds && isValid;
}

Similar to the newPost function the post variable holds the incoming data.

Unlike the new post, we only need to check if there are only keys that can be modified after publishing the post.

The isValid variable checks if the content type is a string (depends on what data type you want it to be) and checks if the content length doesn’t exceed 2000 characters.

This was a basic example of role-based authorization. You can customize these rules accordingly to match your purpose.

I hope this article helped you understand writing Firestore rules. If you find anything difficult to understand feel free to leave a comment.

--

--