Firebase Authentication の自動定期バックアップの実装と import 時の挙動調査

Firebase Authentication (以後、Firebase Auth) は非常に便利なサービスですが、バックアップを定期的に自動取得する機能がありません。
誤操作などによって Firebase Auth のデータが失われた際に備え、バックアップの自動化と import 時の挙動を検証しました。

# 最終的な構成

**Cloud Functions の定期実行により、Cloud KMS で暗号化した Firebase Auth のバックアップを GCS に配置する**構成になります。

![FirebaseAuthenticationバックアップ構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216010/964f39ed-a315-cbb9-8bd8-7886df561c23.png)

# バックアップ処理の概要

まずは上記の構成に至った経緯を解説していきます。

## バックアップ実行方法

公式ドキュメントの「[auth:import と auth:export](https://firebase.google.com/docs/cli/auth?hl=ja)」というページによると、**firebase コマンドで import と export が可能です**。
一方、Firebase の API や JavaScript の SDK を参照しても、バックアップ方法は一切ありません。[^1]

[^1]: API は内部的には存在するのでしょうが、ドキュメントに書かれていない API は互換性の保証などがあやしいかもしれないので、使わない方針にしました。

これを見た私は、何らかの方法で**シェルコマンドを実行するしかないと勘違いしました**。

そのため、Cloud Functions のシェルで `npm install -g firebase-tools` を実行しようとして、ファイルシステムがリードオンリーのためインストールに失敗したり、Cloud Functions から Cloud Build を実行して Cloud Build 上で firebase コマンドを実行する方法を考えたりしました。
しかし、こんな面倒な方法は必要ありませんでした。

**firebase コマンドは `npm install firebase-tools` により、JavaScript から普通に呼び出せます**。
つまり、**Cloud Functions (JavaScript or TypeScript) からバックアップコマンドを直接実行可能**なのです。

したがって、**Cloud Functions の定期実行機能を使うだけで、定期バックアップの自動化が簡単に実現できます**。

## 今回のバックアップ処理の仕様

さて、自動定期バックアップ実現の目処がついたところで、今回のバックアップ処理の使用を簡単に整理します。
前提として、Firebase は無料プランではなく、GCP に請求を設定済みの Blaze プランとします。[^2]

[^2]: 無料プランであっても使いまわせる箇所はあります。

バックアップに関するざっくりした仕様は以下の通りです。

* バックアップの配置先 ... 間違えてバックアップを公開してしまうことがないよう、Firebase が用意してくれるストレージとは別に GCS バケットを作成します
* バックアップファイルの命名 ... gs://{プロジェクト ID}-firebase-authentication-backup-bucket/yyyy/MM/firebase-authentication-backup-yyyyMMdd-hhmmss.csv.encrypted とします
* バックアップ保存期間 ... 30 日とします
* リージョン ... 東京のみとします
* 暗号化 ... KMS のキーを使い、クライアントサイド (Cloud Functions) で暗号化してから GCS に配置します[^3]
* 頻度 ... 毎日深夜 3 時 (日本時間) に実行します

[^3]: KMS キーによる暗号化は GCS の機能でも実現可能ですが、今回は Cloud Functions 上で暗号化することを選びました。

# 自動定期バックアップ構築手順

以下の 3 つを順に解説していきます。

* 保存先の GCS バケットの作成
* KMS キーの作成
* Cloud Functions が使うサービスアカウントへのロール設定
* Cloud Functions にバックアップ自動定期実行関数を作成

## 保存先の GCS バケットの作成

GCS バケットの作成方法として、今回は Cloud Deployment Manager を採用しました。[^4]

[^4]: Terraform や gcloud コマンドでもいいと思います。

作成したのは以下の 2 ファイルです。

```main.yaml
imports:
- path: storage.jinja

resources:
- name: storage
  type: storage.jinja
```

```storage.jinja
resources:

- type: gcp-types/storage-v1:buckets
  name: {{ env["project"] }}-{{ env["deployment"] }}-bucket
  properties:
    location: asia-northeast1
    storageClass: STANDARD
    versioning:
      enabled: true
    # ACL を無効化し、IAM のみでアクセス権限を制御する設定
    # https://cloud.google.com/storage/docs/uniform-bucket-level-access?hl=ja
    iamConfiguration:
      uniformBucketLevelAccess:
        enabled: true
    # 保存期間を過ぎたデータを削除
    # バージョニングのライブのものとライブでないもの両方をを削除するため isLive は true と false 両方指定
    lifecycle:
      rule:
        - action:
            type: Delete
          condition:
            age: 30
            isLive: true
        - action:
            type: Delete
          condition:
            age: 30
            isLive: false
```

これらを配置したディレクトリで以下のコマンドを実行すれば、GCS バケットができます。

```console
$ gcloud deployment-manager deployments create firebase-authentication-backup --config main.yaml
```

## KMS キーの作成

以下の理由から、KMS のキーリングとキーは、Deployment Manager ではなく gcloud コマンドで作成します。

* キーリングとキーは一度作成したら削除できないため、Deployment Manager の delete などが失敗するようになる
* Deployment Manager で自動ローテーションを設定するためには、初回ローテーション日付を Python で算出するコードを書かなくてはならず、記述コストが高い

KMS のキーリングとキーを作成を作成するコマンドは以下の通りです。

```console
$ gcloud services enable cloudkms.googleapis.com
$ gcloud kms keyrings create \
  --location=asia-northeast1 \
  my-keyring
$ gcloud kms keys create \
  --location=asia-northeast1 \
  --keyring=my-keyring \
  --purpose=encryption \
  --rotation-period=90d \
  --next-rotation-time="$(date '+%Y-%m-%dT00:00:00.000000000Z' -d '90 day')" \
  firebase-authentication-backup-key
```

※ 上記のコマンドは Cloud Shell から実行することを想定しています。Cloud Shell などにインストールされている GNU 系の date コマンドを前提としているため、Mac などにインストールされている BSD 系の date コマンドでは正常に動作しません

なお、上記のコマンドではキーを 90 日で自動ローテーションしていますが、ローテーションの前後で復号ができなくなる訳ではないのでご安心ください。

参考

* [Macでdateコマンドが違う件について](https://tatsuno-system.co.jp/2016/06/27/mac%E3%81%A7date%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%E3%81%8C%E9%81%95%E3%81%86%E4%BB%B6%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6/)
* [鍵バージョンの破棄と復元](https://cloud.google.com/kms/docs/destroy-restore?hl=ja)

## Cloud Functions が使うサービスアカウントへのロール設定

キーリングとキーを作成したら、Cloud Functions が KMS を利用した暗号化を実行できるよう、Cloud Functions がデフォルトで使っているサービスアカウント「{プロジェクト ID}@appspot.gserviceaccount.com」に対して cloudkms.cryptoKeyEncrypter のロールを追加します。

```console
$ gcloud kms keys \
  add-iam-policy-binding \
  --location=asia-northeast1 \
  --keyring=my-keyring \
  firebase-authentication-backup-key \
  --member=serviceAccount:"$(gcloud config list --format='value(core.project)')"@appspot.gserviceaccount.com \
  --role=roles/cloudkms.cryptoKeyEncrypter
```

この設定をしないと、Cloud Functions が暗号化を実行しようとしたタイミングでエラーが発生します。

## Cloud Functions にバックアップ自動定期実行関数を作成


まずは必要なライブラリをインストールします。
(TypeScript を使っているため、型定義もインストールします)

```console
$ npm install @google-cloud/kms @google-cloud/storage firebase-admin firebase-tools
$ npm install --save-dev @types/google-cloud__kms @types/google-cloud__storage
```

コードは以下のようになります。

```ts
import * as kms from '@google-cloud/kms';
import * as gcs from '@google-cloud/storage';
import * as admin from "firebase-admin";
import * as functions from "firebase-functions";
import * as fs from "fs";

// 型定義がないため require でインポート
const firebaseTools = require("firebase-tools")

const region = "asia-northeast1";

admin.initializeApp();
const adminAuth = admin.auth();

function getProjectId() {
  const projectId = admin.instanceId().app.options.projectId;
  if (!projectId) {
    throw Error('projectId not exist.')
  }
  return projectId
}

export const backupFirebaseAuthentication = functions
  .region(region)
  .pubsub
  // 毎日深夜 3 時
  .schedule('0 3 * * *')
  .timeZone('Asia/Tokyo')
  .onRun(async (context) => {
    const projectId = getProjectId()
    const bucketName = `${projectId}-firebase-authentication-backup-bucket`

    const now = new Date()
    const timestamp = now.getFullYear()
      + ('0' + (now.getMonth() + 1)).slice(-2)
      + ('0' + now.getDate()).slice(-2)
      + '-' + ('0' + now.getHours()).slice(-2)
      + ('0' + now.getMinutes()).slice(-2)
      + ('0' + now.getSeconds()).slice(-2)
    const plaintextFileName = `firebase-authentication-backup-${timestamp}.csv`

    const tmpDir = '/tmp'
    const tmpPlaintextFileName = `${tmpDir}/${plaintextFileName}`
    console.log(`tmpPlaintextFileName = ${tmpPlaintextFileName}`)
    const tmpCiphertextFileName = `${tmpDir}/${plaintextFileName}.encrypted`
    console.log(`tmpCiphertextFileName = ${tmpCiphertextFileName}`)

    const gcsDestination = `${now.getFullYear()}/${('0' + (now.getMonth() + 1)).slice(-2)}/${plaintextFileName}.encrypted`
    console.log(`gcsDestination = ${gcsDestination}`)

    // ローカルに取得
    await firebaseTools.auth.export(tmpPlaintextFileName, { project: projectId })

    // ファイル読み込み
    const plaintext = fs.readFileSync(tmpPlaintextFileName)

    // 暗号化
    const kmsClient = new kms.KeyManagementServiceClient()
    const keyName = kmsClient.cryptoKeyPath(projectId, region, 'my-keyring', 'firebase-authentication-backup-key')
    const [result] = await kmsClient.encrypt({ name: keyName, plaintext })
    fs.writeFileSync(tmpCiphertextFileName, result.ciphertext)

    // GCS に保存
    const gcsClient = new gcs.Storage()
    const bucket = gcsClient.bucket(bucketName)
    await bucket.upload(tmpCiphertextFileName, { destination: gcsDestination })

    // ローカルのファイルを削除
    fs.unlinkSync(tmpPlaintextFileName)
    fs.unlinkSync(tmpCiphertextFileName)
  })
```

あとは関数をデプロイすれば OK です。

```console
$ firebase deploy
```

これで Firebase Auth の定期自動バックアップが実現できました。

なお、手動でバックアップをとりたくなった場合も、GCP のコンソールから Cloud Scheduler の「実行」を押すだけで簡単にバックアップ可能です。

# リストアについて

バックアップができても、リストアができなければ意味がありません。
そこで、このバックアップデータを fireabse auth:import コマンドで import した際の挙動などを簡単に検証しました。

## 具体的なコマンド

GCS からのファイルダウンロード、KMS での復号、Firebase Auth への import は以下のようなコマンドになります。

```console
$ gsutil cp gs://{GCS 上のバックアップファイル} firebase-authentication-backup.csv.encrypted
$ gcloud kms decrypt \
  --location="asia-northeast1" \
  --keyring="my-keyring" \
  --key="firebase-authentication-backup-key" \
  --plaintext-file="firebase-authentication-backup.csv" \
  --ciphertext-file="firebase-authentication-backup.csv.encrypted"
$ firebase auth:import firebase-authentication-backup.csv
```

## import 時の挙動

import した際の挙動は以下のようになりました。

- Firebase Authentication 上に存在しないデータについて
    - バックアップデータに存在するデータがインポートされる
- Firebase Authentication 上に存在するデータについて
    - バックアップデータとユーザ ID で紐付けられ、メールアドレスなどが上書きされる
    - バックアップデータに存在しないユーザ ID のデータについては、特に何も起こらない (削除されない)

## ハッシュパラメータの保存

Firebase Auth では、メールアドレス・パスワードでユーザ登録した際のパスワードハッシュ化のパラメータを確認することができます。

パスワードハッシュ化のパラメータが異なるデータをインポートした場合、ログインしようとしても、パスワードが異なるというエラーでログインできなくなります。
(パスワードリセットすればログイン可能になります)

ハッシュ化のパラメータをどこかに保存しておけば、パスワードが異なるデータをインポートした際のパスワード再設定も回避可能になります。
今回は、「Firebase プロジェクトを誤削除する可能性」と「ハッシュ化パラメータを安全に管理する手間」を天秤にかけ、あまりメリットが大きくないと考え、そこまでは実施しませんでした。

# Firebase バックアップの SaaS

Firebase Auth のバックアップは結構需要があると思うのですが、自前で実現するのはちょっとだけ手間がかかります。
実は、Firebase のバックアップ機能が弱いことに着目した [Backup Fire](https://backupfire.dev/) という SaaS もありました。

今回は採用しませんでしたが、何かを自前で構築しようとしている時、SaaS やライブラリがないか事前に探すのも非常に大切だと思います。

# 終わりに

Firebase Auth のバックアップは、ゆくゆく公式に提供される機能かもしれませんが、いつになるかは分かりません。
サービスを運営する上で、バックアップがあることによる安心はとても大きいと思います。
ちょっとの手間でできてしまうので、是非やってみてください。

**※ Firebase Auth のバックアップファイルには、漏洩すると大問題になりうるデータが大量に含まれます。この記事の内容は、ストレージの公開設定や暗号鍵の管理、Cloud IAM などを十分に理解したうえで、自己責任でご利用ください**