Cloud Firestoreの勘所 パート3 — セキュリティルール
セキュリティルールでデータを守る🔐
パート2では、セキュリティルールは全許可にしてしまっていましたが、データ構造もある程度決まってきたところで、そろそろその設定をしていきます。
現在のデータ構造
まずは、パート2終了時点でのデータ構造をおさらいすると、次のようになっています。
現状のセキュリティルール
セキュリティルールは、パート2で Start in test mode
を選んだ結果、次のようにすべてのドキュメントへのあらゆる操作を許可した状態になっています。
Firestoreへの接続情報はクライアントや通信内容などから容易に取得可能なため、このままでは全データがだだ漏れですし簡単に破壊されてしまいます。
よくあるWeb APIサーバー + データベースサーバー構成ではデータベースサーバーへの接続許可をWeb APIサーバーなど一部に制限することでセキュリティを担保しますが、Firestoreはクライアントから直接接続する方式なのでそういうわけにはいきません。
Firestoreではセキュリティルールにより読み取り・書き込み権限を過不足なく設定することでデータを守ります🔐言い換えると、セキュリティルールをしっかり設定すれば「クライアント直接接続型のデータベースだからセキュリティが心配」なんてことも決してありません🙆♀️
準備
パート2の記事の後半で書いた、Firebase CLIのインストール・プロジェクトセットアップなど済んだ前提で進めます。
Visual Studio Codeで、Firestoreのセキュリティルール・インデックスのシンタックスハイライト・コード補完を有効にする
次のプラグインをインストールすると、Visual Studio Codeで、Firestoreのセキュリティルール・インデックスのシンタックスハイライト・コード補完が有効になるので、とてもおすすめです。
仕組み上、TypeScriptコードまでのコード補完は望めませんが、それなりにスラスラ書けるようになります。
一旦セキュリティルールで全不許可にする
それでは、セキュリティルールを更新していきます。次のように if false
という条件を加えて、全ドキュメントへのアクセスを明示的に禁止します。
firebase deploy --only firestore:rules
を実行すると、反映されます。大抵は数十秒以内で反映されますが、仕様的には最大1分ほどかかるので、正確に書いたつもりなのに意図通り動かない場合は未反映も疑ってしばらく待つのも良いです🍵
構文的に明らかに誤った書き方で解釈不能な場合は次のようにデプロイエラーになります。
クライアントから実行
パート2と同じく、Swiftで実行していきます。次のようにルートの posts
コレクションを取得しようとすると、
db.collection("posts")
.order(by: "createTime", descending: true)
.getDocuments { (snapshot, error) in
print(error as Any)
snapshot!.documents.forEach { doc in
print(doc)
}
}
全不許可設定がうまくできていれば、次のエラーが出るはずです。
Error Domain=FIRFirestoreErrorDomain Code=7 “Missing or insufficient permissions.”
いかなる理由であれ、セキュリティルールで弾かれた時はこのメッセージだけが表示され、細かい理由は分かりません。
詳細が記載されているとセキュリティリスクが高まるので理由を伏せているのは妥当なものの、開発時は理由が分からなくてハマることも多いです。
この問題に対しては次のような回答がなされています。
Unfortunately this is a known pain point with the beta version of Cloud Firestore. We are working on improvements to make it easier to debug your rules.
個人的には、ベータを脱するあたりでFirebase Webコンソール上でログを確認できるようになるのかなと期待しています👀
[2018/12/13 追記] エミュレーターを用いたローカルテストができるようになりました
Firebase Summit 2018 にてFirestoreエミュレーターの発表・リリースがされました。
ざっくり次のように使います。
firebase serve - only firestore
でローカルエミュレーターを起動しておく- 上のドキュメントに従いながら、普通の単体テストと同様に記述・実行
このおかげで、高速なサイクルで細かいルールの検証テストが可能となりました。
さらに嬉しいことに、このローカルエミュレーターを使うと、実行時エラーが発生した場合にその原因を教えてくれます。
(これまでは構文エラーはデプロイ時に指摘してくれましたが、実行時エラーの場合は詳細ログが無く権限無い場合とまったく区別付きませんでした)
ルートのpostsコレクションのルールを設定
読み取り許可を無条件に与えてみる
次のように /posts/{postId}
へ read
を許可すると、先ほどエラーになったアクセスが成功するはずです。
read
は 以下のANDのショートカットです。
get
(ドキュメントIDによる個別読み取りを許可)list
(コレクション配下のドキュメント一覧読み取りを許可)
次のドキュメントの”Request Methods”に詳細が書かれています。
認証済みユーザーのみアクセス可能にする
認証済みユーザーのみアクセス可能にするには、次のように request.auth != null
の条件を加えます。また、こういった各所で使いそうな処理は関数を定義しておくと良いです。JavaScriptっぽく書くと大体動きます( ´・‿・`)
デプロイして、再度クライアントからアクセスすると、再びエラーが出るはずです。
何かしら認証が必要ということで、一番手っ取り早い匿名認証をしてみましょう。まず、Firebase WebコンソールのAuthenticationのメニューのSIGN-IN METHODタブからAnonymous認証をオンにします。
そして、クライアントで次のコードを実行すると、匿名認証成功です👌
Auth.auth().signInAnonymously { (user, error) in
print(user)
print(error)
}
Firebase Authenticationについてもう少し知りたい場合は、こちらも併読ください。
再度 posts
へアクセスすると、また無事に取得に成功するはずです。
これで、「認証済みユーザーのみがルートの posts
コレクションへ読み取りアクセス可能」という状態になりました。書き込み権限は不指定、つまり禁止設定になっています。この状態でもパート2で設定したCloudFunctions上の、users/{userId}/post
サブコレクションから posts
コレクションへのコピー処理は正常に動きます。
セキュリティルールはあくまでクライアントからのアクセスルールを定義するものであって、Admin SDKからの操作には何の影響も及ぼしません。つまり、Admin SDKから誤って不正なデータを書き込まないようにするという用途には使えず、それはプログラムで「気をつける」しかありません。とはいえ、TypeScriptで型を定義してカチッと書くようにするとそういうミスをしたまま本番デプロイするようなことはほぼ防げる気はしています。
ルートのusersコレクションのルールを設定
このままではルートの users
コレクションに誰もアクセスできないので、次のように調整します。
アクセス先の users
のドキュメントが request.auth.uid
(自身の認証情報のuid)と一致する時のみ、そのドキュメントの取得・作成・更新を許可する、という設定です。ユーザー一覧取得や自身を勝手に削除することは禁止されています(usersドキュメントの削除は認証情報削除とライフサイクルを合わせたいため)。
この状態で、以下を実行するとエラーになります。”mono”というユーザーIDは匿名認証で割り当てられたuidと不合致だからです。
db.collection("users").document("mono")
.setData(["name": "もの2"]) { error in
print(error as Any)
}
ちなみに、更新形の操作はローカルでは一旦 hasPendingWrites: true
の状態で保持されて、クラウド同期でセキュリティルールに引っかかると弾かれて消される(逆に問題無ければコミットされる)、という挙動になっています。上の例では、 collection("users").document("mono")
をローカル監視しているとhasPendingWrites: true
の状態で変更通知が来て “name”が”もの2"になって、そのあとすぐまたhasPendingWrites
も”name”も元の状態に戻る、という動きになります。
次のように自身のデータをsetする処理は正常に動きます。
self.db.collection("users").document(Auth.auth().currentUser!.uid)
.setData(["name": "匿名ユーザー2"]) { error in
print(error as Any)
}
usersへの書き込みデータのバリデーション
先ほどの設定だと、自身の users
ドキュメントにあらゆるデータを設定できてしまい、クライアントのバグや悪意ある攻撃などで意図しないデータが設定されてしまうリスクがあります。
そこで、次のように設定できるフィールドは name
だけとして、かつその値は1文字以上20文字以下に制限しました。
また、ルートの posts
コレクションの authorRef
から所望のユーザーを取得できる必要があるので、 get
は認証済みのユーザーすべてに対して許可に変えました。
ルールは主に以下が基本ですが、このあたりで大体その感覚が掴めてきたのではないでしょうか。
- 条件に応じて各コレクション・ドキュメントへのアクセス許可を制御
- データ内容を検証して条件を満たさない場合は弾く
users/{userId}/posts サブコレクションのルールを設定
次のように users/{自身のuid}/posts
サブコレクションに新規ドキュメントを作成しようとすると、これは権限エラーとなります。まだサブコレクションへのルールが未定義、つまり不許可だからです。
self.db.collection("users").document(Auth.auth().currentUser!.uid)
.collection("posts").document()
.setData([
"title": "匿名ユーザーの記事",
"body": "匿名ユーザーの記事本文",
"createTime": FieldValue.serverTimestamp()
]) { error in
print(error as Any)
}
というわけで、 users/{userId}/posts
サブコレクションのルールを設定していきます。このように、まずはエラーを確認して、そこから所望の仕様を満たすルールを追加してうまく動くことを確認し、さらに冗長な記述があれば関数化、というサイクルが良いです。テスト駆動開発(TDD)のレッド/グリーン/リファクタリングのサイクルと一緒ですね。
ちなみに、 FieldValue.serverTimestamp()
はサーバーで一貫した日時を割り当ててもらいたい時に指定します。 Date()
を渡すとクライアントでの時刻依存なために少し差が生じてしまい、例えばチャットアプリなどではメッセージが実際の順番と反対になってしまうバグなどに繋がります。
users/{userId}/posts
サブコレクションのルールとして、次のように設定してみます。
自身の users
コレクションの posts
サブコレクションとして、以下を許可しました。
- 作成・更新(データの検証付き)
- 削除
今回は分けて書きましたが、write
は以下のANDなので、それで簡単にまとめて書くこともできます。
create
: ドキュメント作成update
: ドキュメント更新delete
: ドキュメント削除
また、本記事では一気に複数条件を書いていますが、前述の通りデバッグしにくいので慣れるまでは、1つずつ書いては動作を確認というのを繰り返すやり方をお勧めします。
ここまでで、投稿型のブログサービスとしてのデータ周りの最低限の設定は整いました。
管理者アカウント制御
応用として、こちらのドキュメントではドキュメントごとに roles
を設定する方法が紹介されています。
本記事では管理系の制御として、特定のユーザーに管理者権限を与えて一般ユーザーのアクセスできない他人のドキュメントにアクセスできるようにしてみます。利用シーンとしては、管理アプリにログインして管理系の操作を行いたいときなどを想定しています。
adminsドキュメントを作成
admins
コレクションに管理者権限を与えたいユーザーIDと同一IDのドキュメントを追加しておきます。
existsビルトイン関数でadminsにアクセスしてきたユーザーIDが存在するかチェック
次のように、 isAdmin()
がtrueだったら、他人のデータ書き換えを可能としてみます。
すると、先ほどはエラーだった他人(“mono”さん)の書き換えに成功しました。
db.collection("users").document("mono")
.setData(["name": "もの2"]) { error in
print(error as Any)
}
実際のアプリでは、 request.auth.uid
ではなく request.auth.token.email
など安定した値の方が扱いやすいと思います。また、 get
関数で admins
ドキュメントのデータによって細かい権限をチェックするのも良いですね。
get
・ exists
関数はこちらの「他のドキュメントへのアクセス」にて説明されています。ちなみに、ドキュメントのリスト取得はルール内ではできません。そういった検証がどうしても必要な場合は、クライアントからCallableでCloud Functionsの関数を呼び出してその中で検証処理をしてデータ更新をすることになります。これに限らず、ルールだけでの解決が難しい場合や、クラウド側だけで複数ドキュメントの一括更新をしたい場合などにたまに取る手段です。
ちなみに、 `/databases/$(database)/documents/admins/DOCUMENT_ID` という書き方をすると、参照型になるのでこの関数に渡す以外にも参照型のフィールドとの比較などにもよく使われます。
カスタムクレームを使う別解もあり
上記の方法では、検証のために exists
によるFirestoreドキュメントアクセスが発生します。カスタムクレームを使うとそれも不要( auth.token
の中身をチェックするだけでよくなる)となります。予めAdmin SDKでカスタムユーザークレームを設定しておく必要があるものの、こちらも良いやり方です。
本記事では説明割愛しますが、以下の動画・ドキュメント・解説記事を見るとよく分かるかと思います。
制限
最後にセキュリティルールの制限にも触れておきます。特に先ほど使った get
・ exists
関数の呼び出し数制限が厳しいことに注意です。またルールの処理の中で実際にFirestoreのクエリがなされるので課金対象になりクエリの処理時間も普通にかかることも一応意識しておくと良いです。
セキュリティルールはどのくらい細かく設定すべき?
ドキュメントDBの性質や上記制限などもあり、特にバリデーションはRDBで制約をガチガチに設定したものほど堅牢にするのは難しく、かつそれを追い求めるとルールもどんどん複雑になってしまうと感じています。
個人的には、次のような感覚でいます。
- 必須: 自分以外のセキュアなドキュメントの読み込みを禁止する
- 必須: 自分で更新するべきではないドキュメントの書き換えを禁止する
- 必須: チートをできないようにする(課金情報はクライアントから直接更新できないようにするなど)
- なるべくやる: 意図しないフィールド・データ種類が書き込まれないようにデータ内容を可能な範囲で検証する(悪意ある攻撃をされても最悪そのユーザーの手元で挙動がおかしくなる程度にとどめられるようにする)
今回定義したルールの最終形コードを貼っておきます:
参考資料
公式ドキュメント
Firestoreをプロダクション利用する上では、セキュリティルールに関する公式ドキュメントにすべて目を通すのは必須レベルだと思っています。
記事
詳しく書かれている良い記事です。基本的に公式ドキュメントだけで済むものの、こういう噛み砕いた記事も併読すると理解が捗るはずです。
解説動画
とても良い解説動画です。実際に操作する様子が見えるとイメージが沸きますね。
パート4はCloud FunctionsのFirestoreトリガーの自動テストを書いていきます🤖