見出し画像

AWS Cognitoユーザーのバックアップ・リストアソリューションを開発する方法

はじめに

こんにちは、エアークローゼットでエンジニアをしているNghiaです。
この記事は、エアークローゼット Advent Calendar 2024 の24日目の記事です!よろしくお願いします!

背景

エアークローゼットではイベント駆動アーキテクチャへの移行を徐々に進めています。マイクロサービス認証認可はAWS Amazon Cognito使用しています。Amazon Cognitoは、ウェブアプリやモバイルアプリ向けのユーザー登録およびログインプロセスを処理するために、世界中の多くの企業が利用しているAWSの高スケーラブルなサービスです。
しかし、AWS Cognitoには現在のところバックアップソリューションが提供されていません。AWSは独自のソリューションを構築するためのフレームワークドキュメントを提供していますが、ユーザープロファイルや関連データを安全に保つことは非常に重要です。バックアップは、データの誤削除といった事故を防ぐだけでなく、ユーザーデータを新しいユーザープールに移行するといった場合にも必要不可欠です。これらの理由から、独自のソリューションを開発することを決めました。このバックアップソリューションは、JavaScript用のaws-sdkと、AWS Lambdaモジュールを設定するためのTerraformを使用して構築されました。本記事では、アーキテクチャ全体の設定の詳細には触れませんが、最も関連性が高いと感じた実装のコア部分を紹介します。

AWS Lambdaを特定のトリガーイベントに連携

全体的なアプローチは、AWS Lambdaをトリガーイベントに連携することでした。このトリガーイベントは新しいユーザーが作成され、そのユーザーがメールアドレスを確認して登録プロセスを完了した時に発生します。
つまり、新しいユーザーが作成され、アカウントが確認されると、この「Post Confirmation」イベントがLambda関数をトリガーし、ユーザーデータをすぐにS3(AWSのオブジェクトストレージサービス)にコピーします。S3は高いスケーラビリティ、データ可用性、セキュリティ、パフォーマンスを提供します。この方法は、ユーザー情報が一度取得されると、それが第二の場所に安全に保存されることを確実にする最初の部分です。

バックアップソリューションは以下に示されています。

const AWS = require("aws-sdk");
const s3 = new AWS.S3();
const cognitoBackupBucket = process.env.COGNITO_BACKUP_BUCKET;
  
async function uploadFileToS3(userInfo) {
  const destParams = {
    Bucket: cognitoBackupBucket,
    Key: "cognito_user_" + userInfo.userName,
    Body: JSON.stringify(userInfo),
  };
  
  await s3.putObject(destParams).promise();
}

/* **** Main Flow **** 
 * Upload the Cognito user created 
 * to a new user file on the S3 bucket
*/
// handles Cognito Post Confirmation Event
exports.handler = (event, context, callback) => {
  uploadFileToS3(event);
  console.log("User Updated", event);
  
  callback(null, event);
} 

データを復元するために、別のLambdaを作成

次のステップは、S3に保存された情報を復元する必要がある場合に実行されます。例えば、ユーザーが誤って削除された場合や、データを別のCognitoユーザープールに移行する必要がある場合です。このために、2番目のAWS Lambdaを作成しました。
このLambdaは、復元したいデータがある場合にAWSコンソールから手動でトリガーする必要があります。手動でトリガーされると、このLambda関数は、ファイルシステムのS3バケット(COGNITO_BACKUP_BUCKET)に保存されたすべてのユーザーをリストし、それをAmazon Cognitoユーザープール(COGNITO_USER_POOL_ID)に更新します。これが新しいユーザープールであっても、古いものであっても構いません。この汎用的なソリューションでは、バケットに保存されているすべてのS3ユーザーが対象となりますが、Cognitoはユーザープールに存在しないユーザーのみをインポートし、既存のユーザーには失敗します。

バックアップ復元ソリューションは以下に示されています。

const AWS = require("aws-sdk");
const fs = require("fs");
const util = require("util");
const requestPromise = require("request-promise");
const uuid = require("uuid");
  
const writeFile = util.promisify(fs.writeFile);
const appendFile = util.promisify(fs.appendFile);
const readFile = util.promisify(fs.readFile);
  
const jobName = uuid();
const csvFile = "/tmp/data.csv";
  
const Bucket = process.env.COGNITO_BACKUP_BUCKET;
const UserPoolId = process.env.COGNITO_USER_POOL_ID;
const CloudWatchLogsRoleArn = process.env.COGNITO_IMPORT_USERS_ROLE_ARN;
  
const s3 = new AWS.S3();
const cognito = new AWS.CognitoIdentityServiceProvider();
  
async function startUserImportJob(JobId) {
  const params = {
    JobId,
    UserPoolId,
  };
  
  await cognito.startUserImportJob(params).promise();
}
  
async function uploadCSVFile(job) {
  try {
    const data = await readFile(csvFile, { encoding: "utf8" });
    await requestPromise.put(
      job.UserImportJob.PreSignedUrl,
      {
        headers: {
          "x-amz-server-side-encryption": "aws:kms",
          "content-type": "text/csv",
        },
        body: data,
      },
      (errUpload) => {
        if (errUpload) {
          throw errUpload;
        }
      }
    );
  } catch (err) {
    throw err;
  }
}
  
async function createUserImportJob() {
  const params = {
    CloudWatchLogsRoleArn,
    JobName: jobName,
    UserPoolId,
  };
  
  return await cognito
    .createUserImportJob(params)
    .promise()
    .catch((err) => {
      throw err;
    });
}
  
async function appendUserToCsv(user, headers) {
  const userAttributes = user.request.userAttributes;
  let csvContent = "";
  headers.forEach((field) => {
    switch (field.includes("custom") || field) {
      case "email":
        csvContent += userAttributes.email + ",";
        break;
      case "email_verified":
        csvContent += (userAttributes.email_verified || false) + ",";
        break;
      case "phone_number_verified":
        csvContent += (userAttributes.phone_number_verified || false) + ",";
        break;
      case "cognito:username":
        csvContent += userAttributes.email;
        break;
      case "cognito:mfa_enabled":
        csvContent += false + ",";
        break;
      case true:
        csvContent += (userAttributes[field] || "") + ",";
        break;
      default:
        csvContent += ",";
    }
  });
  await appendFile(csvFile, csvContent + "\n");
}
  
async function getAllFiles() {
  return new Promise((resolve, reject) => {
    const files = [];
  
    s3.listObjects({ Bucket }).eachPage((error, data) => {
      if (error) {
        reject(error);
        return;
      } else if (!data) {
        resolve(files);
        return;
      }
  
      files.push(...data.Contents);
    });
  });
}
  
async function exportCognitoUsersToCsv() {
  const files = await getAllFiles();
  
  const obj = await cognito
    .getCSVHeader({ UserPoolId })
    .promise()
    .catch((err) => {
      throw err;
    });
  
  const headers = obj.CSVHeader;
  
  await writeFile(csvFile, headers.toString() + "\n");
  
  await Promise.all(
    files.map(async (file) => {
      const content = await s3
        .getObject({ Bucket, Key: file.Key })
        .promise()
        .catch((err) => {
          throw err;
        });
  
      let user = JSON.parse(content.Body.toString());
      appendUserToCsv(user, headers);
    })
  );
}
  
  
/* **** Main Flow ****                                                                                                        * if it is a success, you can check the users updated to the UserPool, 
 * if it fails, check your setup; one recurring flaw is that there is some policy missing… 
 */

// handles manual trigger.
exports.handler = async () => { 
  await exportCognitoUsersToCsv(); // exports the users from s3 to a CSV file
  const job = await createUserImportJob(); // creates an import Job to the Cognito UserPool
  await uploadCSVFile(job); // upload the CSV file to this Job
  await startUserImportJob(job.UserImportJob.JobId); // starts the job created to import the users to the Cognito UserPool
   
  return { statusCode: 200, body: "Cognito restored users with success" }; 
};

注意ポイント

ユーザープールからのユーザーのエクスポート/インポートにはいくつかの制限があります。

  • パスワードのバックアップ: パスワードのバックアップはなく、ユーザーはパスワードをリセットする必要があります。

  • Cognitoのサブ属性の更新: ソフトウェアがこれに依存している場合、サブ属性はカスタム属性にコピーすることができます。あるいは、カスタム属性をサブ属性の代わりにソフトウェアソリューションで使用することも可能です。

  • データの重複: 一部のCognito APIは「Post Confirmation Event」をトリガーするため、S3にデータのバックアップが複数回保存される可能性があります(冗長性を防ぐためにアーキテクチャを深堀りして対策を講じる必要があります)。

まとめ

今回は、AWS LambdaとS3を使用し、バックアップ・リストアソリューションを開発できました。
エアクロではイベント駆動アーキテクチャ開発に挑戦したいエンジニアも積極的に募集中ですので、もしご興味があれば、ぜひ参加してください。->採用サイト

さいごに

最後までご覧いただきありがとうございました🙇‍♂️
エアークローゼット Advent Calendar 2024はまだ続きますので、ぜひ他のエンジニア, デザイナー, PMの記事もご覧いただければと思います。
よろしくお願いします!

いいなと思ったら応援しよう!