Firebase
Firestore
CloudFirestore

Cloud FirestoreのセキュリティルールでgetAfter関数が使えるようになったので使ってみた

最近、Firestoreのruleを書く時に使える関数に、getAfter()なるものが追加されたので早速使ってみました、という内容。

getAter()、何者 :thinking:

リファレンスには次のように書いてあります

Get the projected contents of a document. The document is returned as if the current request had succeeded. Useful for validating documents that are part of a batched write or transaction.

(完全に意訳ですが、) 指定したpathのドキュメントを、 現在の書き込みリクエストが成功したという体 で返却します。トランザクションやバッジ処理による一括書き込み時のruleの検証に役に立ちます。

ふむふむ。。
通常は、 get()exists() 関数でドキュメントを取得したり、存在を確認したりするのですが、一括書き込みやトランザクションで同時に書き込もうとしている別のオブジェクトのpathを指定しても、リクエストがruleを通過するなので、これらの関数で取得したり存在確認ができません。
そこで、このgetAfter()関数を使うことで、同時に書き込んでいるときでも、あたかも指定したドキュメントが書き込み成功しているようにして、書き込まれた後の状態のドキュメントを取得できるようになります。

使える回数に制限がある

ただ、このgetAfter()関数ですが、get()exists()と同様に、一度の評価(リクエスト)で呼び出せる数に制限があります。
単体では3回まで、他の関数と合わせて5回までとなっています。

公式リファレンスの例を見てみる

公式リファレンスにある例を見てみます

service cloud.firestore {
  match /databases/{database}/documents {
    // If you update a city doc, you must also
    // update the related country's last_updated field.
    match /cities/{city} {
      allow write: if request.auth.uid != null &&
        getAfter(
          /databases/$(database)/documents/countries/$(request.resource.data.country)
        ).data.last_updated == request.time;
    }

    match /countries/{country} {
      allow write: if request.auth.uid != null;
    }
  }
}

この例では、cityのドキュメントの書き込みを行うときは、
/databases/$(database)/documents/countries/$(request.resource.data.country) で参照できる countryのドキュメントのlast_update も同時に更新されている(city書き込み時のrequest.timeと一致)のを条件にしています。

試しに使ってみる

リファレンスの例とはまた別の例を挙げて実際に使ってみます。
UserとCartという2つのモデルが以下のようにあったとします。

User {
  cartID: stting
}

Cart {
  userID: string
}

Userを新規で作成するときに同時にCartも作成し、お互いに参照をIDで持つようにして、一括で書き込むことにします。
書き込みの処理はswiftだとざっくりこんな感じになります。

let batch = Firestore.firestore().batch()

let userRef = Firestore.firestore().collection("user").document()
let cartRef = Firestore.firestore().collection("cart").document()
batch.setData(["cartID": cartRef.documentID], forDocument: userRef)
batch.setData(["userID": userRef.documentID], forDocument: cartRef)

batch.commit { error in
    if let error = error {
        print(error)
    } else {
        print("done!")
    }
}

ruleが特に縛られていなくて許可されていればすんなりと通ります。
ここに、user,cartそれぞれ書き込まれる時に、それぞれ参照するIDが自分のものと合致するか見て、正しく書き込まれるかをチェックできるようにします。

だめな例

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

    function userPath(userID) {
      return /databases/$(database)/documents/user/$(userID)
    }

    function cartPath(cartID) {
      return /databases/$(database)/documents/cart/$(cartID)
    }

    match /user/{userID} {
      allow create, update: if isAuthenticated()
                            && get(cartPath(request.resource.data.cartID)).data.userID == userID;
    }

    match /cart/{cartID} {
      allow create, update: if isAuthenticated()
                            && get(userPath(request.resource.data.userID)).data.cartID == cartID;
    }
  }
}

書き込み時に、get() 関数を使って相手のドキュメントを取得して、cart.userIDもしくはuser.cartIDが自身のIDと一致するかをチェックしようとしていますが、
バッジ処理で一括書き込みをしている場合、どちらも書き込み終わる前に取得しようとしてしまい、うまく比較することができません。
そしてこのようにかくと、既にお互いの参照が正しく入ったモデルのupdate時は問題ない場合もあるかもしれませんが、片方ずつ新規作成することもできないので、ruleを緩めるか、、いやでもそれは、、となってしまいます。
上記ルールでデプロイして、先ほどのswiftのコードを実行すると、バッジ処理が失敗します。

getAfter()を使う

そこで今度は get()の代わりにgetAfter() を使います。

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

    function userPath(userID) {
      return /databases/$(database)/documents/user/$(userID)
    }

    function cartPath(cartID) {
      return /databases/$(database)/documents/cart/$(cartID)
    }

    match /user/{userID} {
      allow create, update: if isAuthenticated()
                            && getAfter(cartPath(request.resource.data.cartID)).data.userID == userID;
    }

    match /cart/{cartID} {
      allow create, update: if isAuthenticated()
                            && getAfter(userPath(request.resource.data.userID)).data.cartID == cartID;
    }
  }
}

こうすることで、お互いに一括で書き込んだ時に、userはgetAfterで一括書き込みが成功した場合のcartの情報を取得し、cart.userIDと自身の比較ができ、cartはuserを取得してcartIDと自身の比較ができます。
このルールをデプロイして、swiftのコードを実行すると今度は成功します :tada:

まとめ

今まで、batchで一括書き込みする時に、同時に書き込む異なるモデル間で必要な情報を持っているかruleで縛りたい時に実現できなかったので大変便利になりましたね :tada:

まだβついているので、これからに期待。

参考