Cloud Firestoreのセキュリティルールの設計と書き方
はじめに、これは2020年9月現在の情報を元にした記事であり時間の経過とともに陳腐化する可能性を考慮してください。
Cloud Firestore(以下、Firestore)は20万DAUの規模で使ってもインフラのスケールアップを開発者がやる必要もなく月5万円以下で済むくらいとてもいいデータベースです。
クライアントから直接読み書きができるのでデータベースとやりとりするためだけの薄いAPIサーバーを用意する必要がない代わりに、ユーザーがどういうデータを読んだり書き込んだりしていいのかをセキュリティルールで宣言的に記述する必要があります。
ここではセキュリティルールを可読性とメンテナンス性を考慮した設計と書き方を紹介します。
Firestoreのセキュリティルールの基本
設計について紹介する前にまずセキュリティルールの読み方について簡単に紹介します。
次のセキュリティルールをもとに説明します。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /cities/{city} {
allow read: if <condition>;
allow write: if <condition>;
}
}
}
1行目の rules_version = '2';
はワイルドカードの再帰判定の扱いや、サブコレクションなどの新しいFirestoreのQueryに対応するためのルールが使えるバージョンを宣言しています。バージョン1とほぼ互換性があるので、サブコレクションを使わなくてもとりあえずつけておいてよいでしょう。
2行目の service cloud.firestore
はこれがFirestoreのセキュリティルールであることを宣言しています。これがCloud Storage for Firebaseであれば service firebase.storage
となります。
次に3行目の match /databases/{database}/documents
ではどのデータベースかを特定するために書きます。ここまではだいたい共通で、このあとからが各プロジェクトごとに変わってきます。
5行目の match /cities/{city}
の cities
はコレクション名で {city}
はドキュメントIDです。match
配下でこのドキュメントIDを条件式に利用できます。
6行目の allow read: if <condition>;
は /cities/{city}
をどういう条件のときに読み取ってよいかを記述します。条件は <condition>
に記述し、条件の結果が true
になるとき読み取りが可能になります。6行目はallow write
なので条件が true
になると書き込みが許可されます。
次のセキュリティルールはFirebase Authenticationで認証が通ったユーザーなら誰でも読み書きできるというルールになります。
※ このセキュリティルールは危険なので本番では利用しないように
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /cities/{city} {
allow read: if request.auth != null;
allow write: if request.auth != null;
}
}
}
そして read
は get
と list
に分割することができ。
write
は create
、 update
そして delete
に分割できます。
service cloud.firestore {
match /databases/{database}/documents {
match /cities/{city} {
allow get: if <condition>;
allow list: if <condition>;
}
// A write rule can be divided into create, update, and delete rules
match /cities/{city} {
allow create: if <condition>;
allow update: if <condition>;
allow delete: if <condition>;
}
}
}
設計の原則
アプリを保護するとき考えることが3つあります。
- Authorization
- Schema Validation
- Business Logic
Autorizationはユーザーのアイデンティティを検証。
Schema Validationはデータベースに勝手なフィールドやデータを追加しないことを確実にするためにします。
そしてBisuness Loginはデータを読み込んでいいのは誰なのか、書き換えていいのは誰なのか?お金を払ったユーザーだけなのか?などアプリのビジネス要件に合わせた設計をする必要があります。
データベースの読み取りであれば「Authorization」「Business Logic」の2つを、書き込みであればこれら3つを検証することでアプリを保護することができます。
Authorization
まずはAuzhorizationについての考え方ですが、あるデータにアクセスするときのユーザーの認証で、アクセスしてきたユーザーのアイデンティティを検証します。
次の例は認証なしにデータにアクセスさせたい場合このようなセキュリティルールになります。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userID} {
allow read: if true; // 認証なしでアクセス可能
}
}
}
もしくはユーザー登録をしているユーザーなら誰でも見れるとする場合は、 request.auth
が null
でないことを確認するだけで十分です。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userID} {
allow read: if request.auth != null; // 認証なしでアクセス可能
}
}
}
読み取りをデータの持ち主であるユーザー本人限定にしたい場合は、次のようにリクエストしてきたユーザーのIDとドキュメントIDである userID
を検証することで実現できます。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userID} {
// リクエストしてきたユーザーとIDが一致するか
allow read: if request.auth.uid == userID;
}
}
}
条件をmatchの深いネストに書いていくのも最初のうちはいいですが、セキュリティルールには関数のように条件に名前をつけて使い回すことができるので活用しましょう。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isMe(userID) {
return userID == request.auth.uid;
}
match /users/{userID} {
// リクエストしてきたユーザーとIDが一致するか
allow read: if isMe(userID);
}
}
}
権限はアプリが成長していくごとに複雑になっていきやすいですがシンプルに保つことを心がけましょう。シンプルに保つコツはデータ構造をシンプルに保つことです。
Schema Validation
FirestoreはいわゆるNoSQLのカテゴリーに入る、スキーマーレスなサービスですがセキュリティルールを使うことでスキーマを作ることができます。
そして、Schema ValidationはNoSQLになれてしまっている人だと忘れがちですが、とても大切なのでぜひ覚えておきましょう。
次のようなデータ構造をもとに説明していきます。
user:
name: string
email: string
age: number
created: timestamp
よくあるうっかりパターンから紹介します。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isUserData(data) {
return data.name is string
&& (data.email is string && data.email.matches('^[A-Za-z0-9.$+%]{3,}[@]{1}[A-Za-z0-9]{3,}[.]{1}[A-Za-z0-9]{3,}$'))
&& data.age is number
&& data.created is timestamp;
}
match /users/{userID} {
allow write: if isUserData(request.resource.data);
}
}
}
下の request.resource.data
にリクエストのこれから書き込もうとするデータが含まれます。
そしてスキーマを検証に関する関心事を function isUserData(data)
にまとめています。
スキーマの検証はこの関数の中に書いていきます。
一見、データに必要なフィールドが含まれているか型の検証をしていますしemailに関してはEmailとして有効化どうかの正規表現で検証もしていてよさそうですが実は足りないことがあります。それは、想定外のフィールドがあっても書き込みを許可してしまう状態になっていることです。
次のようなデータの場合でもさきほどのセキュリティルールでは書き込みに成功してしまいます。
{
"other": "data" // 余計なデータ
"name": "k2wanko",
"email": "k2.wanko@gmail.com",
"age": 26,
"created": "2020-09-24T15:00:00.000Z",
}
other
という謎のフィールドが存在するところがポイントです。通常見過ごしていても直ちに問題になりにくいので見過ごすとあとで何だこのデータは?ってなり困るので気をつけましょう。クライアントを開発する人とセキュリティルールを書く人が別れているような開発体制のときによく起きやすい問題です。見過ごしてしまうとあとでフィールドを追加しようとしたときにすでに想定外のフィールドがあり容易にフィールドの追加ができなくなったり、実はアプリで使われているフィールドなんだけどスキーマを変えるときに考慮漏れでデータ移行に失敗したりアプリをグロースしていく段階で問題が顕になってきます。
ではどのように想定外のフィールドを追加させないようにするかというと List.hasOnly(list)
を使うことで実現できます。
さきほど、
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isUserData(data) {
return data.keys().hasOnly(['name', 'email', 'age', 'created'])
&& data.name is string
&& (data.email is string && data.email.matches('^[A-Za-z0-9.$+%]{3,}[@]{1}[A-Za-z0-9]{3,}[.]{1}[A-Za-z0-9]{3,}$'))
&& data.age is number
&& data.created is timestamp;
}
match /users/{userID} {
allow write: if isUserData(request.resource.data);
}
}
}
request.resource.data
は Map型なので kyes()
でフィールドの一覧をList型で取得できます。そして、 hasOnly()
の第一引数にList型で想定されているフィールドの一覧を渡します。これで不要なフィールドが追加されることはありません。
ただこれが大規模になってきたら、管理が大変になるのは目に見えているので何かしら型定義がしやすい言語のASTから自動生成するなどやったほうがいいかなと思っています。
またFirebase Admin SDK経由だと、セキュリティルールを無視して書き込みができてしまうのでサーバー側で謎データを入れ込まないようにする仕組みも必要です。
Business Logic
ビジネスロジックはアプリの種類によって千差万別ではありますが、よくあるサブスクリプションを購入することでプレミアムユーザーになれますというパターンを用いて説明していきます。
プレミアムユーザーの課金の検証はサーバーサイドでやり、正しい購入であると検証できたらカスタムトークンを発行してカスタム認証を通しましょう。
そこでまずはカスタム認証に含めるトークンの設計から始めるのがよいと思います。
よくあるパターンとしては次のようなものがあります。
{
"isPremium": true
}
そしてカスタムトークンで設定したClaimsは request.auth.token
にセットされるので、セキュリティルールでは次のように使うことができます。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isPremium() {
let token = request.auth.token;
return token != null
&& token.isPremium;
}
match /users/{userID} {
allow write: if isPremium();
}
}
}
このようなbooleanで表現するパターンだと、サブスクリプションのプランが増えたときに isXXX
がどんどん増えていきます。
また、次のようなパターンもあります。
{
"subscriptionPlan": "premium"
}
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isPremium() {
let token = request.auth.token;
return token != null
&& token.subscriptionPlan == 'premium';
}
match /users/{userID} {
allow write: if isPremium();
}
}
}
このような文字列で表現する方法もあります。
前者は機能がプランごとに違う場合にルールを書きやすいですが、プランがたくさんある場合フィールドがどんどん増えて複雑になっていく懸念があります。後者は、上位プランが下位プランを包括するときにセキュリティルールをシンプルに保ちやすいです。どちらがいいかはケースバイケースですが 読みやすさ優先で判断するとよいでしょう。
そして、セキュリティルールで表現が無理なくらい複雑なロジックであればサーバーサイドでやることも視野にいれるとよいでしょう。例えばターン制の将棋のようなゲームをセキュリティルールで表現するのはかなりしんどいと思うのでそういう場合は無理せずサーバーサイドでの処理を選択してセキュリティルールを可読できる状態を常に保てるとよいです。
まとめ
以上のAuhotization、Schema Validation、Business Logicをまとめたものがつぎになります。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isMe(userID) {
return userID == request.auth.uid;
}
function isUserData(data) {
return data.keys().hasOnly(['name', 'email', 'age', 'created'])
&& data.name is string
&& (data.email is string && data.email.matches('^[A-Za-z0-9.$+%]{3,}[@]{1}[A-Za-z0-9]{3,}[.]{1}[A-Za-z0-9]{3,}$'))
&& data.age is number
&& data.created is timestamp;
}
function isPremium() {
let token = request.auth.token;
return token != null
&& token.subscriptionPlan == 'premium';
}
match /users/{userID} {
allow read: if isMe(userID) && isPremium();
allow write: if isMe(userID)
&& isUserData(request.resource.data)
&& isPremium();
}
}
}
セキュリティルールを書くときは常に、Auhorization、Schema Validation、Business Logicの3要素を満たしているかを意識しながら書くとよいです。実際はただ書くだけじゃなくてセキュリティルールのテストも書くことで安心してセキュリティルールを更新していけます。
ただ今回紹介した内容は執筆時最新の情報をもとにしているので、常に最新のドキュメントを定期的に見るようにしとくといいでしょう。
Discussion