どうも、CTOの森下です。

みなさん、FirebaseのDBサービスのFirestoreを使っていますでしょうか? 最高のサービスなので使ってない方は是非とも導入をおすすめします。 Firestoreは一般的なDBと違ってクライアントから直接アクセスすることが前提のDBなので、セキュリティルールが特殊です。 今回はそのルール設定についての記事になります。

Firestoreについて

公式の説明[1]は以下になります。

Cloud Firestore は、Firebase と Google Cloud Platform からのモバイル、ウェブ、サーバー開発に対応した、柔軟でスケーラブルなデータベースです。Firebase Realtime Database と同様に、リアルタイム リスナーを介してクライアント アプリ間でデータを同期し、モバイルとウェブのオフライン サポートを提供します。これにより、ネットワークの遅延やインターネット接続に関係なく機能するレスポンシブ アプリを構築できます。Cloud Firestore は、その他の Firebase および Google Cloud Platform プロダクト(Cloud Functions など)とのシームレスな統合も実現します。

GincoでFirestoreを採用している理由

理由としてはFirestoreに次のような機能があるからです。

  • スケーラビリティ
    • Google Cloud Platformの強力なインフラ + 過酷なワークロードにも耐えうる設計
  • データの強整合性
    • 優れた整合性が確保されているのでデータレースなどをあまり考えなくて良い
  • リアルタイムアップデート
    • Firestoreに書き込まれたデータは全てのクライアントにリアルタイムで同期される

この中でもリアルタイムアップデートの機能はアプリを作る上で、非常に便利な機能となっています。 今となってはFirestore無しでアプリを作りたくないですね。

Firestoreのセキュリティルール

上で述べたように、Firestoreは一般的なDBとは違いクライアントから直接アクセスが可能です。セキュリティルールを設定しなければ基本的に誰でもアクセスできる状態になってしまいます。したがって、Firestoreのセキュリティルールをしっかりと設定することが肝要となります。 全てのアクセスを拒否するルールに設定した上で、API経由でしかアクセスできなくする運用方法もあるらしいですが、Firestoreの利点であるオフラインキャッシュやリアルタイムアップデートが使えなくなりますし、今までの開発方式とさほど変わりませんし、Firestoreを使う意味があるんでしょうか?

# 適切なセキュリティルール設定
+----------+     +-------------+
|          +---> |             |
|  Client  |     |  Firestore  |
|          | <---+             |
+----------+     +-------------+


# 全拒否のセキュリティルール + API経由のアクセス
+----------+     +--------------+     +-------------+
|          +---> |              +---> |             |
|  Client  |     |  API Server  |     |  Firestore  |
|          | <---+              | <---+             |
+----------+     +--------------+     +-------------+

しかしながら、Firestoreのルール設定は少々クセがあり、正しく設定できているか確認するのも手間がかかります。 Gincoでは、ルールをデプロイしQAによって動作を確認していたのですが、DBのスキーマが増えていくにつれ非効率になってきたので、今ではルールのテストを全てCIで自動化しています。

CLIを使ったセキュリティルールのデプロイ

セキュリティルールはFirebaseのWebGUIを使って設定可能ですが、CLIを使うこともできます。 CLIでデプロイする場合はセキュリティルールを firestore.rules ファイルに記述します。 その後、以下のコマンドでデプロイ可能です。

$ firebase deploy --only firestore:rules

Gincoでは、 firestore.rules をGitで管理し、CIを使ってテストが通ったあと自動でデプロイされるようにしています。

セキュリティルールの基本構造

セキュリティルールは基本的にドキュメントのパスを指定する match と そのデータへのアクセス権限を指定する allow で構成されます。 パスに{ }を使用するとワイルドカードとなり、そのコレクションの全てのドキュメントを指定できます。

// ここから
service cloud.firestore {
  match /databases/{database}/documents {
// ここまでは定型文

    // Usersコレクションの特定のドキュメント
    match /Users/morishita {
      allow read: if <条件>;
    }

    // Usersコレクションの全てのドキュメント
    match /Users/{userName} {
      allow write: if <条件>;
    }
  }
}

アクセス権限は readwrite に大別されます。 それぞれ更に細かいアクセス権限に分けることができ、readget, listに分けられ、 writecreate, update, delete と分けられます。

  • read
    • get: ドキュメントへの読み取りを許可する
    • list: クエリと複数のドキュメントへの読み取りを許可する
  • write
    • create: 存在しないドキュメントへの書き込みを許可する
    • update: すでに存在するドキュメントへの書き込みを許可する
    • delete: ドキュメントの削除を許可する

また、セキュリティルールはデータの階層にしたがってネストした記述が可能です。

service cloud.firestore {
  match /databases/{database}/documents {

    match Rooms/{room}/Messages/{message} {
      allow read, write: if <condition>;
    }

    // ↑と↓は同じルール

    match /Rooms/{room} {
        match /Messages/{message} {
          allow read, write: if <condition>;
        }
    }
}

余談

公式Docではコレクションとドキュメントのパスは小文字で表現されていることが多いですが、大文字小文字は特に関係ありません。Gincoではコレクションとドキュメントの違いを見やすくするため、コレクションは大文字始まり、ドキュメントは小文字始まりで統一しています。

/Rooms/roomA/Messages/message1

セキュリティルールでハマったポイント

コレクションのルールはそのサブコレクションには適用されない

以下のようなルールがあったとして、直感的にはRoomsコレクション以下の全てのデータにルールが適用されそうですが、実際にはコレクションに定義されたルールはそのサブコレクションには適用されません。 したがって、明示的にサブコレクションのルールも定義する必要があります。

service cloud.firestore {
  match /databases/{database}/documents {

    // Rooms/{room}/Messages/{message}

    // これだけではMessagesにルールは適用されない
    match Rooms/{room} {
      allow read, write: if <condition>;
    }

    // 明示的にサブコレクションのルールも定義する
    match /Rooms/{room} {
        match /Messages/{message} {
          allow read, write: if <condition>;
        }
    }
}

一つでも条件がtrueならアクセスが許可される

あるルールで条件が false になったとしても、それ以外で true になるようなルールが存在する場合、そちらが優先されアクセスが許可されます。 広範囲でアクセスを制限して安心していたら、他のルールで思いもよらないアクセスが許可されている可能性があるので注意が必要です。 コードを見れば当たり前なことなんですが、一般的なDBのイメージ的にどこかが false なら防いでくれそうな気がするのでたまにやらかします。 間違ったルールがアクセスを拒否するものだった場合、実際の動作でうまくいかなくなるので直ぐに分かりますが、アクセス許可のルールを間違って入れてしまった場合、通常の動作では気づかないことが多いので危険です。

service cloud.firestore {
  match /databases/{database}/documents {

    // Rooms以下は全部拒否したので安心!
    match /Rooms/{document=**} {
      allow read, write: if false;
    }

    // こんな感じのを消し忘れるとひどいことに
    match /Rooms/room1 {
      allow read, write: if true;
    }
}

resource.dataとrequest.resource.dataの扱い

データのバリデーションには resource.datarequest.resource.data を用いて行います。 ここでのハマりポイントは、 request.resource.data が何を指しているか分かりづらいことです。 変数の名前的に、 request.resource.data はリクエストされたデータを指すように思えますが、実際にはリクエストが処理されたあとのデータを指します。 つまり、 update などの処理で新しくフィールドを追加する際にそのフィールドだけのバリデーションをかけてしまうと update ができなくなってしまいます。 ちなみに resource.data はリクエストが処理される前のデータを指します。

# イメージ的にはこんな感じ
                 +-----------------+
                 |                 |
                 |  resource.data  | Firestoreのデータ
                 |                 |
                 +------+----------+
                        |
+-----------+           |
|           |  update   |
|  request  +---------> |
|           |           |
+-----------+           v

         +-------------------------+
         |                         |
         |  request.resource.data  | リクエストが処理されたあとのFirestoreのデータ
         |                         |
         +-------------------------+
service cloud.firestore {
  match /databases/{database}/documents {

    match /Users/{userName} {
      allow read: if true;
      allow create: if request.resource.data.keys().hasOnly(["id", "name");
      // updateでageを追加したいがこれではだめ
      allow update: if request.resource.data.keys().hasOnly(["age"]);

      // フィールドが追加された結果をバリデーションするようにすればOK
      allow update: if request.resource.data.keys().hasOnly(["id", "name", "age"]);
    }
}

終わりに

今回はFirestoreのセキュリティルールの基本的なことについてまとめました。 次回は一番重要なバリデーションについて書きたいと思います。

参考文献