Help us understand the problem. What is going on with this article?

個人開発でFirestoreのセキュリティルールを頑張って書いてみたので少し公開する

はじめに

Firestoreを利用されている皆さま、Firestoreのセキュリティルールはどのように書かれているでしょうか。
Firestore使い始めの方は、具体的にどうルールを書けばいいかが分かりづらくないでしょうか。
今回は、私が作ったサービスで具体的にどう書いたかについて一部だけ公開させてもらいます。

なお、私はFirestore歴は長くないため内容はFirestoreに慣れていない個人開発者向けです。
誤っていることがありましたらお手数ながらコメントなどいただけると嬉しいです。

概要

先日、「iken」という、クリエイターが作品の率直な意見をもらい改善するためのWebサービスをリリースしました。
開発全般については別途記事を書かせてもらいたいと思いますが、Firebase大盛り(Firestore、Hosting、
Authentication、Functions、Storage)で作っています。
今回は、このWebサービスで利用したセキュリティルールから一部を抜粋して説明します。
一部の条件とフィールド名等は名前が適当で恥ずか説明しやすくするために変更しています。

ユーザー登録部

前提条件
・Firebase AuthenticationにてTwitterログインのみを実装している
・メインのIDとしてTwitterのscreen_name、つまり@で始まるやつを利用した。私なら@akahori_s
 URLに@以下を使いたかったからscreen_nameを使用しただけで、Twitterの数字のみの固有IDを使用しても問題ない。
 Twitter側でscreen_nameが変更されてもシステム側では変更しない。
 同一のscreen_nameで登録が試みられた場合プログラムでランダム数値を付与する仕様にしてある。
 なお、Authのuidを使わない理由は、匿名でレビューをする仕組みがあるため、他の人のuidを一切見られなくするため。
 例えばドキュメントIDとしてuidを使うと、それ以下の階層のread権限でpathからuidを知れてしまう。
 他の人のuidを一切見られなければ、仮に一箇所の情報が漏れても個人が特定できない。

使う関数

//文字列の型チェックと、最小、最大チェック
function validateString(text, min, max) {
    return text is string && min <= text.size() && text.size() <= max;
}

//createとupdate時に、登録データとしてプログラム側から送られた値を返す。
function incomingData() {
    return request.resource.data;
}

//updateとdelete時に、既にfirestoreにあり、更新もしくは削除されようとしているデータを返す。
function existingData() {
    return resource.data;
}

//ユーザーIDがTwitterのscreen_nameと等しいかどうか確認する。
//auth.token.twitter_screen_nameはユーザ登録時にFunctionsのonCreate関数で
//カスタムクレームとしてTwitterのscreen_nameを設定している。
function isOwnerUser(user_id) {
    return user_id == request.auth.token.twitter_screen_name;
}

ルール

match /users/{user_id} {
        //自分のユーザーデータのみ参照できる。(機密データが入ってないドキュメントのため、
        //ユーザー毎の情報ページ作成後は誰でもOKに変更予定)
        //関数欄参照。ユーザー自身か確認する処理。
        allow read: if isOwnerUser(user_id);

        //新規ユーザーのログイン(登録)時
        allow create:
                //サイズ指定して意図しないフィールドが作られるのを防ぐ
           if incomingData().size() == 7 

                //ドキュメントIDとして指定された{user_id}とuser_idフィールドの値が等しい
                && incomingData().user_id == user_id

          //user_idフィールドの文字数が1~15文字、上限はきっちり調べなくてもTwitter等の上限以上にしておけばOK
                && validateString(incomingData().user_id, 1, 15) 

                //user_name(Twitterの表示名)が1~50文字
                && validateString(incomingData().user_name, 1, 50)

                //user_nameフィールドの値が、Authのトークンのnameと等しい。Twitterログインの場合は
                //nameはTwitterの表示名。
                //https://firebase.google.com/docs/rules/rules-and-auth?hl=ja このあたり参照。
                && incomingData().user_name == request.auth.token.name

                //Twitterのユーザー説明user_descriptionの文字数が0から200文字。
                && validateString(incomingData().user_description, 0, 200)

                //request.auth.token.firebase.identities["twitter.com"][0]は
                //Twitterの固有ID(ユーザーID)で、例えばgoogleなら"google.com"。
                //Twitterログインのみを実装している。
                && incomingData().twitter_id == request.auth.token.firebase.identities["twitter.com"][0]

                //Twitterのプロフィール画像のURL、profile_urlが0から200文字以内。
                && validateString(incomingData().profile_url, 0, 200)

                //作成時刻、更新時刻。更新時でソートする場合があるので登録時に両方入れておくと便利。
                //プログラム側でfirebase.firestore.FieldValue.serverTimestamp()入れてないとだめ
                && incomingData().createdAt == request.time
                && incomingData().updatedAt == request.time

                //securityサブコレクションには秘匿するユーザーデータを、書き込みバッチで同時に入れている。
                //同時に作られたことを確認するための処理。
                //1.securityサブコレには、書き込み前には、指定したuser_idのドキュメントが存在しないか。
                && !exists(/databases/$(database)/documents/users/$(user_id)/security/$(user_id))
           //2.securityサブコレには、書き込み後には、指定したuser_idのドキュメントが存在する予定か。
                && existsAfter(/databases/$(database)/documents/users/$(user_id)/security/$(user_id));

        //アカウントページでユーザーデータを手動で編集の場合の更新と、
        //明示的な再ログイン時に、表示名、プロフィール画像、説明を自動更新するためのルール。
        allow update: 
                //自分のユーザーデータのみ参照できる。
          if isOwnerUseruser_id

                //サイズ指定して意図しないフィールドが作られるのを防ぐ
                && incomingData().size() == 7

                //変更しない項目は、incomingData()、つまり新しいデータとexistingData()、つまり古いデータが等しいことを条件にする
                && incomingData().user_id == existingData().user_id
                && incomingData().twitter_id == existingData().twitter_id
                && incomingData().createdAt == existingData().createdAt

                //変更する項目は通常の制限を書く。ただし他のドキュメントの項目と連動する場合は
                //!get()、getAfterなどで縛る必要がある。
                && validateString(incomingData().user_name, 1, 50)
                && validateString(incomingData().user_description, 0, 200)
                && validateString(incomingData().profile_url, 0, 200)

                //更新日時は更新されていることを確認する。
                && incomingData().updatedAt == request.time;

      //ユーザー情報の中で他の人に見せられないデータを入れる
      match /security/{security_user_id} {

        //ユーザー登録時に情報を保存する、サーバー側での操作以外では読み取り等はしない。
        allow create: 

                //親コレクションのドキュメントID{user_id}と、このサブコレクションの
                //ドキュメントID{security_user_id}が等しい。
                if user_id == security_user_id

                && (一部ルール省略)

                //現状更新することはないが、いつ必要になるかわからないので更新日時フィールドも作っておく。
                && incomingData().createdAt == request.time
                && incomingData().updatedAt == request.time

                //書き込みバッチで、usersコレクションのデータと同時に入れている。
                //同時に作られたことを確認するための処理が必要。上の縛りと対応する縛り。
                //1.usersコレクションにsecurity_user_id(=user_id)と等しいドキュメントIDの
                //ユーザーデータが登録されていないか。
                && !exists(/databases/$(database)/documents/users/$(security_user_id))
                //2.usersコレクションにsecurity_user_id(=user_id)と等しいドキュメントIDの
                //ユーザーデータが登録される予定か。
                && existsAfter(/databases/$(database)/documents/users/$(security_user_id));

      }
}

作品の登録部

使う関数

//文字列の型チェックと、最小、最大チェック
function validateString(text, min, max) {
    return text is string && min <= text.size() && text.size() <= max;
}

//createとupdate時に、登録データとしてプログラム側から送られた値を返す。
function incomingData() {
    return request.resource.data;
}

//updateとdelete時に、既にfirestoreにあり、更新もしくは削除されようとしているデータを返す。
function existingData() {
    return resource.data;
}

//ユーザーIDがTwitterのscreen_nameと等しいかどうか確認する。
//auth.token.twitter_screen_nameはユーザ登録時にFunctionsのonCreate関数で
//カスタムクレームとしてTwitterのscreen_nameを設定している。
function isOwnerUser(user_id) {
    return user_id == request.auth.token.twitter_screen_name;
}

ルール

match /works/{work_id} {
        //公開データのため全開放する。
        allow read: if true;

        //作品を新規登録する場合
        allow create: 
          //作品のコレクションworksはusersコレクションのサブコレのため、作品の管理者であることを確認する。
                 //user_idはusersコレクションのドキュメントID 「match /users/{user_id} {」 このuser_id
          if isOwnerUser(user_id)

                 //入ってきたデータのフィールド数を指定する
                 && incomingData().size() == 13

                 //ドキュメントIDとして指定しているデータもフィールドとして保存してあると便利
                 && incomingData().user_id == user_id
                 && incomingData().work_id == work_id

                 //作品グループ。in [a,b,c] でa,b,cのいずれかであることを確認できる。
                 && incomingData().group in ["web", "app", "game"]

                 //おなじみの文字列の文字数制限。
                 && validateString(incomingData().title, 1, 100)
                 && validateString(incomingData().url, 0, 500)
                 && validateString(incomingData().url2, 0, 500)
                 && validateString(incomingData().description, 1, 1000)

           //画像を登録しているか。
                 && incomingData().is_image_uploaded in [true, false]

                 //公開作品か、非公開作品か。公開作品だとトップページに表示される。
                 && incomingData().is_public_work in [true, false]

                 //募集中か、募集終了しているか。登録直後は募集中になるためtrue。
                 && incomingData().is_open == true

                 //いいね数。最初は0
                 && incomingData().likes_count == 0

                 //いつもの時刻登録
                 && incomingData().createdAt == request.time
                 && incomingData().updatedAt == request.time

                 //作品名等の作品情報(works)と、質問内容等の質問情報(versions)はコレクションを分けている。
                 //作品の登録時は、質問項目も同時に作られる。
                 //1.質問情報(versions)がまだ作られていないことを確認する
                 && !exists(/databases/$(database)/documents/users/$(user_id)/works/$(work_id)/versions/1)
                 //2.After、つまり登録を先読みすると質問項目が作られることを確認する。
                 //質問情報(versions)のルールでも対応する縛りを入れるが、今回は非掲載
                 && existsAfter(/databases/$(database)/documents/users/$(user_id)/works/$(work_id)/versions/1)

        //作品単体でも編集可能で、単体更新するとき。
        allow update: 

        //いいね数のみの更新時
        allow update: 
}

作品の表示部(コレクショングループ)

//作品はusersコレクションのサブコレクションとして散らばっているためcollectionGroupで検索する。
//rules_version = "2"; のおまじないを忘れるとたぶんエラー
match /{path=**}/works/{work_id} {
  //公開作品のみ読み取りを許可する。
  //読み取るプログラム側でも where("is_public_work", "==", true) を忘れると検索されないので注意。
  allow read: if resource.data.is_public_work == true;
}

最後に

一部だけ公開させてもらいましたがどうでしょうか。
これやばくない?というのがあったら教えてください。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away