• トップ
  • ブログ一覧
  • テスト駆動で学ぶ 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・コンピューターの歴史をまとめていきたいと思います!弊社ブログにある記事のみで構成しているため、まだ「未完成状態」...

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    メディアチーム
    メディアチーム
    Show more...

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background