Cloud Firestoreのrulesをテストする

Komerco事業部エンジニアの岸本(id: sgrksmt)です。今日でちょうど入社1年が経ち、現在Komerco -コメルコ-(以下、Komerco)の開発を担当しています。
入社前はお世話になっていたこの技術ブログに自分が投稿する日がくるとは...。

Komercoは、「料理が楽しくなるマルシェアプリ」というコンセプトの元、料理が楽しくなる器やカトラリー、リネン雑貨等を出品/購入できるサービスで、現在はiOS版のアプリケーションを提供しています。

今年2月のCookpad Tech Conf2018や先日催したCookpad Tech Kitchen#16などでもお伝えしてきていますが、現在KomercoではバックエンドでFirebaseを活用しています。
その中で、最近僕が仕組みづくりとして取り組んでいるCloud Firestoreのセキュリティルールのテストの方法についてご紹介します。

Cloud Firestoreのrules

Cloud Firestore(以下、Firestore)では、各プラットフォームで提供しているSDKやREST API経由でデータを安全に読み書きできるよう、セキュリティルールを記述することができます。
主にFirebase Authenticationでの認証を活用しつつ、どのような条件下でドキュメントを読み取ることができるか、書き込むことができるかを設定し、ユーザーのデータを保護します。
また余談にはなりますが、Realtime DBとCloud Storageにもセキュリティルールがあります。

セキュリティルールをしっかり設定しないと、本来読み取られてはいけないデータが悪意のある第三者に読み取られてしまったり、特定のフィールドを書き換えられてしまうといったことが起こります。
一方で、それを防ぐために全てのルールを閉じて、API経由でのみ読み書きできるようにして堅牢にすることも可能ですが、そうするとCloud Firestoreの次のような利点を享受しにくくなります。

  • リアルタイムでの情報の取得
  • オフラインから復帰したときに、ローカルでの変更を書き込む

なので、実際にアプリケーション開発をしていく場合は全ルールを閉じる運用よりは、ルールを適切に設定して運用していくことが多くなるかと思います。

Firestore rulesの確認が大変

Firestoreのrulesを書いて、期待通りに動作するのかを確かめつつ開発をしていきますが、これが非常に 大変かつ面倒 だったりします。 変更をした後、毎回デプロイして、浸透するまで数分待って、正しく動作するかクライアント側で動作させて確かめるのはかなり非効率ですし、 エラーの内容も「permission-denied」程度のものしかないので何が原因か掴みづらいです。
一応、rulesの構文自体が間違っているかどうかはデプロイ前に検出してくれるので、構文がおかしくなっている場合は気づけますが、typoなんてしてしまった日にはなかなか気づきづらく、デプロイと確認だけで日が暮れてしまうこともありえます。
"もっと効率的にrulesを書いて確認したい..."

そう思いながら今年の前半を過ごしていたのですが、2018年の5月末頃にFirestore rulesの動作を書き込む前に確認できる「シミュレータ」の機能がFirebaseのコンソール上に搭載されました。

rulesのシミュレーター

Firestoreのrulesシミュレーターは、FirestoreのProjectからDatabase→Firestore→ルールとたどり、この部分をクリックすることで利用することができます。

f:id:sgrksmt:20180815180611p:plain

開くと、このような画面になっており、指定したドキュメントのパスに対して、readやwriteのルールを記述されたルールを基にシミュレートして確認することができます。

f:id:sgrksmt:20180815180716p:plain

また、認証情報も指定したテストができるので、認証ありきの条件もシミュレートすることが可能です。
以前私がQiitaに掲載した記事でも操作方法など書いていますのでよければ併せて御覧ください。

シミュレータを用いると以下のメリットがあります。

  • 公開前に条件をシミュレートして確認ができるので、誤った条件のものをデプロイしてしまう心配がない
  • すぐにシミュレートして試せるので、デプロイしてから数分待って、、といった具合に時間をロスすることがない
  • 失敗した場合に、何が原因でどこで失敗しているのか指摘してくれるので、原因がわかりやすい
  • 書き込みに関するシミュレートの場合、実際のDBに書き込みが行われるわけではないので、DBを汚してしまうことがない

ただ、シミュレータでのrulesの動作の確認はコンソール上で簡単に確認ができる反面、以下のデメリットがあります

  • listのオペレーションに関して、queryに関する条件の確認ができない
  • getAfter 関数を用いた条件の確認ができない
  • 2つ以上のドキュメントの書き込みに関するテストができない
  • 数百行になってくるとコンソールがやや重たくなるので編集時にストレスがかかる

今後開発を進めていく上で、継続的にrulesが正しく動作するかを確かめられる環境がないと、新機能の追加や大幅な改修、リファクタリングに耐えられないので、
Firestoreのrulesが正しく動くかどうか のテストを構築していくことにしました。


Firestore rulesのテスト

ここからが本題 になります。大まかな流れとしては

  • 開発環境、本番環境とは別の、 テスト環境 用のFirebase Projectを準備する
  • テスト環境にfirestore.rulesファイルをにデプロイする。
  • テストを書き、実際にテスト環境に対してドキュメントの読み書きを行い、Firestore rulesが期待通りの動作をするかをテストする
  • 手元でテストを実行できる他、CI経由でも継続的にテストが行えるようにする

となります。順に、簡単なテストの例も交えつつ説明していきます。

構成

テストの構成としては、次のようになっています。

f:id:sgrksmt:20180815180802p:plain

普段のアプリケーション開発では開発環境用に必要なものをデプロイしたり、DBの読み書きを行っています。
テストのときは、 テスト用 のFirebase Projectを準備し、そこにrulesをデプロイしています。
そして、テストを実行するときは、向き先をテスト環境のFirebase Projectにし、その環境のFirestoreのDBに対して読み書きを実行し、rulesのテストをします。
また、テストは手元からCLI経由で実行する他に、GitHub上にPullRequestが作成された時や、materブランチにマージされたタイミングで、CI経由でテストを実行するようにしています。

また、この記事では詳しく触れませんが、Komercoでは同様に、CloudFunctionsに関連するテストも同様に、テスト環境のFirebase Project上で行っています。

準備

テストを書くにあたり、jestを利用しているので、npmもしくはyarnにて追加します。
jestは、Facebook社がOSSとして提供している、JavaScriptでユニットテストを行うためのフレームワークです。RSpecのような記述が可能となります。
今回はjestを使った場合でのテストの紹介となりますが、テストのフレームワークは任意のものでも構いません。

また、テストに関連するファイルは、 test/ ディレクトリ以下に配置していきます。 KomercoではCloud Functionsのテストもあるので、 test/rules/ ディレクトリ以下に配置しています。

Firebaseの初期化をする

テストで使うFirebaseの初期化をします。
Cloud Functions等でFirebaseを扱うときは、adminSDKが使えるようadmin権限での初期化をすることが多いのですが、
admin権限でFirebaseを初期化してしまうと、設定したrulesに関係なく管理者権限にて読み書きが可能となってしまうので、Webアプリケーションと同様の初期化を行います。

webhelper.ts を任意のテストディレクトリ以下に配置し、以下のように記述します。

import * as firebase from 'firebase'

const config = {
  apiKey: 'API_KEY',
  authDomain: 'AUTH_DOMAIN',
  databaseURL: 'DATABASE_URL',
  projectId: 'PROJECT_ID',
  storageBucket: 'STORAGE_BUCKET',
  messagingSenderId: 'SERNDER_ID'
}

firebase.initializeApp(config)

const auth = firebase.auth()
const firestore = firebase.firestore()
const settings = { timestampsInSnapshots: true }
firestore.settings(settings)

export { firebase, auth, firestore }

各種configの変数はご自身のテスト環境用のプロジェクトのIDを指定してください。(基本的には.env等から参照することになると思います。)
これら初期化した変数は次のようにテストファイルでimportして使用します。

import * as WebHelper from './helper/webhelper'

const postRef = WebHelper.firestore.collection('post').doc()

モデルを定義する

ドキュメントのモデル定義をします。
今回は例として、Postドキュメントの定義をします。

export enum Path {
  Post = '/posts'
}
export interface Post {
  title: string,
  body: string,
  authorID: string,
  isPublished: boolean
}

次項では、このPostドキュメントのcreateオペレーションに関するテストの例をご紹介します。
(すべてのオペレーションの例を紹介したいのですが、長くなるので割愛します。)

テストを書いていく

前提条件として

  • Firebase Authenticationにて認証されたユーザー
  • 定義したパラメータを全て有している
  • post.authorID が、認証ユーザーのuidと一致する

という条件のもと、書き込みが正常に行われるのを期待するケースと、失敗し、permission-deniedがエラーとして返却されるのを期待するケースを記述します。
(失敗するケースは複数想定されますが、ここでは1つのケースに絞ります。)
posts.test.tsを作成し、次のように記述します。

import * as WebHelper from './helper/webhelper'

enum Path {
  Post = '/posts'
}
interface Post {
  title: string,
  body: string,
  authorID: string,
  isPublished: boolean
}

const makePostDocument = (authorID: string) => {
  return <Post>{
    title: 'test post',
    body: 'test post body',
    authorID: authorID,
    isPublished: true
  }
}

const permissionDeniedError = { code: 'permission-denied' }

describe('post document rules', () => {
  jest.setTimeout(10000)

  let postCollectionRef: WebHelper.firebase.firestore.CollectionReference
  beforeAll(() => {
    postCollectionRef = WebHelper.firestore.collection(Path.Post)
  })

  describe('write', () => {
    describe('create', async () => {
      let authUser: any

      beforeEach(async () => {
        authUser = await WebHelper.auth.signInAnonymously()
      })

      afterEach(async () => {
        await WebHelper.auth.signOut()
      })

      describe('when authorID is equal to auth.uid', () => {
        test('should be succeeded', async () => {
          const post = makePostDocument(authUser.user.uid)
          await expect(postCollectionRef.doc().set(post)).resolves.toBeUndefined()
        })
      })

      describe('when authorID is not equal to auth.uid', () => {
        test('should be failed', async () => {
          expect.assertions(1)
          const post = makePostDocument('xxxxxxxxxxx')
          await expect(postCollectionRef.doc().set(post)).rejects.toMatchObject(permissionDeniedError)
        })
      })
    })
  })
})

(モックデータの作成に関する処理、モデル定義等は別途別のファイルに分割するほうが良いですが、今回は例を示すために同一のファイル内に置いています。) ここまでで実行すると、rulesがまだ書けていない場合はテストが通らないと思います。
次にこのテストを通過するための正しいrulesを記述します。

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function incomingData() {
      return request.resource.data;
    }

    match /posts/{postID} {
      allow create: if isAuthenticated()
                    && incomingData().keys().hasAll(requiredFields())
                    && incomingData().authorID == request.auth.uid;

      function requiredFields() {
        return ['title', 'body', 'authorID', 'isPublished'];
      }
    }
  }
}

これをテスト環境にデプロイし、再度テストを実行することで、テストが通るようになります。
(注: 紹介のため割愛しているテストケースがありますので、実際にはもう少しテストケースが多く、厚いものになると思います。また、rules自体も、各種フィールドのvalidationを行ったりと複雑になるかと思います)

事前にテストデータを作成する

テストを書いていると、セキュリティルールの制約を受けずにテストデータを作成したり、
CloudFunctionsで生成したり、事前にDBに格納しているのが前提で、読み出すだけのデータを準備したいケースがでてきます。
その場合はadmin権限でFirebaseを初期化したものを別途用意し、rulesの息のかからないところでテストデータを作成します。
adminhelper.ts というファイルを作成し、そこでAdmin SDKの初期化を行います。

import * as admin from 'firebase-admin'

admin.initializeApp({ credential: admin.credential.cert(require('admin sdk jsonのpath')) })
const auth = admin.auth()
const firestore = admin.firestore()
const settings = { timestampsInSnapshots: true }
firestore.settings(settings)

export { admin, auth, firestore }

(注: admin用のservice accountの取扱には注意です。 外部に漏れぬように 対策する必要があります)
参考: サーバーに Firebase Admin SDK を追加する

これにより、事前にPostドキュメントを作成し、そのPostドキュメントが取得可能かどうかのテストが次のように記述できます。

import * as WebHelper from './helper/webhelper'
import * as AdminHelper from './helper/adminhelper'

enum Path {
  Post = '/posts'
}
interface Post {
  title: string,
  body: string,
  authorID: string,
  isPublished: boolean
}

const permissionDeniedError = { code: 'permission-denied' }

const savePostDocument = async (isPublished: boolean) => {
  const postRef = AdminHelper.firestore.collection(Path.Post).doc()
  await postRef.set({
    title: 'test post',
    body: 'test post body',
    authorID: 'xxxxxxxx',
    isPublished: isPublished
  })
  return postRef
}

describe('post document rules', () => {
  jest.setTimeout(10000)

  let postCollectionRef: WebHelper.firebase.firestore.CollectionReference
  beforeAll(() => {
    postCollectionRef = WebHelper.firestore.collection(Path.Post)
  })

  describe('read', () => {
    describe('get', () => {
      afterEach(async () => {
        await WebHelper.auth.signOut()
      })

      describe('when user is authenticated', () => {
        describe('try to get valid document', () => {
          test('should be succeeded', async () => {
            await WebHelper.auth.signInAnonymously()
            const mockPostRef = await savePostDocument(true)
            await expect(postCollectionRef.doc(mockPostRef.id).get()).resolves.toBeDefined()
          })
        })

        describe('try to get invalid document', () => {
          test('should be failed', async () => {
            expect.assertions(1)
            await WebHelper.auth.signInAnonymously()
            const mockPostRef = await savePostDocument(false)
            await expect(postCollectionRef.doc(mockPostRef.id).get()).rejects.toMatchObject(permissionDeniedError)
          })
        })
      })

      describe('when user is not authenticated', () => {
        test('should be failed', async () => {
          expect.assertions(1)
          const mockPostRef = await savePostDocument(true)
          await expect(postCollectionRef.doc(mockPostRef.id).get()).rejects.toMatchObject(permissionDeniedError)
        })
      })
    })
  })

  // ...writeのrule
})

今回追加した、postドキュメントのgetに関するルールを次のように追加することで、上記テストが通るようになります。

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function incomingData() {
      return request.resource.data;
    }

    function existingData() {
      return resource.data;
    }

    match /posts/{postID} {
      allow get: if isAuthenticated() && existingData().isPublished;
      allow create: if isAuthenticated()
                    && incomingData().keys().hasAll(requiredFields())
                    && incomingData().authorID == request.auth.uid;

      function requiredFields() {
        return ['title', 'body', 'authorID', 'isPublished'];
      }
    }
  }
}

料金面はどうか

実際にテストデータを作って、読み書きを行うので、コスト(料金)がかかるのでは、、と思われるかもしれませんが、
Komercoではrulesのテストに加え、Cloud Functionsのテストも実施しています。Pull Requestが作成される、あるいはmasterブランチが作成されたときにテストを実施していますが、 料金はほとんどそこまでかかっていません。

また、先にも触れましたが、テストを実行する環境と、普段開発している環境を分けているので、開発環境のDBが汚染されたりすることもありません。

Firestoreのルールのテストを書くことでのメリット

テストを書くことによるメリットとしては

  • アプリケーションを手動で動かしたり、シミュレータに頼ることなく(継続的に)rulesが正しく動作するか確かめることができる
  • rulesの変更が容易になる(書き換えた結果、正しくない場合にテストがあることで検知することができる。)

が挙げられます。普段のアプリケーション開発と同様で、テストがあることで、後の変更にも強くなり、rulesを変更することに臆することもなく、また高速に動作検証が行えるようになります。
また、事前にセキュリティを意識したドキュメントの設計がしやすくなり、見通しも良くなるので最近の開発では新機能の追加や改善に伴ってドキュメントの設計をするときは、同時にrulesのテストも記述し開発をしています。

さいごに

Firestoreのrulesに関して、より継続的に確認が行えるためのテストについてご紹介しました。
今回ご紹介したサンプルコードをGitHubにて公開していますので、よければ併せて御覧ください。

今後公式からテストする手段が提供される可能性もありますし、オフライン(ローカル)でのテストが提供される可能性もありますが、
現状Komercoではオンラインテストにて実施して、Firestore rulesの継続的なテストを行えるようにしています。
また、こうした事例に関わらず、Komerco開発チームではFirebaseと向き合って、その上でサービス提供ができるよう努めていきます。


クックパッドでは、Firebaseなど最新技術に興味がある!技術的に挑戦してサービスをより良くしたい!というエンジニアを募集しています。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/