Firebase Security Rules
Tags: security, tutorial
Published: 2020-12-06 01:52:45

#### What is Firebase?

Firebase is a platform that provides easy integration of commonly used services for mobile and web applications. These services include
 - Firestore
 - Cloud Functions
 - Authentication
 - Cloud Storage
 - Realtime Database
 - Hosting
 - Firebase ML

### How do security rules come into play?

Traditionally, access to a database is controlled via server-side code. However, Firebase services such as Firestore allow client-side code to access the database directly. This poses a security risk as client-side code can be viewed by anyone. This is where Firebase's concept of security rules come into play. **Firebase security rules control access to specific resources**. In other words, we can avoid the hassle of writing, maintaining, and deploying server-side code that controls access to resources. Security **rules** can **only** be **written for Realtime Database, Firestore, and Cloud Storage**.

### Writing Security Rules

Security rules act as a middleware for when a service is used (e.g. reading a document in Firestore). When a request to access a service is denied due to security rules, the entire request fails. Rules can be written via Firebase console or an IDE that is using the Firebase CLI. Writing rules in the console provides the benefit of using Rules Playground before deploying the rules.

### Firestore Security Rules

Firestore Security Rules consist of **match statements** and **allow expressions**. Match statements identify documents in your database and allow expressions control access to those documents.

The basic structure of a security rule:
```
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /<some_path>/ {
      allow read: if <some_condition>;
      allow write: if <some_condition>;
    }
  }
}
```
In the example above, we are matching some path in the database and allowing read and write permissions based on a condition. In situations where we need finer access control, we can split read and write into more granular operations. 

read can be broken into 
- ``get`` (applies to **single document read requests**)
- ``list`` (applies to **queries and collection read requests**)

write can be broken into
- ``create``
- ``update``
- ``delete``

We can also define custom functions to improve readability and reusability.

Let's take a look at an example
```
rules_version = '2';  
service cloud.firestore {
  match /databases/{database}/documents {  
    function isAdmin() {
      let userDoc = get(/databases/$(database)/documents/users/$(request.auth.uid));
      return userDoc == null ? false : userDoc.data.isAdmin;
    }
    function isAuthenticated(){
    	return request.auth != null;
    }
    match /users/{userId}{
    	allow read, delete: if isAuthenticated() 
                            && request.auth.uid == resource.id;
        allow create: if request.resource.data.keys().hasAll(['name', 'email', 'isAdmin']) 
                      && request.resource.data.keys().hasOnly(['name', 'email', 'isAdmin'])
                      && request.resource.data.isAdmin == false;
        allow update: if isAuthenticated()
      	              && request.auth.uid == resource.id
                      && request.resource.data.isAdmin == resource.data.isAdmin;
    }
 }
}
```
Based on the example above, we have written the following rules for the users collection:

- reading user(s) and deleting a user is allowed if the user is authenticated & the user document being read/deleted belongs to the authenticated user
- creating a user is allowed if the request body is of the form ``{name, email, isAdmin}`` && the isAdmin field is false (to prevent creating admin accounts from client-side)
- updating a user is allowed if the user is authenticated, the user document being updated belongs to the authenticated user, and the isAdmin field remains unchanged

You might be wondering where variables and methods such as ``get()``, ``request``, ``resource`` are coming from. Well, any custom functions or match statements in the ``service cloud.firestore`` namespace has access to the following:

Properties
- ``request`` (request context)
 - Contains auth and incoming request information via auth & resource property
 - ``request.resource`` is used to enforce write operations (create, update, delete) have specific structure and field validations (e.g. age >= 15)
 - ``request.auth`` is used to provide access based on if the request is authenticated
 - More info can be found at https://firebase.google.com/docs/reference/rules/rules.firestore.Request
- ``resource``
 - resource being written to or read (i.e. document in a collection)
 - Document data and id can be accessed via ``resource.data`` & ``resource.id``
 - Useful for enforcing certain fields cannot be changed. In the example above, we are preventing the ``isAdmin`` field from changing in update user requests by ``request.resource.data.isAdmin == resource.data.isAdmin``.
 - More info can be found at https://firebase.google.com/docs/reference/rules/rules.firestore.Resource

Methods
- ``get(path)`` - **path must be absolute path to a document**
 - Gets the contents of a document
 - Useful when the modification of a document depends on another document
 - e.g. The isAdmin() function in the example above gets the user document based on ``request.auth.uid`` then accesses the ``isAdmin`` field to check if the user is an Admin

The complete list of methods and properties provided in the ``service cloud.firestore`` namespace can be found at https://firebase.google.com/docs/reference/rules/rules.firestore

Firebase also provides functions for working with different property types (e.g. list, boolean, map...etc) when writing rules. In the example above, we are using ``hasOnly()`` and ``hasAll()`` to enforce a user document only has 'name', 'email', and 'isAdmin' field.
 - More info can be found at https://firebase.google.com/docs/reference/rules

### Cloud Storage Security Rules 

Security rules for Cloud Storage are similar to Firestore rules except instead of matching documents in a collection, we match directories or paths to files.

Let's take a look at an example
```
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /product_images/{imageName}
    {
    	allow read, delete: if request.auth != null;
        allow create: if request.auth != null 
                      && request.resource.contentType.matches('image/.*')
                      && request.resource.size < 100000;
    }
  }
}
```
Notice the similarities between Firestore rules and Cloud Storage rules. The main difference being ``service firebase.storage`` namespace provides us with different properties on the ``request`` variable.
  - Full list of the properties can be found at https://firebase.google.com/docs/storage/security/rules-conditions

Based on the example above, we have written the following rules for the product_images directory:

- reading and deleting files is allowed if the user is authenticated
- files can be added if the user is authenticated & the file is an image file that is smaller than 100 000 bytes

### Realtime Database Security Rules

Firebase's Realtime Database stores data as a large JSON object. This means when writing security rules, we match keys in the object instead of documents in a collection (like in Firestore).

The basic structure of a security rule:
```
{
  "rules": {
    "parent_node": {
      "child_node": {
        ".read": <condition>,
        ".write": <condition>,
        ".validate": <condition>,
      }
    }
  }
}
```
Before writing rules, an important concept to keep in mind is that **read & write rules cascade**. In other words, if a rule grants read & write access at a particular path, then it grants access to all child nodes. Furthermore, **shallower rules override deeper rules**.

Let's take a look at an example
```
{
  "rules": {
     "foo": {
        // allows read to /foo/*
        ".read": "true",
        "bar": {
          /* bar can be read so this rule is ignored since read 
             was allowed already */
          ".read": false
        }
     }
  }
}
```

Firebase Realtime Database provides pre-defined variables similar to how ``service firebase.storage`` namespace provides global variables & methods.
- The complete list of variables and methods can be found at https://firebase.google.com/docs/rules/rules-language#building_conditions

A more practical example:
```
{
  "rules": {
    "users": {
      "$uid": {
        ".read": "auth != null && auth.uid == $uid",
        ".write": "auth != null && auth.uid == $uid",
        ".validate": "newData.hasChildren(['name', 'age'])",
        "age": {
            ".validate": "newData.isNumber() &&
                          newData.val() > 0"
         }
      }
    }
  }
}
```
Based on the example above, we have written the following rules for the users node:

- write & read access is allowed user node belongs to the authenticated user
- a child node in the users node must have a 'name' & 'age' property
- the 'age' property must be a number greater than 0

### Conclusion

Although different services (Firestore, Cloud Storage, Realtime Database) have slightly different syntax when writing rules, at a high level we are doing the same thing. More specifically, we match a path to a resource and control access to that resource via read and write conditions. 

Nevertheless,  Firebase security rules serve the purpose of protecting data from malicious users. Thus, I hope this article helped you understand the main concepts when writing security rules! Now go protect your data!