今までセキュリティルールをあまり書かずセキュリティ的にアレなものを量産していたので、
その反省からFirestoreのセキュリティルールについて学んだことを記します。
2020/12/01 追記
この記事は、以下バージョン時点の情報です。
node: 10.15.3
@firebase/testing: 0.12.3
内容案内
本記事では以下について記載しています。
- Firestore セキュリティールールのテスト環境構築方法(TypeScirpt & Jest)
- ユーザー認証のルール記載方法、テスト方法
- スキーマ検証のルール記載方法、テスト方法
- 値のバリデーションのルール記載方法、テスト方法
コードについて、わかりやすさ重視でコメントを多めに記載しています。
また、テストについてはコード量削減のためケースを分けず記載しています。
本記事の内容はすべて以下リポジトリにあります。もし、動かない場合はそちらを参考にしてください。
https://github.com/kawamataryo/practice-firestore-rule-test
1. 環境構築
まずテストを行うための環境を構築します。
TypeScript, Jestの設定
プロジェクトの雛形の作成
$ mkdir tdd_firestore_practice
$ cd tdd_firestore_practice
$ mkdir tests # テスト格納用のディレクトリ
$ touch firestore.test.ts # テストファイル。今後の章で内容は随時追加
次にyarnのプロジェクト作成し、必要モジュールを追加します。
$ yarn init -y
$ yarn add -D jest ts-jest typescript @types/jest
プロジェクト直下に、TypeScriptの設定ファイル、tsconfig.jsonを作成します。
$ vi tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "types": [
      "jest"
    ]
  }
}
続いてpackage.jsonにjestの設定を追記します。
$ vi package.json
{
  "name": "tdd_firestore_practice",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "filesystem": "^1.0.1",
    "jest": "^24.9.0",
    "ts-jest": "^24.0.2",
    "typescript": "^3.6.2"
  },
+  "scripts": {
+    "test": "jest"
+  },
+  "jest": {
+    "moduleFileExtensions": [
+      "ts",
+      "js"
+    ],
+    "transform": {
+      "^.+\\.ts$": "ts-jest"
+    },
+    "globals": {
+      "ts-jest": {
+        "tsconfig": "tsconfig.json"
+      }
+    },
+    "testMatch": [
+      "**/tests/**/*.test.ts"
+    ]
+  }
}
以上で設定は完了です。
firestore.test.tsにテストを追加して実行してみましょう。
describe("サンプル", () => {
  test("サンプルテスト", async () => {
    expect(1 + 2).toBe(3);
  });
});
yarnでテストを実行してみます。
$ yarn test
...
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.254s
Ran all test suites.
✨  Done in 5.57s.
以上でJestとTypeScriptの環境構築が完了です。
Firebaseの初期化、テストの設定
Firebase CLIを追加して、firebase プロジェクトを初期化します。
firebase.json, firestore.indexes.json, firestore.rules, .firebaserc が追加されるはずです。
$ npm install -g firebase-tools
$ firebase init
# 質問ではFirestoreを選択肢し、適当なプロジェクトを選択、その他はすべてEnter
次に、セキュリティルールのローカルテストのためにFirestoreのエミュレーターを追加します。
$ firebase setup:emulators:firestore
そしてFirebaseのテストモジュール@firebase/testを追加してモジュールの準備は完了です。
$ yarn add -D @firebase/testing
最後に、tests/firestore.test.ts にテストの雛形と、最初のルールでのテストを作成します。
テストの雛形は以下のとおりです。Jestのサンプルで作ったテストの記載を削除して firestore.test.ts に記載してください。
雛形についてはこちらの記事をまるっと参考にさせていただきました。 良記事ありがとうございます。
https://qiita.com/trueSuperior/items/214588c5c71073b52814
:firestore.test.ts
import * as firebase from "@firebase/testing";
import * as fs from "fs";
const PROJECT_ID = "qiita-demo";
const RULES_PATH = "firestore.rules";
// 認証付きのFreistore appを作成する
const createAuthApp = (auth?: object): firebase.firestore.Firestore => {
  return firebase
    .initializeTestApp({ projectId: testName, auth: auth })
    .firestore();
};
// 管理者権限で操作できるFreistore appを作成する
const createAdminApp = (): firebase.firestore.Firestore => {
  return firebase.initializeAdminApp({ projectId: testName }).firestore();
};
// user情報への参照を作る
const usersRef = (db: firebase.firestore.Firestore) => db.collection("user");
describe("Firestoreセキュリティルール", () => {
  // ルールファイルの読み込み
  beforeAll(async () => {
    await firebase.loadFirestoreRules({
      projectId: PROJECT_ID,
      rules: fs.readFileSync(RULES_PATH, "utf8")
    });
  });
  // Firestoreデータのクリーンアップ
  afterEach(async () => {
    await firebase.clearFirestoreData({ projectId: PROJECT_ID });
  });
  // Firestoreアプリの削除
  afterAll(async () => {
    await Promise.all(firebase.apps().map(app => app.delete()));
  });
  // 以降にテストを記載
  // ... 
});
そして最初のテストを書きます。
firebase init時のルールは以下のとおりです。
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write;
    }
  }
}
すべてのドキュメントに対して、読み込みと書き込みを許可しているので、実際に認証がなくとも読み書きできるかテストします。
(先程の雛形の以降にテストを記載のコメント以降に追加してください。)
  test("認証がなくとも読み書きが可能", async () => {
    const db = createUnAuthApp();
    const user = usersRef(db).doc("test");
    await firebase.assertSucceeds(user.set({ name: "太郎" }));
    await firebase.assertSucceeds(user.get());
  });
Firebaseのローカルエミュレーターを起動して、Jestを実行します。
(以降のテストではすべてこの方法で確認してください。)
$ firebase serve --only firestore  # ローカルエミュレーターの起動
$ yarn test
yarn run v1.17.3
warning ../../../../package.json: No license field
$ jest
 PASS  tests/firestore.test.ts
  Firestoreセキュリティルール
    ✓ 認証がなくとも読み書きが可能 (174ms)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.303s, estimated 4s
Ran all test suites.
✨  Done in 3.28s.
無事通りましたね。こちらでfirestoreのテスト準備も完了です。
2. 要件確認
以降でセキュリティルールに追加していく項目を定めます。
今回はとても簡略化したusersコレクションのみを持つユーザー管理システムを想定します。

想定する検証項目は以下のとおりです。
| 種別 | ルール | 
|---|---|
| ユーザー認証 | 認証を持つユーザーは自分のuidと同様のドキュメントIDのドキュメントだけを閲覧、作成、編集、削除可能 | 
| スキーマ検証 | name、gender、ageの3つのプロパティを持つ | 
| スキーマ検証 | name、genderは文字列、ageは数値である | 
| データのバリデーション | nameは1文字以上30文字以内である | 
| データのバリデーション | genderはmale,female,genderDiverseの3種類だけが選べる | 
| データのバリデーション | ageは0〜150の数値である | 
3. TDDでセキュリティールールを実装
ではここからがやっと本題。
テスト駆動でセキュリティールールを実装していきます。
最初に、環境構築で書いていたテストを削除 してセキュリティルールを以下何も許可しない状態にしてください。
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}
その上で、エミュレーターを起動します。(起動しておかないと以降のテストが落ちるので注意!!)
$ firebase emulators:start --only firestore
i  Starting emulators: ["firestore"]
i  firestore: Serving WebChannel traffic on at http://localhost:8081
i  firestore: Emulator logging to firestore-debug.log
✔  firestore: Emulator started at http://localhost:8080
i  firestore: For testing set FIRESTORE_EMULATOR_HOST=localhost:8080
✔  All emulators started, it is now safe to connect.
認証のテスト
まず以下ユーザー認証のルールのテストを追加します
| 種別 | ルール | 
|---|---|
| ユーザー認証 | 認証を持つユーザーは自分のuidと同様のドキュメントIDのドキュメントだけを閲覧、作成、編集、削除可能 | 
firestore.test.tsの雛形の中に以下を追記してください。
各記述の意味はコメントのとおりです。
...
  const correctUserData = {
    name: "suzuki taro",
    gender: "male",
    age: 30
  };
  describe("ユーザー認証情報の検証", () => {
    test("自分のuidと同様のドキュメントIDのユーザー情報だけを閲覧、作成、編集、削除可能", async () => {
      // taroで認証を持つDBの作成
      const db = createAuthApp({ uid: "taro" });
      // taroでusersコレクションへの参照を取得
      const userDocumentRef = db.collection("users").doc("taro");
      // 自分のuidと同様のドキュメントIDのユーザー情報を追加可能
      await firebase.assertSucceeds(userDocumentRef.set(correctUserData));
      // 自分のuidと同様のドキュメントIDのユーザー情報を閲覧可能
      await firebase.assertSucceeds(userDocumentRef.get());
      // 自分のuidと同様のドキュメントIDのユーザー情報を編集可能
      await firebase.assertSucceeds(
        userDocumentRef.update({ name: "SUZUKI TARO" })
      );
      // 自分のuidと同様のドキュメントIDのユーザー情報を削除可能
      await firebase.assertSucceeds(userDocumentRef.delete());
    });
    test("自分のuidと異なるドキュメントは閲覧、作成、編集、削除が出来ない", async () => {
      // 事前にadmin権限で別ユーザーでのデータを準備
      createAdminApp()
        .collection("users")
        .doc("taro")
        .set(correctUserData);
      // hanakoで認証を持つDBの作成
      const db = createAuthApp({ uid: "hanako" });
      // taroでusersコレクションへの参照を取得
      const userDocumentRef = db.collection("users").doc("taro");
      // 自分のuidと同様のドキュメントIDのユーザー情報を追加不可
      await firebase.assertFails(userDocumentRef.set(correctUserData));
      // 自分のuidと同様のドキュメントIDのユーザー情報を閲覧不可
      await firebase.assertFails(userDocumentRef.get());
      // 自分のuidと同様のドキュメントIDのユーザー情報を編集不可
      await firebase.assertFails(
        userDocumentRef.update({ name: "SUZUKI TARO" })
      );
      // 自分のuidと同様のドキュメントIDのユーザー情報を削除不可
      await firebase.assertFails(userDocumentRef.delete());
    });
  });
...
この状態でテストを実行すると、、、
想定通り操作不可のほうは、成功しますが、操作可能なほうが失敗しRed です。
です。
次にこれをGreenにしていきます。
セキュリティルールを以下のように変更してください。
rules_version = '2';
service cloud.firestore {
  // ユーザー認証の関数
  function isAuthUser(auth, userId) {
    return auth != null && auth.uid == userId // 認証があり、uidとuserIdが一致する
  }
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read:  if isAuthUser(request.auth, userId);
      allow create: if isAuthUser(request.auth, userId);
      allow update: if isAuthUser(request.auth, userId);
      allow delete: if isAuthUser(request.auth, userId);
    }
  }
}
新しく matchに usersの階層を定義して、更に {userId}でドキュメントIDをキャプチャしています。
その上で、 isAuthUserの関数を定義して、内部で auth.uid の検証を行うというものです。
では再度実行してみましょう。
無事Green ! テストが通りましたね。これで認証のルール設定は完了です。
 ! テストが通りましたね。これで認証のルール設定は完了です。
スキーマ検証
続いてスキーマを検証します。
FirestoreはNoSQLでスキーマレスなので、RDBのように構造が固定ではなく、なんでも放り込むことができます。
なのでもし、定形の構造をドキュメントに求める場合は、バグを防ぐためにも必ずスキーマ検証を行いましょう。
ここでは以下を検証します。
| 項目 | ルール | 
|---|---|
| スキーマ検証 | name、gender、ageの3つのプロパティを持つ | 
| スキーマ検証 | name、genderは文字列、ageは数値である | 
firestore.test.tsの雛形の中に以下を追記してください。
  describe("スキーマの検証", () => {
    test("正しくないスキーマの場合は作成できない", async () => {
      // taroで認証を持つDBの作成
      const db = createAuthApp({ uid: "taro" });
      // taroでusersコレクションへの参照を取得
      const userDocumentRef = db.collection("users").doc("taro");
      // 想定外のプロパティがある場合
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, place: "japan" })
      );
      // プロパティの型が異なる場合
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, name: 1234 })
      );
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, gender: true })
      );
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, age: "1" })
      );
    });
    test("正しくないスキーマの場合は編集できない", async () => {
      // 事前にadmin権限で別ユーザーでのデータを準備
      createAdminApp()
        .collection("users")
        .doc("taro")
        .set(correctUserData);
      // taroで認証を持つDBの作成
      const db = createAuthApp({ uid: "taro" });
      // taroでusersコレクションへの参照を取得
      const userDocumentRef = db.collection("users").doc("taro");
      // 想定外のプロパティがある場合
      await firebase.assertFails(userDocumentRef.update({ place: "japan" }));
      // プロパティの型が異なる場合
      await firebase.assertFails(userDocumentRef.update({ name: 1234 }));
      await firebase.assertFails(userDocumentRef.set({ gender: true }));
      await firebase.assertFails(userDocumentRef.set({ age: "1" }));
    });
  });
この状態で実行すると、、
無事Red  で失敗しますね。
で失敗しますね。assertFailsで操作が失敗することを期待しているのですが、スキーマ検証をルールに入れてないので操作が出来てしまい失敗しています。
では、ここにルールを追加します。
セキュリティルールは以下のように修正してください。
rules_version = '2';
service cloud.firestore {
  //ユーザー認証の関数
  function isAuthUser(auth, userId) {
    return auth != null && auth.uid == userId // 認証があり、uidとuserIdが一致する
  }
  // スキーマ検証の関数
  function isValidUserSchema(user) {
    return user.size() == 3 // ドキュメントは3つのプロパティをもつこと
      && 'name' in user && user.name is string // nameのプロパティがあり、nameの型がstringであること
      && 'gender' in user && user.gender is string // genderのプロパティがあり、genderの型がstringであること
      && 'age' in user && user.age is number // ageのプロパティがあり、ageの型がnumberであること
  }
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read:  if isAuthUser(request.auth, userId);
      allow create: if isAuthUser(request.auth, userId) && isValidUserSchema(request.resource.data);
      allow update: if isAuthUser(request.auth, userId) && isValidUserSchema(request.resource.data);
      allow delete: if isAuthUser(request.auth, userId);
    }
  }
}
isValidUserSchemaという判定関数を追加して、createとupdateでスキーマを検証しています。
これで再度テストを実行してみましょう!
Green  ですね!
 ですね!
これでスキーマのテストは追加完了です。
値のバリデーション
最後にバリデーションです。
なんとセキュリティルールは値のバリデーションまで出来ます。
以下を検証してみます。
| 項目 | ルール | 
|---|---|
| データのバリデーション | nameは1文字以上30文字以内である | 
| データのバリデーション | genderはmale,female,genderDiverseの3種類だけが選べる | 
| データのバリデーション | ageは0〜150の数値である | 
まずテストの追加 firestore.test.ts の雛形の中に以下を追記してください。
  describe("値のバリデーション", () => {
    test("nameは1文字以上30文字以内である", async () => {
      // taroで認証を持つDBの作成
      const db = createAuthApp({ uid: "taro" });
      // taroでusersコレクションへの参照を取得
      const userDocumentRef = db.collection("users").doc("taro");
      // 正しい値ではデータを作成できる
      await firebase.assertSucceeds(
        userDocumentRef.set({ ...correctUserData, name: "a".repeat(30) })
      );
      // 正しくない値ではデータを作成できない
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, name: "" })
      );
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, name: "a".repeat(31) })
      );
    });
    test("`gender`は`male`, `female`, `genderDiverse`の3種類だけが選べる", async () => {
      // taroで認証を持つDBの作成
      const db = createAuthApp({ uid: "taro" });
      // taroでusersコレクションへの参照を取得
      const userDocumentRef = db.collection("users").doc("taro");
      // 正しい値ではデータを作成できる
      await firebase.assertSucceeds(
        userDocumentRef.set({ ...correctUserData, gender: "male" })
      );
      await firebase.assertSucceeds(
        userDocumentRef.set({ ...correctUserData, gender: "female" })
      );
      await firebase.assertSucceeds(
        userDocumentRef.set({ ...correctUserData, gender: "genderDiverse" })
      );
      // 正しくない値ではデータを作成できない
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, gender: "" })
      );
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, gender: "男性" })
      );
    });
    test("`age`は0〜150の数値である", async () => {
      // taroで認証を持つDBの作成
      const db = createAuthApp({ uid: "taro" });
      // taroでusersコレクションへの参照を取得
      const userDocumentRef = db.collection("users").doc("taro");
      // 正しい値ではデータを作成できる
      await firebase.assertSucceeds(
        userDocumentRef.set({ ...correctUserData, age: 0 })
      );
      await firebase.assertSucceeds(
        userDocumentRef.set({ ...correctUserData, age: 150 })
      );
      // 正しくない値ではデータを作成できない
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, age: -1 })
      );
      await firebase.assertFails(
        userDocumentRef.set({ ...correctUserData, age: 151 })
      );
    });
  });
それでテストを実行すると、、
想定通りRed  で失敗ですね
で失敗ですね
次にセキュリティルールを以下のように修正します。
rules_version = '2';
service cloud.firestore {
  // ユーザー認証の関数
  function isAuthUser(auth, userId) {
    return auth != null && auth.uid == userId // 認証があり、uidとuserIdが一致する
  }
  
  // スキーマ検証の関数
  function isValidUserSchema(user) {
    return user.size() == 3 // ドキュメントは3つのプロパティをもつこと
      && 'name' in user && user.name is string // nameのプロパティがあり、nameの型がstringであること
      && 'gender' in user && user.gender is string // genderのプロパティがあり、genderの型がstringであること
      && 'age' in user && user.age is number // ageのプロパティがあり、ageの型がnumberであること
  }
  // 値のバリデーションの関数
  function isValidUserData(user) {
    return 1 <= user.name.size() && user.name.size() <= 30 // nameは1文字以上30文字以内であること
      && user.gender.matches('male|female|genderDiverse') // `gender`は`male`, `female`, `genderDiverse`の3種類だけが選べる
      && 0 <= user.age && user.age <= 150 // `age`は0〜150の数値である
  }
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read:  if isAuthUser(request.auth, userId);
      allow create: if isAuthUser(request.auth, userId) && isValidUserSchema(request.resource.data) && isValidUserData(request.resource.data);
      allow update: if isAuthUser(request.auth, userId) && isValidUserSchema(request.resource.data) && isValidUserData(request.resource.data);
      allow delete: if isAuthUser(request.auth, userId);
    }
  }
}
データの判定を行うisValidUserDataを追加し、createとupdateの際に検証しています。
ではテストを実行してみましょう。
無事すべて通りましたね。
長かったですがこれで、すべての項目のルール追加が完了です! 
終わりに
以上TDDで学ぶ Firestoreセキュリティルール の書き方でした。
Firestoreのセキュリティルールは本当に色々な検証ができるので、セキュリティルールを学ぶことでより強固なアプリが作れると思います。
また、今回取り上げたローカルテストの方法を試すこと、早いサイクルで改善が行えます。
今回の記事が何かしらの助けになれば幸いです。
また私も絶賛勉強中なので、記事中に誤りがあったり、もっと効率的な書き方があればコメント欄でどしどし指摘お願いします!!
参考
こちらで書いた内容は、ほぼほぼ以下書籍から得た知識によるものです。
良書ありがとうございます!





