firestore security ruleでcollectionをwildcardで書いてみた話

こんにちは。SENSYN Roboticsの高と申します。
今回初投稿ですが、firestoreのsecurity rulesでやってみたことでも書いてみようと思います。
特に新しい発見とかある訳ではないですが、公式に（多分）書いてないことを今回やってみたので、もし参考になれば…程度のものになってます。
それでは本題に入ります。

この資料を読む価値があるか判断材料になれればと、３行まとめ。
- 公式docの例文見ると、パスのdocument部分は基本wildcard使ってますけどcollectionは違いますよね。
- 同じfieldを使って判断するcollection同士では、wildcard使えばrule短くできるんじゃね？と思いました。
- やってみたら普通にできました。以上。

## firestoreのsecurity rules
改めて言うほどのものでもありませんが、簡単に概要でも。
firestoreのデータに適切なユーザのみが読み書きできるように制限するルールですね。
backend側でadmin-sdkを使ってれば全く気にする必要がないものになってます。
ただし、clientにsdkを導入してfirestoreも使ってるのであれば、割と細かく設定する必要があるかと。
全て許可にしてしまうと
clientを改造されて、もしくはバグで出来た穴を突かれて、
内部のデータが流出されたりするリスクがありますので。

## まずは簡単なやつから
公式docにも書いてるものですが、最も簡単なパターンから。
`users` collectionがあります。名前通りユーザーのデータが入ります。
これはユーザー本人のみが読み書きできるようにしたいですね。
よって、こう書きます。
```
match /users/{user} {
    allow read, write: if request.auth != null && resource.id == request.auth.uid
}
```

## 組織IDで権限を付与する
自分が関わっているプロダクト([ショルイラ：記録・報告アプリ](https://shoruira.sensyn-robotics.com/preregistration/))だと、全てのユーザーアカウントは一つ以上のチームに所属することになります。
もちろん、チームの情報を持つ `teams` collectionがあって、これの閲覧ですとか変更ですとかは所属しているユーザーのみ行うことができます。
ユーザーは所属しているチームのIDを `teamIds` fieldに持ってますので…こうなります。
```
match /teams/{team} {
    allow read, write: if request.auth != null && resource.id in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.teamIds
}
```
本当はユーザの権限も見る必要があったりするんですがそこのところは割愛します。

そして、レポートなどユーザーに作成される全てのデータは特定のチーム配下となります。
ですので、ユーザーが作成するさまざまなデータは全て `teamId` fieldを持ちます。
チームに所属しているユーザがそのチーム配下のデータを弄れれば良いので…
同じ `teamId` を持っているユーザーが読み書きできるようにすれば良いんですね！簡単。
```
function authCheck() {
    return request.auth != null
}
function getUserTeamIds() {
    return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.teamIds
}
match /reports/{report} {
    allow read, write: if authCheck() && resource.data.teamId in getUserTeamIds()
}
```
長いのは好かんのでfunctionも入れました。

さて、この `reports` のルールをそのままコピペして全collection分作れば良いんですね！
…もちろん、それでも問題なく動きますが。同じ内容の繰り返しであまりにも長くなってしまいます。

## wildcardを利用し複数collectionでルールを共有させる
なんとか短くできないものか…と、考えてみると。
公式docにはパス内に{}を挟めばwildcardになりますよ、と書いてて
別にcollectionでは使ってはダメみたいな話もないのでwildcard使えば短くできるんじゃね、と思いますよね。

なので下のように少し変えてみました。
```
match /{collection}/{document} {
    allow read, write: if authCheck() && resource.data.teamId in getUserTeamIds()
}
```
このまま保存して、ルールプレイグラウンドで試してみますと。
問題なく判定が通ります！
もちろん、 `reports` `groups` `reportSummary` など `teamId` fieldを持っている collectionであればどれでも通るようになりました。

## NULLエラーになるのが嫌だから追加対応

よし、これで終了…と行きたかったのですが、今度は他で気になるところが。
例えば、プレイグラウンドで権限のないデータへのアクセステストをするとですね。
判定が通り、読み取れる場合は
`シミュレートされた読み取りが許可されました`
allow判定になるruleが存在せず、拒否される場合は
`シミュレートされた読み取りが拒否されました`
のメッセージになります。
今回書いたルールで試しても、 `reports` などに対しては上記のどれかのメッセージが出ます。

ただし、更に上に書いている `teams` に対して権限のないユーザーで試してみると。
`シミュレーションの実行中にエラーが発生しました - Error: simulator.rules line [xx], column [xx]. Null value error.`
このようなエラーになります。
理由はもちろん、 `teams` collectionは `teamId` fieldを持たないから、ですね。
一応軽く数パターン確認してみたところ、このままでも実クライアントで使う分には問題がないことは確認しているのですが。
- allow判定になるルールが一つでもあれば、そちらが適用される。エラーになるルールがあるからってallowが潰れることはない
- エラーによる失敗であっても、クライアント側の例外は普通の判定失敗同様、`FirebaseError: Missing or insufficient permissions.` となる

元々プレイグラウンドではmatchした全てのルールを見せてくれる便利機能があるのですがエラーになってしまうとなぜかこれが見えなくなったり、
そもそも意図しないcollectionでmatchして判定に入ること自体あまり宜しくない気もするので回避策を考えます。
とは言っても、単にcollectionの名前で判定すれば良いだけですけどね。

```
function isTeamDataCollection(collection) {
    return collection in ['groups', 'reportSummary', 'reports', '...']
}
match /{collection}/{document} {
    allow read, write: if authCheck() && isTeamDataCollection(collection) && resource.data.teamId in getUserTeamIds()
}
```
条件を追加することで　`teamId` を持つcollection以外が無駄に判定されることを回避できます。
これでスッキリ！！

## まとめ
全部まとめるとこんな感じになりました。
```
function authCheck() {
    return request.auth != null
}
function getUserTeamIds() {
    return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.teamIds
}
function isTeamDataCollection(collection) {
    return collection in ['groups', 'reportSummary', 'reports', '...']
}
match /users/{user} {
    allow read, write: if authCheck() && resource.id == request.auth.uid
}
match /teams/{team} {
    allow read, write: if authCheck() && resource.id in getUserTeamIds()
}
match /{collection}/{document} {
    allow read, write: if authCheck() && isTeamDataCollection(collection) && resource.data.teamId in getUserTeamIds()
}
```
そこそこ短く書けたのではないでしょうか。

完全初心者が手探りで実装してきた内容を段階踏みながら記録しただけですので無駄に長くなってしまいましたが今回はここまでとなります。
それでは、（もしあれば）また次の投稿で！