• トップ
  • ブログ一覧
  • テスト駆動で学ぶ Firestore セキュリティルール【カスタム関数編:第2回】
  • テスト駆動で学ぶ Firestore セキュリティルール【カスタム関数編:第2回】

    広告メディア事業部広告メディア事業部
    2020.06.05

    IT技術

    【第2回】テスト駆動で成績データコレクションのルールを実装する

    前回は、カスタム関数について簡単に解説した後、「カスタム関数編」で扱う成績コレクション records の要件を設定し、ルール実装に使用するテスト環境とテストデータを準備しました。

    今回は、前回設定した要件に沿って、データ追加ルールの実装を進めていきます

    前回の記事はこちら

    前回の記事は、以下をご参照ください。

    featureImg2020.06.04テスト駆動で学ぶ Firestore セキュリティルール【カスタム関数編:第1回】テスト駆動で成績データコレクションのルールを実装する【第1回】この記事では、全4回に分けて、Firestore セキュ...

    Firestore 上のデータ追加ルールの実装について

    テストの作成

    テストを作成する前に、まずはデータ追加の要件を再確認します。

    1. admin ユーザ以外は追加不可
    2. 成績フィールドは空のマップ
    3. 所定のフォーマットでないデータは追加不可

    上記の要件に沿って、テストを作成していきます

    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ユーザ以外は追加不可
    8teacherユーザは追加不可 (80ms)
    9studentユーザは追加不可 (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ユーザ以外は追加不可
    8teacherユーザは追加不可 (79ms)
    9studentユーザは追加不可 (73ms)

    テスト結果に変化はないので、問題なくルールを調整することができました。

    以上で、データ追加用のルールは実装完了です!

    最後に、次のテストとの競合を避けるため、テストを終了してください。

    第3回へつづく!

    「カスタム関数編」第2回となるこの記事では、カスタム関数を取り入れつつ、成績コレクション records へのデータ追加用のセキュリティルールを実装してみました。

    次回は、成績コレクション records のデータ更新用セキュリティルールの実装を進めていきます。

    ぜひ、ご覧ください!

    第3回の記事はこちら

    featureImg2020.05.07テスト駆動で学ぶ Firestore セキュリティルール 【データ比較編 / 前編】テスト駆動で書籍コレクションのルールを実装するこの記事では、前編・後編の二回に分けて、Firestore セキュリティ...

    こちらの記事もオススメ!

    featureImg2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...

    featureImg2020.08.04エンジニアの働き方 特集社員としての働き方社員としてのエンジニアの働き方とは?ライトコードのエンジニアはどんな働き方をしてるのか、まとめたいと...

    featureImg2020.07.27IT・コンピューターの歴史特集IT・コンピューターの歴史をまとめていきたいと思います!弊社ブログにある記事のみで構成しているため、まだ「未完成状態」...

    広告メディア事業部

    広告メディア事業部

    おすすめ記事