Firebase's Firestore is a database that applications can communicate with directly from the client-side. As a result, there are many security considerations that developers need to take into consideration.
To help secure our databases, we can leverage Firestore's Security Rules using a scripting language (CEL), which can be extended using functions, not too differently from Javascript.
For both Cloud Firestore and Cloud Storage, we can use the following syntax:
service <<name>> { match <<path>> { // Allow the request if the following conditions are true. allow <<methods>> : if <<condition>> }}
For example, for writing Cloud Firestore's root path, we'd be using the following rules:
service cloud.firestore { match /databases/{database}/documents { // rules here }}
Document Paths
The path
variable is the path to your Firestore document. If you specify a path to a document, the ID of the document is defined as a variable:
match /organizations/{organizationId} { // rules here}
For nested collections, we can insert additional rules within the same scope:
match /organizations/{organizationId} { match /invites/{inviteId} { }}
Methods
Methods will disallow any read and write by default. Writing and reading permissions can be written in batch or granularly.
For example, we can write the following conditions:
match /organizations/{organizationId} { allow read: if canRead(); allow write: if canWrite();}
In most situations, you want to write granular conditions.
Reading documents can be split as follows:
list
- when we want to query a collection of documentsget
- when we want to query a single document
Writing a document, instead, can be split in the following ways:
create
- when we create a new documentupdate
- when we write to an existing documentdelete
- when we delete an existing document
match /organizations/{organizationId} { allow list: if canList(); allow get: if canGet(); allow create: if canCreate(); allow update: if canUpdate(); allow delete: if canDelete();}
Recursive Wildcards
If you want to apply a specific rule recursively to a deep hierarchy, we can use the following syntax:
match /organizations/{organizationId=**} { allow read: if condition();}
Security Rules are not filters
One important something you should notice is that Firestore's rules are not filters: queries that violate the Security Rules will be rejected.
Debugging Firestore's Security Rules using the Emulator UI
As you may know, the Firebase Emulators offer the ability to debug your Firestore database and the queries/writes against it.
If you aren't sure how to run the emulators, take a look at our guide for setting up the Firebase Emulators for your application.
If you have followed the guide above, you should be able to view the Firestore Emulator UI at http://localhost:4000/firestore.
By clicking one of the rows, we can view and analyze the scope of the request that the Firestore security rules have evaluated.
In certain situations, Firestore can be quirky and not fully documented, so it can be necessary to inspect what data is available to the security rules.
You can inspect the scope of a Firestore request using the sidebar the right-hand side after clicking on a request.
Knowing how to debug our security rules is the first but essential step to start developing them effectively.
Evaluating an existing document's data
When the client is executing a write
(create, update or delete), we can access the data of the document written passed via the request.
We can use the scoped data request.resource
to access the document. We should use the data
property to access the data itself.
In the image below, you can see the scope of a request that was used to create a new Firestore document:
The function below is a quick way to access incoming data being written to a Firestore document:
function incomingData() { return request.resource.data;}
How to use this function? Let's write a security rule that allows us to create a document if the document contains a field with a specific value:
function canCreateOrganization() { return incomingData().id != null;}service cloud.firestore { match /databases/{database}/documents { match /organizations/{organizationId} { allow create: if canCreateOrganization(); } }}
In the function above, we're checking that the id
property of the document exists.
Evaluating the incoming document's data
When we're evaluating data of documents that are already in the database, we need to use the scope resource.data
:
function existingData() { return resource.data;}
For example, let's assume the user wants to read the data from an organization
document, and that this document contains a map in which each key
is the ID of a member.
Explained: if the current user's ID is a key of the map
means the member belongs to the organization.
We can use the rule below:
match /organizations/{organizationId} { allow read: if userId() in existingData().members;}
Checking that the user is authenticated
The following function allows us to check that the user is authenticated.
Remember, this only means that the user is not anonymous, not that they're authorized to perform a specific action.
function userId() { return request.auth.uid;}function isSignedIn() { return userId() != null;}
Accessing any path in Firestore
The security rule can allow us to access any path in the database by using the correct path.
For example, knowing the user's ID (as it is passed via the request
scope), we could access the user record using the get
function and the following syntax:
get(/databases/$(database)/documents/users/$(userId));
Notice: this will cost reads. Every time our rule reads another document, it will be billed as an additional read. It's a good practice to write security rules efficiently, but sometimes it's simply impossible not to do so.
Similarly, we can use the exists
function to check that a Firestore path exists:
function userExists() { return exists(/databases/$(database)/documents/users/$(userId));}
Retrieve the currently signed-in user record
As we learned above, we can access paths in Firestore if we have the data to build the paths to retrieve them.
Let's assume your application, like MakerKit, has a collection named users
where we store the data belonging to each user. Your Firestore's collection name can differ, so you may need to update the snippet below.
It can be very helpful to read your current user's record to access data such as permissions, the organizations they belong to, etc.
The functions below help you access the data regarding the currently selected user:
function getUser(userId) { return get(/databases/$(database)/documents/users/$(userId));}function getCurrentUser() { return getUser(userId());}
Disallow writing to specific fields
The following function can prevent clients from writing the fields passed in as strings when creating a new document. This can be useful when you want to disallow particular writes from clients.
// use this function to prevent from writing certain fields// to the document, such as sensitive data (permissions, etc.)// create: if fieldsNotInCreateAction(["permissions"]);function fieldsNotInCreateAction(fields) { return !(request.resource.data.keys().hasAll(fields));}
Disallow updates to specific fields
The following function can prevent clients from updating the fields passed in as strings:
function fieldsNotInUpdateAction(fields) { return (fieldsNotInCreateAction(fields) && !(resource.data.keys().hasAll(fields)));}
Strictly typing writes
Firestore can also allow us to check if the fields sent by the client have the correct data type.
The following snippet has been taken straight from the Firstore documentation:
allow create: if request.resource.data.tags is list && request.resource.data.tags[0] is string && request.resource.data.product is map && request.resource.data.product.name is string && request.resource.data.product.quantity is int }}
Of course, the best way to write long and complex logic is to split it into its own function:
function canCreateReview(review) { return review.score is int && review.headline is string && review.content is string && review.author_name is string && review.review_date is timestamp}// conditionsallow create: if canCreateReview(request.resource.data);