
テスト駆動で学ぶ Firestore セキュリティルール【カスタム関数編:第2回】
2021.12.20
【第2回】テスト駆動で成績データコレクションのルールを実装する
前回は、カスタム関数について簡単に解説した後、「カスタム関数編」で扱う成績コレクション records の要件を設定し、ルール実装に使用するテスト環境とテストデータを準備しました。
今回は、前回設定した要件に沿って、データ追加ルールの実装を進めていきます。
前回の記事はこちら
前回の記事は、以下をご参照ください。
Firestore 上のデータ追加ルールの実装について
テストの作成
テストを作成する前に、まずはデータ追加の要件を再確認します。
- admin ユーザ以外は追加不可
- 成績フィールドは空のマップ
- 所定のフォーマットでないデータは追加不可
上記の要件に沿って、テストを作成していきます。
tests/data.create.test.ts ファイルを追加して、以下のようにコードを記述してください。
テストのコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | // Firestoreエミュレータのホストとポートを指定 process.env.FIRESTORE_EMULATOR_HOST = "localhost:58080"; import { FirestoreTestSupporter } from "firestore-test-supporter"; import * as path from "path"; import * as firebase from "@firebase/testing"; // テストデータと初期データのセットアップクラスの読み込み import { users } from "./data/collections/users"; import { records } from "./data/collections/records"; import InitialData from "./data/InitialData"; describe("成績データの追加テスト", () => { const rulesFilePath = path.join(__dirname, "firestore.rules"); const supporter = new FirestoreTestSupporter("my-test-project", rulesFilePath); beforeEach(async () => { // セキュリティルールの読み込み await supporter.loadRules(); // usersコレクションに初期データを追加 const initialData = new InitialData(rulesFilePath); await initialData.setupUsers() }); afterEach(async () => { // データのクリーンアップ await supporter.cleanup() }); test('要件にあったデータの追加に成功', async () => { // adminユーザで認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.admin); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertSucceeds(doc.set(records.initialData)) }); test('ログインしていないユーザは追加不可', async () => { // 認証されていないクライアントを取得 const db = supporter.getFirestore(); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertFails(doc.set(records.initialData)) }); describe('adminユーザ以外は追加不可', () => { test('teacherユーザは追加不可', async () => { // teacherユーザで認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.teacher); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertFails(doc.set(records.initialData)) }); test('studentユーザは追加不可', async () => { // studentユーザで認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.student); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertFails(doc.set(records.initialData)) }); }); test('成績フィールドは空のマップ', async () => { const db = supporter.getFirestoreWithAuth(users.sample.admin); const doc = db.collection(records.collectionPath).doc(records.initialData.id); // 成績フィールドに空のマップ以外の値を設定 const badInitialData = { ...records.initialData, record: records.sample.record }; await firebase.assertFails(doc.set(badInitialData)) }); test('所定のフォーマットでないデータは追加不可', async () => { for (const key of Object.keys(records.initialData)) { const db = supporter.getFirestoreWithAuth(users.sample.admin); const doc = db.collection(records.collectionPath).doc(records.initialData.id); // 情報の不足した初期データの追加に失敗 const initialDataClone: any = { ...records.initialData }; delete initialDataClone[key]; await firebase.assertFails(doc.set(initialDataClone)); // 不正な型の初期データの追加に失敗 initialDataClone[key] = null; await firebase.assertFails(doc.set(initialDataClone)) } }); }); |
テストの概要
各テストで共通の内容
前準備で用意した InitialData クラスを使い、 beforeEach() 内で users コレクションの初期データを用意しています。
またテスト毎に、テスト対象となるロールを持ったユーザで認証された クライアントを取得しています。
「成績フィールドは空のマップ」であるテスト
テストの内容に沿って、データ追加の前に、リクエストデータの成績フィールドへ空のマップ以外の値を設定しています。
「所定のフォーマットでないデータは追加不可」となるテスト
情報が不足したデータと、不正な型をもつデータを用意し、データの追加チェックを実行。
情報が不足しているデータとして、任意のフィールドを削除したデータを作成しています。
また、不正な型をもつデータとして、任意のフィールドに null を設定したデータを用意しました。
Firestore 上のデータ追加ルールを実装してみる
それでは、作成したテストに沿って、セキュリティルールを実装していきましょう。
全ての create アクセスを許可するルールを設定
まずは、 students/{studentId}/records パスの create アクセスを全て許可し、「要件にあったデータの追加に成功」するテストを通したところからスタートします。
その後、失敗した残りのテストを、上から順に通して行きたいと思います。
tests/firestore.rules ファイルを追加して、以下のようにルールを設定してください。
1 2 3 4 5 6 7 8 9 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ // 全てのcreateアクセスを許可 allow create: if true; } } } |
テストをスタート
ファイルを追加したら、以下のコマンドを実行して、テストをスタートしてください。
1 | npm run test-watch tests/data.create.test.ts |
テスト結果
テスト結果は、以下のようになります。(テスト結果の冒頭のみを抜粋)
1 2 3 4 5 6 7 8 9 | FAIL tests/data.create.test.ts 成績データの追加テスト √ 要件にあったデータの追加に成功 (2150ms) × ログインしていないユーザは追加不可 (111ms) × 成績フィールドは空のマップ (89ms) × 所定のフォーマットでないデータは追加不可 (89ms) adminユーザ以外は追加不可 × teacherユーザは追加不可 (105ms) × studentユーザは追加不可 (96ms) |
期待した通りに、 要件にあったデータの追加に成功 するテストが通ったので、失敗しているテストを順次、上から通して行きます。
認証をチェックする項目をルールに追加
ログインしていないユーザは追加不可 となるように、セキュリティルールに認証をチェックする項目を追加します。
セキュリティルールを、以下のように調整してください。
1 2 3 4 5 6 7 8 9 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ // 認証チェック allow create: if request.auth.uid != null; } } } |
テスト結果
テスト結果は、以下の通りとなります。
1 2 3 4 5 6 7 8 9 | FAIL tests/data.create.test.ts 成績データの追加テスト √ 要件にあったデータの追加に成功 (2181ms) √ ログインしていないユーザは追加不可 (88ms) × 成績フィールドは空のマップ (69ms) × 所定のフォーマットでないデータは追加不可 (71ms) adminユーザ以外は追加不可 × teacherユーザは追加不可 (108ms) × studentユーザは追加不可 (77ms) |
ここで、 request.auth.uid != null により、ログイン状態をチェックしています。
ですが、このままだと少しわかりにくく、ルールが複雑になってきたときに見通しが悪そうです。
ログインチェック用のカスタム関数を定義
ログイン状態の確認はこの後、何度も出てくる内容なので、ログインチェック用の関数を定義して、再利用可能な状態に整理しておきます。
以下のように、セキュリティルールを調整してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ // ログインチェック function isLoggedIn(){ return request.auth.uid != null; } allow create: if isLoggedIn(); } } } |
ここで、ログインチェック関数として isLoggedIn() を定義しています。
isLoggedIn() 内で request 変数を使用しているように、カスタム関数内でも request 変数や resource 変数を利用することができます。
カスタム関数を使って、許可条件に名前付けしたことで、ルールの見通しがよくなりました。
ルールの変更が、テスト結果に影響していないか確認しておきます。
テスト結果
テスト結果は、以下の通りです。
1 2 3 4 5 6 7 8 9 | FAIL tests/data.create.test.ts 成績データの追加テスト √ 要件にあったデータの追加に成功 (2190ms) √ ログインしていないユーザは追加不可 (88ms) × 成績フィールドは空のマップ (78ms) × 所定のフォーマットでないデータは追加不可 (68ms) adminユーザ以外は追加不可 × teacherユーザは追加不可 (76ms) × studentユーザは追加不可 (69ms) |
テスト結果に変化はないので、問題なくルールを整理することができました。
「成績フィールドは空のマップ」となるようにルールを設定
続けて、 成績フィールドは空のマップ となるようにルールを調整します。
以下のように、ルールを調整してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ function isLoggedIn(){ return request.auth.uid != null; } allow create: if isLoggedIn() && request.resource.data.record == {}; } } } |
request.resource.data.record == {} の条件により、リクエストデータの成績フィールドが、空マップであることをチェックしています。
テスト結果
テスト結果は、以下の通りです。
1 2 3 4 5 6 7 8 9 | FAIL tests/data.create.test.ts 成績データの追加テスト √ 要件にあったデータの追加に成功 (2134ms) √ ログインしていないユーザは追加不可 (82ms) √ 成績フィールドは空のマップ (71ms) × 所定のフォーマットでないデータは追加不可 (65ms) adminユーザ以外は追加不可 × teacherユーザは追加不可 (88ms) × studentユーザは追加不可 (69ms) |
カスタム関数でルールを整理
ログインチェックの場合と同様に、ルールの見通しをよくするため、カスタム関数を使ってルールを整理します。
以下のように、ルールを調整してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ function isLoggedIn(){ return request.auth.uid != null; } function isRecordEmpty(_resource){ return _resource.data.record == {} } allow create: if isLoggedIn() && isRecordEmpty(request.resource); } } } |
成績フィールドが、空マップであるかをチェックする関数として isRecordEmpty() を定義しました。
カスタム関数には、引数を利用することができます。
isRecordEmpty() 関数は、チェック対象の resoruce を引数で指定するように定義。
そのため、リクエストデータ、データベース上のデータの双方のチェックに利用することができます。
テスト結果
テスト結果は、以下の通りとなります。
1 2 3 4 5 6 7 8 9 | FAIL tests/data.create.test.ts 成績データの追加テスト √ 要件にあったデータの追加に成功 (2204ms) √ ログインしていないユーザは追加不可 (93ms) √ 成績フィールドは空のマップ (85ms) × 所定のフォーマットでないデータは追加不可 (64ms) adminユーザ以外は追加不可 × teacherユーザは追加不可 (101ms) × studentユーザは追加不可 (85ms) |
テスト結果に変化はないので、ルールの調整に問題はなさそうです。
「所定のフォーマットでないデータは追加不可」となるルールを設定
続いて、 所定のフォーマットでないデータは追加不可 となるようにルールを調整します。
以下のように、ルールを調整してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ function isLoggedIn(){ return request.auth.uid != null; } function isRecordEmpty(_resource){ return _resource.data.record == {} } // リクエストデータのフォーマットチェック function isValidFormat(){ return request.resource.data.id is string && request.resource.data.studentId is string && request.resource.data.name is string && request.resource.data.season is string && request.resource.data.homeroomTeacher is string && request.resource.data.record is map } allow create: if isLoggedIn() && isRecordEmpty(request.resource) && isValidFormat(); } } } |
フォーマットチェック用の関数 isValidFormat() を定義し、フォーマットチェックを全て isValidFormat() 関数内で行っています。
先ほどのルールでは、「allow式」にフォーマットチェックの条件を、そのまま記述していました。
そのため、条件設定の記述がだらだらと長くなってしまっていて、ルールの全体がわかりにくなっていました。
カスタム関数を使って、フォーマットチェックを1つにまとめたことで、「allow式」の内容がスッキリしてわかりやすくなりました。
※ 解説の簡素化のため、今回は最初から、カスタム関数を使ってルールを整理・調整しています。
ですが、実際のルールの実装では、ここまでの実装の流れと同様、「エラー→とりあえずテストを通す→リファクタリング(カスタム関数利用など)」という流れで実装を進めるのがおすすめです。
テスト結果
テスト結果は、以下の通りとなります。
1 2 3 4 5 6 7 8 9 | FAIL tests/data.create.test.ts 成績データの追加テスト √ 要件にあったデータの追加に成功 (2125ms) √ ログインしていないユーザは追加不可 (84ms) √ 成績フィールドは空のマップ (72ms) √ 所定のフォーマットでないデータは追加不可 (230ms) adminユーザ以外は追加不可 × teacherユーザは追加不可 (90ms) × studentユーザは追加不可 (76ms) |
所定のフォーマットでないデータは追加不可 となるテストが通りました。
「admin ユーザ以外は追加不可」となるルールを設定
続いては、 adminユーザ以外は追加不可 となるテストを通します。
以下のように、ルールを調整してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ function isLoggedIn(){ return request.auth.uid != null; } // adminロールチェック function isAdmin(){ return isLoggedIn() && "admin" in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles; } function isRecordEmpty(_resource){ return _resource.data.record == {} } function isValidFormat(){ return request.resource.data.id is string && request.resource.data.studentId is string && request.resource.data.name is string && request.resource.data.season is string && request.resource.data.homeroomTeacher is string && request.resource.data.record is map } allow create: if isAdmin() && isRecordEmpty(request.resource) && isValidFormat(); } } } |
カスタム関数 isAdmin() を定義して、「allow式」の条件中で isLoggedIn() 関数を呼び出していた部分を、 isAdmin() に変更しました。
ログインチェックは、 isAdmin() 内に移しています。
isAdmin() 内で使用している get() 関数数は、データベース上の別のコレクションのデータを参照する関数です。
get() 関数で指定するパス名に変数を使用する場合は、上記のルールで使用しているように、 $(変数名) と記述する必要があります。
isAdmin() 関数の場合は、 users コレクションのリクエストユーザのデータを参照しています。
認証されたユーザで、かつリクエストユーザのロール情報に admin が含まれる場合に true が返されます。
テスト結果
テスト結果は、以下の通りとなります。
1 2 3 4 5 6 7 8 9 | PASS tests/data.create.test.ts 成績データの追加テスト √ 要件にあったデータの追加に成功 (2192ms) √ ログインしていないユーザは追加不可 (75ms) √ 成績フィールドは空のマップ (67ms) √ 所定のフォーマットでないデータは追加不可 (222ms) adminユーザ以外は追加不可 √ teacherユーザは追加不可 (80ms) √ studentユーザは追加不可 (76ms) |
全てのテストが成功し、要件に沿ったデータ追加ルールが実装できました。
せっかくのテスト環境なので、カスタム関数を使って、もう少しだけルールを整理・調整したいと思います。
次回以降のデータ更新ルールや取得ルールの実装で、teacher ユーザや student ユーザの判定が必要となります。
カスタム関数 isAdmin() を汎用化
isAdmin() の中身を少し調整して、汎用化しておきます。
以下のように、ルールを調整してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ function isLoggedIn(){ return request.auth.uid != null; } // 指定ロールのチェック function isUserRole(role){ return isLoggedIn() && role in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles; } function isAdmin(){ return isUserRole("admin"); } function isRecordEmpty(_resource){ return _resource.data.record == {} } function isValidFormat(){ return request.resource.data.id is string && request.resource.data.studentId is string && request.resource.data.name is string && request.resource.data.season is string && request.resource.data.homeroomTeacher is string && request.resource.data.record is map } allow create: if isAdmin() && isRecordEmpty(request.resource) && isValidFormat(); } } } |
ユーザロールのチェック用に関数 isUserRole() を定義しました。
isUserRole() 関数は、引数にロールを指定することで、対象のロールを持ったユーザかどうかを判定します。
関数 isAdmin() 内では、 isUserRole() 関数の引数に admin ロールを指定して admin ユーザかどうかを判定しています。
同様に、引数に teacher ロールを指定すれば teacher ユーザであるかどうかを判定することができます。
テスト結果
テスト結果は、以下の通りです。
1 2 3 4 5 6 7 8 9 | PASS tests/data.create.test.ts 成績データの追加テスト √ 要件にあったデータの追加に成功 (2118ms) √ ログインしていないユーザは追加不可 (83ms) √ 成績フィールドは空のマップ (73ms) √ 所定のフォーマットでないデータは追加不可 (221ms) adminユーザ以外は追加不可 √ teacherユーザは追加不可 (79ms) √ studentユーザは追加不可 (73ms) |
テスト結果に変化はないので、問題なくルールを調整することができました。
以上で、データ追加用のルールは実装完了です!
最後に、次のテストとの競合を避けるため、テストを終了してください。
第3回へつづく!
「カスタム関数編」第2回となるこの記事では、カスタム関数を取り入れつつ、成績コレクション records へのデータ追加用のセキュリティルールを実装してみました。
次回は、成績コレクション records のデータ更新用セキュリティルールの実装を進めていきます。
ぜひ、ご覧ください!
第3回の記事はこちら
こちらの記事もオススメ!
書いた人はこんな人

- 「好きを仕事にするエンジニア集団」の(株)ライトコードです!
ライトコードは、福岡、東京、大阪の3拠点で事業展開するIT企業です。
現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。
いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。
システム開発依頼・お見積もり大歓迎!
また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」「WEBディレクター」を積極採用中です!
インターンや新卒採用も行っております。
以下よりご応募をお待ちしております!
https://rightcode.co.jp/recruit
ライトコードの日常12月 1, 2023ライトコードクエスト〜東京オフィス歴史編〜
ITエンタメ10月 13, 2023Netflixの成功はレコメンドエンジン?
ライトコードの日常8月 30, 2023退職者の最終出社日に密着してみた!
ITエンタメ8月 3, 2023世界初の量産型ポータブルコンピュータを開発したのに倒産!?アダム・オズボーン