
テスト駆動で学ぶ Firestore セキュリティルール【カスタム関数編:第4回】
2021.12.20
【第4回】テスト駆動で成績データコレクションのルールを実装する
前回の記事では、成績コレクション records のデータ更新ルールを実装しつつ、ルールの見通しをよくするため、カスタム関数を使ってルールを整理してみました。
今回は、成績コレクション records のデータ取得・削除用ルールの実装を進めていきます。
データ取得・削除用ルールの作成後、最後に、これまでに作成した全てのテストを実行して、作成したセキュリティルールが第1回に設定した要件に沿ったルールとなっているかを確認します。
前回の記事はこちら
前回の記事は、以下をご参照ください。
Firestore 上のデータ取得ルールの実装について
テストの作成
ここまでの実装と同様に、まずは、データ取得の要件を再確認します。
- admin ユーザはデータの取得可
teacher ユーザの取得要件
- 担任でないデータは取得不可
student ユーザの取得要件
- 自分以外のデータは取得不可
- 対象シーズンが成績評価期間の場合は取得不可
上記の要件に沿って、テストを作成します。
tests/data.read.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 95 96 97 98 99 100 101 102 103 | 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 { seasons } from "./data/collections/seasons"; 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(); // 各コレクションに初期データを追加 const initialData = new InitialData(rulesFilePath); await initialData.setupUsers(); await initialData.setupRecords(); await initialData.setupSeasons(); }); afterEach(async () => { await supporter.cleanup() }); test("adminユーザはデータの取得可", async () => { // adminユーザで認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.admin); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertSucceeds(doc.get()); }); test("ログインしていないユーザは取得不可", async () => { // 認証されていないクライアントを取得 const db = supporter.getFirestore(); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertFails(doc.get()); }); describe("teacherユーザの取得要件", () => { let doc: firebase.firestore.DocumentReference; beforeEach(() => { // teacherユーザで認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.teacher); doc = db.collection(records.collectionPath).doc(records.initialData.id); }); test('要件にあったデータの取得に成功', async () => { await firebase.assertSucceeds(doc.get()); }); test("担任でないデータは取得不可", async () => { // データベース上のデータの担任を変更 const dbWithAdmin = supporter.getAdminFirestore(); const docWithAdmin = dbWithAdmin.collection(records.collectionPath).doc(records.initialData.id); const newData = { ...records.validUpdateDataForTeacher, homeroomTeacher: "other_teacher" }; await firebase.assertSucceeds(docWithAdmin.update(newData)); await firebase.assertFails(doc.get()); }) }); describe("studentユーザの取得要件", () => { test('要件にあったデータの取得に成功', async () => { // データベース上のデータの成績評価期間フラグをfalseに変更 const dbWithAdmin = supporter.getAdminFirestore(); const seasonDoc = dbWithAdmin.collection(seasons.collectionPath).doc(seasons.initialData(true).id); await firebase.assertSucceeds(seasonDoc.update(seasons.initialData(false))); // studentユーザで認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.student); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertSucceeds(doc.get()); }); test("自分以外のデータは取得不可", async () => { // データベース上のデータの成績評価期間フラグをfalseに変更 const dbWithAdmin = supporter.getAdminFirestore(); const seasonDoc = dbWithAdmin.collection(seasons.collectionPath).doc(seasons.initialData(true).id); await firebase.assertSucceeds(seasonDoc.update(seasons.initialData(false))); // 成績データの生徒と異なる生徒で認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.other_student); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertFails(doc.get()); }); test("対象シーズンが成績評価期間の場合は取得不可", async () => { // studentユーザで認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.student); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertFails(doc.get()); }) }) }); |
テストの概要
各テストで共通の内容
テストごとに、テスト対象となるロールユーザで認証されたクライアントを取得しています。
teacher ユーザの取得要件のテスト
担任でないデータは取得不可 となるテストでは、データ取得前に、データベース上のデータの担任フィールドを、リクエストデータの担任とは異なる担任に変更。
student ユーザの取得要件のテスト
要件にあったデータの取得に成功 となるテストと、 自分以外のデータは取得不可 となるテストでは、データベース上のデータの成績評価期間フラグを「false」に変更して、成績評価期間外に設定しています。
Firestore 上のデータ取得ルールを実装してみる
それでは、作成したテストに沿って、セキュリティルールを実装していきましょう。
全ての read アクセスを許可するルールを設定
データ更新ルールの実装のときと同じように、今回もまず、データ取得に関するルールだけを実装し、最後に、作成した全てのルールと統合したいと思います。
まずは、 records コレクションの read アクセスを全て許可し、「要件にあったデータの追加に成功」するテストだけを通します。
以下のように、セキュリティルールを調整してください。
1 2 3 4 5 6 7 8 9 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ // 全てのreadアクセスを許可 allow read: if true; } } } |
テストをスタート
以下のコマンドを実行して、データ取得ルール用のテストをスタートしてください。
1 | npm run test-watch tests/data.read.test.ts |
テスト結果
1 2 3 4 5 6 7 8 9 10 11 | FAIL tests/data.read.test.ts (5.181s) 成績データの取得テスト √ adminユーザはデータの取得可 (2150ms) × ログインしていないユーザは取得不可 (145ms) teacherユーザの取得要件 √ 要件にあったデータの取得に成功 (86ms) × 担任でないデータは取得不可 (109ms) studentユーザの取得要件 √ 要件にあったデータの取得に成功 (109ms) × 自分以外のデータは取得不可 (100ms) × 対象シーズンが成績評価期間の場合は取得不可 (74ms) |
adminユーザはデータの取得可 となるテストと、teacher ユーザ、student ユーザの 要件にあったデータの取得に成功 するテストが成功しました。
これまで同様に、失敗したテストを順次、通していきます。
「ログインしていないユーザは取得不可」となるルールを設定
ログインしていないユーザは取得不可 となるよう、以下のようにルールを調整してください。
1 2 3 4 5 6 7 8 9 10 11 12 | rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /students/{studentId}/records/{recordId}{ function isLoggedIn(){ return request.auth.uid != null; } allow read: if isLoggedIn(); } } } |
テスト結果
1 2 3 4 5 6 7 8 9 10 11 | FAIL tests/data.read.test.ts 成績データの取得テスト √ adminユーザはデータの取得可 (97ms) √ ログインしていないユーザは取得不可 (117ms) teacherユーザの取得要件 √ 要件にあったデータの取得に成功 (1174ms) × 担任でないデータは取得不可 (134ms) studentユーザの取得要件 √ 要件にあったデータの取得に成功 (115ms) × 自分以外のデータは取得不可 (104ms) × 対象シーズンが成績評価期間の場合は取得不可 (82ms) |
ログインしていないユーザは取得不可 となるテストが通りました。
認証チェックをロールごとに分割
ここで、admin、teacher、student ユーザ、それぞれのアクセス許可条件を個別に設定するため、認証チェックをロールごとに分割します。
以下のように、セキュリティルールを調整してください。
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 | 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 isTeacher(){ return isUserRole("teacher"); } // studentロールチェック function isStudent(){ return isUserRole("student"); } allow read: if isAdmin(); allow read: if isTeacher(); allow read: if isStudent(); } } } |
データ追加・更新ルールの実装で定義した isAdmin()、 isTeacher() 関数と同様、 student ロールのチェック関数 isStudent() を定義しています。
ここまでに定義した、ロールチェック用のカスタム関数を使って、認証チェックをロールごとに分割しています。
ルールの調整による影響がないか確認するため、テスト結果を確認しておきます。
テスト結果
1 2 3 4 5 6 7 8 9 10 11 | FAIL tests/data.read.test.ts 成績データの取得テスト √ adminユーザはデータの取得可 (1203ms) √ ログインしていないユーザは取得不可 (111ms) teacherユーザの取得要件 √ 要件にあったデータの取得に成功 (75ms) × 担任でないデータは取得不可 (104ms) studentユーザの取得要件 √ 要件にあったデータの取得に成功 (92ms) × 自分以外のデータは取得不可 (92ms) × 対象シーズンが成績評価期間の場合は取得不可 (62ms) |
テスト結果に変化はないので、続いて teacherユーザの取得要件 テストを通していきます。
「担任でないデータは取得不可」となるルールを設定
担任でないデータは取得不可 となるように、セキュリティルールを以下のように調整してください。
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 | 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 isTeacher(){ return isUserRole("teacher"); } function isStudent(){ return isUserRole("student"); } function isHomeroomTeacher(){ return request.auth.uid == resource.data.homeroomTeacher } allow read: if isAdmin(); allow read: if isTeacher() && isHomeroomTeacher(); allow read: if isStudent(); } } } |
ここで、teacher ユーザに対する許可条件に、更新ルールの実装時に定義した isHomeroomTeacher() 関数を追加して、担任チェックを行っています。
テスト結果
1 2 3 4 5 6 7 8 9 10 11 | FAIL tests/data.read.test.ts 成績データの取得テスト √ adminユーザはデータの取得可 (2124ms) √ ログインしていないユーザは取得不可 (125ms) teacherユーザの取得要件 √ 要件にあったデータの取得に成功 (74ms) √ 担任でないデータは取得不可 (95ms) studentユーザの取得要件 √ 要件にあったデータの取得に成功 (94ms) × 自分以外のデータは取得不可 (102ms) × 対象シーズンが成績評価期間の場合は取得不可 (75ms) |
teacherユーザの取得要件 テストが全て通りました。
student ユーザの取得要件をルールに設定
続いて、 studentユーザの取得要件 テストを通していきます。
以下のように、ルールを調整してください。
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 | 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 isTeacher(){ return isUserRole("teacher"); } function isStudent(){ return isUserRole("student"); } function isHomeroomTeacher(){ return request.auth.uid == resource.data.homeroomTeacher } function isEvaluationPeriod(){ return get(/databases/$(database)/documents/seasons/$(resource.data.season)).data.evaluationPeriod } // 本人データチェック function isSelfData(){ return request.auth.uid == studentId } allow read: if isAdmin(); allow read: if isTeacher() && isHomeroomTeacher(); allow read: if isStudent() && isSelfData() && !isEvaluationPeriod(); } } } |
ここで、student ユーザの許可条件に、本人データチェック関数 isSelfData() と、データ更新ルールの実装時に定義した成績評価期間チェック関数 isEvaluationPeriod() を追加しています。
isSelfData() 関数では、リクエストユーザと「match ステートメント」中の studentId 変数が一致するかをチェックし、リクエストユーザ本人のデータかどうかを確認しています。
成績評価期間のチェックでは、 isEvaluationPeriod() に論理否定演算子「 !」を添えて、評価期間外の取得を許可しています。
テスト結果
1 2 3 4 5 6 7 8 9 10 11 | PASS tests/data.read.test.ts 成績データの取得テスト √ adminユーザはデータの取得可 (2134ms) √ ログインしていないユーザは取得不可 (117ms) teacherユーザの取得要件 √ 要件にあったデータの取得に成功 (95ms) √ 担任でないデータは取得不可 (99ms) studentユーザの取得要件 √ 要件にあったデータの取得に成功 (97ms) √ 自分以外のデータは取得不可 (95ms) √ 対象シーズンが成績評価期間の場合は取得不可 (63ms) |
全てのテストが通りました!
以上で、データ取得用のルールは実装完了です。
次のテストとの競合を避けるため、テストを終了してください。
Firestore 上のデータ削除ルールの実装について
テストの作成
次に、データ削除ルールを実装します。
ここまでのルール実装と同様、テストを作成する前に、まずはデータ削除の要件を再確認します。
- admin ユーザ以外は削除不可
上記の要件に沿って、テストを作成します。
tests/data.delete.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 | 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(); // 各コレクションに初期データを追加 const initialData = new InitialData(rulesFilePath); await initialData.setupUsers(); await initialData.setupRecords(); await initialData.setupSeasons(); }); afterEach(async () => { await supporter.cleanup() }); describe("adminユーザ以外は削除不可", () => { test("ログインしていないユーザはデータの削除不可", async () => { // 認証されていないクライアントを取得 const db = supporter.getFirestore(); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertFails(doc.delete()); }); test("adminユーザはデータの削除に成功する", async () => { // adminユーザで認証されたクライアントを取得 const db = supporter.getFirestoreWithAuth(users.sample.admin); const doc = db.collection(records.collectionPath).doc(records.initialData.id); await firebase.assertSucceeds(doc.delete()); }); 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.delete()); }); 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.delete()); }) }) }); |
テストの概要
認証されていないユーザ、teacher ユーザ、student ユーザのそれぞれについて、データの削除不可テストを行います。
admin ユーザについては、要件に沿って削除が成功するかどうかをチェックします。
Firestore 上のデータ削除ルールを実装してみる
続いて、作成したテストに沿って、セキュリティルールを実装していきます。
ここまでのルール実装と同様、データ削除に関する部分だけを実装し、その後、これまでに作成したルールと統合します。
全ての delete アクセスを許可するルールを設定
削除ルールについては、簡単にルールを調整する程度なので、最初にルールを作成し、続いて、テストをパスするかを簡単に確認します。
以下のように、セキュリティルールを調整してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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"); } allow delete: if isAdmin(); } } } |
データ追加ルールの実装で定義した isAdmin() 関数を使って、admin ユーザのみデータの削除を許可しています。
テストをスタート
以下のコマンドを実行して、データ取得ルール用のテストをスタートしてください。
1 | npm run test tests/data.delete.test.ts |
テスト結果
1 2 3 4 5 6 7 | PASS tests/data.delete.test.ts 成績データの削除テスト adminユーザ以外は削除不可 √ ログインしていないユーザはデータの削除不可 (2210ms) √ adminユーザはデータの削除に成功する (124ms) √ teacherユーザはデータの削除不可 (112ms) √ studentユーザはデータの削除不可 (113ms) |
全てのテストが通りましたので、以上でデータ削除用のルールは実装完了です。
リグレッションテスト
最後に、これまでの記事で実装したルールを統合し、全てのテストを再度実行します。
テストにより、ルールの調整を進める中で「ルールが要件から外れていないか?」確認します。
前回までに実装したルールへ、今回作成したデータの取得・削除ルールを統合すると以下のようになります。
全てのルールを統合してテストを再実行
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 | 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 isTeacher(){ return isUserRole("teacher"); } function isStudent(){ return isUserRole("student"); } 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 } function isNotUpdate(key){ return request.resource.data[key] == resource.data[key] } function isNotUpdateImmutables(){ return isNotUpdate("id") && isNotUpdate("studentId") && isNotUpdate("season") } function isHomeroomTeacher(){ return request.auth.uid == resource.data.homeroomTeacher } function isEvaluationPeriod(){ return get(/databases/$(database)/documents/seasons/$(resource.data.season)).data.evaluationPeriod } function isSelfData(){ return request.auth.uid == studentId } allow create: if isAdmin() && request.resource.data.record == {} && isValidFormat(); allow update: if isAdmin() && isNotUpdateImmutables() && isNotUpdate("record") && isValidFormat(); allow update: if isTeacher() && isHomeroomTeacher() && isNotUpdateImmutables() && isNotUpdate("name") && isNotUpdate("homeroomTeacher") && isEvaluationPeriod() && isValidFormat(); allow read: if isAdmin(); allow read: if isTeacher() && isHomeroomTeacher(); allow read: if isStudent() && isSelfData() && !isEvaluationPeriod(); allow delete: if isAdmin(); } } } |
全てのテストを実行
以下のコマンドを実行して、ここまでに追加した全てのテストを実行してください。
1 | npm run test |
テスト結果
1 2 3 4 5 6 7 8 9 10 | PASS tests/data.update.test.ts (5.817s) PASS tests/data.read.test.ts PASS tests/data.delete.test.ts PASS tests/data.create.test.ts Test Suites: 4 passed, 4 total Tests: 32 passed, 32 total Snapshots: 0 total Time: 10.156s, estimated 12s Ran all test suites. |
全てのテストに成功しました。以上で、最初に設定した要件に沿ったルールを実装することができました。
まとめ
以上、成績評価システムを例に、成績コレクションのルールを実装すると共に、セキュリティルールでのカスタム関数の利用について解説してみました。
最後に、今回の内容を簡単にまとめてみたいと思います。
- カスタム関数で条件に名前付け!
- カスタム関数で重複回避!
- カスタム関数で条件が再利用できる!
- カスタム関数でセキュリティルールがスッキリ!
- 締めのリグレッションテスト!
これにて、『カスタム関数編』は終了となります!
4回にわたり、ご愛読いただきありがとうございます!
次回の「テスト駆動で学ぶ Firestore セキュリティルール」の連載にもご期待ください!
こちらの記事もオススメ!
データ検証編はこちら
「データ検証編」では、リクエストデータ、データベース上のデータの参照などについて解説
データ比較編はこちら
「データ比較編」では、データの比較や型チェックなどについて解説
カスタム関数編はこちら
「カスタム関数編」では、「比較編」で作成したルールをもとに、カスタム関数の使い方を解説
書いた人はこんな人

- 「好きを仕事にするエンジニア集団」の(株)ライトコードです!
ライトコードは、福岡、東京、大阪の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世界初の量産型ポータブルコンピュータを開発したのに倒産!?アダム・オズボーン