Er… Why?
Having done a complete web app using Firebase, I did find there are a few non-obvious things about it. One thing that kept tripping me up is the Firestore rules.
If you want users to directly read and write the data in Firestore, without needing a function or a separate server (like Node.js & Express), then Firebase Rules are what will enforce security and data validation for you.
What I found is that some of the nuances of Firebase rules aren’t too obviously documented. I have created this guide to hopefully save you some time with them if you use them.
Here I am talking about rules just for Firestore. This doesn’t cover Storage or Realtime Database. (Storage rules are quite similar though).
Before I talk about rules, let’s cover some basics about the data itself in Firestore and what Rules are. Feel free to skip over these sections if you know this already.
Firestore Structure
Firestore is a document-oriented database. This means the structure is one of collections of documents. Documents are identified by a key, and contain key/value pairs, which can contain further key/value pairs similar to a JSON structure.
You can get documents by the collection name and key, and you can also run queries to get all documents in a collection where the document meets conditions you specify.
The Firestore can be interacted via the regular API. This is for apps / sites, used by both anonymous users and authenticated users of the database. It can also be interacted with via the Admin API, which is for example used by cloud functions that need unfettered access to all of the data.
Rules only apply to the regular API.
For further details, see the Firestore Data Model documentation.
Rules basics
Firestore rules allow you to decide whether to accept or deny any request to read or write data, based on:
- The request
- The user
- The record being amended
- The updated data
- Other records in Firestore
This allows rules to serve at least two main functions:
- Authorization – i.e. what can this user (or unauthenticated request) do?
- Validation – i.e. is the new record or update valid?
All a rule can do is accept or reject a request. It cannot filter a request.
If a user does a request that would return 10 records, but the rules denies 1 of those records, then the request simply fails, rather than returning the 9 records they can get.
Rules can be defined for the operations of get, list, create, update and delete, with the shortcut keywords:
- “read” means both get & list
- “write” means create, update & delete.
If you want to understand the syntax of rules or the operations, you can read the documentation here. Or just read the recipes and hopefully it should make sense intuitively.
Rules Recipes
With the basics out of the way, I want to provide lots of example of rules.
The thing I struggled with when using rules is “how to do this?”. I got stuck on the difference between request.resource
and resource
, and why some rules didn’t seem to have any effect, or just kept giving errors.
Therefore I think these recipes should help you, hopefully, by reducing the time to get something working and debuggable. You can then customize the recipe for your needs using the Firebase documentation. Please give me feedback in the comments if something does not make sense, is not right, or isn’t covered. Happy to help on your specific problem, and add to my knowledge too.
Allow nothing
The allow nothing recipe is the rule that doesn’t allow any operations on a collection.
To do this, do nothing! By default, with no rules you cannot access the data in any way. Which is a good thing.
Allow everyone to read
To allow everyone, both authenticated and unauthenticated to read all documents in the collection – i.e. both list the documents and read their contents:
match /collection/{item} {
allow read: if true;
}
Allow authenticated users only to read
This allows anyone who is logged in to read all documents in the collection:
match /collection/{item} {
allow read: if request.auth != null;
}
Allow authenticated users with a specific token to read
Firebase authentication allows you to set up extra details for a user when they are created (and these can be updated) which can be queried during the rules:
match /collection/{item} {
allow read: if request.auth != null &&
request.auth.token.isAdmin == true;
}
Having used tokens, I am not too keen on them, because the are invisible in admin interfaces (so you have to write script code to see what tokens a user has), and you will probably need to keep the token information in sync with the database data anyway. The next example is the way I prefer to do it…
Allow authenticated users based on profile criteria
You can use the incoming authentication uid to look up details about the user, and then check if they should have access:
match /collection/{item} {
allow read: if request.auth != null &&
get(/databases/$(database)/documents/users/$(request.auth.uid)).isAdmin == true
}
Allow only the “owner” of an object to read
You can store the id of the user who “owns” this object as a string, and then compare this to the requesting user:
match /collection/{item} {
allow read: if request.auth != null &&
request.auth.uid == resource.data.uid;
}
Social Network User Profile Pattern
The following shows a basic pattern for a user profile, so that someone can only create or update a user profile for them, but you can read other peoples profiles, even anonymously.
match /users/{uid} {
allow write, update: if request.auth != null && request.auth.uid == uid;
allow read: if true;
}
Of course, you can change this to a more private social network by only allowing authenticated users to read profiles:
match /users/{uid} {
//...
allow read: if request.auth != null;
}
Multitenant
Multitenant is the idea of having different organisations (e.g. companies) that have users, where from a security point of view they are siloed, so you can only see data within your company.
There are two ways to do this: Firstly, you can use a tenantId field on each record to distinguish them, or secondly create subcollections within a tenant collection.
In my experience for maximum future flexibility and ease of client programming, I recommend adding a tenantId field and to avoid subcollections because they make calling code more complicated.
Here is an example of changing the user rules from the Social Network example to allow reading only for users in the same company:
match /users/{uid} {
//...
allow read: if request.auth != null &&
get(/databases/$(database)/documents/users/$(request.auth.uid)).companyId == resource.data.companyId
}
Note that something needs to set the companyId initially, so probably you would want that to be trusted code, for example running in a function.
Subcollection matching
The syntax for matching a subcollection item is this giving you access to the id of both the parent and child collection:
match /collection/{collectionId}/subcollection/{subCollectionId} {
//...
}
This said, my experience with subcollections leads me to think by default you should go for single level collections for everything.
To do this, reference the parent collection by having a parentId
string field (no need for a reference field, they are tricky to work with inside rules, and I don’t see the benefit of them over a string)
It makes client code much simpler, while (from what I see) offering no real disadvantage. Please comment if I am wrong about this!
Data Validations
With Firebase by default accepting any shape of data, you might want to validate that fields exist, don’t exist, or have certain types or constraints. Remember with rules you don’t get much error information if they fail, so you also want to check these on the client before sending any data for a good user experience.
For this I would split out the validations into a function, so you can call this from different rules. A validation may look something like this:
function validateEvent(event) {
return event.keys().hasAll(['title', 'description', 'imageName', 'dateAndTimeOfFirstRecurrance',
'timeZone', 'recurrenceType', 'numberOfRecurrences', 'questions', 'invitees',
'breakTimeMs', 'rules' ]) &&
event.title is string &&
event.description is string &&
event.imageName is string || event.imageName == null &&
event.dateAndTimeOfFirstRecurrance is number &&
event.timeZone is string &&
event.recurrenceType is string &&
event.numberOfRecurrences is number &&
event.questions is list &&
event.invitees is list &&
event.invitees is list &&
event.breakTimeMs is number &&
event.rules is string &&
event.title.size() > 0 &&
event.title.size() < 100 &&
event.description.size() > 0 &&
event.description.size() < 10000 &&
event.rules.size() < 1000 &&
event.dateAndTimeOfFirstRecurrance > 0 &&
event.timeZone.size() > 0 &&
event.timeZone.size() < 1000 &&
event.recurrenceType.size() > 0 &&
event.recurrenceType.size() < 1000 &&
event.questions.size() > 0 &&
event.breakTimeMs >= 10000 &&
event.breakTimeMs <= 3600000
//... etc ...
}
match /events/{e} {
//...
allow create, update: if request.auth != null &&
validateEvent(request.resource.data)
}
This is a living blog post!
I will add more examples to this as I encounter them, and I am sure I have forgotten a few. Watch this space.
Hi
I’ve been having some problems writing the firestore security rules so that permission to read a collection will be dependent on data within another collection – it’s to do with the get() function in security rules.
Could you demonstrate with Firestore and Flutter interfaces near you (if not Flutter then the language of your preference) how the get() function should be called when all of the relevant data is two sub-collections from the parent collection please? With some photos I could emulate what you show in order to understand how the rule should be written.
What do you think?
Thank you for your time and for your assistance.
Hi, good question. I have not done this myself, and it has been a while since I have had my head in this space. These days I would probably use ChatGPT to help give an outline of the solution, and then try it out (and fix it up).
This is what it said, by the way: https://gist.github.com/mcapodici/edd0b843eb7af1038d3bc42367b0ef7d