├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets └── imgs │ ├── hosted-zone.png │ └── multi-region-data-residency-architecture.png ├── cdk ├── .eslintrc.json ├── bin │ └── multi-region-app.ts ├── cdk.json ├── lib │ ├── certificate-stack.ts │ ├── multi-region-app-stack.ts │ ├── simple-lambda.ts │ └── static-site-stack.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── package-lock.json └── src ├── .eslintrc.json ├── app ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── index.html │ ├── manifest.json │ ├── mock-receipt.png │ └── robots.txt ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── Dashboard.tsx │ ├── Login.tsx │ ├── Profile.tsx │ ├── Receipt.tsx │ ├── Sensor.tsx │ ├── Stats.tsx │ ├── aws-exports.ts │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── tailwind.css └── tsconfig.json ├── lambda ├── config-handler.ts ├── pre-auth-handler.ts ├── pre-sign-up-handler.ts └── utils.ts ├── package-lock.json ├── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | 10 | !lambda/*.js 11 | 12 | *.iml 13 | .idea 14 | .envrc 15 | .env.* 16 | 17 | .DS_Store 18 | *.zip -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building a multi-region architecture with data residency 2 | 3 | This repository demonstrates how to deploy a multi-region architecture with data residency for sensitive data, such as Personally Identifiable Information (PII) or Personal Health Information (PHI) data. To maintain data residency for each region, the architecture operates under a [silo model](https://docs.aws.amazon.com/wellarchitected/latest/saas-lens/silo-pool-and-bridge-models.html) with its own isolated infrastructure stack per region. 4 | 5 | The architecture is suitable for businesses in specific verticals such as Health-care / Life-sciences (HCLS) and FinTech, with business requirements to isolate customer PII/PHI data to a specific region, expanding globally from a single-region architecture, and/or operating in strict regulatory or compliance environments. 6 | 7 | For more details, see [Scale across borders: build a multi-region architecture while maintaining data residency](https://community.aws/posts/scale-beyond-borders) or [Video: Architectures to scale your startup to multiple regions](https://www.twitch.tv/awsonair/video/1851203333). 8 | 9 | ## Solution Overview 10 | 11 | ![Multi Region Data Residency Architecture](assets/imgs/multi-region-data-residency-architecture.png) 12 | 13 | * The solution is for demonstrative purposes only. For production use, please ensure that you consider additional security, such as [MFA](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-mfa.html). 14 | 15 | * The solution will incur costs associated with the resources. To minimize costs, ensure that you clean up after deployment. 16 | 17 | ### Front-end React Application 18 | 19 | In addition to the global API, the repository also comes with a front-end React application: 20 | 21 | * **Single-region with global edge:** The front-end is deployed to a single (primary) region with CloudFront for caching and global edge locations to reduce end-user latency. 22 | * **User interface (powered by Amplify UI)**: The login and sign up experience is built using the [Authenticator](https://ui.docs.amplify.aws/react/connected-components/authenticator) Amplify UI component. This accelerates the addition of complete authentication flows to your application with minimal boilerplate. 23 | * **Global API:** By default, it will connect the global API (e.g. `app.mystartup.com`) which will automatically route to the user's closest region using Amazon Route 53 [Latency-based routing](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy-latency.html). 24 | * **Switching countries:** In addition, the application allows you to switch countries and connect to the corresponding backend via `.mystartup.com`. This is done by fetching the region configuration from the `/config/` API endpoint and `Amplify.configure()`. 25 | 26 | ### Prerequisites 27 | 28 | - An [AWS account](https://portal.aws.amazon.com/billing/signup#/start) 29 | - Installed and authenticated [AWS CLI](https://docs.aws.amazon.com/en_pv/cli/latest/userguide/cli-chap-install.html) (authenticate with an [IAM](https://docs.aws.amazon.com/IAM/latest/UserGuide/getting-started.html) user or an [AWS STS](https://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html) Security Token) 30 | - Installed and setup [AWS Cloud Development Kit (AWS CDK)](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html) 31 | - Installed Node.js, TypeScript and git 32 | 33 | ### Let’s get you started 34 | 35 | #### 1. Make sure you completed the prerequisites above and cloned this repo. 36 | 37 | ``` 38 | git clone git@github.com:aws-samples/multi-region-data-residency 39 | ``` 40 | 41 | #### 2. Open the repository in your preferred IDE and familiarize yourself with the structure of the project. 42 | 43 | ``` 44 | . 45 | ├── cdk CDK code that defines our environment 46 | ├── assets 47 | └── images Image assets 48 | └── src 49 | └── lambda Handler code of the lambda functions 50 | └── app Demo react app 51 | ``` 52 | 53 | #### 3. Install dependencies 54 | 55 | The node.js dependencies are declared in a `package.json`. 56 | This project contains a `package.json` file in two different folders: 57 | 58 | - `cdk`: Dependencies required to deploy your stack with the CDK 59 | - `src`: Dependencies required for the Lambda function, i.e. TypeScript types for AWS SDK 60 | 61 | Install the required dependencies: 62 | 63 | ``` 64 | cd cdk && npm install 65 | cd .. 66 | cd src && npm install 67 | ``` 68 | 69 | #### 4. Create Route53 hosted zone 70 | 71 | Before deploying the stack, a domain-name must be configured in Amazon Route 53 which will be used to configure the CDK stack and related sub-domains for the multi-region deployment. 72 | 73 | For testing purposes a new domain can be registered, alternatively you can use an existing domain-name provisioned within your AWS Account, or create a new sub-domain with delegated NS records to Route 53 (e.g myapp.startup.com as a new Public Hosted zone). 74 | 75 | ![Hosted zone config](./assets/imgs/hosted-zone.png) 76 | 77 | Note the `Hosted zone ID` which will be used in the following step. 78 | 79 | #### 5. Configure environment settings 80 | 81 | Before deploying the CDK stack the following environment variables need to be defined. 82 | 83 | Regions - Define which regions to deploy the multi-region stack, for demonstrative purposes, we will deploy to the ap-southeast-2 (Sydney), us-east-2 (Ohio) regions. (This will be displayed as Australia and United States respectively in the user interface on `/src/app/App.tsx`.) 84 | 85 | ``` 86 | export REGIONS="ap-southeast-2, us-east-2" 87 | ``` 88 | 89 | Hosted Zone Id - Specify the hosted zone ID from step 4 90 | 91 | ``` 92 | export HOSTEDZONEID="Z2938XXZZZ" 93 | ``` 94 | 95 | Site Domain - Specify the root domain which will be used (e.g mystartup.com) 96 | 97 | ``` 98 | export SITEDOMAIN="mystartup.com" 99 | ``` 100 | 101 | AWS Account ID -- Specify the primary AWS Account ID which will be used to deploy the stack 102 | 103 | ``` 104 | export CDK_DEFAULT_ACCOUNT="YOUR_AWS_ACCOUNT_ID" 105 | ``` 106 | 107 | #### 5. Deploy static web-application 108 | 109 | Navigate to the `src/app` folder and build the static React app using: 110 | 111 | ``` 112 | npm run build 113 | ``` 114 | 115 | When ready to deploy, navigate to the `cdk` folder and run the following commands. 116 | 117 | `cdk synth` will synthesize a CloudFormation template from your CDK code. If you haven't worked with CDK in your account before, you need to [bootstrap](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html) the required resources for the CDK with `cdk bootstrap`, otherwise skip this step. Note that bootstrapping needs to be performed in every Region you plan to deploy to. You can then deploy the template with `cdk deploy`. 118 | 119 | ``` 120 | cdk synth 121 | # replace account id and Region codes (us-east-1 required for SSL certificate) 122 | cdk bootstrap $CDK_DEFAULT_ACCOUNT/us-east-1 $CDK_DEFAULT_ACCOUNT/eu-west-1 $CDK_DEFAULT_ACCOUNT/ap-southeast-2 123 | cdk deploy --all 124 | ``` 125 | 126 | #### 6. Test the application 127 | 128 | Connect to the test application e.g. `frontend.mystartup.com` 129 | ## Cleaning up 130 | 131 | When you are done, make sure to clean everything up. 132 | 133 | Run the following command to shut down the resources created in this workshop. 134 | 135 | ``` 136 | cdk destroy --all 137 | ``` 138 | 139 | There may also some resources that will need to be deleted manually (such as the DynamoDB Global Table). 140 | 141 | ## Security 142 | 143 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 144 | 145 | ## License 146 | 147 | This library is licensed under the MIT-0 License. See the LICENSE file. 148 | -------------------------------------------------------------------------------- /assets/imgs/hosted-zone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/multi-region-data-residency/68d337b7565930854c491aa45f9dc5d112a1b180/assets/imgs/hosted-zone.png -------------------------------------------------------------------------------- /assets/imgs/multi-region-data-residency-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/multi-region-data-residency/68d337b7565930854c491aa45f9dc5d112a1b180/assets/imgs/multi-region-data-residency-architecture.png -------------------------------------------------------------------------------- /cdk/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "airbnb-base" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "parserOptions": { 11 | "ecmaVersion": 13, 12 | "sourceType": "module" 13 | }, 14 | "plugins": [ 15 | "@typescript-eslint" 16 | ], 17 | "rules": { 18 | "no-new": "off", 19 | "max-classes-per-file": "off", 20 | "import/extensions": "off", 21 | "max-len": "off" 22 | }, 23 | "settings": { 24 | "import/resolver": { 25 | "node": { 26 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cdk/bin/multi-region-app.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import MultiRegionAppStack from '../lib/multi-region-app-stack'; 5 | import CertificateStack from '../lib/certificate-stack'; 6 | import StaticSiteStack from '../lib/static-site-stack'; 7 | 8 | const app = new cdk.App(); 9 | 10 | const REGIONS = process.env.REGIONS || 'ap-southeast-2, us-east-2, eu-west-1'; 11 | const account = process.env.CDK_DEFAULT_ACCOUNT; 12 | const siteDomain = process.env.SITEDOMAIN || ''; 13 | const hostedZoneId = process.env.HOSTEDZONEID || ''; 14 | 15 | const regionsToDeploy = REGIONS.split(',').map((r) => r.trim()); 16 | 17 | // Global stack 18 | const certStack = new CertificateStack(app, 'CertStack-us-east-1', { 19 | env: { 20 | account, 21 | region: 'us-east-1', 22 | }, 23 | crossRegionReferences: true, 24 | siteDomain, 25 | hostedZoneId, 26 | }); 27 | 28 | // Regional stacks 29 | const primaryRegion = regionsToDeploy[0]; 30 | 31 | regionsToDeploy.forEach((region) => { 32 | const regionCodesToReplicate = regionsToDeploy.filter((r) => r !== region); 33 | const siteSubDomain = region; 34 | 35 | new MultiRegionAppStack(app, `App-Backend-${region}`, { 36 | env: { 37 | account, 38 | region, 39 | }, 40 | crossRegionReferences: true, 41 | primaryRegion, 42 | regionCodesToReplicate, 43 | siteDomain, 44 | siteSubDomain, 45 | hostedZoneId, 46 | }); 47 | }); 48 | 49 | // Deploy the static front-end only in the primary region 50 | // Note Cloudfront has global Points-of-Presence (PoP) to reduce latency globally 51 | const { certificate } = certStack; 52 | 53 | new StaticSiteStack(app, `App-Frontend-${primaryRegion}`, { 54 | env: { 55 | account, 56 | region: primaryRegion, 57 | }, 58 | crossRegionReferences: true, 59 | siteDomain, 60 | hostedZoneId, 61 | certificate, 62 | }); 63 | -------------------------------------------------------------------------------- /cdk/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/multi-region-app.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 25 | "@aws-cdk/core:target-partitions": [ 26 | "aws", 27 | "aws-cn" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cdk/lib/certificate-stack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CfnOutput, Stack, StackProps, 3 | } from 'aws-cdk-lib'; 4 | import { Construct } from 'constructs'; 5 | import { HostedZone, IHostedZone } from 'aws-cdk-lib/aws-route53'; 6 | import { Certificate, CertificateValidation } from 'aws-cdk-lib/aws-certificatemanager'; 7 | 8 | interface CertificateStackProps extends StackProps { 9 | siteDomain: string, 10 | hostedZoneId: string, 11 | } 12 | 13 | // SSL Certificate has to be deployed into us-east-1 14 | // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_certificatemanager-readme.html#cross-region-certificates 15 | 16 | export default class CertificateStack extends Stack { 17 | public zone: IHostedZone; 18 | 19 | public certificate: Certificate; 20 | 21 | constructor(scope: Construct, id: string, props: CertificateStackProps) { 22 | super(scope, id, props); 23 | const { siteDomain, hostedZoneId } = props; 24 | 25 | const zone = HostedZone.fromHostedZoneAttributes(this, 'Zone', { 26 | zoneName: siteDomain, 27 | hostedZoneId, 28 | }); 29 | 30 | // TLS certificate 31 | const certificate = new Certificate(this, 'AppCertificate', { 32 | domainName: `*.${siteDomain}`, 33 | validation: CertificateValidation.fromDns(zone), 34 | }); 35 | 36 | new CfnOutput(this, 'CertificateArn', { value: certificate.certificateArn }); 37 | this.certificate = certificate; 38 | this.zone = zone; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /cdk/lib/multi-region-app-stack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CfnOutput, CfnResource, RemovalPolicy, Stack, StackProps, 3 | } from 'aws-cdk-lib'; 4 | import { Construct } from 'constructs'; 5 | import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; 6 | import { 7 | UserPool, VerificationEmailStyle, UserPoolClient, AccountRecovery, StringAttribute, ClientAttributes, 8 | } from 'aws-cdk-lib/aws-cognito'; 9 | import * as CustomResources from 'aws-cdk-lib/custom-resources'; 10 | import * as iam from 'aws-cdk-lib/aws-iam'; 11 | import { Certificate, CertificateValidation } from 'aws-cdk-lib/aws-certificatemanager'; 12 | import { StringParameter } from 'aws-cdk-lib/aws-ssm'; 13 | import { ARecord, CfnRecordSet, HostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; 14 | import { Cors, DomainName, EndpointType, LambdaIntegration, RestApi, SecurityPolicy } from 'aws-cdk-lib/aws-apigateway'; 15 | import { ApiGatewayDomain } from 'aws-cdk-lib/aws-route53-targets'; 16 | import { SimpleLambda } from './simple-lambda'; 17 | 18 | interface MultiRegionAppStackProps extends StackProps { 19 | regionCodesToReplicate: string[], 20 | primaryRegion: string, 21 | siteDomain?: string, 22 | siteSubDomain?: string, 23 | certificate?: Certificate, 24 | hostedZoneId?: string, 25 | } 26 | 27 | export default class MultiRegionAppStack extends Stack { 28 | constructor(scope: Construct, id: string, props: MultiRegionAppStackProps) { 29 | super(scope, id, props); 30 | 31 | const { 32 | regionCodesToReplicate, 33 | primaryRegion, 34 | siteDomain, 35 | siteSubDomain, 36 | hostedZoneId, 37 | } = props; 38 | 39 | const { region, account } = Stack.of(this); 40 | const siteHost = `${siteSubDomain}.${siteDomain}`; 41 | const globalSiteHost = `app.${siteDomain}`; 42 | const frontendSiteHost = `frontend.${siteDomain}`; 43 | const tableName = 'UserResidency'; 44 | 45 | // Cognito User Pool 46 | const userPool = new UserPool(this, 'AppUserPool', { 47 | selfSignUpEnabled: true, 48 | accountRecovery: AccountRecovery.PHONE_AND_EMAIL, 49 | userVerification: { 50 | emailStyle: VerificationEmailStyle.CODE, 51 | }, 52 | autoVerify: { 53 | email: true, 54 | }, 55 | standardAttributes: { 56 | email: { 57 | required: true, 58 | mutable: true, 59 | }, 60 | }, 61 | customAttributes: { 62 | country: new StringAttribute({ mutable: true }), 63 | }, 64 | }); 65 | 66 | // TODO: Add Cognito test users in each region (https://github.com/awesome-cdk/cdk-userpool-user) 67 | // TODO: Smarts to trigger Lambda pre-register 68 | 69 | const cognitoDomainPrefix = `app-${account}-${region}`; 70 | userPool.addDomain('AppCognitoDomain', { 71 | cognitoDomain: { 72 | domainPrefix: cognitoDomainPrefix, 73 | }, 74 | }); 75 | 76 | const standardCognitoAttributes = { 77 | givenName: true, 78 | familyName: true, 79 | email: true, 80 | emailVerified: true, 81 | address: true, 82 | birthdate: true, 83 | gender: true, 84 | locale: true, 85 | middleName: true, 86 | fullname: true, 87 | nickname: true, 88 | phoneNumber: true, 89 | phoneNumberVerified: true, 90 | profilePicture: true, 91 | preferredUsername: true, 92 | profilePage: true, 93 | timezone: true, 94 | lastUpdateTime: true, 95 | website: true, 96 | }; 97 | 98 | const clientReadAttributes = new ClientAttributes() 99 | .withStandardAttributes(standardCognitoAttributes) 100 | .withCustomAttributes(...['country']); 101 | 102 | const clientWriteAttributes = new ClientAttributes() 103 | .withStandardAttributes({ 104 | ...standardCognitoAttributes, 105 | emailVerified: false, 106 | phoneNumberVerified: false, 107 | }) 108 | .withCustomAttributes(...['country']); 109 | 110 | const userPoolClient = new UserPoolClient(this, 'UserPoolClient', { 111 | userPool, 112 | oAuth: { 113 | callbackUrls: [ 114 | `https://${frontendSiteHost}`, 115 | 'http://localhost:3000/', 116 | ], 117 | }, 118 | readAttributes: clientReadAttributes, 119 | writeAttributes: clientWriteAttributes, 120 | preventUserExistenceErrors: true, 121 | }); 122 | 123 | new CfnOutput(this, 'CognitoUserPoolId', { 124 | value: userPool.userPoolId, 125 | exportName: 'CognitoUserPoolId', 126 | }); 127 | 128 | new StringParameter(this, 'ParamUserPoolId', { 129 | parameterName: 'CognitoUserPoolId', 130 | stringValue: userPool.userPoolId, 131 | }); 132 | 133 | new CfnOutput(this, 'CognitoUserPoolClientId', { 134 | value: userPoolClient.userPoolClientId, 135 | exportName: 'CognitoUserPoolClientId', 136 | }); 137 | 138 | new StringParameter(this, 'ParamUserPoolClientId', { 139 | parameterName: 'CognitoUserPoolClientId', 140 | stringValue: userPoolClient.userPoolClientId, 141 | }); 142 | 143 | // Add pre-Auth Lambda Handler 144 | const preAuthHandlerLambda = new SimpleLambda(this, 'PreAuthHandler', { 145 | entryFilename: 'pre-auth-handler.ts', 146 | handler: 'handleEvent', 147 | name: 'PreAuthHandler', 148 | description: 'Handles Amazon Cognito pre auth Lambda trigger', 149 | envVariables: { 150 | USER_RESIDENCY_TABLE: tableName, 151 | }, 152 | }); 153 | 154 | // IAM role for Pre-Auth 155 | preAuthHandlerLambda.fn.addToRolePolicy(new iam.PolicyStatement( 156 | { 157 | actions: ['dynamodb:Query'], 158 | resources: [ 159 | `arn:aws:dynamodb:${region}:${account}:table/${tableName}`, 160 | `arn:aws:dynamodb:${region}:${account}:table/${tableName}/*`, 161 | ], 162 | }, 163 | )); 164 | 165 | // Add pre-signup Lambda Handler 166 | const preSignUpHandlerLambda = new SimpleLambda(this, 'PreSignUpHandler', { 167 | entryFilename: 'pre-sign-up-handler.ts', 168 | handler: 'handleEvent', 169 | name: 'PreSignUpHandler', 170 | description: 'Handles Amazon Cognito pre sign-up Lambda trigger', 171 | envVariables: { 172 | USER_RESIDENCY_TABLE: tableName, 173 | }, 174 | }); 175 | 176 | // Add DynamoDB PutItem IAM Role to preSignUpHandlerLambda.fn Service Role 177 | preSignUpHandlerLambda.fn.addToRolePolicy(new iam.PolicyStatement( 178 | { 179 | actions: ['dynamodb:PutItem'], 180 | resources: [ 181 | `arn:aws:dynamodb:${region}:${account}:table/${tableName}`, 182 | `arn:aws:dynamodb:${region}:${account}:table/${tableName}/*`, 183 | ], 184 | }, 185 | )); 186 | 187 | // Add Cognito Lambda Triggers 188 | new CustomResources.AwsCustomResource(this, 'UpdateUserPool', { 189 | resourceType: 'Custom::UpdateUserPool', 190 | onCreate: { 191 | region: this.region, 192 | service: 'CognitoIdentityServiceProvider', 193 | action: 'updateUserPool', 194 | parameters: { 195 | UserPoolId: userPool.userPoolId, 196 | LambdaConfig: { 197 | PreAuthentication: preAuthHandlerLambda.fn.functionArn, 198 | PreSignUp: preSignUpHandlerLambda.fn.functionArn, 199 | }, 200 | }, 201 | physicalResourceId: CustomResources.PhysicalResourceId.of(userPool.userPoolId), 202 | }, 203 | policy: CustomResources.AwsCustomResourcePolicy.fromSdkCalls({ resources: CustomResources.AwsCustomResourcePolicy.ANY_RESOURCE }), 204 | }); 205 | 206 | const invokeCognitoTriggerPermission = { 207 | principal: new iam.ServicePrincipal('cognito-idp.amazonaws.com'), 208 | sourceArn: userPool.userPoolArn, 209 | }; 210 | 211 | preSignUpHandlerLambda.fn.addPermission('InvokePreSignUpHandlerPermission', invokeCognitoTriggerPermission); 212 | preAuthHandlerLambda.fn.addPermission('InvokePreSignUpHandlerPermission', invokeCognitoTriggerPermission); 213 | 214 | // DynamoDB table to store user residency on the AWS region 215 | if (region === primaryRegion) { 216 | const ddbGlobalTableRemovalPolicy = RemovalPolicy.RETAIN; 217 | 218 | const globalTable = new Table(this, tableName, { 219 | billingMode: BillingMode.PAY_PER_REQUEST, 220 | removalPolicy: ddbGlobalTableRemovalPolicy, // Retain DynamoDB table 221 | tableName, 222 | partitionKey: { 223 | name: 'userId', 224 | type: AttributeType.STRING, 225 | }, 226 | sortKey: { 227 | name: 'region', 228 | type: AttributeType.STRING, 229 | }, 230 | replicationRegions: regionCodesToReplicate, 231 | }); 232 | 233 | const customReplicaResource = globalTable.node.children.find((child) => (child as any).resource?.cfnResourceType === 'Custom::DynamoDBReplica') as CfnResource; 234 | 235 | customReplicaResource.applyRemovalPolicy(ddbGlobalTableRemovalPolicy); 236 | } else { 237 | Table.fromTableName(this, tableName, tableName); 238 | } 239 | 240 | // Add an API Gateway and Lambda backend stack 241 | const zone = HostedZone.fromHostedZoneAttributes(this, 'Zone', { 242 | zoneName: siteDomain as string, 243 | hostedZoneId: hostedZoneId as string, 244 | }); 245 | 246 | const regionCertificate = new Certificate(this, 'AppCertificate', { 247 | domainName: `*.${siteDomain}`, 248 | validation: CertificateValidation.fromDns(zone), 249 | }); 250 | 251 | const restApi = new RestApi(this, `Api-${region}`, { 252 | restApiName: `Api-${region}`, 253 | endpointConfiguration: { 254 | types: [EndpointType.REGIONAL], 255 | }, 256 | defaultCorsPreflightOptions: { 257 | allowOrigins: Cors.ALL_ORIGINS, 258 | }, 259 | deployOptions: { 260 | variables: { 261 | REGION: region, 262 | }, 263 | }, 264 | }); 265 | 266 | // Add front-end config Lambda Handler 267 | const frontendConfigHandlerLambda = new SimpleLambda(this, 'ConfigHandler', { 268 | entryFilename: 'config-handler.ts', 269 | handler: 'handleEvent', 270 | name: 'ConfigHandler', 271 | description: 'Handles config metadata for frontend consumption', 272 | }); 273 | 274 | // Add SSM access to frontendConfigHandlerLambda.fn Service Role 275 | frontendConfigHandlerLambda.fn.addToRolePolicy(new iam.PolicyStatement({ 276 | actions: ['ssm:GetParameters'], 277 | resources: ['*'], 278 | })); 279 | 280 | const frontendConfig = restApi.root.addResource('config'); 281 | 282 | frontendConfig.addMethod( 283 | 'GET', 284 | new LambdaIntegration(frontendConfigHandlerLambda.fn, { proxy: true }), 285 | ); 286 | 287 | const apigwGlobalDomainName = new DomainName(this, `${region}DomainName`, { 288 | domainName: globalSiteHost, 289 | certificate: regionCertificate, 290 | securityPolicy: SecurityPolicy.TLS_1_2, 291 | }); 292 | 293 | const apigwRegionalDomainName = new DomainName(this, `${region}DomainName-Regional`, { 294 | domainName: siteHost, 295 | certificate: regionCertificate, 296 | securityPolicy: SecurityPolicy.TLS_1_2, 297 | }); 298 | 299 | apigwGlobalDomainName.addBasePathMapping(restApi); 300 | apigwRegionalDomainName.addBasePathMapping(restApi); 301 | 302 | /* 303 | TODO:Route53 Health Checks 304 | 305 | const executeApiDomainName = Fn.join('.', [ 306 | restApi.restApiId, 307 | 'execute-api', 308 | region, 309 | Fn.ref('AWS::URLSuffix'), 310 | ]); 311 | 312 | const healthCheck = new CfnHealthCheck(this, `${region}HealthCheck`, { 313 | healthCheckConfig: { 314 | type: 'HTTPS', 315 | fullyQualifiedDomainName: executeApiDomainName, 316 | port: 443, 317 | resourcePath: `/${restApi.deploymentStage.stageName}/health`, 318 | }, 319 | }); 320 | */ 321 | const dnsRecord = new ARecord(this, `Api-${region}-Global`, { 322 | zone, 323 | recordName: globalSiteHost, 324 | target: RecordTarget.fromAlias(new ApiGatewayDomain(apigwGlobalDomainName)), 325 | }); 326 | 327 | const recordSet = dnsRecord.node.defaultChild as CfnRecordSet; 328 | recordSet.region = region; 329 | // recordSet.healthCheckId = healthCheck.attrHealthCheckId; 330 | recordSet.setIdentifier = `Api-${region}`; 331 | 332 | // Add the regional domain name for direct access without latency routing 333 | new ARecord(this, `Api-${region}-Region`, { 334 | zone, 335 | recordName: siteHost, 336 | target: RecordTarget.fromAlias(new ApiGatewayDomain(apigwGlobalDomainName)), 337 | }); 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /cdk/lib/simple-lambda.ts: -------------------------------------------------------------------------------- 1 | import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; 2 | import { Runtime, Tracing } from 'aws-cdk-lib/aws-lambda'; 3 | import { Duration } from 'aws-cdk-lib'; 4 | import { Construct } from 'constructs'; 5 | 6 | export interface SimpleLambdaProps { 7 | memorySize?: number; 8 | reservedConcurrentExecutions?: number; 9 | runtime?: Runtime; 10 | name: string; 11 | description: string; 12 | entryFilename: string; 13 | handler?: string; 14 | timeout?: Duration; 15 | envVariables?: any; 16 | } 17 | 18 | export class SimpleLambda extends Construct { 19 | public fn: NodejsFunction; 20 | 21 | constructor(scope: Construct, id: string, props: SimpleLambdaProps) { 22 | super(scope, id); 23 | 24 | this.fn = new NodejsFunction(this, id, { 25 | entry: `../src/lambda/${props.entryFilename}`, 26 | handler: props.handler ?? 'handler', 27 | runtime: props.runtime ?? Runtime.NODEJS_18_X, 28 | timeout: props.timeout ?? Duration.seconds(5), 29 | memorySize: props.memorySize ?? 1024, 30 | tracing: Tracing.ACTIVE, 31 | functionName: props.name, 32 | description: props.description, 33 | // depsLockFilePath: path.join(__dirname, '..', '..', 'src', 'package-lock.json'), 34 | environment: props.envVariables ?? {}, 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /cdk/lib/static-site-stack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CfnOutput, RemovalPolicy, Stack, StackProps, Fn, Duration, 3 | } from 'aws-cdk-lib'; 4 | import { Construct } from 'constructs'; 5 | import { 6 | ARecord, RecordTarget, CfnRecordSet, HostedZone, 7 | } from 'aws-cdk-lib/aws-route53'; 8 | import { S3Origin } from 'aws-cdk-lib/aws-cloudfront-origins'; 9 | import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; 10 | import { Certificate } from 'aws-cdk-lib/aws-certificatemanager'; 11 | import { 12 | AllowedMethods, Distribution, HeadersFrameOption, HeadersReferrerPolicy, IDistribution, ResponseHeadersPolicy, SecurityPolicyProtocol, ViewerProtocolPolicy, 13 | } from 'aws-cdk-lib/aws-cloudfront'; 14 | import { BlockPublicAccess, Bucket, IBucket } from 'aws-cdk-lib/aws-s3'; 15 | import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; 16 | import { IUserPool, IUserPoolClient } from 'aws-cdk-lib/aws-cognito'; 17 | import { StringParameter } from 'aws-cdk-lib/aws-ssm'; 18 | 19 | /** 20 | * Static site infrastructure, which deploys site content to an S3 bucket. 21 | * 22 | * The site redirects from HTTP to HTTPS, using a CloudFront distribution, 23 | * Route53 alias record, and ACM certificate. 24 | */ 25 | 26 | interface StaticSiteProps extends StackProps { 27 | siteDomain: string, 28 | certificate: Certificate, 29 | hostedZoneId: string, 30 | userPool?: IUserPool, 31 | userPoolClient?: IUserPoolClient, 32 | userPoolId?: string, 33 | userPoolClientId?: string, 34 | } 35 | 36 | export default class StaticSiteStack extends Stack { 37 | public readonly bucket : IBucket; 38 | 39 | public readonly distribution : IDistribution; 40 | 41 | constructor(scope: Construct, id: string, props: StaticSiteProps) { 42 | super(scope, id, props); 43 | 44 | const { 45 | siteDomain, 46 | hostedZoneId, 47 | certificate, 48 | } = props; 49 | 50 | const { account, region } = Stack.of(this); 51 | 52 | const siteHost = `frontend.${siteDomain}`; 53 | new CfnOutput(this, 'Site', { value: `https://${siteHost}` }); 54 | 55 | // Content bucket 56 | const bucket = new Bucket(this, 'StaticSiteBucket', { 57 | bucketName: `app-static-${account}-${region}`, 58 | publicReadAccess: false, 59 | blockPublicAccess: BlockPublicAccess.BLOCK_ALL, 60 | removalPolicy: RemovalPolicy.DESTROY, // NOT recommended for production code 61 | autoDeleteObjects: true, // NOT recommended for production code 62 | }); 63 | new CfnOutput(this, 'StaticSiteBucketArn', { value: bucket.bucketArn }); 64 | 65 | // Secure static website 66 | const responseHeadersPolicy = new ResponseHeadersPolicy(this, 'SecurityHeadersResponseHeaderPolicy', { 67 | comment: 'Security headers response header policy', 68 | securityHeadersBehavior: { 69 | contentSecurityPolicy: { 70 | override: true, 71 | contentSecurityPolicy: `default-src 'self' *.${siteDomain} *.amazonaws.com`, 72 | }, 73 | strictTransportSecurity: { 74 | override: true, 75 | accessControlMaxAge: Duration.days(2 * 365), 76 | includeSubdomains: true, 77 | preload: true, 78 | }, 79 | contentTypeOptions: { 80 | override: true, 81 | }, 82 | referrerPolicy: { 83 | override: true, 84 | referrerPolicy: HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN, 85 | }, 86 | xssProtection: { 87 | override: true, 88 | protection: true, 89 | modeBlock: true, 90 | }, 91 | frameOptions: { 92 | override: true, 93 | frameOption: HeadersFrameOption.DENY, 94 | }, 95 | }, 96 | }); 97 | 98 | // CloudFront distribution 99 | const distribution = new Distribution(this, 'SiteDistribution', { 100 | certificate, 101 | domainNames: [siteHost], 102 | minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021, 103 | defaultRootObject: 'index.html', 104 | defaultBehavior: { 105 | origin: new S3Origin(bucket), 106 | compress: true, 107 | allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS, 108 | viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 109 | responseHeadersPolicy, 110 | }, 111 | }); 112 | new CfnOutput(this, 'DistributionId', { value: distribution.distributionId }); 113 | 114 | // Get Route53 hosted zone for the domain 115 | const zone = HostedZone.fromHostedZoneAttributes(this, 'Zone', { 116 | zoneName: siteDomain, 117 | hostedZoneId, 118 | }); 119 | 120 | // Insert the A record e.g. frontend.mystartup.com 121 | new ARecord(this, `SiteRegionRecord-${region}`, { 122 | zone, 123 | recordName: siteHost, 124 | target: RecordTarget.fromAlias(new CloudFrontTarget(distribution)), 125 | }); 126 | 127 | // Get Cognito parameters from SSM 128 | const userPoolId = StringParameter.valueForStringParameter(this, 'CognitoUserPoolId'); 129 | const userPoolClientId = StringParameter.valueForStringParameter(this, 'CognitoUserPoolClientId'); 130 | 131 | // Create a config metadata 132 | // This will be consumed by the React app using fetch() 133 | const config = { 134 | region: Stack.of(this).region, 135 | userPoolId, 136 | userPoolClientId, 137 | siteDomain, 138 | }; 139 | 140 | // Deploy site contents to S3 bucket 141 | new BucketDeployment(this, 'DeployS3', { 142 | sources: [Source.asset('../src/app/build'), Source.jsonData('config.json', config)], 143 | destinationBucket: bucket, 144 | distribution, 145 | distributionPaths: ['/*'], 146 | }); 147 | 148 | // Output resources to stack for reference 149 | this.bucket = bucket; 150 | this.distribution = distribution; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-region-app", 3 | "version": "0.1.0", 4 | "bin": { 5 | "multi-region-app": "bin/multi-region-app.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "cdk": "cdk" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "^20.4.1", 14 | "@typescript-eslint/eslint-plugin": "^5.61.0", 15 | "@typescript-eslint/parser": "^5.61.0", 16 | "aws-cdk": "2.86", 17 | "esbuild": "^0.18.11", 18 | "eslint": "^8.44.0", 19 | "eslint-config-airbnb-base": "^15.0.0", 20 | "eslint-plugin-import": "^2.27.5", 21 | "ts-node": "^10.9.1", 22 | "typescript": "^5.1.6" 23 | }, 24 | "dependencies": { 25 | "aws-cdk-lib": "2.86", 26 | "constructs": "^10.2.69", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2018" 7 | ], 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "experimentalDecorators": true, 21 | "strictPropertyInitialization": false, 22 | "typeRoots": [ 23 | "./node_modules/@types" 24 | ] 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "cdk.out" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-region-data-residency", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "multi-region-data-residency" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "airbnb-base" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "parserOptions": { 11 | "ecmaVersion": 13, 12 | "sourceType": "module", 13 | "project": "tsconfig.json" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | "@typescript-eslint/comma-dangle": [ 20 | "error", 21 | "only-multiline" 22 | ], 23 | "import/extensions": "off", 24 | "import/no-extraneous-dependencies": "off", 25 | "import/prefer-default-export": "off" 26 | }, 27 | "settings": { 28 | "import/resolver": { 29 | "node": { 30 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /public/config.json 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/app/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /src/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@aws-amplify/ui-react": "^5.0.4", 7 | "@headlessui/react": "^1.7.15", 8 | "@heroicons/react": "^2.0.18", 9 | "@tailwindcss/forms": "^0.5.4", 10 | "@testing-library/jest-dom": "^5.16.5", 11 | "@testing-library/react": "^13.4.0", 12 | "@testing-library/user-event": "^13.5.0", 13 | "@types/jest": "^27.5.2", 14 | "@types/node": "^16.18.38", 15 | "@types/react": "^18.2.14", 16 | "@types/react-dom": "^18.2.6", 17 | "aws-amplify": "^5.3.3", 18 | "heroicons": "^2.0.18", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-icons": "^4.10.1", 22 | "react-router-dom": "^6.14.2", 23 | "react-scripts": "5.0.1", 24 | "typescript": "^4.9.5", 25 | "web-vitals": "^2.1.4" 26 | }, 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "eslintConfig": { 34 | "extends": [ 35 | "react-app", 36 | "react-app/jest" 37 | ] 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "devDependencies": { 52 | "autoprefixer": "^10.4.14", 53 | "postcss": "^8.4.27", 54 | "tailwindcss": "^3.3.3" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 25 | HelloHealth (Mock Startup) - Multi-region data residency demo 26 | 27 | 28 | 29 |
30 | 40 | 41 | -------------------------------------------------------------------------------- /src/app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/app/public/mock-receipt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/multi-region-data-residency/68d337b7565930854c491aa45f9dc5d112a1b180/src/app/public/mock-receipt.png -------------------------------------------------------------------------------- /src/app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/app/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/src/App.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Routes, Route, HashRouter } from 'react-router-dom' 3 | import Login from './Login' 4 | import Dashboard from './Dashboard' 5 | 6 | export default function App() { 7 | return ( 8 | 9 | 10 | 11 | } 14 | /> 15 | } 18 | /> 19 | 20 | 21 | } 24 | /> 25 | 26 | 27 | 28 | ) 29 | } -------------------------------------------------------------------------------- /src/app/src/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | This example requires some changes to your config: 3 | 4 | ``` 5 | // tailwind.config.js 6 | module.exports = { 7 | // ... 8 | plugins: [ 9 | // ... 10 | require('@tailwindcss/forms'), 11 | ], 12 | } 13 | ``` 14 | */ 15 | import './tailwind.css' 16 | 17 | import { Fragment, useState } from 'react' 18 | import { Dialog, Menu, Transition } from '@headlessui/react' 19 | import { 20 | Bars3Icon, 21 | BellIcon, 22 | BoltIcon, 23 | CalendarIcon, 24 | ChartPieIcon, 25 | Cog6ToothIcon, 26 | DocumentDuplicateIcon, 27 | FolderIcon, 28 | HomeIcon, 29 | UsersIcon, 30 | XMarkIcon, 31 | UserCircleIcon, 32 | PhotoIcon, 33 | ArrowDownTrayIcon, 34 | } from '@heroicons/react/24/outline' 35 | import { ChevronDownIcon, HeartIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid' 36 | 37 | // Navigation menu 38 | import Receipt from './Receipt' 39 | import Stats from './Stats' 40 | import Profile from './Profile' 41 | import Sensor from './Sensor' 42 | 43 | const navigation = [ 44 | { name: 'Stats', href: '#', icon: HomeIcon, current: true }, 45 | { name: 'Provider Receipts', href: '#', icon: FolderIcon, current: false }, 46 | { name: 'Health IOT Sensor', href: '#', icon: BoltIcon, current: false }, 47 | { name: 'My Profile', href: '#', icon: Cog6ToothIcon, current: false }, 48 | ] 49 | 50 | function classNames(...classes: any) { 51 | return classes.filter(Boolean).join(' ') 52 | } 53 | 54 | export default function Dashboard() { 55 | const [sidebarOpen, setSidebarOpen] = useState(false) 56 | 57 | const [menu, setMenu] = useState("Stats") 58 | 59 | const handleClick = (props: any) => { 60 | 61 | navigation.map((item) => { 62 | item.current = false 63 | }) 64 | 65 | setMenu(props.name) 66 | 67 | navigation.map((item) => { 68 | 69 | if (props.name == item.name) { 70 | item.current = true 71 | } 72 | 73 | }) 74 | 75 | } 76 | 77 | return ( 78 | <> 79 |
80 | 81 | 82 | 91 |
92 | 93 | 94 |
95 | 104 | 105 | 114 |
115 | 119 |
120 |
121 | {/* Sidebar component, swap this element with another sidebar if you like */} 122 | 172 |
173 |
174 |
175 |
176 |
177 | 178 | {/* Static sidebar for desktop */} 179 |
180 |
181 |
182 | 183 | 👋 184 | 185 | 186 | HelloHealth 187 | 188 |
189 | 232 |
233 |
234 | 235 |
236 |
237 | 241 | 242 | {/* Separator */} 243 | 302 |
303 | 304 | ) 305 | } 306 | -------------------------------------------------------------------------------- /src/app/src/Login.tsx: -------------------------------------------------------------------------------- 1 | import { Amplify } from 'aws-amplify'; 2 | import { Authenticator, Button, Heading, SelectField, useAuthenticator } from '@aws-amplify/ui-react'; 3 | import '@aws-amplify/ui-react/styles.css'; 4 | import awsExports from './aws-exports'; 5 | import { useEffect, useState } from 'react'; 6 | import { FaGithub } from 'react-icons/fa'; 7 | 8 | // TODO: Globalize country to region mapping (e.g. via CDK) 9 | let countryToRegion : {[key: string]: string}; 10 | 11 | countryToRegion = { 12 | 'Australia': 'ap-southeast-2', 13 | 'United States': 'us-east-2', 14 | }; 15 | 16 | const getCountryFromRegion = (region: string) : string => { 17 | // Find the country (key) based on the value of countryToRegion 18 | for (let country in countryToRegion) { 19 | if (countryToRegion[country] === region) { 20 | return country; 21 | } 22 | } 23 | return ''; 24 | } 25 | 26 | const getRegionFromCountry = (country: string) : string => { 27 | // Find the region (value) based on the key of countryToRegion 28 | return countryToRegion[country]; 29 | } 30 | 31 | export default function Login() { 32 | 33 | // Get current host URL 34 | let host = window.location.host; 35 | const hostSplit = host.split('.'); 36 | 37 | // Get site domain based on host, strip sub-domain 38 | const siteDomain = hostSplit.slice(1, hostSplit.length).join('.'); 39 | 40 | // React states for hooks 41 | const [region, setRegion] = useState(''); 42 | const [stackCountry, setStackCountry] = useState(''); 43 | const [country, setCountry] = useState(''); 44 | const [apiUrl, setApiUrl] = useState(`https://app.${siteDomain}`); 45 | 46 | // Configure runtime Config to integrate with CDK 47 | // Source: https://dev.to/aws-builders/aws-cdk-and-amplify-runtime-config-1md2 48 | Amplify.configure(awsExports); 49 | 50 | const fetchConfig = (apiUrl: string) => { 51 | fetch(`${apiUrl}/config`) 52 | .then((response) => response.status === 200 && response.json()) 53 | .then((context) => { 54 | const { region, cognitoUserPoolId, cognitoUserPoolClientId } = context; 55 | const runtimeConfig = { 56 | "aws_project_region": region, 57 | "aws_cognito_region": region, 58 | "aws_user_pools_id": cognitoUserPoolId, 59 | "aws_user_pools_web_client_id": cognitoUserPoolClientId, 60 | } 61 | const mergedConfig = { ...awsExports, ...runtimeConfig }; 62 | const setCountryBasedOnRegion = getCountryFromRegion(region); 63 | setRegion(region); 64 | setStackCountry(setCountryBasedOnRegion); 65 | setCountry(setCountryBasedOnRegion); 66 | Amplify.configure(mergedConfig); 67 | }) 68 | .catch((e) => console.log('Cannot fetch config.json')); 69 | } 70 | 71 | useEffect(() => fetchConfig(apiUrl), [apiUrl]); 72 | 73 | // Function to switch API region 74 | const switchRegion = (region: string = '', country: string = '') => { 75 | if ( region === '' ) { 76 | setApiUrl(`https://app.${siteDomain}`); 77 | setRegion(region); 78 | } else { 79 | setApiUrl(`https://${region}.${siteDomain}`); 80 | setRegion(region); 81 | } 82 | 83 | if ( country ) { 84 | setCountry(country); 85 | setStackCountry(country); 86 | } 87 | } 88 | 89 | // Fun: Emoji visualization for country 90 | // (Thanks to Amazon CodeWhisperer) 91 | const stackCountryEmoji = 92 | stackCountry === 'Australia' ? '🇦🇺' : 93 | stackCountry === 'United Kingdom' ? '🇬🇧' : 94 | stackCountry === 'United States' ? '🇺🇸' : 95 | stackCountry === 'Singapore' ? '🇸🇬' : 96 | ''; 97 | 98 | const SignUpFormFields = { 99 | FormFields() { 100 | const { validationErrors } = useAuthenticator(); 101 | 102 | const handleSelectCountryChange = (e: React.ChangeEvent) => { 103 | switchRegion(getRegionFromCountry(e.target.value), e.target.value); 104 | } 105 | const selectCountryHasError = !!validationErrors.country; 106 | const selectCountryErrorMessage = validationErrors.country as string; 107 | 108 | return ( 109 | <> 110 | {/* Re-use default `Authenticator.SignUp.FormFields` */} 111 | 112 | 113 | {/* Append with Country field */} 114 | 122 | 123 | 124 | 125 | 126 | ); 127 | }, 128 | }; 129 | 130 | return ( 131 |
132 | 133 |
134 | Multi-Region Demo 135 | {stackCountry} {stackCountryEmoji} 136 |
AWS Region: {region}
137 |
API Endpoint: {apiUrl}
138 | 144 |
145 | 146 | 152 | {({ signOut, user }) => ( 153 |
154 |
155 | Hello {user?.username} 156 |
157 |
158 | {user?.attributes?.['custom:country']} 159 |
160 |
161 | 162 |
163 |
164 | )} 165 |
166 | 167 | 172 | 173 |
174 | ); 175 | } -------------------------------------------------------------------------------- /src/app/src/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | UserCircleIcon, 3 | PhotoIcon, 4 | } from '@heroicons/react/24/outline' 5 | 6 | export default function Profile() { 7 | 8 | return( 9 |
10 |
11 | 12 |
13 |

Personal Information

14 |

15 | Complete your profile for our records. 16 |

17 | 18 |
19 |
20 | 23 |
24 | 31 |
32 |
33 | 34 |
35 | 38 |
39 | 46 |
47 |
48 | 49 |
50 | 53 |
54 | 61 |
62 |
63 | 64 |
65 | 68 |
69 | 79 |
80 |
81 | 82 |
83 | 86 |
87 | 94 |
95 |
96 | 97 |
98 | 101 |
102 | 109 |
110 |
111 | 112 |
113 | 116 |
117 | 124 |
125 |
126 | 127 |
128 | 131 |
132 | 139 |
140 |
141 |
142 |
143 | 144 | 145 |
146 | 147 |
148 | 151 | 157 |
158 |
159 | ) 160 | } -------------------------------------------------------------------------------- /src/app/src/Receipt.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | UserCircleIcon, 3 | PhotoIcon, 4 | CheckIcon, 5 | XMarkIcon 6 | } from '@heroicons/react/24/outline' 7 | 8 | import { Fragment, useState } from 'react' 9 | import { Dialog, Transition } from '@headlessui/react' 10 | 11 | // TODO: Pull from Lambda/S3, mock data for UI prototype 12 | 13 | const receipts = [ 14 | { status: "Pending", name: "Happy Feet", category: 'Reflexology', date: '24th May 2023', id: 1 }, 15 | { status: "Approved", name: "Phyiso First", category: 'Physiotherapy', date: '20th May 2023', id: 2 }, 16 | { status: "Approved", name: "Vision Plus", category: 'Optometrist', date: '4th Feb 2023', id: 3 }, 17 | { status: "Approved", name: "Smile Now", category: 'Dental', date: '1st Jan 2023', id: 4 }, 18 | ] 19 | 20 | export default function Receipt() { 21 | 22 | const [open, setOpen] = useState(false) 23 | 24 | const handleView = () => { 25 | 26 | setOpen(true) 27 | 28 | } 29 | 30 | return ( 31 | 32 |
33 | 34 | 35 | 36 |
37 | 38 |
39 |
40 |
41 | 50 | 51 |
52 |
53 |
54 | 55 | Receipt 56 | 57 |
58 | 67 |
68 |
69 |
70 |
71 | 72 | 73 | 74 |

Provider

75 |

76 | Happy Feet 77 |

78 | 79 |

Category

80 |

81 | Reflexology 82 |

83 | 84 |

Date

85 |

86 | 24th May 2023 87 |

88 | 89 | 90 | 91 | 92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | 102 |
103 |
104 |
105 |

Upload New Receipt

106 |

107 | Upload and manage your healthcare provider receipts for reimbursement. 108 |

109 | 110 |
111 |
112 | 115 |
116 |
117 | 118 | 126 |
127 |
128 |
129 | 130 |
131 | 134 |
135 |
136 | 155 |
156 |
157 |
158 | 159 | 160 | 161 | 162 |
163 | 166 |
167 |
168 |
169 |
182 |
183 |
184 |
185 |
186 |
187 | 188 |
189 | 190 |
191 | 194 | 200 |
201 |
202 | 203 | 204 |

Manage Receipts

205 |

206 | View and manage existing receipts. 207 |

208 | 209 |
210 |
211 |
212 |
213 | 214 | 215 | 216 | 219 | 220 | 223 | 224 | 227 | 228 | 231 | 232 | 235 | 236 | 239 | 240 | 241 | 242 | {receipts.map((receipt) => ( 243 | 244 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 258 | 263 | 264 | ))} 265 | 266 |
217 | Provider 218 | 221 | Category 222 | 225 | Status 226 | 229 | Date 230 | 233 | View 234 | 237 | Delete 238 |
245 | {receipt.name} 246 | {receipt.category}{receipt.status}{receipt.date} 254 | handleView()} className="text-indigo-600 hover:text-indigo-900"> 255 | View 256 | 257 | 259 | 260 | Delete 261 | 262 |
267 |
268 |
269 |
270 |
271 | 272 |
273 | 274 | ) 275 | } -------------------------------------------------------------------------------- /src/app/src/Sensor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | UserCircleIcon, 3 | PhotoIcon, 4 | } from '@heroicons/react/24/outline' 5 | 6 | // TODO: Pull from Lambda/DynamoDB, mock data for UI prototype 7 | 8 | const iot = [ 9 | { value: "120", metric: "BPM", category: 'Heart Rate', date: '24th May 2023', id: 1 }, 10 | { value: "94%", metric: "SpO2", category: 'Blood Oxygen Level', date: '24th May 2023', id: 1 }, 11 | { value: "3.66", metric: "mmo/L", category: 'Blood Glucose Concentration', date: '24th May 2023', id: 1 }, 12 | 13 | { value: "123", metric: "BPM", category: 'Heart Rate', date: '25th May 2023', id: 1 }, 14 | { value: "96%", metric: "SpO2", category: 'Blood Oxygen Level', date: '25th May 2023', id: 1 }, 15 | { value: "3.81", metric: "mmo/L", category: 'Blood Glucose Concentration', date: '25th May 2023', id: 1 }, 16 | 17 | 18 | ] 19 | 20 | export default function Sensor() { 21 | 22 | return ( 23 | 24 |
25 | 26 |
27 |
28 |
29 |

Health IOT Sensor

30 |

31 | Specify the metrics from your Health IOT sensor to track your health metrics. 32 |

33 | 34 |
35 |
36 | 39 |
40 |
41 | BPM 42 | 50 |
51 |
52 |
53 | 54 |
55 | 58 |
59 |
60 | SpO2 61 | 69 |
70 |
71 |
72 | 73 |
74 | 77 |
78 |
79 | mmol/L 80 | 88 |
89 |
90 |
91 | 92 |
93 | 94 |
95 | 96 |
97 | 98 |
99 | 102 | 108 |
109 |
110 | 111 | 112 |

Manage Metrics

113 |

114 | View and manage existing IOT sensor data. 115 |

116 | 117 |
118 |
119 |
120 |
121 | 122 | 123 | 124 | 127 | 128 | 131 | 132 | 133 | 136 | 137 | 140 | 141 | 142 | 143 | {iot.map((metric) => ( 144 | 145 | 148 | 149 | 150 | 151 | 152 | 157 | 158 | ))} 159 | 160 |
125 | Event 126 | 129 | Value 130 | 134 | Date 135 | 138 | Delete 139 |
146 | {metric.category} 147 | {metric.value}{metric.date} 153 | 154 | Delete 155 | 156 |
161 |
162 |
163 |
164 |
165 | 166 |
167 | 168 | ) 169 | } -------------------------------------------------------------------------------- /src/app/src/Stats.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CheckCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline' 3 | 4 | 5 | export default function Stats() { 6 | 7 | function classNames(...classes: any) { 8 | return classes.filter(Boolean).join(' ') 9 | } 10 | 11 | // TODO: Pull from Lambda/DynamoDB, mock data for UI prototype 12 | const stats = [ 13 | { name: 'Receipts', value: '5', change: '+4.75%', changeType: 'positive' }, 14 | { name: 'Health Sensor Events', value: '450', change: '+54.02%', changeType: 'negative' }, 15 | ] 16 | 17 | const activity = [ 18 | { id: 1, type: 'added', person: { name: 'Healthcare Receipt' }, date: '7d ago', dateTime: '2023-01-23T10:32' }, 19 | { id: 2, type: 'deleted', person: { name: 'Healthcare Receipt' }, date: '6d ago', dateTime: '2023-01-23T11:03' }, 20 | { id: 3, type: 'event sent', person: { name: 'IOT Sensor' }, date: '6d ago', dateTime: '2023-01-23T11:24' }, 21 | { id: 4, type: 'event sent', person: { name: 'IOT Sensor' }, date: '2d ago', dateTime: '2023-01-24T09:12' }, 22 | { id: 4, type: 'event deleted', person: { name: 'IOT Sensor' }, date: '2d ago', dateTime: '2023-01-24T09:12' }, 23 | { id: 5, type: 'event sent', person: { name: 'IOT Sensor' }, date: '1d ago', dateTime: '2023-01-24T09:20' }, 24 | { id: 6, type: 'updated', person: { name: 'Profile' }, date: '1d ago', dateTime: '2023-01-24T09:20' }, 25 | { id: 6, type: 'event', person: { name: 'Login' }, date: 'now', dateTime: '2023-01-24T09:20' }, 26 | ] 27 | 28 | return( 29 | <> 30 | {/* Stats */} 31 | 32 |
33 |
34 |
35 |
37 |
38 |

Demo Mode

39 |
40 |

41 | Welcome to the HelloHealth (Mock Startup) demonstration for multi-region data residency using AWS. The application demonstrates storing customer PII (Personally Identifiable Information) and PHI (Personal Health Information) in a geographicaly isolated region based on the users country of residence. The architecture includes Amazon Cognito for authentication, unique S3 buckets per region for file uploads and DynamoDB tables in each region for customers healthcare (PHI) data and profile (PII) data. For more information see the aws-samples Github repo. 42 |

43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 | {stats.map((stat, statIdx) => ( 51 |
58 |
{stat.name}
59 |
65 | {stat.change} 66 |
67 |
68 | {stat.value} 69 |
70 |
71 | ))} 72 |
73 |
74 | 75 | 76 |
77 | {/* Activity feed */} 78 |

My Activity

79 |
    80 | {activity.map((activityItem, activityItemIdx) => ( 81 |
  • 82 |
    88 |
    89 |
    90 | 91 | <> 92 |
    93 | {activityItem.type === 'paid' ? ( 94 |
  • 112 | ))} 113 |
114 |
115 | 116 | 117 | ) 118 | 119 | } -------------------------------------------------------------------------------- /src/app/src/aws-exports.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten. 3 | 4 | const awsconfig = { 5 | "aws_project_region": "ap-southeast-1", 6 | "aws_cognito_region": "ap-southeast-1", 7 | "aws_user_pools_id": "", 8 | "aws_user_pools_web_client_id": "", 9 | "aws_cognito_username_attributes": [ 10 | "EMAIL" 11 | ], 12 | "aws_cognito_social_providers": [], 13 | "aws_cognito_signup_attributes": [ 14 | "EMAIL", 15 | ], 16 | "aws_cognito_mfa_configuration": "OFF", 17 | "aws_cognito_mfa_types": [ 18 | "SMS" 19 | ], 20 | "aws_cognito_password_protection_settings": { 21 | "passwordPolicyMinLength": 8, 22 | "passwordPolicyCharacters": [] 23 | }, 24 | "aws_cognito_verification_mechanisms": [ 25 | "EMAIL" 26 | ] 27 | }; 28 | 29 | export default awsconfig; 30 | -------------------------------------------------------------------------------- /src/app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | a { color: #777; } 16 | a:hover { text-decoration: none; color: #000 } 17 | 18 | .wave { 19 | animation-name: wave-animation; /* Refers to the name of your @keyframes element below */ 20 | animation-duration: 2.5s; /* Change to speed up or slow down */ 21 | animation-iteration-count: 2; /* Never stop waving :) */ 22 | transform-origin: 70% 70%; /* Pivot around the bottom-left palm */ 23 | display: inline-block; 24 | } 25 | 26 | @keyframes wave-animation { 27 | 0% { transform: rotate( 0.0deg) } 28 | 10% { transform: rotate(14.0deg) } /* The following five values can be played with to make the waving more or less extreme */ 29 | 20% { transform: rotate(-8.0deg) } 30 | 30% { transform: rotate(14.0deg) } 31 | 40% { transform: rotate(-4.0deg) } 32 | 50% { transform: rotate(10.0deg) } 33 | 60% { transform: rotate( 0.0deg) } /* Reset for the last half to pause */ 34 | 100% { transform: rotate( 0.0deg) } 35 | } 36 | 37 | @tailwind base; 38 | @tailwind components; 39 | @tailwind utilities; -------------------------------------------------------------------------------- /src/app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /src/app/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/app/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/app/src/tailwind.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/multi-region-data-residency/68d337b7565930854c491aa45f9dc5d112a1b180/src/app/src/tailwind.css -------------------------------------------------------------------------------- /src/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/lambda/config-handler.ts: -------------------------------------------------------------------------------- 1 | import { SSMClient } from "@aws-sdk/client-ssm"; 2 | import { GetParametersCommand } from "@aws-sdk/client-ssm"; 3 | 4 | export async function handleEvent(event: any) { 5 | console.log('Config handler', event); 6 | 7 | // Get region 8 | const region = process.env.AWS_REGION; 9 | 10 | // Get SSM parameter using AWS SDK v3 11 | const ssm = new SSMClient({ 12 | region: region, 13 | }); 14 | 15 | const ssmRequest = new GetParametersCommand({ Names: [ 16 | 'CognitoUserPoolClientId', 17 | 'CognitoUserPoolId', 18 | ]}); 19 | const ssmResponse = await ssm.send(ssmRequest); 20 | const parameters = ssmResponse.Parameters; 21 | 22 | const responseBody = { 23 | region, 24 | cognitoUserPoolClientId: parameters ? parameters[0].Value : '', 25 | cognitoUserPoolId: parameters ? parameters[1].Value : '', 26 | }; 27 | 28 | const response = { 29 | "statusCode": 200, 30 | "headers": { 31 | "Access-Control-Allow-Headers" : "Content-Type", 32 | "Access-Control-Allow-Origin": "*", 33 | "Access-Control-Allow-Methods": "OPTIONS,POST,GET" 34 | }, 35 | "body": JSON.stringify(responseBody), 36 | "isBase64Encoded": false 37 | }; 38 | 39 | return response; 40 | } -------------------------------------------------------------------------------- /src/lambda/pre-auth-handler.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'; 3 | 4 | export async function handleEvent(event: any) { 5 | console.log('Pre Auth Handler - Received event ', event); 6 | 7 | // Get the AWS Region of this Lambda 8 | const region = event.region; 9 | 10 | // Hash user email string 11 | const email = event.userName; 12 | const emailHash = crypto.createHash('md5').update(email).digest("hex"); 13 | 14 | // Get DynamoDB record in UserResidency table based on emailHash 15 | const dynamoDBClient = new DynamoDBClient({ region }); 16 | const userResidencyTable = process.env.USER_RESIDENCY_TABLE; 17 | 18 | const command = new QueryCommand({ 19 | KeyConditionExpression: "userId = :userId", 20 | ExpressionAttributeValues: { 21 | ":userId": { S: emailHash }, 22 | }, 23 | TableName: userResidencyTable, 24 | }); 25 | 26 | const response = await dynamoDBClient.send(command); 27 | 28 | let userRegion = ''; 29 | response.Items?.forEach((user) => { 30 | userRegion = user.region.S as string; 31 | }); 32 | 33 | if ( response.Items && userRegion !== region ) { 34 | throw new Error("You account is not associated with this region. Please login to the correct region"); 35 | } 36 | 37 | return event; 38 | } -------------------------------------------------------------------------------- /src/lambda/pre-sign-up-handler.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 3 | import { PutCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; 4 | 5 | export async function handleEvent(event: any) { 6 | console.log('Pre Auth Handler - Received event ', event); 7 | 8 | // Auto confirm user in Cognito for demo purposes 9 | event.response.autoConfirmUser = true; 10 | 11 | if (event.request.userAttributes.hasOwnProperty("email")) { 12 | event.response.autoVerifyEmail = true; 13 | 14 | // Get the AWS Region of this Lambda 15 | const region = event.region; 16 | 17 | // Hash user email string 18 | const email = event.request.userAttributes.email; 19 | const emailHash = crypto.createHash('md5').update(email).digest("hex"); 20 | 21 | // Write to DynamoDB UserResidency table 22 | const client = new DynamoDBClient({ region }); 23 | const docClient = DynamoDBDocumentClient.from(client); 24 | const dynamoDBTableName = process.env.USER_RESIDENCY_TABLE; 25 | const command = new PutCommand({ 26 | TableName: dynamoDBTableName, 27 | Item: { 28 | userId: emailHash, 29 | region: region, 30 | } 31 | }); 32 | const response = await docClient.send(command); 33 | console.log('Pre Auth Handler - Wrote user residency to DynamoDB table'); 34 | console.log(response); 35 | } 36 | 37 | return event; 38 | } -------------------------------------------------------------------------------- /src/lambda/utils.ts: -------------------------------------------------------------------------------- 1 | export default function generateLambdaProxyResponse(httpCode: number, jsonBody: string) { 2 | return { 3 | body: jsonBody, 4 | statusCode: httpCode, 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@aws-sdk/client-dynamodb": "^3.369.0", 4 | "@aws-sdk/client-ssm": "^3.370.0", 5 | "@aws-sdk/lib-dynamodb": "^3.369.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2020"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "allowSyntheticDefaultImports": true, 11 | "noImplicitThis": true, 12 | "alwaysStrict": true, 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": false, 17 | "inlineSourceMap": true, 18 | "inlineSources": true, 19 | "experimentalDecorators": true, 20 | "strictPropertyInitialization": false, 21 | "typeRoots": ["./node_modules/@types"], 22 | "types": ["node"] 23 | }, 24 | "exclude": ["cdk.out"] 25 | } 26 | --------------------------------------------------------------------------------