Cloud Firestoreのセキュリティルールファイルを分割する

Cloud Firestoreのセキュリティルールファイルを分割する

September 18, 2021

Firebase CLI で生成された Firebase プロジェクト内の firestore.rules がセキュリティルールを定義するファイルです。

初期状態の内容は以下です。

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

セキュリティルールはすべてこのファイルに書いていくことになりますが、そんなことをしているとこのファイルの内容が膨大な量になってしまい、いつの日か嫌気が差すであろうことは想像に難くありませんよね。

こういうときはファイルを分割しましょう!…という発想に誰もが至りますが、ところがどっこい、これは JavaScript ファイルではありません。

拡張子を見ると、これは .rules というファイルで、Firestore 固有の書き方になります。いわゆる DSL ですね。

ということで残念ながら import の仕組みは存在しません。ファイル分割の仕組みを期待する声はかなり大きいと思うのですが、公式が提供していないのはなぜなのでしょうね。実際に同じ思いを抱えている人はそれなりに見つかります。

ここで、上記の質問内に出てくる方でセキュリティルールのファイル分割/統合を行うパッケージを作った方がいました。

これらを見て思いつきました。

セキュリティルール結合シェルスクリプトを作ればいいじゃない。

結局のところやりたいことは、複数のファイルを firestore.rules という1つにまとめるだけですから、それぐらいの処理ならばシェルスクリプトで十分だろうと。

ということで、同じ悩みを抱えている方に向けてスクリプトをご紹介するわけですが、最初に言い訳させてください。自分さえ使えれば良いと作ったものなのでコードが汚いというのと、そもそもシェルスクリプトを書いたのは今回が2、3回目のド素人なので、何がきれいなシェルスクリプトなのかさえ良くわかっていないと言う点です。

でもちゃんと動くことは保証するのでご安心を。シェルスクリプト得意な方はお好きにカスタマイズしてお使いください。

TL;DR #

とりあえず各ファイルを載せます。解説は後述します。

ファイルの配置 #

├ 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 ~ && ~ && ~; // 実際には ~ に何か条件を書いていく
}

解説 #

concat が結合シェルスクリプト、それ以外が firestore.rules を作るための素材(=分割されたファイル)です。

concat 内の print_source_files() が concat 対象のファイルをコンソールに吐くだけの確認用関数で main() の方が実際の結合処理を行う関数です。

それ以外のファイルに説明は以下の通りです。

  • header.rules: ヘッダー部分の外枠ファイル
  • footer.rules: フッター部分の外枠ファイル
  • routes.rules: ドキュメントのパスを指定するファイル
  • helper.rules: ヘルパー関数を定義しているファイル(これがあると便利です)
  • functions/ 配下の各ファイルが実際に true または false を返す関数で、 routes.rules の1つ1つに対応しています。(xxx.rulesyyy.ruleszzz.rulesの記述内容は適当です。)

後は作成した concat を実行すれば firestore.rules が生成されます。

./concat

Permission denied となり実行できなかった場合は、ファイルの権限を変更してください。

chmod 755 concat

結果 #

上記内容で concat を動かすと、以下 firestore.rules が作成されました。

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 ~ && ~ && ~; // 実際には ~ に何か条件を書いていく
    }

    // 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('/'));
    }
  }
}

以上 #

甘えてないでファイル結合処理のスクリプトぐらい自分で書けば?という意図で Firebase が意識的にファイル分割機能を公式提供していない可能性もある??

なにはともあれこの記事がお役に立てば幸いです!