Firestoreエミュレータ+Jestでセキュリティルールをテストする!
IT技術
Firestore(ファイアストア)とは?
「Firestore」は、Google製のスケーラブルなドキュメントデータベースです。
他の Firebase サービス群と同様に、サーバやスケーリングの管理が不要なため、開発者はフロントエンドの開発に集中することができます。
また、従来のデータベースは、サーバプログラムを通して「データの取得・更新」を行うのが一般的です。
それに対して「Firestore」では、サーバを介さず、フロントエンドから直接、データを取得・更新するかのように扱う事ができるのです。
セキュリティルールが肝
Firestore では、「セキュリティルール(データの読み書きの許可・拒否ルール)」を記述したファイルを設置する事で、データへのアクセス制限を設定することができます。
非常に有能かつ便利な Firestore ですが、セキュリティルールの設定には細心の注意を払う必要があります。
Firestore の利点
クライアントコードに直接、データの「取得・更新」処理を書くことが出来るのが、Firestore の大きな利点の1つです。
ですが、同時に Firestore への接続情報を、クライアントコードに記述する事が必要となります。
例えば、ウェブクライアントから Firestore を利用する場合、記述された接続情報は、知識のある訪問者であれば容易に確認することができます。
データを悪用・破壊される恐れ
セキュリティルールの設定に不備がある場合、取得した接続情報を使用して、データの「取得」や「改ざん・削除」が可能となります。
なので、訪問者が悪意のあるユーザであった場合、データを悪用されたり、破壊される恐れがあります。
データの悪用や破壊を防ぐためには、セキュリティルールの設定に細心の注意を払う必要があります。
つまり、データへのアクセスを適切に制限する事が重要となります。
セキュリティルールを作成する
今回は、肝となるセキュリティルールを、「Firestoreエミュレータ」とFacebook製のテストフレームワーク「Jest」を使って、テストする方法を解説していきたいと思います。
まずは、「Jest」と「Firestoreエミュレータ」をセットアップします。
その後、実際にサンプルテストとセキュリティルールを作成していきたいと思います。
Jest(ジェスト)のセットアップ
必要な環境
Node.js | v8.0.0 以上 |
Jest環境の構築
まず最初に、「Jest」を使うために必要なパッケージをインストールします。
デモ用のプロジェクトディレクトリを作成して、プロジェクトルートで以下のコマンドを実行してください。
1npm init -y
2npm i -D jest @types/jest
項目の追加・調整
生成された package.json に、以下の項目を、追加・調整してください。
1{
2 // ...
3 "scripts": {
4 "test": "jest",
5 "test-watch": "jest --watch"
6 },
7 // ...
8 "jest": {
9 "moduleFileExtensions": [
10 "js",
11 "rules"
12 ]
13 }
14}
scripts フィールドには、テストに使用するコマンドを追加しています。
test コマンドは「Jest」によるテストの実施に使用。
test-watch コマンドはファイル監視&自動再テストに使用します。
jest フィールドでは、「Jest」の設定を行っています。
moduleFileExtensions では、テスト対象となる拡張子を指定しています。
また、rules 拡張子を追加しています。
これは、test-watch コマンドを使用する際に、テスト対象であるセキュリティルールの設定ファイルを監視するためです。
Jestの動作チェック
プロジェクトルートに sample.test.js を作成し、以下のように記述してください。
1describe("Jest動作チェック", () => {
2 test("サンプルテスト", async () => {
3 expect(3 + 7).toBe(10);
4 });
5});
テストファイルを追加したら、実際にテストしてみましょう!
テストの実行
以下のコマンドを入力し、テストを実行してください。
1npm run test sample.test.js
環境や「Jest」のバージョンにもよりますが、おおよそ以下のように結果が表示されれば、テストは成功です!
1 PASS ./sample.test.js
2 Jest動作チェック
3 √ サンプルテスト (2ms)
4
5Test Suites: 1 passed, 1 total
6Tests: 1 passed, 1 total
7Snapshots: 0 total
8Time: 1.318s, estimated 2s
9Ran all test suites.
Firestoreエミュレータのセットアップ
Firebase CLIのインストール
エミュレータをセットアップするには、Firebase CLI が必要となります。
以下のコマンドを実行して Firebase CLI をインストールしてください。
1npm install -g firebase-tools
エミュレータをインストール
以下のコマンドを実行して、エミュレータをインストールしてください。
1firebase setup:emulators:firestore
エミュレータの起動設定
プロジェクトルートに firebase.json を追加して、以下のように記述してください。
1{
2 "firestore": {
3 "rules": "firestore.rules"
4 },
5 "emulators": {
6 "firestore": {
7 "port": "58080"
8 }
9 }
10}
firestore.rules プロパティは、エミュレータ起動時に読み込む「セキュリティルールファイル」を指定しています。
emulators.firestore.port プロパティは、起動するエミュレータのポートを指定しています。
エミュレータは、デフォルトでは 8080 ポートで起動します。
ですが、8080 ポートは、他のサービスのポートと衝突する事が多いので、今回はポートを 58080 に変更しています。
エミュレータの起動
以下のコマンドを実行して、エミュレータを起動してください。
1firebase emulators:start --only firestore
以上で、Firestoreエミュレータのセットアップは完了です。
次は、セットアップした「Firestoreエミュレータ」と「Jest」を使って、実際にセキュリティルールのテストをしていきます。
Firestoreセキュリティルールをテスト
ついに、セキュリティルールをテストする環境が整いました。
では、いよいよ、セキュリティルールをテストしていきます!
今回は、折角なので、テスト可能な環境を活かします。
テスト駆動開発ライクなスタイルで、
- 1、テスト作成
- 2、エラー確認
- 3、コードの調整
- 4、テストパス
という流れで解説を進めていきたいと思います。
目標
以下のアクセス環境を目標に、テストとセキュリティルールの実装を進めて行きます。
- 認証されているユーザは、 fruits コレクションのドキュメントを読み書きできる。
- 認証されていないユーザは、 fruits コレクションのドキュメントを読み書きでない。
- fruites 以外のコレクションは、認証の有無にかかわらず、全てのユーザが読み書きできない。
必要なパッケージのインストール
プロジェクトルートで、以下のコマンドを実行して、テストに必要なパッケージをインストールしてください。
1npm i -D @firebase/testing
テストコードの作成
まずは、目標に沿ってテストを作成します。
プロジェクトルートに、テストファイル firestore.rules.test.js を追加して、以下のように記述してください。
1// エミュレータホストの指定。デフォルトポートでエミュレータを起動した場合は不要
2process.env.FIRESTORE_EMULATOR_HOST = "localhost:58080";
3
4const fs = require("fs");
5const firebase = require("@firebase/testing");
6
7// 認証なしFirestoreクライアントの取得
8function getFirestore() {
9 const app = firebase.initializeTestApp({
10 projectId: "my-test-project"
11 });
12
13 return app.firestore();
14}
15
16// 認証付きFirestoreクライアントの取得
17function getFirestoreWithAuth() {
18 const app = firebase.initializeTestApp({
19 projectId: "my-test-project",
20 auth: {uid: "test_user", email: "test_user@example.com"}
21 });
22
23 return app.firestore();
24}
25
26describe("fruitsコレクションへの認証付きでのアクセスのみを許可", () => {
27 beforeEach(async () => {
28 // セキュリティルールの読み込み
29 await firebase.loadFirestoreRules({
30 projectId: "my-test-project",
31 rules: fs.readFileSync("firestore.rules", "utf8")
32 });
33 });
34
35 afterEach(async () => {
36 // 使用したアプリの削除
37 await Promise.all(firebase.apps().map(app => app.delete()))
38 });
39
40 describe('fruitsコレクションへの認証付きアクセスを許可', () => {
41 test('認証なしでのデータ保存に失敗', async () => {
42 const db = getFirestore();
43 const doc = db.collection('fruits').doc('apple');
44 await firebase.assertFails(doc.set({color: 'red'}))
45 });
46
47 test('認証ありでのデータ保存に成功', async () => {
48 const db = getFirestoreWithAuth();
49 const doc = db.collection('fruits').doc('orange');
50 await firebase.assertSucceeds(doc.set({color: 'orange'}))
51 });
52
53 test('認証なしでの取得に失敗', async () => {
54 const db = getFirestore();
55 const doc = db.collection('fruits').doc('strawberry');
56 await firebase.assertFails(doc.get())
57 });
58
59 test('認証ありでの取得に成功', async () => {
60 const db = getFirestoreWithAuth();
61 const doc = db.collection('fruits').doc('cherry');
62 await firebase.assertSucceeds(doc.get())
63 });
64 });
65
66 describe('fruits以外のコレクションへのアクセス禁止', () => {
67 test('認証なしでのデータ保存に失敗', async () => {
68 const db = getFirestore();
69 const doc = db.collection('countries').doc('japan');
70 await firebase.assertFails(doc.set({language: 'japanese'}))
71 });
72
73 test('認証ありでのデータ保存に失敗', async () => {
74 const db = getFirestoreWithAuth();
75 const doc = db.collection('vegetables').doc('tomato');
76 await firebase.assertFails(doc.set({color: 'red'}))
77 });
78
79 test('認証なしでの取得に失敗', async () => {
80 const db = getFirestore();
81 const doc = db.collection('vehicles').doc('car');
82 await firebase.assertFails(doc.get())
83 });
84
85 test('認証ありでの取得に失敗', async () => {
86 const db = getFirestoreWithAuth();
87 const doc = db.collection('prefectures').doc('tokyo');
88 await firebase.assertFails(doc.get())
89 });
90 });
91});
テストコードで使用した変数やメソッドの解説
process.env.FIRESTORE_EMULATOR_HOST
アクセス先のFirestoreエミュレータのホストは FIRESTORE_EMULATOR_HOST で指定します。
今回は、Firestoreエミュレータの起動ポートを 58080 に変更しているので、localhost:58080 をホストに指定しています。
デフォルトポートの 8080 で起動している場合は変更は必要ありません。
firebase.initializeTestApp({projectId: <projectId>, auth: <auth>})
エミュレータにセキュリティルールをロードするメソッドです。
テストコードではテスト対象のセキュリティルールの読み込みを行っています。
firebase.loadFirestoreRules({ projectId: <projectId>, rules: <rules> })
初期化されたFirebaseアプリを返します。
auth を指定すると、指定ユーザとして認証されたアプリが返されます。
auth を指定しない場合は認証なしのアプリが返されます。
テストコードでは、テスト対象のセキュリティルールをロードした projectId ( loadFirestoreRulesauth で指定した projectId )を指定して、Firebaseアプリを取得しています。
async/await
セキュリティルールのロードや、データの保存・取得は非同期処理であるため、await表記により、非同期処理の完了を待機しています。
関連記事
テストの実行
テスト駆動のスタイルにならって、とりあえずテストを実行し、エラーさせてみます。
以下のコマンドを実行して、作成したテストを実行してください。
1npm run test-watch firestore.rules.test.js
ENOENT: no such file or directory, open '...firerules.rules' のようなエラーメッセージが表示されます。
firestore.rules ファイルがないという事なので、エラーメッセージに従って、firestore.rules ファイルを追加します。
アクセス拒否の設定
まずは安全運転で、全てのアクセスを拒否する設定を追加します。
firestore.rules ファイルを追加して、以下のように記述してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 // 全てのアクセスをブロック
5 match /{document=**} {
6 allow read, write: if false;
7 }
8 }
9}
firestore.rules を追加すると、自動でテストが再実施され、以下のようなテスト結果が表示されます。
1 FAIL ./firestore.rules.test.js
2 fruitsコレクションへの認証付きでのアクセスのみを許可
3 fruitsコレクションへの認証付きアクセスを許可
4 √ 認証なしでのデータ保存に失敗 (1079ms)
5 × 認証ありでのデータ保存に成功 (248ms)
6 √ 認証なしでの取得に失敗 (44ms)
7 × 認証ありでの取得に成功 (85ms)
8 fruits以外のコレクションへのアクセス禁止
9 √ 認証なしでのデータ保存に失敗 (39ms)
10 √ 認証ありでのデータ保存に失敗 (35ms)
11 √ 認証なしでの取得に失敗 (31ms)
12 √ 認証ありでの取得に失敗 (34ms)
13 // ... エラー詳細部分は省略
全てのアクセスをブロックしているため、fruits コレクションへの認証付きでのアクセスも拒否されています。
アクセスを許可する設定
fruits コレクションへの認証付きアクセスを許可する設定を追加します。
以下のように、firestore.rules を調整してください。
1rules_version = '2';
2service cloud.firestore {
3 match /databases/{database}/documents {
4 // 全てのアクセスをブロック
5 match /{document=**} {
6 allow read, write: if false;
7 }
8
9 // fruitsコレクションについては、認証されたユーザのみ読み書きを許可
10 match /fruits/{fruit} {
11 allow read, write: if request.auth.uid != null;
12 }
13 }
14}
テスト結果
再び、テストが自動で実施され、以下のようなテスト結果が表示されます。
1 PASS ./firestore.rules.test.js
2 fruitsコレクションへの認証付きでのアクセスのみを許可
3 fruitsコレクションへの認証付きアクセスを許可
4 √ 認証なしでのデータ保存に失敗 (1087ms)
5 √ 認証ありでのデータ保存に成功 (52ms)
6 √ 認証なしでの取得に失敗 (55ms)
7 √ 認証ありでの取得に成功 (43ms)
8 fruits以外のコレクションへのアクセス禁止
9 √ 認証なしでのデータ保存に失敗 (37ms)
10 √ 認証ありでのデータ保存に失敗 (36ms)
11 √ 認証なしでの取得に失敗 (31ms)
12 √ 認証ありでの取得に失敗 (30ms)
13 // ... エラー詳細部分は省略
全てのテストが成功しました。
これで「 fruits コレクションに認証付きのユーザだけが読み書きできるセキュリティルール」を作成することができました。
まとめ
以上、Firestoreエミュレータにも「Jest」を使って、セキュリティルールをテストしてみました。
簡単に、今回の内容をまとめてみたいと思います。
- 「Firestore」はとても便利
- 便利さゆえのリスクもあり
- セキュリティルールが肝
- Firestoreエミュレータを使えば、セキュリティルールをローカルでテスト可能
なお、作成したテストは、CI(継続的インティグレーション) に組み込む事で、継続的にアクセス制限の漏れをチェックすることができます。
ぜひ、セキュリティルールテストの、CI への組み込みも検討してみてください。
こちらの記事もオススメ!
2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...
2020.08.04エンジニアの働き方 特集社員としての働き方社員としてのエンジニアの働き方とは?ライトコードのエンジニアはどんな働き方をしてるのか、まとめたいと...
2020.07.27IT・コンピューターの歴史特集IT・コンピューターの歴史をまとめていきたいと思います!弊社ブログにある記事のみで構成しているため、まだ「未完成状態」...
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
「好きを仕事にするエンジニア集団」の(株)ライトコードです! ライトコードは、福岡、東京、大阪の3拠点で事業展開するIT企業です。 現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。 いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。 システム開発依頼・お見積もり大歓迎! また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」「WEBディレクター」を積極採用中です! インターンや新卒採用も行っております。 以下よりご応募をお待ちしております! https://rightcode.co.jp/recruit