MFA設定必須のCognitoのクロスアカウントマイグレーションについて

Cognitoのクロスアカウントマイグレーションについて

はじめに

こんにちは、計測プラットフォーム開発本部SREブロックの近藤です。普段はZOZOMATやZOZOGLASS、ZOZOFITなどの計測技術に関わるシステムの開発、運用に携わっています。

計測プラットフォーム開発本部では、複数のプロダクトを運用していますが並行して新しいプロダクトも開発しています。SREチームでは増え続けるプロダクトの運用負荷に対して改善は行っていますが、さらなるプロダクトの拡張に備えてZOZOFITの開発運用を別チームへ移管することになりました。移管作業の中でAWSリソースを別チームが管理するAWSアカウントへ移行する作業が発生することになりました。本記事では移行時に遭遇した課題と、その課題の解決に至るまでの取り組みをご紹介します。

目次

背景・課題

まず、ZOZOFITを移管する上でAWSのリソースを別アカウントへ移行する事を検討しました。別チームの管理となるため管理上は別アカウントへ移行するのが適切な形です。ただし、クロスアカウントでのリソース移行は制約も多いため、慎重に検討する必要があります。このためまずはクロスアカウントでのデータ移行方法と影響範囲について調査しました。

ZOZOFITのシステム構成は以下の記事で詳細を記載していますが、データの移行対象となるのは、S3、RDS、Cognitoのユーザープール、の3つでした。前提として今回の移行ではサービス停止(ダウンタイムが発生する)が許容されていました。この中で、S3はレプリケーションを事前に設定し、RDSはスナップショットを利用してそれぞれデータを移行するため、クロスアカウント固有の事情で影響が大きくなることはないと判断しました。一方で、Cognitoのユーザプール移行に関しては未知の部分だったので調査から始まりました。

調査

Cognitoのユーザープールの移行方法について調査した結果、AWS公式ドキュメントから、以下の2つの方法があるとわかりました。どちらの方法もクロスアカウント固有の制約はなく、この2つの移行方法についてそれぞれのPros/Consを整理して比較しました。なお、どちらの方法でもセッション情報が引き継がれず、ユーザーがサインアウト状態になる影響も判明しました。この点に関しては共通事項のため比較要素としては記載していません。

  1. 1CSVファイルからユーザプールへのインポート
  2. 2ユーザ移行Lambdaを利用した、イベントドリブンのユーザ移行
移行方法 Pros Cons
CSVファイルからユーザプールへのインポート すべてのユーザの移行が一括で行える パスワードリセットが全ユーザに強制される
ユーザ移行Lambdaを利用した、イベントドリブンのユーザ移行 サインインまたはパスワードをリセットすることで、データの移行が完了する ユーザがサインインまたはパスワードリセットを行った際にしか移行が行われないため、移行に長期間を要する

Pros/Consを比較した結果、ユーザ影響の少ないユーザ移行Lambdaによるデータ移行を移行方法として選択する方針となりました。しかし、ここで1つ課題が見つかりました。移行対象のユーザープールではMFAの設定を必須にしており、移行時にMFAの有効化ができるか懸念があったためです。他社の事例を見ると回避手段がないように見えましたがAWSのテクニカルアカウントマネージャーに相談したところ、ユーザ移行Lambdaを利用してMFAの設定を有効化する方法を教えていただきました。懸念事項の回避手段が見つかり、実際にユーザ移行Lambdaを作成することになりました。

ユーザ移行Lambdaの作成

まずはユーザ移行Lambdaの挙動を整理するために簡易的なダイアグラムとフローチャートを用意しました。

簡易ダイアグラム

ユーザ移行Lambdaの簡易ダイアグラム

フローチャート

サインイン パスワードリセット
cognito_migration_sign-in cognito_migration_password-reset

移行先アカウントのユーザープールに紐づけられたユーザ移行Lambdaが移行元のユーザープールのデータを取得する形となっています。ユーザ移行Lambdaは取得したユーザのデータの取得をレスポンスとして返すだけで、実際の登録処理は行いません。実際の移行先のユーザープールへのデータ登録はAWS側が行います。ここまででユーザ移行Lambdaの動きを簡単に説明しましたが、ユーザ移行Lambdaの実処理に関して実装時に注意したポイントを記載します。

ユーザ移行Lambdaの処理

ここからユーザ移行Lambdaのコードと実装時に注意したポイントを解説します。AWSで提供されているドキュメントを参考にしながらPythonで実装しました。処理の流れとしては、最初にユーザの存在を確認し、次にイベント情報をみて処理を分岐させます。サインインの場合は認証を処理した上でレスポンスを返し、パスワードリセットの場合は何もせずにレスポンスを返す形になっています。ここで注意したポイントは以下の2つです。

  • データ移行の観点から、レスポンスに含めるユーザ情報は移行元ユーザープールの情報を利用する
  • レスポンスを受け取るのはAWS側であるため、AWS公式ドキュメントに記載されているコードに沿って実装することを優先し、例外などもそのまま返す
import boto3
from boto3.session import Session
import json
import os

def lambda_handler(event, context):
    # setting src resource info
    SRC_ROLE_ARN  = os.environ['SRC_ROLE_ARN']
    SRC_USER_POOL_ID = os.environ['SRC_USER_POOL_ID']
    SRC_USER_POOL_CLIENT_ID = os.environ['SRC_USER_POOL_CLIENT_ID']
    SRC_AWS_REGION = os.environ['SRC_AWS_REGION']

    # switch to src aws account
    sts_cli = boto3.client('sts')
    response = sts_cli.assume_role(
      RoleArn=SRC_ROLE_ARN,
      RoleSessionName="switch_role_session"
    )

    session = Session(
      aws_access_key_id=response['Credentials']['AccessKeyId'],
      aws_secret_access_key=response['Credentials']['SecretAccessKey'],
      aws_session_token=response['Credentials']['SessionToken'],
      region_name=SRC_AWS_REGION
    )

    src_client = session.client('cognito-idp')
    # get user info from src cognito by input user info from event
    username = event['userName']

    try:
      user = src_client.admin_get_user(UserPoolId=SRC_USER_POOL_ID, Username=username)
    except Exception as e:
      print(f"Unexpected {e=}, {type(e)=}")
      raise e

    # initiate auth from src cognito, if admin_initiate_auth is failed then is raised error
    if event['triggerSource'] == 'UserMigration_Authentication':
      print('UserMigration_Authentication')

      password = event['request']['password']
      try:
        response = src_client.admin_initiate_auth(
          UserPoolId=SRC_USER_POOL_ID,
          ClientId=SRC_USER_POOL_CLIENT_ID,
          AuthFlow="ADMIN_NO_SRP_AUTH",
          AuthParameters={
            'USERNAME': username,
            'PASSWORD': password
          }
        )
      except Exception as e:
        print(f"Unexpected {e=}, {type(e)=}")
        raise e

      event['response']['finalUserStatus'] = 'CONFIRMED'
      event['response']['enableSMSMFA'] = True
    elif event['triggerSource'] == 'UserMigration_ForgotPassword':
      print('UserMigration_ForgotPassword')

    # common response sign-in/password reset
    # create new user on dst cognito by response data
    # just now, new user is active and verified
    for userattribute in user['UserAttributes']:
      if userattribute['Name'] == 'phone_number':
        phone_number = userattribute['Value']
      if userattribute['Name'] == 'email':
        email = userattribute['Value']
      if userattribute['Name'] == 'email_verified':
        email_verified = userattribute['Value']
      if userattribute['Name'] == 'phone_number_verified':
        phone_number_verified = userattribute['Value']
      if userattribute['Name'] == 'custom:user_id':
        user_id = userattribute['Value']

    event['response']['userAttributes'] = {
      'username': username,
      'email': email,
      'custom:user_id': user_id,
      'email_verified': email_verified,
      'phone_number': phone_number,
      'phone_number_verified': phone_number_verified
    }
    event['response']['messageAction'] = 'SUPPRESS'

    # output migration user_id
    print(f"Migration: {user_id=}")

    return event

IAMの設定

今回はクロスアカウントでの移行となるため、移行元と移行先でそれぞれRoleを作成しました。移行元では移行先のユーザ移行Lambdaが実行するRoleに権限を委譲する目的でRoleを用意しています。

移行元で用意するRole

  IAMRoleLambdaFunctionImportCognitoUserPool:
    Type: 'AWS::IAM::Role'
    Properties:
      RoleName: 'import-cognito-user-pool-role'
      AssumeRolePolicyDocument:
        Version: '2008-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              AWS: !Sub 'arn:aws:iam::${DestinationAWSAccountID}:role/${DestinationAWSIamRole}'
            Action: 'sts:AssumeRole'
      Policies:
        - PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'cognito-idp:AdminGetUser'
                  - 'cognito-idp:AdminInitiateAuth'
                Resource:
                  - !Ref CognitoUserPoolSrcArn
          PolicyName: 'import-cognito-user-pool-policy'

移行先のユーザ移行Lambdaの実行Role

  IAMRoleLambdaFunctionImportCognitoUserPool:
    Type: 'AWS::IAM::Role'
    Properties:
      RoleName: 'import-cognito-user-pool-lambda-function-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action: 'sts:AssumeRole'
      Policies:
        - PolicyDocument:
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:DescribeLogStreams'
                  - 'logs:PutLogEvents'
                Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*'
          PolicyName: 'import-cognito-user-pool-lambda-function-policy'
        - PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'sts:AssumeRole'
                Resource: !Sub 'arn:aws:iam::${SourceAWSAccountID}:role/${SourceAWSIamRole}'
          PolicyName: 'import-cognito-user-pool-assume-role-policy'

移行後

ユーザ移行Lambdaで発生した例外は準正常系のみであり、想定内の挙動に収まりました。また、移行後に全ユーザがサインアウト状態となるため、想定以上のサインインが行われた場合にCognitoのAPIのRateLimitに抵触することを懸念しましたが、無事想定内に収まりました。結果、大きな問題は起きませんでしたが、いくつか想定外の問題が発生しました。

移行後に顕在化した問題

ユーザ移行Lambdaを経由した場合、ユーザ認証が大文字と小文字を区別するcase-sensitiveな判定になってしまう

今回は移行処理のため、移行元のユーザのemailをレスポンスとして返却していましたが、この点で問題が発生しました。具体的にはLambdaが受け取ったusernameとLambdaから返されるレスポンスのemailが完全に一致していない場合はデータ移行がされないとわかりました。通常の処理では大文字と小文字を区別しないcase-insensitiveな判定をしているため、挙動が違う形となってしまいました。通常ケースとユーザ移行Lambdaを経由した場合の挙動の違いは以下の通りです。

ケース ユーザの入力値 DBに保存されている値 結果
通常のサインイン USER@example.com user@example.com 成功
ユーザ移行Lambda経由のサインイン USER@example.com user@example.com 失敗
通常のパスワードリセット USER@example.com user@example.com 成功
ユーザ移行Lambda経由のパスワードリセット USER@example.com user@example.com 失敗

テスト時に検証できていなかったケースで、移行後にAPIサーバ側でDBに保存されているユーザのemailを取得し、CognitoにはDBから取得したemailを受け渡す形に修正し、問題を解消しました。

ユーザ移行Lambdaを経由したサインイン時にSMSが2通届いてしまう

サインイン時にユーザ移行Lambdaを経由した場合、移行先と移行元のCognitoからそれぞれSMSが届いてしまう問題が発生しました。こちらはテスト時にも発生していた問題でしたが、見落としてしまいました。切り替え後に移行元のCognitoでMFAを無効化することで対応しました。単純なテスト時の見落としですが、切り替え後に移行元のCognitoでMFAを無効化するべきだったこと、一時的にであれこの事象を許容できない場合はダウンタイムが不可避なこともわかりました。

まとめ

いくつか移行後に問題が見つかりましたが、大きなトラブルなくユーザ負担も最小限に抑えて移行が完了しました。また、今回の移行作業は経験したことのない作業だったので多くの知見が得られました。

特に認証機構として利用しているCognitoについてデータ移行を想定していなかったこともあり、実際に調査してみないとわからない部分が多くありました。いざ移行するとなった時に初めて方法を検討する形となったのも反省点です。どのようなサービスであれ移行を考慮した上での技術選定は大切だなと改めて学びました。

計測プラットフォーム開発本部では、今回紹介したように、新規サービスの開発を活発に行いながら運用負荷を削減することでバランスを取って働いています。このような環境を楽しみ、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。

corp.zozo.com


  1. CSVファイルのインポートについては、AWS公式ドキュメントに詳細な仕様が記載されています。
  2. ユーザ移行Lambdaについては、AWS公式ドキュメントに詳細な仕様が記載されています。
カテゴリー