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