Firebaseで、Firestore & Authenticationのバックアップ/リストア環境を構築する

###この記事を書いた背景

現在作成しているFirebaseアプリにて、運用フェーズに必要なDBのバックアップ/リストアを構築。
Firebaseでは、firebase-toolを利用したCLIでのバックアップ/リストアのコマンドが用意されているが、今回はリソースを一箇所に集中するために、Firebase functionsで実現できるようにしたい。
構築までに、いろいろなポイントで躓いたので、メモとして残すことにした。


### 実現させたい事
1. Production環境のfirestoreを、毎日3:00、Cloud StorageにExportする
1. Production環境のAuthenticationを、毎日3:00、Cloud StorageにExportする
1. Staging環境のfirestoreを、毎日4:00、Cloud StorageのデータをfirestoreにImportする
1. Staging環境のAuthenticationを、毎日4:00、Cloud StorageのデータをfirestoreにImportする
1. Authenticationにはメールアドレス、パスワードなどの個人情報があるので、KMSを経由して暗号化させる
1. 上記のExport/Importの成功/失敗をSlackに通知する
1. 上記を、functionsで実行させる



### FirestoreのExport
事前にCloud Storageに保存用のバケットを作成する必要あり。
https://qiita.com/atomyah/items/0c64e11e52c1690eb48e

```functions/src/exportFirestore.js

const functions = require("firebase-functions");
const moment = require("moment");

const firestore = require("@google-cloud/firestore");
const client = new firestore.v1.FirestoreAdminClient();
const bucketName = "gs://backup-firestore-daily";
const fileName = moment().format("YYYY-MM-DD");

const notifySlack = require("./module/notifySlack");

module.exports = functions
  .region("asia-northeast1")
  .pubsub.schedule("0 3 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(async (context) => {

    const env = functions.config().functions.env;
    if (env !== "production") {
      return false;
    }
    notifySlack("deploy", `START export firestore: ${env}`, "grey");

    const projectId = JSON.parse(process.env.FIREBASE_CONFIG).projectId;
    const exportDatabaseName = client.databasePath(projectId, "(default)");

    await client
      .exportDocuments({
        name: exportDatabaseName,
        outputUriPrefix: `${bucketName}/${fileName}`,
        collectionIds: [],
      })
      .then(() => {
        notifySlack("deploy", `SUCCESS export firestore: ${env}`, "good");
      })
      .catch((err) => {
        notifySlack(
          "deploy",
          `FAILS export firestore: ${env} --> ${err}`,
          "danger"
        );
      });

    return { message: "finish" };
  });
```

ここでのポイントは、firestoreインスタンスのexportDocumentsメソッドを利用。
https://cloud.google.com/firestore/docs/reference/rest/v1/projects.databases/exportDocuments?hl=ja
引数のcollectionIdsにある配列内に、特定のコレクションを指定する事も可能。[]にすると、全てのコレクションを取得する。

functions内の冒頭で、下記のif文処理をしているのは、Exportは本番環境のみが実行するため。

```
 const env = functions.config().functions.env;
 if (env !== "production") {
   return false;
 }
```

functions.config().functions.env は、事前にCLIからfirebaseに登録する必要あり。（変数名は、何でもOK）

```
$ firebase functions:config.set functions.env="production" --project production
$ firebase functions:config.set functions.env="staging" --project staging
```

こんな感じで、FirestoreのExportは簡単。



### FirestoreのImport

```functions/srt/importFirestore.js

const functions = require("firebase-functions");
const moment = require("moment");

const firestore = require("@google-cloud/firestore");
const client = new firestore.v1.FirestoreAdminClient();
const bucketName = "gs://backup-firestore-daily";
const fileName = moment().format("YYYY-MM-DD");

const notifySlack = require("./module/notifySlack");

module.exports = functions
  .region("asia-northeast1")
  .pubsub.schedule("0 4 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(async (context) => {

    const env = functions.config().functions.env;
    if (env !== "staging") {
      return false;
    }
    notifySlack("deploy", `START import firestore: ${env}`, "grey");

    const projectId = JSON.parse(process.env.FIREBASE_CONFIG).projectId;

    await client
      .importDocuments({
        name: `projects/${projectId}/databases/(default)`,
        inputUriPrefix: `${bucketName}/${fileName}`,
        collectionIds: [],
      })
      .then((responses) => {
        notifySlack("deploy", `SUCCESS import firestore: ${env}`, "good");
      })
      .catch((err) => {
        notifySlack(
          "deploy",
          `FAILS import firestore: ${env} --> ${err}`,
          "danger"
        );
      });

    return { message: "finish" };
  });
```

ここでのポイントは、先ほど作成したStorageのバケット（backup-firestore-daily）に対して、Staging環境からデータを読み取るためのIAM rolesを付与する必要がある。そうしないと、IAMのエラーが出る。
IAM rolesの追加方法は、下記を参考。
https://blog.jicoman.info/2019/10/access-gcs-bucket-in-other-project/



### AuthenticationのExport

Firebaseアプリでは、認証まわりをFirebase Authentication（以後Auth）を利用すると思う。FirebaseのCLIでも、この辺りのExport/Importコマンドは用意されている。
Authは、メールアドレス、パスワードなどの個人情報が含まれているので、今回はKMSを利用して暗号化したデータをStorageに保存することにする。

KMSに関して詳しく知りたい方は、下記を参考に。
https://cloud.google.com/kms/docs?hl=ja

```functions/src/exportAuth.js

const functions = require("firebase-functions");
const moment = require("moment");
const admin = require("firebase-admin");

const firebaseTools = require("firebase-tools");
const fs = require("fs");
const gcs = require("@google-cloud/storage");
const kms = require("@google-cloud/kms");
const region = "asia-northeast1";

const bucketName = "backup-authentication-daily";
const bucketNameOfClaims = "backup-authentication-claims-daily";
const fileNameDate = moment().format("YYYY-MM-DD");
const tmpDir = "/tmp";
let resultStatus = "OK";

const kmsProjectId = "prod-myApp-a869e";
const keyring = "my-auth-keyring";
const key = "my-auth-key";

const notifySlack = require("./module/notifySlack");

module.exports = functions
  .region("asia-northeast1")
  .pubsub.schedule("0 3 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(async (context) => {

    const env = functions.config().functions.env;
    if (env !== "production") {
      return false;
    }
    notifySlack("deploy", `START export Auth: ${env}`, "grey");

    const projectId = JSON.parse(process.env.FIREBASE_CONFIG).projectId;
    const plaintextFileName = `${fileNameDate}.json`;
    const tmpPlaintextFilePath = `${tmpDir}/${plaintextFileName}`;
    const tmpCiphertextFilePath = `${tmpDir}/${plaintextFileName}.encripted`;

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

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

    // 暗号化
    const kmsClient = new kms.KeyManagementServiceClient();
    const keyName = kmsClient.cryptoKeyPath(kmsProjectId, region, keyring, key);

    const [result] = await kmsClient.encrypt({ name: keyName, plaintext });
    fs.writeFileSync(tmpCiphertextFilePath, result.ciphertext);

    // GCS に保存
    const gcsClient = new gcs.Storage();
    const bucket = gcsClient.bucket(bucketName);
    await bucket.upload(tmpCiphertextFilePath);

    // ローカルのファイルを削除
    fs.unlinkSync(tmpPlaintextFilePath);
    fs.unlinkSync(tmpCiphertextFilePath);

    /////////
    /// export custom_claims
    ////////
    const string_text = JSON.parse(plaintext.toString("utf-8"));
    const claimsArr = [];
    for (const row of string_text["users"]) {
      await admin
        .auth()
        .getUser(row["localId"])
        .then((userRecord) => {
          if (userRecord.customClaims) {
            claimsArr.push({
              uid: row["localId"],
              userType: userRecord.customClaims.userType,
              status: userRecord.customClaims.status,
            });
          }
          resultStatus = "OK";
        })
        .catch((err) => {
          resultStatus = "NG";
        });
    }
    const claimsHash = { custom_claims: claimsArr };

    const claimsTextFileName = `${fileNameDate}_custom_claims.json`;
    const claimsTextFilePath = `${tmpDir}/${claimsTextFileName}`;
    fs.writeFileSync(claimsTextFilePath, JSON.stringify(claimsHash));

    const gcsClientClaims = new gcs.Storage();
    const bucketClaims = gcsClientClaims.bucket(bucketNameOfClaims);
    await bucketClaims.upload(claimsTextFilePath);

    // ローカルのファイルを削除
    fs.unlinkSync(claimsTextFilePath);

    if (resultStatus === "OK") {
      notifySlack("deploy", `SUCCESS export Auth: ${env}`, "good");
    } else {
      notifySlack("deploy", `FAILS export Auth: ${env} --> ${err}`, "danger");
    }

    return { message: "OK" };
  });
```

ここでのポイントは、前半にてAuthのバックアップをKMSを利用してStorageに保存。後半にて、AuthのCustom ClaimsをStorageに保存。（claimsには運用に必要な情報しかないので、KMSは利用しない。）

悲しいことに、firebaseTools.auth.exportのメソッドでは、下記のAuth情報しか取れない。。。
https://orangelog.site/firebase/firebase-user-csv-import/
Authに紐づいているCustom Claimsは、authのuidを使ってEachで回して取得するという力技が必要。（Firebaseの今後の改善に期待！）

今回利用した、KMSのkeyringとkeyは下記コマンドで作成。

```
// 設定したいプロジェクトをセット
$ gcloud config set project prod-myApp-a869a

// keyringの作成
$ gcloud kms keyrings create \
--location=asia-northeast1 \
my-auth-keyring 

// keyの作成
$ gcloud kms keys create \
  --location=asia-northeast1 \
  --keyring=my-auth-keyring \
  --purpose=encryption \
  my-auth-key  

// 暗号化EncriyptのIAM rolesを付与 for Production
$ gcloud kms keys \
add-iam-policy-binding \
--location=asia-northeast1 \
--keyring=my-auth-keyring \
my-auth-key \
--member=serviceAccount:prod-myApp-a869e@appspot.gserviceaccount.com \
--role=roles/cloudkms.cryptoKeyEncrypter

// 複合化DecriyptのIAM rolesを付与 for Staging
$ gcloud kms keys \
add-iam-policy-binding \
--location=asia-northeast1 \
--keyring=my-auth-keyring \
my-auth-key \
--member=serviceAccount:staging-myApp-a342e@appspot.gserviceaccount.com \
--role=roles/cloudkms.cryptoKeyDecrypter

```

作成したkeyにrotationのオプションを付けることも出来ます。
https://qiita.com/os1ma/items/f7a00e82c0758bb08c13



### AuthenticationのImport
事前に利用しているバケットに対して、Stagingから読み取りできるように、IAM rolesを付与する必要あり。（やることはFirestoreのImportと同じ。）


```functions/src/importAuth.js

const functions = require("firebase-functions");
const admin = require("firebase-admin");
const moment = require("moment");

const fs = require("fs");
const gcs = require("@google-cloud/storage");
const kms = require("@google-cloud/kms");
const region = "asia-northeast1";

const bucketName = "backup-authentication-daily";
const bucketNameOfClaims = "backup-authentication-claims-daily";
const fileName = moment().format("YYYY-MM-DD");
let resultStatus = "OK";
const tmpDir = "/tmp";

const kmsProjectId = "prod-myApp-a869e";
const keyring = "my-auth-keyring";
const key = "my-auth-key";

const notifySlack = require("./module/notifySlack");

module.exports = functions
  .region("asia-northeast1")
  .pubsub.schedule("0 4 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(async (context) => {

    const env = functions.config().functions.env;
    if (env !== "staging") {
      return false;
    }
    notifySlack("deploy", `START import Auth: ${env}`, "grey");

    const plaintextFileName = `${fileName}.json`;
    const ciphertextFileName = `${plaintextFileName}.encripted`;
    const tmpPlaintextFileName = `${tmpDir}/${plaintextFileName}`;
    const tmpCiphertextFileName = `${tmpDir}/${plaintextFileName}.encripted`;

    // GCS からLocalの/tempにDL
    const options = {
      destination: tmpCiphertextFileName,
    };
    const gcsClient = new gcs.Storage();
    const bucket = gcsClient.bucket(bucketName);
    await bucket.file(ciphertextFileName).download(options);

    // ファイル読み込み
    const ciphertext = fs.readFileSync(tmpCiphertextFileName);

    // 複合化
    const kmsClient = new kms.KeyManagementServiceClient();
    const keyName = kmsClient.cryptoKeyPath(kmsProjectId, region, keyring, key);
    const [result] = await kmsClient.decrypt({
      name: keyName,
      ciphertext,
    });
    const plaintext = result.plaintext.toString("utf8");
    fs.writeFileSync(tmpPlaintextFileName, plaintext);

    const jsonText = JSON.parse(plaintext); //parse json
    const userAuthes = []; //この配列の中にパースしたcsvの中身をkey-value形式で入れていく。

    jsonText["users"].forEach(function(response) {
      if (response["localId"]) {
        userAuthes.push({
          uid: response["localId"],
          email: response["email"],
          passwordHash: Buffer.from(response["passwordHash"], "base64"),
          passwordSalt: Buffer.from(response["salt"], "base64"),
        });
      }
    });

    await admin
      .auth()
      .importUsers(userAuthes, {
        hash: {
          algorithm: "SCRYPT",
          key: Buffer.from(functions.config().auth.hash.key, "base64"),
          saltSeparator: Buffer.from("Bw==", "base64"),
          rounds: 8,
          memoryCost: 14,
        },
      })
      .then(function(results) {
        results.errors.forEach(function(indexedError) {
          console.log(">>>>>> Error importing user " + indexedError.index);
          resultStatus = "NG";
        });
      })
      .catch(function(error) {
        console.log(">>>>>> catch Error importing users:", error);
        resultStatus = "NG";
      });

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

    /////
    // Import custom claims
    ////
    const claimsTextFileName = `${fileName}_custom_claims.json`;
    const claimsTextFilePath = `${tmpDir}/${claimsTextFileName}`;

    // GCS からLocalの/tempにDL
    const gcsClientClaims = new gcs.Storage();
    const bucketClaims = gcsClientClaims.bucket(bucketNameOfClaims);
    await bucketClaims.file(claimsTextFileName).download({
      destination: claimsTextFilePath,
    });

    const claimsCiphertext = fs.readFileSync(claimsTextFilePath);
    const jsonClaimsData = JSON.parse(claimsCiphertext.toString("utf-8"));

    for (const row of jsonClaimsData.custom_claims) {
      if (row.uid) {
        await admin.auth().setCustomUserClaims(row.uid, {
          userType: row.userType,
          status: row.status,
        });
      }
    }

    // ローカルのファイルを削除
    fs.unlinkSync(claimsTextFilePath);

    if (resultStatus === "OK") {
      notifySlack("deploy", `SUCCESS import Auth: ${env}`, "good");
    } else {
      notifySlack("deploy", `FAILS import Auth: ${env}`, "danger");
    }

    return { message: resultStatus };
  });
```

ここでのポイントは、functionsのAdminを利用して、admin.auth().importUsers でAuthをImportするのですが、パスワードは暗号化されているので、下記を参考にFirebase SCRYPTでパスワードをハッシュしてユーザーをインポートする必要がある。
https://firebase.google.com/docs/auth/admin/import-users?hl=ja

パスワードハッシュパラメータは、重要な機密情報なので、functions.config()に登録して利用します。（firebaseで、取得メソッドが用意されている？と嬉しいのですが。。。）


### 今後の課題

1. admin.auth().importUsersでは、一度に1000人までしかimportできないので、Authの件数が増えたらeachで回すなどの工夫が必要
2. Custom ClaimsのExport/Importにて、Eachで回して、1人づつ取得/設定を行っている。Authの件数が増えた時の処理速度が気になる
3. Firestore、AuthともにImportをすると、差分が残る仕様になっている。下記のような状況が発生するので、将来的にゴミデータが残り続ける。解決策としては、StagingのFirestoreを一度空にしてからImportする方法があるが、そうすると、毎回のImportにかかる処理時間が増えて、利用料金も増える。

```
＜Import前＞
Production：user A, userB
Staging：user B, userC

＜Import後＞
Production：user A, userB
Staging：userA, user B, userC
```

### 最後に。。。

Firebaseはアプリ作成に関しては、とても簡単で優れていると思う一方、リリース後の運用フェーズを考えると、もう少し改善余地があると思う。
例えば、Custom Claimsの一括Export/Importなどのメソッドが出来ないなど。。。
さらに期待をするなら、FirebaseのGUIからBackup/Restoreを簡単に登録できる仕組みがあったらいいと思う。
以前利用していた、HerokuではAdd-onで簡単に設定が出来ていたので、今後のFirebaseに進化に期待！