Firebase
Firestore

Firestore rules tips

この記事は、当初Firebase.yebisu #2で発表しようと思っていた内容をまとめたものです。
(私がインフルエンザに罹ってしまい発表できなくなったため...)
なんとか当日に間に合わすぞと書いたので、多分また後日更新したりサンプルあげたりします、ご了承ください


皆さんはFirestore使ってますか?使ってる人はrulesも書けていますか?
今日は↓みたいな状態から脱却するための基礎知識やtipsをまとめていこうと思います。

ルール何書いたらいいの🤔
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}

基本は許可制

基本的にallow xxx: if yyyy; で条件を書かなければ、デフォルトでは全てfalseと解釈されます。なので、以下の例だと /fooに対するドキュメントの読み書きはOKですが、 /bar、`/baz に対するドキュメントの読み書きはNGです。
不用意にallowしなければセキュリティとしては高い状態を保てます。強い。

service cloud.firestore {
  match /databases/{database}/documents {
    match /foo/{fooID} {
      allow read, write: if true; // fooドキュメントだけ読み書きが許された世界
    }
  }
}

また、falseの時を丁寧に書く必要はないです。

ちなみに、

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

ここまではおまじないと思ってもらえれば大丈夫です。 /databases/{database}/documents で、FirestoreのDBの / (ルート) を指し示していると思ってもらえたら。

matchはネストして書ける

基本的にはmatch文は直列で長く書くよりはネストさせて書いたほうが絶対良いです。

  • 階層が把握しやすい
  • 各階層のドキュメントに付与するワイルドカード変数の使用、関数定義のときに都合が良い

のが挙げられます。

before
service cloud.firestore {
  match /databases/{database}/documents/user/{userID} {
    // ...
  }

  match /databases/{database}/documents/blog/{blogID} {
    // ...
  }

  match /databases/{database}/documents/blog/{blogID}/images/{imageID} {
    // ...
  }
}

:thinking:

after
service cloud.firestore {
  match /databases/{database}/documents {
    match /user/{userID} {
      // ...
    }

    match /blog/{blogID} {
      // ...

      match /images/{imageID} {
        // ...
      }
    }
  }
}

:tada:

allowできる動作

基本的にはread/writeで、それぞれ更に細かく分類されています。

  • read
    • get
    • list (queryでゲットするときとか)
  • write
    • create
    • update
    • delete

readはあまりget/listで分類して制御することは少なそう(listで、queryの制限かけることはありそう)だが、writeの場合はdeleteを含んでいるので、
特段deleteを許容しない場合は allow create, update: if xxx; としておくと良さそう。
また、それぞれの動作に別々の条件を付けることももちろん可能。

allow read: if request.auth != null;
allow create, update: if request.auth != null && request.resource.data.userID == request.auth.uid;
allow delete: if request.auth != null && resource.data.userID == request.auth.uid;

ワイルドカード

match /user/{userID}{userID}の部分の事を指します。変数名は何でも良いです。私はxxxIDで統一しています。ちょうど各documentのIDにあたるので。
そのワイルドカードは変数として使うことができ、ルールが実際に適応される時に、参照されるdocumentのIDが内部的に展開され用いられるようになります。
以下は簡単な例です。

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

基本はカスケードされない

基本的にはルールはサブコレクションに勝手に反映されない(カスケードされない)ので、別途自分でサブコレクションを掘ってルールを書くか、今のルールのままカスケードしてくれと明示的に書く必要があります。
カスケードにするには、match文のpathのワイルドカード変数のあとに =** を付与してあげます。

service cloud.firestore {
  match /databases/{database}/documents {
    match /user/{userID=**} {
      // ...
    }
  }
}

これで、/user/{userID}以下のサブコレクション全てに、/user/{userID}と同じルールが適応されます。
ちなみにカスケードを適応した場合、その中にmatch文をネストして書くことができなくなるので注意です。

この書き方はできなくなる
service cloud.firestore {
  match /databases/{database}/documents {
    match /user/{userID=**} {
      // ...
      match /item/{itemID} {
        // ... 
      }
    }
  }
}

「Glob(**) resource rules should not have child resource rules.」 というエラーが発生します。

allChildren=**

上述のカスケードでは、そのドキュメントと、そのドキュメント以下の全てのサブコレクションでルールを分けたい場合に困ることになります。
そういう時は以下のようにallChildren=**を用いると、ルールを分けることができます

service cloud.firestore {
  match /databases/{database}/documents {
    match /article/{articleID} {
      allow read: if wwww;
      allow write: if xxxx;
      match {allChildren=**} {
        allow read: if yyyy;
        allow write: if zzzz;
      }
    }
  }
}

これで、articleドキュメントに対するルールと、article以下の全てのサブコレクションに対する一律のルールとで分離することができます。

ルールは狭く深く、不用意に浅いところで許可しない

Firestoreで重要なルールとして、許可制であることと、もう一つはルールはなるべく狭く深いところで必要なときに許可してあげることです。
あるドキュメントに対して、複数のルールが別々に定義されていた場合は、それらのルールが and ではなく、 or で評価される点に注意してください。
例えば、 /user/{userID}に対して

  • allow write: if request.auth != null && request.auth.uid == userID
  • allow write: if true

という複数条件が与えられて(しまっていた)場合、前者で防げていたとしても、後者が成立してしまい、結果として書き込みが許可されてしまうので、ルールの意味を成さなくなります。

よく嵌まる例

各種モデルに一律でrequest.auth != nullであることをread, writeに付与しようと思って次のように書きます。

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function isUserAuthenticated(userID) {
      return request.auth.uid == userID;
    }

    // `/` 以下に isAuthenticated() を適応
    match /{allChildren=**} {
      allow read, write: if isAuthenticated();
    }

    match /user/{userID} {
      // userIDとauth.uidが一致するか且つ、何かしらの条件に一致する
      allow read, create, update: if isUserAuthenticated(userID) && someCondition();
    }
  }
}

実はこれは間違いで、これも/user/{userID}に対して、認証さえ通っているユーザーであれば自由に書き込みができてしまう状態になってしまいます。穴だらけ。。

正しくは以下のように面倒でも狭く深くするのが正解です。

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function isUserAuthenticated(userID) {
      return request.auth.uid == userID;
    }

    match /user/{userID} {
      allow read, create, update: if isAuthenticated() 
                                  && isUserAuthenticated(userID) 
                                  && someCondition;
    }
  }
}

関数定義を活用する

Realtime Databaseのルールとの大きな違いの1つとして、 自分で関数が定義できる点 が挙げられます。
定義の形としてはこんな感じです。引数、返戻値は自由に決められます。

function foobar(arg) {
}

また、定義箇所も自由で、状況に応じて配置することが出来ます。
自分のプロジェクトでは、以下のような関数を定義して、何度も何度も同じような処理を書くのを避けるようにしています。

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function isUserAuthenticated(userID) {
      return request.auth.uid == userID;
    }

    match /version/{v} {
      function getUser(userID) {
        return get(/databases/$(database)/documents/version/$(v)/user/$(userID));
      }

      match /user/{userID} {
        allow read: if isAuthenticated();
        allow write: if isUserAuthenticated(userID);
      }

      match /article/{articleID} {
        allow read: if true;
        allow write: if isUserAuthenticated(request.resource.data.userID) 
                     && getUser(request.resource.data.userID).data.isActive == true
      }
    }
  }
}

ちなみにgetUser関数だけ違う場所に書いているのは、get関数でpathを指定する時に、versionまでのワイルドカード変数が必要だからです。

事前に組み込まれている関数、変数を活用する

事前に準備されているものが多いので、実はルールを構成するのにそんなに困らなかったりします。
特に使うなってのをいくつか挙げておきます。

request, resource

恐らく最も使う変数。前者は動作実行時の状態を表していて、後者はその時のDBの状態を表しています。
なのでrequest.resourceresource では若干異なってきます。
writeの権限で、 request.resource.data.xxx == resource.data.xxx として値が変わっていないかの判定で書いたりもします。
尚delete実行の場合はrequest.resource.dataに値が入ってこないことがあるので、deleteの時にfield valueを参照したいときはresource.dataを見るのが良さそう。

request.auth

auth認証が通っている前提で組んでいくのであればほぼ使うと思います。また、user的なモデルのdocumentID==auth.uidとして作成するようにしていると色々やりやすいです。

get()exists()

事前に準備されている関数で、引数にpathを渡してあげると、前者は存在すればそのオブジェクトを返し、後者は存在しているかどうかをbool値で返してくれる

get(/databases/$(database)/documents/user/$(userID))

pathは/databaseから始める必要がある。
$(変数)で変数展開が行える。
返ってくるオブジェクトのfield valueを取りたい場合は、get(...).data.nameのように書く。

in

xxx in yyy のようにして、 xxxがyyy(のmap)に含まれているかみたいなのをチェックするのに使う

match /group/{groupID} {
  allow read: if request.auth.uid in request.resource.data.members
}

詳しくはここ見るとどんなのがあるのか分かります。

更新時に特定のkeyの値が変更されていないことを保証したい

例えばcreate時に入れた情報のうち、nameフィールドだけは、その後のupdateで書き換え不可にしたい場合なんかは以下のようにする。

match /user/{userID} {
  allow read: if ...;
  allow create: if ...;
  allow update: if request.auth.uid != userID && request.resource.data.name == resource.data.name;
}

これで、更新はユーザー本人かつ、nameフィールドは作成時のものから変更されていない時のみ許可となる。複数あれば && で繋げていく。

Field ValueがObjectの場合の、Object内の値を参照したい

FirestoreではField valueにObject(json)を持つことが出来ます

スクリーンショット 2018-02-20 19.05.23.png

こういうやつですね。

この、例で言うfoo,barにrule上でアクセスしたい場合ですが、以下のようにしてアクセスすることができます。

request.resource.data.info.foo
request.resource.data.info.foo

特に躓くポイントはなく。ただ、Objectでの == は(恐らく)うまく動かない。
ので中身までチェックしてあげるのが吉。

Field ValueがReferenceの場合のあれこれ

また、Firestoreは他のdocumentへの参照をfield valueとして持つことができます。

スクリーンショット 2018-02-20 19.09.40.png

こういうやつですね。

rule上では、path型のオブジェクトとして扱われるので、以下のような利用が可能です。

// path型オブジェクトなのでそのままget(),exists()に渡せる
get(request.resource.data.userReference)
exists(request.resource.data.userReference)
request.resource.data.userReference is path // true

また、特定のreferenceと、IDを渡してあげて、参照先が一致するか判定する関数もこんな感じで組めます。

function matchUserReference(ref, userID) {
  return ref != null && ref == get(/databases/$(database)/documents/version/$(v)/user/$(userID)).__name__;
}

__name__は事前に用意された変数で、オブジェクトのpathを返してくれます。

以下は雑多な例ですが、記事を作成、更新する時に、article.userReferenceをセットするのが必須で、
さらにその参照がrequest.auth.uidと一致するuserの参照と一致していたら許可、みたいな感じになっています。

match /article/{articleID} {
  allow read: if true;
  allow write: if matchUserReference(request.resource.data.userReference, request.auth.uid)
}

サンプルないの?

今週中にはなんとかあげたい気持ちです、もうしばらくおまちください... :pray:

ruleはgit管理+CLIでデプロイにする

毎回webのコンソールで書いて更新..だと非効率だし変更管理もできないので、firestore.rulesファイルをgit管理し、CLIでデプロイするようにします。

セットアップ

まだ手元でセットアップしていなければ

$ firebase init firestore

を行い、指示に従ってセットアップすると firestore.rulesが生成されます。(内容は直近のconsoleの内容が入ってくるはず)

デプロイ

ルールのみデプロイする場合は

$ firebase deploy --only firestore:rules

でいけます。

ルール書くのにおすすめのプラグイン

VSCodeを使っているならtoba/vsfireがおすすめです。
rulesを書く時に syntaxが効いてくれるので書きやすくなります。ある程度の予約語(?)ならauto-completionもかかります。

それ以外は、、、 :thinking:

まとめ

駆け足になりましたが以上になります。ぼちぼち更新します。
firebase.yebisu#2で発表できなかったのが悔しい...

参考