Firestore Security Rules の書き方と守るべき原則

公開:2020/09/23
更新:2020/09/30
21 min読了の目安(約19000字TECH技術記事
Likes29

概要と投稿の背景

本投稿では、下記の参考にした記事や動画を通じて私が学習した、Firestore Security Rules の書き方と守るべき原則についてまとめます。

まず、そのような学習に有益な情報をオープンに発信して下さっているディベロッパー・クリエイター、Google 公式の皆さんへのリスペクトと感謝を表明します。本当にありがとうございます。

私と同じ学習者・初学者の方の参考になればと思い、具体的なユースケースを想定しながら、実際のコードを含む形でまとめています。

参考にした記事や動画

本記事では、以下のような記事や動画を通じて学んだ内容をまとめています。

前提

本記事では、

  • ローカルマシンでの Firebase CLI の環境設定
  • 作業用ディレクトリ (記事内では、firebaseというディレクトリと、その直下に test というディレクトリがあることを想定しています)の作成

が済んでいることを前提としています。私の個人ブログには、ローカルマシンでの Firebase CLI の環境設定に関する説明も行っていますので、環境設定がまだの方は参考にして下さい。

また、Visual Studio Code の Firebase というプラグインがルールの記述に役に立つので、事前にインストールすることをおすすめします。

ルールの基本的な記述方法

firebase ディレクトリで、firebase init を実行し、Firestore Security Rules を記述する準備を済ませた段階で、下記のようなファイルが生成されていることを確認して下さい。

  • .firebasesec
  • .gitignore
  • firebase.json
  • firestore.indexes.json
  • firestore.rules

ルールは、firestore.rules というファイルに書いていきます。

firestore.rules には、(人によって少し異なる可能性もありますが)下記のような内容が記述されているでしょう。

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        allow read, write: false;
  }
}

冒頭の

rules_version = '2'

は、2019年5月以降に利用可能になった、Cloud Firestore セキュリティルールの新しいバージョンである趣旨の説明が「【公式】Cloud Firestore セキュリティ ルールを使ってみる」に記されています。必要なおまじないだと思って、そのまま記述しておきましょう。

ルール全体を取り囲む下記の内容も、必須のおまじないだと認識して問題ありません。match /databases/{database}/documents は、対象の Cloud Firestore のデータベースのルートを表しており、下記のコメントアウトの部分に、ルールを書いていくことになります。

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        // ここにルール(ホワイトリスト形式)を書いていく
  }
}

ルールは、read (get, list に分類される)と write (create, update, delete に分類される)のそれぞれのオペレーションをひとつひとつ条件付きで許可していくホワイトリスト形式で書いていきます。つまり、何も書かなければ何の読み書きオペレーションも行えない(が、安全である)ということで、アプリケーションに求められる読み書きオペレーションを許可するためのルールを、ユースケースを吟味しながら必要な分だけ書いていく(ルールを書く度にセキュリティに穴を開けることになるとも言える)ことになります。

ルールの基本的な書き方は、allow <許可する読み書きのオペレーション>: if <許可する条件>; であり、match /<対象のコレクション・ドキュメントへのパス> という方法で書く、「Firestore のどのコレクション、どのドキュメントに対する読み書きか」という情報と合わせて、次のようになります。

match /<some_path>/ {
    allow read, write: if <some_condition>;
}

したがって、すべての Firestore 内のすべてのデータに、すべての読み書きを許可するルールは下記の通りになります。

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /{document=**} {
            allow read, write: if true;
        }
    }
}

しかし、このようなルールは当然セキュリティ的に問題なので、絶対にこのような内容を本番環境に書いてはいけません。

というのは、{document=**} のワイルドカード表記によって、データベース全体の任意のドキュメントを対象に、allow read, write: if true; つまり、すべての読み書きを許可している、ということであるためです。

逆に、すべての読み書きを、いかなる条件でも許さないという記述は、

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /{document=**} {
        allow read, write: if false;
        }
    }
}

となります。

たとえば、データベース全体の任意のドキュメントに対し、ユーザーIDが null でない、つまりアプリケーションにサインインしている場合に読み書きを許可するというルールは、下記のような記述となります(これも安全な記述ではないので、学習の参考とするのは構いませんが、本番環境には採用すべきでありません)。

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /{document=**} {
            allow read, write: if request.auth.uid != null;
        }
    }
}

request は、本記事のルールの記述の中にも何度も現れますが、ルールの記述のどこからでもアクセスできる値です。request.auth とすれば、上記の例のようにユーザー情報に関する値が参照でき、request.resource.data とすれば、読み書きのオペレーションの際にクライアントから送られてくる値などが参照できるので、覚えておいて下さい。

ルールの記述に関する知識と守るべき原則

(投稿者である私も含め)初心者には難しく聞こえる注文のようですが、本番環境にリリースするようなアプリケーションにおいてその Security Rules は、アプリケーションに想定されるユースケースに対して、ホワイトリスト的に、適切な条件下でのみ、最低限の読み書き操作を許可する内容でなければなりません。上でも述べた通り、ルール(allow <読み書きのオペレーション>: if <許可する条件>;)を書くということは、セキュリティに穴をひとつずつ開けていくことに等しいと認識しておく必要があるということです。

たとえ、クライアント側で、適切な権限モデルやビジネスロジックが実装されていたとしても、意図していなかったユースケースや悪意のあるユーザーによって、意図していないリクエストをサーバに送れば、たとえば自分以外のユーザーの個人情報を含む users テーブルへアクセスできてしまったり、予期していない値が Firestore に登録されてバグが発生するなどの可能性もあります。このことを理解すると、Firestore Security Rules についてきちんと学んでおく必要性が実感できると思います。

また、ルールの記述に際して最低限守るべき原則として「allow write は、絶対に書いてはいけない」というのがあります。

Firestore Security Rules において、read, write のオペレーションは、それぞれ、次の読み書き操作を含んでいます。

  • read
    • get:単一のドキュメントの取得
    • list:クエリによるドキュメントの取得
  • write
    • create:ドキュメントを生成する
    • update:ドキュメントの一部のフィールドを更新する
    • delete:ドキュメントを削除する

allow write と書いて問題となるのは、createupdate のルールを delete と同一の条件で記述することになる点です。delete には、リクエストに含まれるデータを参照するための request.resource が設定されず、データの構造や内容を参照した上でのバリデーションを行うことができません。そのため、createupdate のオペレーションを、データの構造や内容に対する検証を一切行わずに許可する挙動を引き起こすこととなり、安全なセキュリティルールを確立できないことを意味してしまいます。createupdate すらも、ルールを区別することを怠って全く同一の条件で許可するケースはほぼないと、認識しておきましょう。

具体的なアプリケーションを想定したルールの記述

本記事では、より実践的な内容を含めるために、具体的なアプリケーションとそのユースケースの一部を想定して話を進めます。

今回は、家計簿アプリに必要な Security Rules を考えることとしましょう。この家計簿アプリは、ざっくりと、各ユーザーが自身のアカウントでログインして使用することを前提として、支出を記録していくアプリケーションです。そのデータベースの構造、つまり Firestore のコレクションやドキュメント、各ドキュメントが保持するフィールドは、次の通りとします。

  • root/
    • (collection) users/
      • (document) users/{user ID}/
        • (field) createdAt: timestamp
        • (field) email: string
        • (field) userId: {userId}, string
      • (collection) users/{user ID}/expenses/
        • (document) users/{user ID}/expenses/{expense ID}/
          • (field) content: string
          • (field) price: int
          • (field) createdAt: timestamp

少し補足で説明すると、

  • データベースのルートに、users というコレクションを定義し、その中に ユーザーID と同じ名前を使った users/{user ID}/ というパスでユーザードキュメントを定義しています。
  • users コレクションの下に、各ユーザーに支出データは属しているという意味合いで、expenses というコレクションを定義し、users/{user ID}/expenses/{expense ID}/ というパスで、支出データのドキュメントを定義しています。
  • createdAt, email, userId は、ユーザーデータとして保持するフィールドです。それぞれの右に併記した変数型を想定します。
  • content, price, createdAt は、それぞれ家計簿に登録される支出の内容、値段、データ作成日時であり、支出データとして保持するフィールドです。それぞれの右に併記した変数型を想定します。

という状況です。

説明のために、実際の状況よりは簡単な形をしていますが、実践的ないろいろなアプリケーションのデータ構造もこれに似た形になっているでしょう。

それでは、具体的なユースケースを考えながら、ルールを記述していきます。

例)自身のユーザーデータを参照するユースケース

まず、ユーザーがアプリケーション内のマイページのような画面で、自身のユーザーデータを参照するようなユースケースを想定したルールを記述することにしましょう。

そのような操作が許可されるユーザーが満たすべき条件は、

  1. Firebase Auth での何らかの認証(メールアドレスとパスワードによる認証や、各種 SNS などの外部サービスなどによる認証)が行われていること、つまり、ユーザーのアプリケーションへのログインが済んでいること。
  2. ユーザーデータをリクエストしたユーザーのユーザー ID が、要求しているユーザー情報のドキュメント ID に一致していること。

のようになるはずです。

特に、2点目を外してしまうと、予期しない使われ方をされたときに、他人のユーザー情報を取得しようとするリクエストが許可されてしまうことになってしまいます。

そのルールは次のように記述できます。

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /users/{userId} {
            allow get: request.auth != null // 条件 1 
            && userId == request.auth.uid; // 条件 2
        }
    }
}

このように、users コレクション下の任意のユーザードキュメントへのパスは、/users/{userId} のように表現でき、そこで記述するルール内で、userId という値を用いることができます。

match /users/{userId} {
    // ここにルールを記述する
    // userId(各ユーザードキュメントのドキュメント ID) の値が使用できる
}

read オペレーションが含む getlist は、write オペレーションと異なりそれほど神経質に区別する必要はありませんが、今回は、マイページから参照するユーザードキュメントは明らかに自分自身のひとつだけなので、get でルールを書いておきました。

また私自身、【YouTube】moga さんの YouTube 動画 から学んだことですが、このような汎用的なルールは、ルールの外側に function として切り出して定義しておいて、呼び出すようにすれば、すっきりと効率的にルールが書けるので役に立ちます。

そこで、上の1, 2の条件を、moga さんの解説にならって、それぞれ isAnyAuthentificated(「何らかの認証が済んでいる」という意味合い) と isUserAuthentificated(「ユーザー ID も含めて、適切な者であるという認可」という意味合い)という function 名で、次のように切り出して記述しておきました。

rules_version = '2';

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

        // 切り出した関数の記述
        function isAnyAuthentificated() { // 条件 1
            return request.auth != null;
        }
    
        function isUserAuthentificated(userId) { // 条件 1 かつ 条件 2
            return isAnyAuthentificated() && userId == request.auth.uid;
        }

        // ルールの記述
        match /users/{userId} {
            // ユーザー情報の取得のルール
            allow get: if isUserAuthentificated(userId); // 条件 1 かつ 条件 2
        }
    }
}

例)ユーザーの新規登録時の、ユーザーデータの作成のユースケース

次に、ユーザーの新規登録時の、ユーザーデータの作成のユースケースについて考えていきます。

本人以外にそのアカウントの作成を許可する訳にはいかないので、上で説明したユーザー情報の取得と同様の条件が必要となります。

加えて、今度はスキーマ検証と、入力データのバリデーションについても考える必要があるので説明します。

スキーマ検証とは、読み書きするデータの構造や種類に関する検証のことです。Firestore は、スキーマレスなデータベース(通常のデータベースで言う列などが定義されていない)のため、たとえば、意図していない余計なフィールドをもつドキュメントが作成されたり、意図したものと異なる型のデータが入力されてしまうような現象が発生し、不具合に繋がる可能性があるので、その検証が必要となります。

バリデーションとは、クライアントから送信されてきた値が、各フィールドに設定されるのに適切なものかどうかを判断するステップのことです。たとえば、支出の価格フィールドに、負の値や大きすぎる値が入力されるのはルールでも防いでおくべきです。

たとえクライアント側の UI で正しく入力フォームを設定し、クライアント側で入力フォームの内容に対してバリデーションを行ったつもりでも、予期していないリクエストがサーバ側に送られる可能性は否定できません。

そのため、スキーマ検証と値のバリデーションも、Firestore Security Rules でチェックすべき大切な要素です。

今回想定している家計簿アプリにおいて、ユーザーの新規登録時の、ユーザーデータの作成のユースケースとして考慮すべきポイントには、次のような内容があるでしょう。

  1. ユーザーから送信されるデータ(フィールド)の数はちょうど3つである。
  2. その3つのフィールドは、created At, email, userId の3つである。
  3. ユーザーデータとして送信されてくる値は、それぞれ意図した型と一致(順に、timestamp, string, string)している。
  4. userId として送信されてくる値は、その値を書き込むユーザードキュメントの ID に一致している。

冗長なところや突っ込みどころのある箇所も含まれるかもしれません。厳密にしようとすれば、たとえば、

  • createdAt として送信されてくる値は、そのサーバタイムスタンプの値と一致している。
  • email として送信されてくる値は、メールアドレスとして適切な形式である(正規表現を用いてバリデーションする)。
  • string 型として送信されてくる値は、文字数が所定の長さ以下である(不適切に長い文字列は拒否する)。

のような条件は、容易に思いつきますが、今回は簡単のために上記の4点でルールを組みます。例によって、コードの読みやすさとメンテナンサビリティを考慮して、新たに、isValidUserCreate という function を定義することにして、次のように書きました。

rules_version = '2';

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

        function isAnyAuthentificated() {
            return request.auth != null;
        }
    
        function isUserAuthentificated(userId) {
            return isAnyAuthentificated() && userId == request.auth.uid;
        }

        // 新たに追加した関数
        function isValidUserCreate(user, targetUserId) {
            return user.size() == 3
            && 'createdAt' in user && user.createdAt is timestamp
            && 'email' in user && user.email is string
            && 'userId' in user && user.userId is string && user.userId == targetUserId;
        }

        // ルールの記述
        match /users/{userId} {

            // ユーザー情報の取得のルール
            allow get: if isUserAuthentificated(userId);

            // ユーザー情報の作成のルール
            allow create: if isUserAuthentificated(userId)
            && isValidUserCreate(request.resource.data, userId);
        }
    }
}

解説を加えると、

条件 1:ユーザーから送信されるデータ(フィールド)の数がはちょうど3つである。

return user.size() == 3;

条件 2:その3つのフィールドは、created At, email, userId の3つである(つまり、条件 1 と合わせて、request.resource.data に3つのフィールドが存在することが言えれば同値な条件である)。

'createdAt' in user && 'email' in user && 'userId' in user;

条件 3:ユーザーデータとして送信されてくる値は、それぞれ意図した型と一致(順に、timestamp, string, string)している。

user.createdAt is timestamp && user.email is string && user.userId is string;

条件 4:userId として送信されてくる値は、その値を書き込むユーザードキュメントの ID に一致している。

user.userId == targetUserId;

ということです。

users コレクションに関しては、他にも、

  • マイページから、登録しているメールアドレスを変更する
  • アプリケーションの利用終了時に、アカウントを破棄する

のようなユースケースで、updatedelete に関するオペレーションも考えられますが、今回は省略して、expenses コレクションに話題を移します。

例)支出データの取得・追加・更新・削除のユースケース

expense コレクションは、users コレクションの各ユーザーデータの配下に保存されるので、その階層構造を反映させて、expenses に関するルールは、次の位置に記述していけば良いことになります。

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /users/{userId} {
            match /expenses/{expenseId} {
                // expenses コレクションのルールはここに記述する
            }
        }
    }
}

想定している家計簿アプリの支出データの取得・追加・更新・削除には、大雑把に、次のようなユースケースが考えられるでしょう。

  • 各ユーザーが自身の支出を、その内容 (content)、値段 (price) とともに入力し(またその際、入力日時 (createdAt) も一緒に自動で登録される)、データベースにドキュメントを追加できる。
  • アプリケーションのメイン画面で、入力された支出一覧が表示(取得)できる。
  • アプリケーションのメイン画面で確認できる支出一覧から、特定の支出を選択すると、その支出の登録内容(3つのフィールドの値)が確認でき、内容 (content)や値段 (price)を編集して更新することができる。
  • 誤って登録してしまった支出が存在すれば、それを削除することができる。

当然、他のユーザーの支出を参照したり、編集・削除することはできない想定なので、下記では逐一言及せずに話を進めます。

まず、自身の支出データの読み取りについては、ここでは getlist を神経質に区別せずに、

  • 参照しようとしている支出データの持ち主は本人である。

というのが、許可する条件と言って良いでしょう。よって、ルールは次のように記述できます。

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /users/{userId} {
            match /expenses/{expenseId} {
                // 支出データの読み取りのルール
                allow read: if isUserAuthentificated(userId);
            }
        }
    }
}

次に、支出の登録(追加)の create オペレーションについて、満たすべき条件を、

  • 追加する支出ドキュメントは、users コレクションの本人のユーザードキュメント配下である。
  • 支出の内容 (content)、値段 (price)、登録日時 (createdAt) の3つのフィールドを正しく受け取っている。
  • 各フィールドの型は、それぞれ、string, int, timestamp である。
  • 支出内容の文字列は0文字以上100文字以内、支出の値段は0円以上100万円以下である。
  • createdAt のタイムスタンプの値が、サーバ側でリクエストを受け取った日時と一致している。

としておきます。すると、ルールは次のように記述できます(isValidExpenseAdd という function を新たに定義しています)。

rules_version = '2';

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

            // 切り出した関数の記述
            function isValidExpenseAdd(expense) {
                return expense.size() == 3
                && 'content' in expense && expense.content is string && expense.content.size() > 0 && expense.content.size() < 100
                && 'price' in expense && expense.price is int && (expense.price >= 0 && expense.price <=1000000)
                && 'createdAt' in expense && expense.createdAt is timestamp && expense.createdAt == request.time;
            }

            match /expenses/{expenseId} {
                // 支出データの作成のルール
                allow create: if isUserAuthentificated(userId) && isValidExpenseAdd(request.source.data);
            }
        }
    }
}

支出の編集(更新)の update オペレーションについては、create オペレーションと類似のルールとなることはすぐに分かりますが、必ずしもすべてのフィールドが更新されるわけではありません。しかし、request.resource には、最終的に Firestore に保存されるすべてのフィールドが設定されることに注意しましょう(よって expense.size() == 3 の記述はかわらず必要です)。次のような条件を考えれば良いでしょう。

  • create オペレーションとほぼ同一のルールを適用する。
  • createdAt は更新させない想定なので、createdAt はもともと Firestore に保存されていた値と、update オペレーションによって送られてきた値が一致している。

という条件でルールを記述することにします(isValidExpenseUpdate という function を新たに定義しています)。

また、ここで特筆すべきは、resource 変数の使い方です。createdAt は更新させない想定なので、もともと Firestore に保存されていた値と、update オペレーションによって送られてきた値が一致しているかどうかを検証する必要があります。resource 変数は、セキュリティルールの中で利用可能なグローバル変数で、request がアクセスしているパスにもともと存在している値を参照することができます。つまり、request.resource.data.createdAt == resource.data.createdAt のような記述によって、その検証ができるということです。

rules_version = '2';

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

            // 切り出した関数の記述
            function isValidExpenseUpdate(expense) {
                return expense.size() == 3
                && 'content' in expense && expense.content is string && expense.content.size() > 0 && expense.content.size() < 100
                && 'price' in expense && expense.price is int && (expense.price >= 0 && expense.price <=1000000)
                && 'createdAt' in expense && expense.createdAt is timestamp
                && expense.createdAt == resource.data.createdAt; // resource 変数との比較
            }

            match /expenses/{expenseId} {
                // 支出データの更新のルール
                allow update: if isUserAuthentificated(userId) && isValidExpenseUpdate(request.resource.data);
            }
        }
    }
}

最後に、delete オペレーションについては、

  • 支出ドキュメントを削除できるのは、そのドキュメントの所有者本人のみである

ことが条件なので、シンプルに次のようになります。

rules_version = '2';

service cloud.firestore {
    match /databases/{database}/documents {
        match /users/{userId} {
            match /expenses/{expenseId} {
                // 支出データの削除のルール
                allow delete: if isUserAuthentificated(userId);
            }
        }
    }
}

まとめ

以上、ここまでに記述したすべてのルールを整理すると次の通りとなります。

rules_version = '2';

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

        // 切り出した関数の記述
        function isAnyAuthentificated() {
            return request.auth != null;
        }
    
        function isUserAuthentificated(userId) {
            return isAnyAuthentificated() && userId == request.auth.uid;
        }

        function isValidUserCreate(user, targetUserId) {
            return user.size() == 3
            && 'createdAt' in user && user.createdAt is timestamp
            && 'email' in user && user.email is string
            && 'userId' in user && user.userId is string && user.userId == targetUserId;
        }

        function isValidExpenseAdd(expense, targetUserId) {
            return expense.size() == 3
            && 'content' in expense && expense.content is string && expense.content.size() > 0 && expense.content.size() < 100
            && 'price' in expense && expense.price is int && (expense.price >= 0 && expense.price <=1000000)
            && 'createdAt' in expense && expense.createdAt is timestamp && expense.createdAt == request.time;
        }

        function isValidExpenseUpdate(expense) {
                return expense.size() == 3
                && 'content' in expense && expense.content is string && expense.content.size() > 0 && expense.content.size() < 100
                && 'price' in expense && expense.price is int && (expense.price >= 0 && expense.price <=1000000)
                && 'createdAt' in expense && expense.createdAt is timestamp
                && expense.createdAt == resource.data.createdAt;
        }

        match /users/{userId} {

            // ユーザー情報の取得のルール
            allow get: if isUserAuthentificated(userId);

            // ユーザー情報の作成のルール
            allow create: if isUserAuthentificated(userId)
            && isValidUserCreate(request.resource.data, userId);

            match /expenses/{expenseId} {

                // 支出データの読み取りのルール
                allow read: if isUserAuthentificated(userId);

                // 支出データの作成のルール
                allow create: if isUserAuthentificated(userId)
                && isValidExpenseAdd(request.source.data, userId);

                // 支出データの更新のルール
                allow update: if isUserAuthentificated(userId)
                && isValidExpenseUpdate(request.resource.data);

                // 支出データの削除のルール
                allow delete: if isUserAuthentificated(userId);

            }
        }
    }
}

最後に

「Firestore Security Rules の書き方と守るべき原則」と題した記事内容は以上です。

不明点や誤りを見つけた際にはお知らせ下さいますと幸いです。

  • ローカルマシンでの Firebase CLI の環境設定
  • Firestore Security Rules の ユニットテスト

などについても日を改めて投稿してみたいと思っているのでよろしくお願いします。