Andreas' Blog

Adventures of a software engineer/architect

RBAC with Google Firestore

2018-08-12 7 min read anoff

This post will explain how to implement role based access control (RBAC) using the Google Firestore serverless database. Firebase and Firestore in particular with the concept presented in this post offers the most seamless integration of serverless infrastructure with a mobile client at this point. It has become my go to backend for all minor web apps I build.

Firestore basics

Firestore is database that is part of Googles Firebase suite for mobile app development. It is currently in beta and has the potential to replace the current Firebase Realtime Database due to its superior API and features. > Cloud Firestore is a flexible, scalable database for mobile, web, and server development from Firebase and Google Cloud Platform

For those that never used a Firebase database; it is a NoSQL document oriented database. Firestore allows you to nest documents by creating multiple collections inside a document.

Screenshot of a Firestore database with nested collections

The Firebase suite is built for mobile development and provides SDKs for all major languages. JavaScript/Node.js, Swift, Objective C, Android, Java, Python, Ruby, Go. The SDKs allow add, query or delete data as well as other operations required when interacting with a database as a client. One feature I really like is the possiblity to register your client to receive updates automatically. This allows you to build three way data binding in realtime applications easily. This is a feature I used in my first project with Firebase.

In combination with the Firebase authentication provider you can limit access the database to people that are logged in. The auth provider also provides an SDK and requires only a few lines of code to implement in a web app.

Firebase application design

Firestore Security Rules

The ability to create a detailed rule set make Firestore enable use cases for a serverless database without any backend code and still keeping data secure. It is also the foundation for building a role based access control.

All roles and authorization rules will be enforced by the Firestore server

Security rules are written in a JavaScript-like syntax but have their own methods. First you nest match operators to specify the document level you want to be affected by this rule. Use {wildcards} that can later on be referenced in the rule definition. Granting/denying access is done via an allow <method> if statement that grants access if it returns true or otherwise blocks the transaction.

service cloud.firestore {
  match /databases/{database}/documents {
    allow read, write: if <some_condition>;
    allow delete: if false;
  }
}

There are five methods that can be specified:

  1. get: retrieve a single document
  2. list: read an entire collection or perform queries
  3. create: write to non existing documents
  4. update: write to existing documents
  5. delete: remove a document

The modifying operations 3-5 can be addressed using the write method instead of specifying them individually, read applies both get and list. If multiple rules match for a request only one needs to resolve to true for the request to be successful.

// allow anyone to read but only signed in users to create/update; only a specific user can delete
service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{document=**} {
      allow read: if true;
      allow create, update: if request.auth != null;
      allow delete: if request.auth.uid == 'an0xff';
    }
  }
}

RBAC example scenario

We will setup RBAC for a simple content site with posts that can be commented with the following roles:

  • admin: can assign roles
  • writer: can create new posts, can modify its own posts
  • editor: can edit any post, delete comments
  • user: can create and modify its own comments, can modify his user settings

On the root level of Firestore we add three different collections. One holds the content, one for user details and one that implements the roles per user. The reason to separate role assignments from the user document is to easily allow users to modify their own details without giving them the possibility to grant themselves new roles.

Class diagram of the database structure

Required security rules for RBAC

Given the collection setup and the above role definition we can define the rules we need to implement for each collection.

UserRolesPostsComments
getanyoneuser (own), adminanyoneanyone
listnooneadminanyoneanyone
createnoonenoonewriteruser
updateuser (own profile)adminwriter (own), editoruser (own)
deleteuser (own profile)noonewriter (own)user (own), editor

Implementing security rules for RBAC

We start with a default DENY ALL policy setup for the collections in our project.

service cloud.firestore {
  match /databases/{database}/documents {
    // this addresses any entry in the user collection
    match /users/{user} {
     	allow read, write: if false;
    }
    // rules for the roles setup
    match /roles/{user} {
      allow read, write: if false;
    }

    match /posts/{post} {
      allow read, write: if false;
    }

    match /posts/{post}/comments/{comment} {
      allow read, write: if false;
    }
  }
}

RBAC helper functions

We start by implementing a few custom functions that help us define role based rules.

service cloud.firestore {
  match /databases/{database}/documents {
    ...
    // the request object contains info about the authentication status of the requesting user
    // if the .auth property is not set, the user is not signed in
    function isSignedIn() {
      return request.auth != null;
    }
    // return the current users entry in the roles collection
    function getRoles() {
      return get(/databases/$(database)/documents/roles/$(request.auth.uid)).data
    }
    // check if the current user has a specific role
    function hasRole(role) {
      return isSignedIn() && getRoles()[role] == true;
    }
    // check if the user has any of the given roles (list)
    function hasAnyRole(roles) {
      return isSignedIn() && getRoles().keys().hasAny(roles);
    }
  }
}

With these functions in the security rules you can now easily implement security roles based on the users roles.

Users collection

First we make sure only the user itself can modify its data and anyone can view a specific user profile to enable them to see who posted comments.

match /users/{user} {
  // anyone can see a specific users profile data (name, email etc), in a real scenario you might want to make this more granular
  allow get: if true;
  // noone can query for users
  allow list, create: if false;
  // users can modify their own data
  allow update, delete: if request.auth.uid == user;
}

Roles collection

Next we enforce the rules for the role collection which should only allow admins to set roles. Of course a user should be able to retrieve its own designated roles in order to switch frontend features accordingly.

match /roles/{user} {
  allow get: if request.auth.uid == user || hasRole('admin');
  allow list: if hasRole('admin');
  allow update: if hasRole('admin');
  allow create, delete: if false;
}

Posts collection

This one is a little trickier because we first need to figure out who actually created the post if we want to enforce the update rule. Any writer should be able to create a new post but only update their own. An editor on the other hand should be able to update anyones post but not create one.

match /posts/{post} {
  allow get, list: if true;
  allow create: if hasRole('writer');
  // check if the post author is identical to requesting user
  allow update: if (hasRole('writer') && resource.data.author == request.auth.uid) || hasRole('editor');
  allow delete: if hasRole('writer') && resource.data.author == request.auth.uid;
}

Comments collection

Make sure that users can modify or delete their comments and editors can moderate (delete) anyones comments. Anyone should be able to see read the available comments.

match /posts/{post}/comments/{comment} {
  allow get, list: if true;
  allow create: if hasRole('user');
  // check if the comment author is identical to requesting user
  allow update: if resource.data.author == request.auth.uid);
  allow delete: if hasRole('editor') || (hasRole('user') && resource.data.author == request.auth.uid);
}

Summary

You created a Firestore ruleset that enforces data privacy and integrity by limiting access to resources based according to a role design. To bring RBAC to your application you need to consider a few things:

  1. Deploy the security rules into your Firebase project, highly recommend using the CLI or Travis
  2. Implement a Firebase function that creates an entry in the users and roles collection for each new user using the authentication trigger
  3. Log in to your app and then manually grant yourself admin rights via the Firebase console

To see a similar design implemented you can check out my techradar project which also showcases how to implement RBAC on the frontend and setting up Travis CI to deploy Firebase rules. There are additional features available to secure Firestore data for example checking which parts of a resource are being changed in an update process to allow users to update only specific properties.

Drop me a message on Twitter if you have any feedback about this post. Also check out my previous post on creating PlantUML diagrams.

comments powered by Disqus