Amazon Cognitoユーザープールの短RPO DR(Disaster Recovery)対策
こんにちは!かめでございます。
今回、AWS CognitoのユーザプールのDR対策に関して検討する機会がありましたので、ご紹介します。
背景と課題
今回、あるプロジェクトでのDR要件として、near-0のRPO(目標復旧時点)が求められるケースがありました。そのプロジェクトは認証機構を用いるのですが、プロジェクトの制約でクラウドプロバイダーとしてはAWSしか利用できないという制約があり、認証機構としてはCognitoを利用する前提となっていました。
ただ、Cognitoはバックアップやクロスリージョンレプリケーションのような仕組みは標準では提供しておりません。そのため今回は、near-0のRPOを実現するために、準リアルタイムでのユーザープールバックアップ&レストアを行えるような仕組みを検討してみました。
AWSリファレンスアーキテクチャについて
AWS より、「Cognito User Profiles Export リファレンスアーキテクチャ」が公開されており、また、このリファレンスアーキテクチャについてより詳しく解説されたブログ 「「Cognito User Profiles Export リファレンスアーキテクチャ」を試してみる」も公開されています。
こちらのアーキテクチャについてざっくり言うと、Congitoユーザープールに登録されている全ユーザー情報を定期的にダンプし、別リージョンで復旧することができるようになっています。
しかしながら、今回目標とするnear-0でのRPOを目指すためには、ユーザー数が多い状況で全ユーザ情報をダンプする方法では満たせないため、別の手段を検討しました。
今回のアーキテクチャにおける制約
事前にお断りを申し上げておくと、今回検討したアーキテクチャでは「「Cognito User Profiles Export リファレンスアーキテクチャ」を試してみる」に記載の制約と同等の、以下の制約が存在しますので、要件に応じて採用可否の検討を行ってください。
- 以下の場合、このソリューションを用いたバックアップは行なえません。
- ユーザープールで多要素認証が有効になっている場合。
- ログインに使用できるエイリアスとして、E メールと電話番号の両方が許可されている場合。
- その他の機能制限
- セキュリティの観点から、Amazon Cognito はユーザーのパスワードをエクスポートする機能を提供しないため、パスワードはバックアップされません。リストアされたユーザープールを使用する際には、ユーザーにパスワードをリセットしていただくようご依頼ください。
- バックアップ元とリストア先のユーザープールでは、各ユーザーの sub 属性が変わります。アプリケーションのロジックにおいて、一意の ID として sub 属性を使用している場合は、バックアップ元とリストア先のユーザープールで sub 属性を対応させる必要があります。この際、バックアップ元のユーザープールで sub 属性をカスタム属性としてコピーしておくと、DynamoDB テーブルにバックアップできるようになり、リストア後のユーザープールで sub 属性の対応関係を確認するために利用できます。
- Amazon Cognito 経由で登録されたユーザー情報のみバックアップできます。サードパーティの認証(ソーシャルログイン)を使用して登録されたユーザーの情報はバックアップされません。ソーシャルログイン経由で登録されたユーザーは、リストア先のユーザープールに切り替わった段階で再度ログインしていただくようご依頼ください。また、ID プロバイダーの設定も引き継がれないため、リストア先のユーザープールでバックアップ元と同様に設定してください。
- ユーザープールのアドバンスドセキュリティ機能をご使用の場合、ログイン履歴はバックアップされません。
- グループに紐づけられた IAM ロールの情報はバックアップされません。
- 記憶済みデバイスの情報はバックアップされません。
また、上記に加えて本サンプルコードでは以下の制約が発生します。
- トランザクションを用いてバックアップを行っているわけでは無いため、ユーザの作成・変更・削除操作が成功してもバックアップが完全にそれに同期している保証はありません。例えばCongitoにユーザーがサインアップ成功してから、LambdaがDynamoDBに情報を書き込むまでの間に障害等が発生し、書き込みが不可能となった場合に、データの欠落が発生します。(「Cognito User Profiles Export リファレンスアーキテクチャ」のフルバックアップと組み合わせて利用するのが望ましいかと考えていますが、どう組み合わせるのがベストなのかは考えてみようかと思っています)
- Cognitoにおけるメールアドレス変更処理に関しては、以下のブログに記載の通り、メールアドレス変更後にログインできなくなるという現象が発生します。この現象を回避するためには、変更後のメールアドレスの検証処理を自前で構築しないといけませんが、今回の例ではその処理を省略しているため、ご注意ください。
- サンプルコードのため、後述のユーザ情報更新/削除用REST APIの認証処理は省略しています。ワークロードの認証機構に併せて実装ください。
システム構成
準リアルタイムでのバックアップを実現するための仕組みは、フルバックアップのリファレンスアーキテクチャから比べると構成としてはかなり単純なものになります。
ポイントとしては、Cognitoのサインアップ 確認後のLambdaトリガーにLambdaを登録することで、サインアップが完了したユーザの情報がリアルタイムで得られるので、ユーザがサインアップしたタイミングでユーザ個別でのバックアップを行うことができるという点になります。このトリガーで得られた情報をDynamoDBグローバルテーブルを用いて即座にユーザーデータをバックアップリージョンへ転送します。
ユーザープールへのデータ復旧につきましては、要件によってはバックアップリージョンへの切り替えタイミングで一括で行えば良いケースも多いと思いますが、今回の例では、バックアップリージョン内のCognitoユーザープールへのデータ復旧もリアルタイムで実施しています。メインリージョンにて行われたユーザープールの変更はDynamoDBのグローバルテーブルを通してバックアップリージョンへ転送されるため、DynamoDB StreamsにてLambdaをトリガし、データ変更内容をバックアップリージョンのCognitoユーザープールへと復旧します。
またユーザー情報の更新やユーザーの削除を行う場合は、CognitoのデフォルトUIではそういった操作を行うことができないため、専用のREST APIを作成し、ユーザ情報更新/削除を行うようにしています。
IaCソースコード
今回はCDKを用いてインフラストラクチャーを展開するためのコードを作成しました。
メインリージョンはus-east-1
、バックアップリージョンはap-northeast-1
としています。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { CfnGlobalTable } from "aws-cdk-lib/aws-dynamodb";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { UserPool, CfnUserPool } from "aws-cdk-lib/aws-cognito";
import { BACKUP_REGION, DYNAMODB_TABLE_NAME, MAIN_REGION } from "../const";
import { LambdaIntegration, RestApi } from "aws-cdk-lib/aws-apigateway";
import {
ManagedPolicy,
Policy,
PolicyStatement,
Role,
ServicePrincipal
} from "aws-cdk-lib/aws-iam";
export class MainRegionStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// create DynamoDB Global Table
const dynamoDbGlobalTable = new CfnGlobalTable(this, "userBackupTable", {
tableName: DYNAMODB_TABLE_NAME,
attributeDefinitions: [
{
attributeName: "userName",
attributeType: "S",
},
],
keySchema: [
{
attributeName: "userName",
keyType: "HASH",
},
],
replicas: [
{
region: MAIN_REGION,
},
{
region: BACKUP_REGION,
},
],
billingMode: "PAY_PER_REQUEST",
streamSpecification: {
streamViewType: "NEW_AND_OLD_IMAGES",
},
sseSpecification: {
sseEnabled: true,
},
});
// create Role for Lambda
const postConfirmationLambdaRole = new Role(this, "postConfirmationLambdaRole", {
assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
],
});
// add Policy for PutItem
const dynamoDbPutItemPolicy = new Policy(this, "dynamoDbPutItem", {
statements: [
new PolicyStatement({
actions: ["dynamodb:PutItem"],
resources: [dynamoDbGlobalTable.attrArn],
}),
],
});
dynamoDbPutItemPolicy.attachToRole(postConfirmationLambdaRole);
// create Lambda for PutItem
const postConfirmationLambda = new NodejsFunction(this, "postConfirmation", {
entry: "lambda/post-confirmation/index.ts",
handler: "handler",
runtime: Runtime.NODEJS_18_X,
role: postConfirmationLambdaRole,
memorySize: 1024,
});
// create Cognito userpool
const userPool = new UserPool(this, "userPool", {
userPoolName: "MainUserPool",
mfa: cdk.aws_cognito.Mfa.OFF,
email: cdk.aws_cognito.UserPoolEmail.withCognito(),
selfSignUpEnabled: true,
userVerification: {
emailStyle: cdk.aws_cognito.VerificationEmailStyle.LINK,
},
autoVerify: {
email: true,
},
standardAttributes: {
email: {
required: true,
mutable: true,
},
},
lambdaTriggers: {
postConfirmation: postConfirmationLambda,
},
deletionProtection: false,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
userPool.addDomain("mainUserPoolDomain", {
cognitoDomain: {
domainPrefix: "cgnt-dr-test-main",
},
});
userPool.addClient("mainClient", {
generateSecret: true,
oAuth: {
flows: {
authorizationCodeGrant: true,
},
callbackUrls: ["http://localhost:3000"],
},
authFlows: {
userPassword: true,
adminUserPassword: true,
},
});
const cfnUserPool = userPool.node.defaultChild as unknown as CfnUserPool;
cfnUserPool.userAttributeUpdateSettings = {
attributesRequireVerificationBeforeUpdate: ["email"],
};
// Role for updating user attributes
const updateUserLambdaRole = new Role(this, "updateUserLambdaRole", {
assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
],
});
// add Policy for PutItem
const dynamoDbUpdateItemPolicy = new Policy(this, "dynamoDbUpdateItem", {
statements: [
new PolicyStatement({
actions: ["dynamodb:UpdateItem"],
resources: [dynamoDbGlobalTable.attrArn],
}),
],
});
dynamoDbUpdateItemPolicy.attachToRole(updateUserLambdaRole);
// add policy for AdminUpdateUserAttributes
const updateCognitoUserPolicy = new Policy(this, "updateCognitoUserPolicy", {
statements: [
new PolicyStatement({
actions: ["cognito-idp:AdminUpdateUserAttributes"],
resources: [userPool.userPoolArn],
}),
],
});
updateCognitoUserPolicy.attachToRole(updateUserLambdaRole);
// Role for deleting user attributes
const deleteUserLambdaRole = new Role(this, "deleteUserLambdaRole", {
assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
],
});
// add policy for PutItem
const dynamoDbDeleteItemPolicy = new Policy(this, "dynamoDbDeleteItem", {
statements: [
new PolicyStatement({
actions: ["dynamodb:DeleteItem"],
resources: [dynamoDbGlobalTable.attrArn],
}),
],
});
dynamoDbDeleteItemPolicy.attachToRole(deleteUserLambdaRole);
// add policy for AdminDeleteUser
const deleteCognitoUserPolicy = new Policy(this, "deleteCognitoUserPolicy", {
statements: [
new PolicyStatement({
actions: ["cognito-idp:AdminDeleteUser"],
resources: [userPool.userPoolArn],
}),
],
});
deleteCognitoUserPolicy.attachToRole(deleteUserLambdaRole);
// create Lambda for updateUser
const updateUserLambda = new NodejsFunction(this, "updateUser", {
entry: "lambda/update-user/index.ts",
handler: "handler",
runtime: Runtime.NODEJS_18_X,
role: updateUserLambdaRole,
environment: {
USER_POOL_ID: userPool.userPoolId,
},
});
// create Lambda for deleteUser
const deleteUserLambda = new NodejsFunction(this, "deleteUser", {
entry: "lambda/delete-user/index.ts",
handler: "handler",
runtime: Runtime.NODEJS_18_X,
role: deleteUserLambdaRole,
environment: {
USER_POOL_ID: userPool.userPoolId,
},
});
const userConfigApi = new RestApi(this, "userConfig");
const usersResource = userConfigApi.root.addResource("users");
const userIdResource = usersResource.resourceForPath("{userId}");
userIdResource.addMethod("PUT", new LambdaIntegration(updateUserLambda));
userIdResource.addMethod("DELETE", new LambdaIntegration(deleteUserLambda));
}
}
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { Table } from "aws-cdk-lib/aws-dynamodb";
import { Runtime, StartingPosition } from "aws-cdk-lib/aws-lambda";
import { UserPool } from "aws-cdk-lib/aws-cognito";
import { DYNAMODB_TABLE_NAME } from "../const";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { ManagedPolicy, Policy, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { DynamoEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
import {
AwsCustomResource,
AwsCustomResourcePolicy,
AwsSdkCall,
PhysicalResourceId
} from "aws-cdk-lib/custom-resources";
import { Stack } from "aws-cdk-lib";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
export class BackupRegionStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// create Cognito userpool
const userPool = new UserPool(this, "userPool", {
userPoolName: "BackupUserPool",
});
// AWS SDK call to get DynamoDBStreams
const awsSdkCall: AwsSdkCall = {
service: "DynamoDBStreams",
action: "listStreams",
region: Stack.of(this).region,
physicalResourceId: PhysicalResourceId.of(`${DYNAMODB_TABLE_NAME}ListStreams`),
parameters: {
TableName: DYNAMODB_TABLE_NAME,
},
};
// Custom resource to get DynamoDBStreams
const call = new AwsCustomResource(this, `${DYNAMODB_TABLE_NAME}GetTableStreams`, {
onCreate: awsSdkCall,
onUpdate: awsSdkCall,
logRetention: RetentionDays.ONE_DAY,
policy: AwsCustomResourcePolicy.fromStatements([
new PolicyStatement({
actions: ["dynamodb:*"],
resources: ["*"],
}),
]),
});
// Get DynamoDB global table
const userBackupTable = Table.fromTableAttributes(this, DYNAMODB_TABLE_NAME, {
tableName: DYNAMODB_TABLE_NAME,
tableStreamArn: call.getResponseField("Streams.0.StreamArn"),
});
// Role for lambda
const onStreamsUpdateRole = new Role(this, "Role", {
assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
],
});
// add policy for AdminDeleteUser
const editCognitoUserPolicy = new Policy(this, "editCognitoUserPolicy", {
statements: [
new PolicyStatement({
actions: [
"cognito-idp:AdminCreateUser",
"cognito-idp:AdminUpdateUserAttributes",
"cognito-idp:AdminDeleteUser"
],
resources: [userPool.userPoolArn],
}),
],
});
editCognitoUserPolicy.attachToRole(onStreamsUpdateRole);
const onStreamsUpdate = new NodejsFunction(this, "onStreamsUpdate", {
entry: "lambda/on-streams-update/index.ts",
handler: "handler",
runtime: Runtime.NODEJS_18_X,
role: onStreamsUpdateRole,
environment: {
USER_POOL_ID: userPool.userPoolId,
},
});
onStreamsUpdate.addEventSource(
new DynamoEventSource(userBackupTable, { startingPosition: StartingPosition.TRIM_HORIZON })
);
}
}
import { PostConfirmationTriggerEvent } from "aws-lambda";
import { DynamoDB } from "aws-sdk";
import { DYNAMODB_TABLE_NAME } from "../../const";
export const handler = async (event: PostConfirmationTriggerEvent) => {
try {
// Backup user data to DynamoDB
const db = new DynamoDB.DocumentClient();
console.log(event);
const userName = event.userName;
const email = event.request.userAttributes.email;
const res = await db
.put({
TableName: DYNAMODB_TABLE_NAME,
Item: {
userName,
email,
},
})
.promise();
console.log(res.$response);
return event;
} catch (e) {
console.error(e);
throw e;
}
};
import { APIGatewayProxyEvent, APIGatewayEventRequestContext, APIGatewayProxyCallback } from "aws-lambda";
import { CognitoIdentityServiceProvider, DynamoDB } from "aws-sdk";
import { DYNAMODB_TABLE_NAME } from "../../const";
export const handler = async (
event: APIGatewayProxyEvent,
context: APIGatewayEventRequestContext,
callback: APIGatewayProxyCallback) => {
try {
console.log(event);
if (!event.body) {
return callback("body cannot be null");
}
const bodyJson = JSON.parse(event.body);
const email = bodyJson.email as string;
if (!email) {
return callback("email cannot be undefined");
}
// Forcibly update user data in Cognito UserPool
const cognito = new CognitoIdentityServiceProvider();
const result = await cognito
.adminUpdateUserAttributes({
UserPoolId: process.env.USER_POOL_ID as string,
Username: event.pathParameters?.userId as string,
UserAttributes: [
{
Name: "email_verified",
Value: "true",
},
{ Name: "email", Value: email },
],
})
.promise();
console.log("update user result: ", result);
// Update user data in DynamoDB
const db = new DynamoDB.DocumentClient();
await db
.update({
TableName: DYNAMODB_TABLE_NAME,
Key: {
userName: event.pathParameters?.userId,
},
UpdateExpression: "set email = :e",
ExpressionAttributeValues: {
":e": email,
},
})
.promise();
callback(null, {
statusCode: 200,
body: "OK",
});
} catch (e) {
console.error(e);
throw e;
}
};
import { APIGatewayProxyEvent, APIGatewayEventRequestContext, APIGatewayProxyCallback } from "aws-lambda";
import { CognitoIdentityServiceProvider, DynamoDB } from "aws-sdk";
import { DYNAMODB_TABLE_NAME } from "../../const";
export const handler = async (
event: APIGatewayProxyEvent,
context: APIGatewayEventRequestContext,
callback: APIGatewayProxyCallback) => {
try {
console.log(event);
// Forcibly update user data in Cognito UserPool
const cognito = new CognitoIdentityServiceProvider();
const result = await cognito
.adminDeleteUser({
UserPoolId: process.env.USER_POOL_ID as string,
Username: event.pathParameters?.userId as string,
})
.promise();
console.log("delete user result: ", result);
// Delete user data in DynamoDB
const db = new DynamoDB.DocumentClient();
await db
.delete({
TableName: DYNAMODB_TABLE_NAME,
Key: {
userName: event.pathParameters?.userId,
},
})
.promise();
callback(null, {
statusCode: 204,
body: "",
});
} catch (e) {
console.error(e);
throw e;
}
};
import { DynamoDBRecord, DynamoDBStreamEvent } from "aws-lambda";
import { CognitoIdentityServiceProvider } from "aws-sdk";
export const handler = async (event: DynamoDBStreamEvent) => {
try {
console.log(JSON.stringify(event));
for await (const record of event.Records) {
await handleRecord(record);
}
} catch (e) {
console.error(e);
throw e;
}
};
const handleRecord = async (record: DynamoDBRecord) => {
switch (record.eventName) {
case "INSERT":
await onInsert(record);
break;
case "MODIFY":
await onModify(record);
break;
case "REMOVE":
await onRemove(record);
break;
default:
throw new Error(`Unknown event for event: ${record.eventID}`);
}
};
const onInsert = async (record: DynamoDBRecord) => {
const userName = record.dynamodb?.NewImage?.userName.S as string;
const email = record.dynamodb?.NewImage?.email.S as string;
console.log(`Insert: username=${userName}, email=${email}`);
const cognito = new CognitoIdentityServiceProvider();
const result = await cognito
.adminCreateUser({
UserPoolId: process.env.USER_POOL_ID as string,
Username: userName,
UserAttributes: [
{
Name: "email_verified",
Value: "true",
},
{ Name: "email", Value: email },
],
})
.promise();
console.log("Create user result: ", result);
};
const onModify = async (record: DynamoDBRecord) => {
const userName = record.dynamodb?.NewImage?.userName.S as string;
const email = record.dynamodb?.NewImage?.email.S as string;
console.log(`Modify: username=${userName}, email=${email}`);
const cognito = new CognitoIdentityServiceProvider();
const result = await cognito
.adminUpdateUserAttributes({
UserPoolId: process.env.USER_POOL_ID as string,
Username: userName,
UserAttributes: [
{
Name: "email_verified",
Value: "true",
},
{ Name: "email", Value: email },
],
})
.promise();
console.log("Update user result: ", result);
};
const onRemove = async (record: DynamoDBRecord) => {
const userName = record.dynamodb?.OldImage?.userName.S as string;
const email = record.dynamodb?.OldImage?.email.S as string;
console.log(`Remove: username=${userName}, email=${email}`);
const cognito = new CognitoIdentityServiceProvider();
const result = await cognito
.adminDeleteUser({
UserPoolId: process.env.USER_POOL_ID as string,
Username: userName,
})
.promise();
console.log("Delete user result: ", result);
};
export const DYNAMODB_TABLE_NAME = "CognitoBackupTable";
export const MAIN_REGION = "us-east-1";
export const BACKUP_REGION = "ap-northeast-1";
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { MainRegionStack } from "../lib/main-region-stack";
import { BACKUP_REGION, MAIN_REGION } from "../const";
import { BackupRegionStack } from "../lib/backup-region-stack";
const app = new cdk.App();
const mainRegionStack = new MainRegionStack(app, "MainRegionStack", {
env: {
region: MAIN_REGION,
},
});
new BackupRegionStack(app, "BackupRegionStack", {
env: {
region: BACKUP_REGION,
},
}).addDependency(mainRegionStack);
インフラストラクチャーのデプロイ
通常のCDKのデプロイと同様ですが、
cdk bootstrap
で初回のみブートストラップを実行した後、
cdk deploy --all
でデプロイを行ってください。
動作確認
サインアップ
まず新規ユーザとしてサインアップを行ってみましょう。
今回メインリージョンで作成したCognitoにアクセスし、MainUserPoolを選択します。
次にユーザープール内の「アプリケーションの統合」タブを開いたときの下部に、作成されたアプリケーションクライアントが表示されていますので、これを選択します。
「ホストされたUI」の項目内に「ホストされたUIを表示」ボタンがありますので、これをクリックします。
ホストされたサインイン画面が開きますので、「SIgn Up」を選択します。
その後サインアップ画面で適切な情報を入力し、サインアップを実行します。
その後サインアップ時に指定したメールアドレスに確認用メールが来るため、確認処理を実行してください。
サインアップが完了したら、ユーザープールにユーザーが作成されていることを確認してください。
新規ユーザー情報はDynamoDBのus-east-1/ap-northeast-1リージョンのテーブルにもレコードとして記録されていることが確認できます。
バックアップリージョンのCognitoユーザープール(BackupUserPool)にも同様の情報でユーザーが復元されています。
しかしながら、制約でも申し上げたとおり、メインリージョンで設定されたパスワードはバックアップできないため、バックアップリージョンのユーザープールでサインイン処理を行う場合はパスワードリセットが必要になります。
ユーザー情報の変更
今回の例では、ユーザー情報を変更するためのREST APIを作成しています。
us-east-1リージョンのAPI Gateway内に「userConfig」APIが作成されているため、選択してください。
PUTメソッドを選択し、テストボタンを押下します。
パスは作成したユーザーのユーザー名、リクエスト本文はJSON形式で変更後のメールアドレスを指定し、テストを実行してください。
テスト実行後、メインリージョン/バックアップリージョンのCognitoユーザープール内のユーザー情報が変更されていること、DynamoDB上のレコードも変更されていることが確認できます。
ユーザー削除
今回の例では、ユーザー削除に関しても、ユーザー情報の変更と同様にREST APIで行えるようにしています。
ユーザー情報の変更と同様に、「userConfig」を選択肢、今度はDELETEメソッドを選択します。
パスは作成したユーザーのユーザー名を指定し、テストを実行してください。
テスト実行後、メインリージョン/バックアップリージョンのCognitoユーザープール内のユーザー情報が削除されていること、DynamoDB上のレコードも削除されていることが確認できます。
ソースコードリポジトリ
今回のサンプルコードは以下のリポジトリでMITライセンスにて公開しています。
https://github.com/tak-kam/cognito-dr
最後に
このように、制約付きではありますが、Cognitoユーザープールの準リアルタイムバックアップが可能であることが確認できました。
ワークロードの要件によっては制約が問題ないケース、他の形で制約を回避出来るケースも多いかと思いますので、参考にしていただければ幸いでございます!