The way to split the file of Cloud Firestore Security Rules

The way to split the file of Cloud Firestore Security Rules

September 26, 2021

This article is a translation of a Japanese article I posted earlier.

Original article


firestore.rules in the Firebase project generated by the Firebase CLI is the file that defines the security rules.

The initial contents are as follows.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

All the security rules will be written in this file, but it’s not hard to imagine that the content of this file will become so large that you will get tired of it one day.

“OK, Let’s split the file!” That’s what everyone thinks, but unfortunately this is not a JavaScript file.

This is a .rules file, which is written in a Firestore-specific way, so-called DSL.

So, unfortunately, there is no import mechanism. I think there is a lot of expectation for a file splitting mechanism, but I wonder why the official does not provide it. In fact, you can find a good number of people who have the same feeling.

Here, one of the respondents above has created a package to concatenate files of security rules.

I saw these and had an idea.

Why don’t you create a security rule binding shell script?

All I wanted to do was to combine multiple files simply into a single firestore.rules, so I thought a shell script would be sufficient for that kind of processing.

I’m going to introduce a script for those who have the same problem, but first let me make an excuse. First of all, the code is messy because I made it as long as I could use it, and secondly, this is the second or third time I’ve written a shell script, so I don’t even know what a clean shell script is.

But don’t worry, I guaranteed it to work correctly.

If you are good at writing shell scripts, feel free to customize it as you like.

TL;DR #

I list each file first, then explain later.

Structure and files #

├ concat
└ rules/
  ├ header.rules
  ├ footer.rules
  ├ routes.rules
  ├ helper.rules
  └ functions/
     ├ xxx.rules
     ├ yyy.rules
     └ zzz.rules

concat #

#!/bin/bash

readonly rules_dir='/rules'
readonly header_file='header.rules'
readonly footer_file='footer.rules'
readonly route_file='routes.rules'
readonly helper_file='helper.rules'
readonly funcs_dir='/functions'
readonly in_files=(
  'xxx.rules'
  'yyy.rules'
  'zzz.rules'
)
readonly out_file='firestore.rules'

print_source_files() {
  echo $route_file
  for file in ${in_files[@]}; do
    echo $file
  done
  echo $helper_file
}

main() {
  readonly work_file='firestore.rules.work'
  touch $work_file

  cat ".${rules_dir}/${header_file}" >> $work_file

  cat ".${rules_dir}/${route_file}" | sed -e '/^$/d' | sed 's/^/    /g' >> $work_file
  echo >> $work_file

  for file in ${in_files[@]}; do
    echo "    // ${file} //" >> $work_file
    cat ".${rules_dir}${funcs_dir}/${file}" | sed -e '/^$/d' | sed 's/^/    /g' >> $work_file
    echo >> $work_file
  done

  echo "    // ${helper_file} //" >> $work_file
  cat ".${rules_dir}/${helper_file}" | sed -e '/^$/d' | sed 's/^/    /g' >> $work_file

  cat ".${rules_dir}/${footer_file}" >> $work_file

  cat $work_file > $out_file
  rm $work_file
}

print_source_files
main

header.rules #

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

footer.rules #

  }
}

routes.rules #

match /xxx/{xxxId} {
  allow get: if getXxx();
}

match /yyy/{yyyId} {
  allow get: if getYyy(yyyId);

  match /zzz/{zzzId} {
    allow get: if getZzz(yyyId, zzzId);
  }
}

healper.rules #

// @return {boolean}
function isAuthenticated() {
  return request.auth != null;
}

// @param {string} id - User id
// @return {boolean}
function uidMatches(id) {
  return id == request.auth.uid;
}

// @param {array} paths - e.g. ['collection name', 'document id']
// @return {boolean}
function docExists(paths) {
  return exists(getPath(paths));
}

// @return {string} - Authentication UID
function getAuthUid() {
  return request.auth.uid;
}

// @return {string} - Authentication Email
function getAuthEmail() {
  return request.auth.token.email;
}

// @return {object} - Requested Data
function getRequestedData() {
  return request.resource.data;
}

// @return {object} - Resource Data
function getResourceData() {
  return resource.data;
}

// @param {array} paths - e.g. ['collection name', 'document id', 'collection name', 'document id']
// @return {path} - Document path
function getPath(paths) {
  return path([['databases', database, 'documents'].join('/'), paths.join('/')].join('/'));
}

xxx.rules #

function getXxx() {
  return isAuthenticated();
}

yyy.rules #

function getYyy(yyyId) {
  return uidMatches(yyyId);
}

zzz.rules #

function getZzz(yyyId, zzzId) {
  let authUid = getAuthUid();
  let reqData = getRequestedData();
  return ~ && ~ && ~; // practically, write some conditions at ~.
}

Explanation #

The concat is the shell script to concatenate files, and the rest is the material (=splitted files) to make firestore.rules.

The print_source_files() in concat is just a confirmation function to print the target files to the console, and the main() is the actual concatenation function.

For all other files, the description is as follows

  • header.rules: A file for the header section.
  • footer.rules: A file for the footer section.
  • routes.rules: A file that specifies the path to the document.
  • helper.rules: A file that defines helper functions (this is useful).
  • Each file under functions/ is actually a function that returns true or false, one for each of the routes.rules. (The contents of xxx.rules, yyy.rules, and zzz.rules are temporary.)

Now, run the concat and firestore.rules will be generated.

./concat

If you get a Permission denied message and cannot execute the file, change the permissions on the file.

chmod 755 concat

Result #

I run concat with the above contents, and the following firestore.rules was created.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /xxx/{xxxId} {
      allow get: if getXxx();
    }
    match /yyy/{yyyId} {
      allow get: if getYyy(yyyId);
      match /zzz/{zzzId} {
        allow get: if getZzz(yyyId, zzzId);
      }
    }

    // xxx.rules //
    function getXxx() {
      return isAuthenticated();
    }

    // yyy.rules //
    function getYyy(yyyId) {
      return uidMatches(yyyId);
    }

    // zzz.rules //
    function getZzz(yyyId, zzzId) {
      let authUid = getAuthUid();
      let reqData = getRequestedData();
      return ~ && ~ && ~; // practically, write some conditions at ~.
    }

    // helper.rules //
    // @return {boolean}
    function isAuthenticated() {
      return request.auth != null;
    }
    // @param {string} id - User id
    // @return {boolean}
    function uidMatches(id) {
      return id == request.auth.uid;
    }
    // @param {array} paths - e.g. ['collection name', 'document id']
    // @return {boolean}
    function docExists(paths) {
      return exists(getPath(paths));
    }
    // @return {string} - Authentication UID
    function getAuthUid() {
      return request.auth.uid;
    }
    // @return {string} - Authentication Email
    function getAuthEmail() {
      return request.auth.token.email;
    }
    // @return {object} - Requested Data
    function getRequestedData() {
      return request.resource.data;
    }
    // @return {object} - Resource Data
    function getResourceData() {
      return resource.data;
    }
    // @param {array} paths - e.g. ['collection name', 'document id', 'collection name', 'document id']
    // @return {path} - Document path
    function getPath(paths) {
      return path([['databases', database, 'documents'].join('/'), paths.join('/')].join('/'));
    }
  }
}

Thank you for reading. #

I hope this article was helpful!