どうも、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 <条件>;
}
}
}
アクセス権限は read
と write
に大別されます。 それぞれ更に細かいアクセス権限に分けることができ、read
は get
, list
に分けられ、 write
は create
, 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.data
と request.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のセキュリティルールの基本的なことについてまとめました。 次回は一番重要なバリデーションについて書きたいと思います。