全部見える！GCP ロギングで見るFirestoreのアクセス履歴

# 初めに

サーバーレスではないインフラでは、当たり前のように全てのアクセスがある程度ログに吐き出せたりしているものですが、Firestoreの場合、クライアントから直接アクセスされるので、Firestore自身にその機能を実装してもらわない限り取ることができませんでした。

だいぶ前ではありますが、GCPの監査ログとしてその機能が付いたので、どうログとして保存されるか確認してみました。

まず結果のまとめはこちらです。

|対象フィールド| クライアント | Firebase Console | Firebase Admin | Firebase Functions |
|:-:|:-:|:-:|:-:|:-:|
|authenticationInfo.principalEmail|service-プロジェクト番号@firebase-rules.iam.gserviceaccount.com|アクセスした時のアカウントのメールアドレス|firebase-adminsdk-ランダム@プロジェクト名.iam.gserviceaccount.com|service-プロジェクトID@gcf-admin-robot.iam.gserviceaccount.com|
|読み込みのmethodName|google.firestore.v1.Firestore.Listen|google.firestore.admin.v1.FirestoreAdmin.GetDatabase<br>google.firestore.v1.Firestore.ListCollectionIds<br>google.firestore.v1.Firestore.Listen<br>など|google.firestore.v1.Firestore.RunQuery|google.firestore.v1.Firestore.RunQuery|
|書き込みのmethodName|google.firestore.v1.Firestore.Write|google.firestore.v1.Firestore.Write|google.firestore.v1.Firestore.Commit|google.firestore.v1.Firestore.Commit|

意味も含めた詳しい内容を以下から見ていきます。

# Firestoreへのアクセスパターン

Firebaseから使った場合、以下の４つが主なアクセスパターンだと思います。

- クライアントからのアクセス
- Functionsからのアクセス
- Firebase Adminからのアクセス
- Firebase Console(もしくはGCP Console）からのアクセス

下の３つは全てFirebase Adminを利用しているのですが、ロギングでどう見えた方が変わるのかを確認したいのであえて分けてます。

では、それぞれのアクセス方法でどう表示されるか見ていきましょう。

# 監査ログの有効化

そもそも、監査ログの設定を行わないと保存は行われません。
監査ログは、GCP Consoleの`IAMと管理 > 監査ログ`から設定ができます。

さまざまなサービスに対する監査ログが選択できますが、Firestoreは`Firestore/Datastore API`になります。

サービス名の横のチェックをクリックすると、右側に`管理読み取り`、`データ読み取り`、`データ書き込み`のチェックが出てくるので全てチェックして保存します。

![スクリーンショット 2022-11-12 23.38.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341912/cbbfc0e3-b6dd-93e6-405b-9a7f7c7e763e.png)

これで準備は完了です。

# アクセス経路別のログの内容

アクセスの経路別に、それぞれのログの出方を見ていきます。

## Firebaseコンソールからのアクセス

まずはFistoreのページを開いた瞬間から。

GCP Consoleのロギングを開くと、一気にいろんな情報が出てきているかもしれませんが、今回のアクセスとしては以下の情報が該当します。
```
authenticationInfo.principalEmail: "アクセスした時のアカウントのメールアドレス"
methodName: "google.firestore.admin.v1.FirestoreAdmin.GetDatabase"
resourceName: "projects/対象のプロジェクト/databases/(default)"
```
以降この書き方でログを表現します。protoPayload以下のパスを"."で表現しています。

公式ページを見るとこの処理は"データベースに関する情報を取得します。"となっています。
操作内容と一致します。

もう一つが以下のものです。
```
methodName: "google.firestore.v1.Firestore.ListCollectionIds"
```
methodName以外は一緒なので、省略しました。
この処理は、コレクションIDの一覧を取得するというものですね。
今はコレクションが１つもないのでこれだけで終わっています。

では、１つドキュメントを作って見ましょう。
"messages/aB70SbzoVAMEK0ajhOO4"というパスにcontent="aiueo"というデータを作ってみます。

一気にログが増えたと思います。
順に見ていきます。

```
methodName: "google.firestore.v1.Firestore.Listen"
request.addTarget.documents.documents[0]: "projects/対象のプロジェクト/databases/(default)/documents/messages/aB70SbzoVAMEK0ajhOO4"
```

ちょっと不思議ですが最初に対象のドキュメントを見るための処理が動いています。
MethodNameをドキュメントで見ると、変更をListenするとなっていました。

```
methodName: "google.firestore.v1.Firestore.Write"
request.writes[0].update.name: "projects/対象のプロジェクト/databases/(default)/documents/messages/aB70SbzoVAMEK0ajhOO4"
```

次に書き込みが書いてありますね。
おそらくなんですが、値が追加されているものをConsole上でリアルタイムに見えるようにするために、Listenが先に動いているんだと思います。
この他のパスを見てもフィールドの内容に関わるものはありませんでした。
監査ログではあくまで追加した時のパスまでしかわからないようにです。

いくつか同じログが出ながら、以下の処理も追加で保存されています。

```
methodName: "google.firestore.v1.Firestore.ListDocuments"
request.collectionId: "messages"
request.pageSize: 300
```

これで、コレクション内のドキュメントも一気に取っているようにです。
pageSizeが300になっていたので、一気に300件取るようになっていそうです。

さらに追加されたものを見てみると以下のようなものもありました。

```
methodName: "google.firestore.v1.Firestore.Listen"
request.addTarget.query.structedQuery.from[0].collectionId: "messages"
request.addTarget.query.structedQuery.orderBy[0].direction: "ASCENDING"
request.addTarget.query.structedQuery.orderBy[0].field.fieldPath: "__name__"
```

Console画面は、いろんなものを一気に表示するので、裏ではけっこういろんなRequestが発生しているようです。

次はフィールドの変更をしてみましょう。

contentを"aiueo1"にしてみます。

```
methodName: "google.firestore.v1.Firestore.Write"
request.writes[0].update.name: "projects/対象のプロジェクト/databases/(default)/documents/messages/aB70SbzoVAMEK0ajhOO4"
request.writes[0].currentDocument.exists: "true"
request.writes[0].updateMask.FieldPaths[0]: "content"
```

もう見る側の処理は大変すぎるのでWriteだけ見ます。
追加の時と同じ関数になっています。
違うのは、currentDocument.existsがtrueで入っているところですね。
公式ドキュメントを見ると、このドキュメントが存在しない場合はエラーになるようにするリクエストのようです。
あとは、updateMaskですね。これは変更対象を絞り込む設定になります。

別のフィールドを追加した時のリクエストもほぼ同じで、updateMaskの中身が変わっているだけでした。

次は削除です。

```
methodName: "google.firestore.v1.Firestore.Write"
request.writes[0].delete: "projects/対象のプロジェクト/databases/(default)/documents/messages/aB70SbzoVAMEK0ajhOO4"
```

シンプルですね。対象のパスが指定され削除が実行されています。
ここもWriteのメソッドが使われています。

コンソールにはクエリービルダーなるものもありますが無限に増えていきそうなのでここは省略します。


## クライアントからのアクセス

Web用のFirebase SDK経由でアクセスをして見ます。
まず単純にaddDocを使って、メッセージを追加してみます。
追加するドキュメントは先ほどの全く同じものです。

```
authenticationInfo.principalEmail: "service-プロジェクト番号@firebase-rules.iam.gserviceaccount.com"
methodName: "google.firestore.v1.Firestore.Write"
request.writes[0].currentDocument.exists: "false"
request.writes[0].update.name: "projects/対象のプロジェクト/databases/(default)/documents/messages/QCI7EgvI3yKAX45KXQrX"
```

ここで面白い結果が出ました。
Firebase consoleで作成した時とほぼ同じものが使われています。
addDocは必ず新しいデータを作るという意図があるので、currentDocument.existsがfalseで設定されていますが、それ以外は一緒です。
ちなみにcurrentDocument.existsがfalseの場合は、必ずドキュメントがないことを保証するので自動生成されたidが被っていた場合は、エラーになります。addDocはそこまで保証されてくれてるんですね。

あとは、principalEmailが変わってますね。サービスアカウントのメールアドレスが入っています。
GCPのIAMで何者か確認してみます。
`IAMと管理 > IAM`を開いて見ますが、そこには入っていません！
ドメイン部分をコピーして公式で確認してみます。
そうするとFirebaseによって自動的に発行されるサービスアカウントのようです。
クライアントからのアクセスは常にこのサービスアカウントが利用されます。

書き込みの方法は、この他にもあるのでそれぞれやっていきます。
更新はどうなるでしょう？

contentを"aiueo1"にしてみます。

```
methodName: "google.firestore.v1.Firestore.Write"
request.writes[0].update.name: "projects/対象のプロジェクト/databases/(default)/documents/messages/aB70SbzoVAMEK0ajhOO4"
request.writes[0].currentDocument.exists: "true"
request.writes[0].updateMask.FieldPaths[0]: "content"
```

やはりコンソールの時と同じですね。

ではちょっと方向性が違うCommitを使った方法をやってみます。
結果同じでした。トランザクションを使うからといって監査ログとしては同じように記録するようです。

書き込みはこれくらいにして読み込み側を確認してみます。
次にgetDocsを使ってデータを取得してみます。

```
methodName: "google.firestore.v1.Firestore.Listen"
request.addTarget.query.structedQuery.from[0].collectionId: "messages"
request.addTarget.query.structedQuery.orderBy[0].direction: "ASCENDING"
request.addTarget.query.structedQuery.orderBy[0].field.fieldPath: "__name__"
```

ここもConsoleと同じものが利用されていました。

さらにgetDocを使ってみます。

```
methodName: "google.firestore.v1.Firestore.Listen"
request.addTarget.documents.documents[0]: "projects/kontikun-simple-chat/databases/(default)/documents/messages/QCI7EgvI3yKAX45KXQrX"
```

ここもConsoleを見たときに見た形です。

ここで発覚したのは、ListenとなっていたからといってonSnapshotではないということです。
基本的に読み取りの場合は全てListenが使われるようです。

ではonSnapshotに変えたときに違いは出るでしょうか？やってみます。
うーん、違いはほとんどありませんでした。
唯一違うのが"request.addTarget.targetId"が2だったのが６になっているところです。
ソースコードを読むとどうやらこの辺で区別してそうだけどはっきりとはわかりませんでした。

## Firebase Adminからのアクセス

サービスアカウントをダウンロードして、nodeを使ってFirebase Admin SDK経由でアクセスも試してみます。

ここでは細かいことを言わず結果だけ書きます。
コレクションを読み取る処理を書いてみます。

```
authenticationInfo.principalEmail: "firebase-adminsdk-ランダム@プロジェクト名.iam.gserviceaccount.com"
methodName: "google.firestore.v1.Firestore.RunQuery"
```

予想通りですが、principalEmailが違いました。
今回はIAMに載っていたもので、キーを作成したサービスアカウントが表示されました。

では書き込みもやってみます。他のアクセスで行ったものと同じ追加をやってみます。

```
methodName: "google.firestore.v1.Firestore.Commit"
request.writes[0].currentDocument.exists: "false"
request.writes[0].update.name: "projects/プロジェクト名/databases/(default)/documents/messages/Zi7wsfJVlbSf0DjafU7M
```

今度はmethodNameが違っていました。
ちなみに更新を行った場合も、batchを使ったものでも一緒でした。

Firebase Admin SDKを利用する場合、読み込みは全てRunQueryとなり、書き込みは全てCommitを使うことになりそうです。


## Firebase Functionsからのアクセス

最後にFirebase Functionsからのアクセスです。

```
authenticationInfo.principalEmail: "service-プロジェクトID@gcf-admin-robot.iam.gserviceaccount.com"
methodName: "google.firestore.v1.Firestore.RunQuery"
```

ここも予想通りですね。
Firebase Adminを使うのは一緒なのでmethodNameに違いは出ません。
principalEmailがFunctionsが使うサービスアカウントになっています。

ここに関しては、いくつかのプロジェクトを確認した結果、作った時に変わっているようです。


# まとめ

Firestoreについて、それぞれのアクセスから監査ログがどう保存されるかをまとめました。

このまとめた結果から、ログ エクスプローラで検索条件を変更しながら必要な情報を摘出することができるようになると思いますし、`ロギング > ログルーター`の_Defaultの除外フィルターを変更することで、保存するべきログだけを保存するといった条件も作れるようになると思います。

ただ、この辺も結構頻繁に変更が発生している部分かと思いますので、正しく扱えているかどうかは随時確認しながらやっていただく方が良いかと思います。