Firebase Cloud Firestoreのセキュリティールールでmd5値を条件に利用した話　具体的な事例も

## はじめに

個人開発で以下のような機能をCloud Firestoreで実装しようとした際に、以下の`既に招待を承認済み（グループに参加済み）のユーザーへの招待は不可能`をどう実現するか？で迷った。

- 前提
  - グループという1人以上が参加している集団
  - グループのメンバーには、招待を受けて参加する
- （招待について）実現したい機能 
  - 既に招待を承認済み（グループに参加済み）のユーザーへの招待は不可能
  - 既に招待を受けているが、その招待が未承認 or 拒否であれば、再度招待を行える（拒否は間違えて拒否してしまう場合があるので、未承認は招待を忘れてどこにあるか分からなくなったりする事があるので、それぞれ再招待をOKにする）
 
今回は上記の機能を実現するために検討した方法いくつかと、最終的な機能の実現方法を備忘録として残す。

## まず、躓いたポイント

ルールでは、コレクションのドキュメントのフィールドに〇〇を含むものがあるか？をチェックする術がない。

具体的には、どういうことか見ていく。まず、以下のような`group_invites/{inviteId}`のコレクションがあり、そのドキュメントとして`aaa`や`bbb`がある状態を考える。

- group_invites/{inviteId}（コレクション）
  - inviteId=aaa（ドキュメント）
    - group_id：hogehoge
    - invited_user_email：foo@example.com
    - ...
  - inviteId=bbb（ドキュメント） 
    - group_id：hogehoge
    - invited_user_email：huga@example.com
    - ...

上記の状態のgroup_invitesコレクションに、新たに`ccc`というドキュメント（以下のようなドキュメント）が追加されようとした際に（つまり招待を行おうとした際に）、`aaa`や`bbb`に`ccc`の`group_id`と`invited_user_email`に一致するドキュメントがコレクション内にあるか？を判定する術はない。

- group_invites/{inviteId}（コレクション）
  - inviteId=ccc（追加されようとしているドキュメント）
    - group_id：hogehoge
    - invited_user_email：foo@example.com
    - ...

確かに、ルールを実装する際に[exists()](https://firebase.google.com/docs/reference/rules/rules.firestore#.exists)や[get()](https://firebase.google.com/docs/reference/rules/rules.firestore#.get)というメソッドを利用する事はできるが、あくまでドキュメントIDが分かっていないとこれらのメソッドを呼び出す事はできない。そのため、どうしたって上記のような考え方（既に存在するドキュメントのフィールド値に、追加されようとしているドキュメントと同じフィールド値のものがあるか？）というルールを実装する事は不可能。

※これは仮説だが、Firestoreのルール検証は高速で動く事が求められるので、上記のようなルールを実装できないようになっているのでは？と思う。つまり、DBにデータを追加するリクエストが来て、それを許可するか？を判定する処理に時間がかかればDBのリクエストへの応答時間が長くなり本末転倒だろう。
具体的には、コレクション内のドキュメントに含まれるか？を検証するという事は、つまりはコレクション内の全てのドキュメントを総当たりで確認しなければならず[^1]、そんな事をすると時間がかかってNGだろう（データ数が数千とかならいけるだろうが、この手のサービスは億単位を想定すると思われるので）。

[^1]: 正確には1つでも見つかればいいので、総当たりではないが、基本的にデータがばらつくようにドキュメントIDを割り振るので規則性がなく、順番もないので実質総当たりになると思われる。ドキュメントIDの採番についてのベストプラクティスは、[Cloud Firestore のベスト プラクティス](https://firebase.google.com/docs/firestore/best-practices?hl=ja)の[ドキュメント ID](https://firebase.google.com/docs/firestore/best-practices?hl=ja#document_ids)や[狭いドキュメント範囲に対する高頻度の読み取り、書き込み、削除](https://firebase.google.com/docs/firestore/best-practices?hl=ja#high_read_write_and_delete_rates_to_a_narrow_document_range)を参照。

では、どうするか…？で考えた1つの方法が、もう招待を作成する（`group_invites/{inviteId}`にドキュメントを追加する）際のルールには、重複招待（既にグループのメンバーへの招待）を許可しないルールを実装するのをあきらめるというもの。それについては以下で見ていく。

## 招待の重複を諦める（既に招待を承認済みでも招待を送れる仕様にしてしまう）

一番最初によぎったのがこの方針で、そもそも重複招待を許可し、招待自体は作成できてしまうという仕様にしてしまうというもの。ではどうやって同じ人が同じグループのメンバーになるという状態を避けるかだが、招待は作成できても、ドキュメントIDの重複を発生させることで招待の承認時にエラーにして、既にグループのメンバーである人への招待を無効にするという方法で重複で同じメンバーがグループにいる状態を避ける。

具体的には、以下のようなサブコレクションでグループのメンバーを表現しているとして、そのサブコレクション`member_users`のドキュメントIDをFirebase Authenticationのuidにすれば、同じコレクション内に同じドキュメントIDを持つドキュメント（データ）は作成できないので、上記の仕様を実現できる。

- /groups/{groupId}（コレクション）
  - ... 
  - /member_users/{userId}（サブコレクション）
    - userId：uid_hoge（ドキュメントID＝uid_hoge）
      - email
      - name
      - ... 
    - userId：uid_foo（ドキュメントID＝uid_foo）
      - email
      - name
      - ... 

実際に同じドキュメントIDのドキュメントを作成しようとすると、`Error: 6 ALREADY_EXISTS: entity already exists`というエラーになるので、同じメンバーがグループのメンバーとして登録される、という事は防げる。

```js:functions/index.js
import functions from 'firebase-functions';
import admin from 'firebase-admin';

admin.initializeApp();
const db = admin.firestore();

export const updateInvite = functions
	.region('asia-northeast1')
	.firestore.document('/messages/{documentId}')
	.onUpdate((snap, context) => {
        const { groupId, uid } = snap.data();
		const groupMemberUsersDocRef = db
			.collection('groups')
			.doc(groupId)
			.collection('member_users')
			.doc(uid);

		return groupMemberUsersDocRef.create({ id: 'test' });
	});
```
```console:errorログ
⚠  functions: Error: 6 ALREADY_EXISTS: entity already exists: app: "dev~{project_id}"
path <
  Element {
    type: "groups"
    name: "S6B9JZDpUewx749sR8lP"
  }
  Element {
    type: "member_users"
    name: "jWqKHKmbKbaA0DRqpCWSQasSQ6zj"
  }
>

    at callErrorFromStatus (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@grpc/grpc-js/build/src/call.js:31:19)
    at Object.onReceiveStatus (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@grpc/grpc-js/build/src/client.js:192:76)
    at Object.onReceiveStatus (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@grpc/grpc-js/build/src/client-interceptors.js:360:141)
    at Object.onReceiveStatus (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@grpc/grpc-js/build/src/client-interceptors.js:323:181)
    at /home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@grpc/grpc-js/build/src/resolving-call.js:94:78
    at processTicksAndRejections (node:internal/process/task_queues:78:11)
for call at
    at ServiceClientImpl.makeUnaryRequest (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@grpc/grpc-js/build/src/client.js:160:34)
    at ServiceClientImpl.<anonymous> (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@grpc/grpc-js/build/src/make-client.js:105:19)
    at /home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@google-cloud/firestore/build/src/v1/firestore_client.js:227:29
    at /home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/google-gax/build/src/normalCalls/timeout.js:44:16
    at repeat (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/google-gax/build/src/normalCalls/retries.js:80:25)
    at /home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/google-gax/build/src/normalCalls/retries.js:118:13
    at OngoingCallPromise.call (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/google-gax/build/src/call.js:67:27)
    at NormalApiCaller.call (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/google-gax/build/src/normalCalls/normalApiCaller.js:34:19)
    at /home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/google-gax/build/src/createApiCall.js:84:30
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
Caused by: Error
    at WriteBatch.commit (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@google-cloud/firestore/build/src/write-batch.js:433:23)
    at DocumentReference.create (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/@google-cloud/firestore/build/src/reference.js:321:14)
    at file:///home/study/workspace/firebase-cloud-functions-trial/functions/index.js:27:32
    at cloudFunction (/home/study/workspace/firebase-cloud-functions-trial/functions/node_modules/firebase-functions/lib/v1/providers/https.js:51:16)
    ...
⚠  Your function was killed because it raised an unhandled error.
```

ただ、そもそもこの仕様の方針は以下のような問題がありNGだろうという結論になった。

- ユーザーから見ると、既に所属しているグループから改めて招待が来ることになり、混乱を招く
- 改めてきた招待を承認しようとしたらエラーになるので、またそこでも混乱する（エラーメッセージで既に所属済みのグループなので招待を承認してもエラーになった、というのは伝えられるかもしれないが、だったらそもそも招待を作成する時点で止めるべきでは？という話になるだろう）
- システム上は招待を無限に送る事ができてしまうので、意味のない招待のゴミデータが作成され、受け取るユーザー側ではノイズになる

というわけでこの方法ではない仕様にする事を考えた。最終的には、重複招待（既にグループのメンバーであるユーザーに対する招待）はできないようにする、というあるべきの仕様を実現する事にした。それについては次の章で。

※余談だが、上記の招待を承認した後のグループのメンバーへの登録処理はAdmin SDK（バックエンド）で行い、フロントエンドでの処理ではない実装になっていたが、それはグループのメンバーへの追加時のセキュリティールールを実装できないため。

## 重複招待（既にグループのメンバーであるユーザーに対する招待）を防ぐ

ここで本記事のタイトルにも書いていたmd5を利用した実装が出てくる。md5の値をルールで利用する事で、重複招待（既にグループのメンバーであるユーザーに対する招待）を防ぐ実装をしてみたいと思う。

前提として、以下のようなコレクション、サブコレクションがあるとする。

- /groups/{groupId}（`groups`コレクション）
  - /member_users/{userId}（`member_users`サブコレクション）
- /users/{userId}（`users`コレクション）

`/groups/{groupId}/member_users/{userId}`には、`/users/{userId}`のコピーを保存する（非正規化）（ちなみに、上記は[過去記事](https://qiita.com/yuta-katayama-23/items/fc8d114606c4ad14e212#%E3%83%A6%E3%83%BC%E3%82%B9%E3%82%B1%E3%83%BC%E3%82%B9%E3%81%AB%E5%90%88%E3%82%8F%E3%81%9B%E3%81%9F%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E3%83%AB%E3%83%BC%E3%83%AB%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B)の続きになるので、そちらも参照頂けると幸いです）。

上記のような前提で、重複招待（既にグループのメンバーであるユーザーに対する招待）を防ぐ方法についてみていく。結論から言うと、`/member_users/{userId}`の`userId`をメールアドレスのmd5にするという方針を取る。そうする事で、招待しようとしているユーザーのメールアドレスで`/groups/{groupId}/member_users/{userId}`にユーザーが既に存在するか？を確認できるようになる。

詳細を見ていく。まず、グループのメンバー（`/groups/{groupId}/member_users/{userId}`）にデータ（ドキュメント）を追加する際に、その`userId`をメールアドレスのmd5で作成する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/7957d908-0f1d-b2cb-e5b4-7589929ed9a6.png)

そして、グループへの招待を`/group_invites/{inviteId}`というコレクションで表現するとして、そのドキュメントのフィールドを以下のように定義する。

- /groups/{groupId}（コレクション）
  - /invites/{inviteId}（サブコレクション）
    - inviteId：aaabbb（ドキュメントID）
      - group_id
      - invited_user_email
      - inviter_uid
      - ... 

このグループへの招待を作成に対するセキュリティールールを以下のように実装すると、`isNotGroupMemberInvitedUser()`の条件で、既に`/groups/{groupId}/member_users/{userId}`にデータがあればcreateを拒否できるようになる。`isNotGroupMemberInvitedUser()`の条件では、`invited_user_email`のメールアドレスのmd5値を`userId`にして、exists()関数でドキュメントが存在するか？をチェックしている。

```
    match /groups/{groupId} {
      allow create: if isSignedIn() && int(get(/databases/$(database)/documents/users/$(request.auth.uid)).data.owner_group_count) < 5 && request.resource.data.created_by == request.auth.uid;
      ...

      match /invites/{inviteId} {
        function isValidKeys() {
          return request.resource.data.keys().hasAll(["group_id","group_name","invited_user_email","inviter_uid","inviter_first_name","inviter_last_name","inviter_logo_uri"])
        }

        function isValidInviter() {
          return "inviter_uid" in request.resource.data && request.resource.data.inviter_uid == request.auth.uid;
        }

        function isValidInvitId() {
          return inviteId == hashing.md5(request.resource.data.invited_user_email).toHexString().lower();
        }

        function isGroupMember() {
          return exists(/databases/$(database)/documents/groups/$(request.resource.data.group_id)/member_users/$(reqAuthEmailToMd5()));
        }

        function isNotInvited() {
          return !exists(/databases/$(database)/documents/groups/$(groupId)/invites/$(request.resource.id));
        }

        function isNotGroupMemberInvitedUser() {
          let md5InvitedUserEmail = hashing.md5(request.resource.data.invited_user_email).toHexString().lower();
          return !exists(/databases/$(database)/documents/groups/$(request.resource.data.group_id)/member_users/$(md5InvitedUserEmail));
        }

        allow create: if isSignedIn() && isValidKeys() && isValidInviter() && isValidInvitId() && isGroupMember() && isNotInvited() && isNotGroupMemberInvitedUser();
      }
    }
```

※`/member_users/{userId}`の`userId`をメールアドレスのmd5にして、他のセキュリティールールを実装できるのか？だが、Firestoreのセキュリティールールでは、`request.auth`という変数にアクセスでき、`request.auth.token.email`に認証済みのユーザーのメールアドレスの情報が格納されている（request.authの詳細については[ここ](https://qiita.com/yuta-katayama-23/items/fc8d114606c4ad14e212#requestauth)を参照）。つまり、以下のようなルールを実装できるので、例えば、グループの情報を取得できるユーザーをそのメンバーに制限する、というルールは以下のように実装できるので問題ない。

```
...
    function reqAuthEmailToMd5() {
          return hashing.md5(request.auth.token.email).toHexString().lower();
    }
...
    match /groups/{groupId} {
      ...
      allow read, update: if isSignedIn() && exists(/databases/$(database)/documents/groups/$(groupId)/member_users/$(reqAuthEmailToMd5()));
      
      match /member_users/{userId} {
        ...
      }
```

## まとめとして

今回は、md5を利用してセキュリティールールを実装するという事をやってみた。今まではユーザー情報に関してのセキュリティールールを実装する場合は、大抵`request.auth.uid`を使っていたので、メールアドレスのmd5にするという事で少し実現できる機能の幅が広がり、新鮮だった。

※そもそもSDKでFirestoreにリクエストを送る前に、フロントエンドのバリデーションで既にグループのメンバーであれば、招待をできないようにする実装はできるが、普通各ユーザーの操作は並列で行われるので、以下のような場合に、フロントエンドのバリデーションチェックをしても重複招待できてしまうので、セキュリティールールの実装は不可欠だと思われる。

- 招待を受けたユーザーAがいる
- グループのメンバーであるユーザーXが、ユーザーAを招待しようとしている
- ユーザーXが画面上でユーザーAの招待を実行したとほぼ同時に、既に受け取っている招待からユーザーAが招待を承認した時、ネットワークの状態などでユーザーAの招待承認が先に実行され、Firestoreの`/member_users/{userId}`コレクションにデータが作成された
- その後、ユーザーXからのユーザーAへの招待作成のリクエストがFirestoreに到達した場合、Firestoreのセキュリティールールがなければ、その招待は作成されてしまう＝重複招待（既にグループのメンバーであるユーザーに対する招待）になってしまう