Learn how to use Firebase Security Rules to implement role-based Firestore access
I’ll try to explain this with an example so that you can easily understand when to use this authentication method 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 several userdocuments. The user document has a field role 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 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 has four main fields, the content (also the image), 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 with that document.
Let’s understand the logic of 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 reading 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 whether it is published or not. So we added an or admin check, which checks if the requesting user is admin or not and allows the 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 the 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
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.
Finally, returns the boolean of all these variables.
Now, the 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.