├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── INSTALL.md ├── LICENSE ├── NOTICE ├── POOLING_API_KEYS.md ├── README.md ├── WALKTHROUGH.md ├── assets └── images │ ├── AWSConsole_APIKeys.png │ ├── AWSConsole_UsagePlans.png │ ├── amplify-create-mfa-redacted.png │ ├── amplify-create.png │ ├── amplify-login.png │ ├── architecture.png │ ├── react-local-server.png │ ├── walkthru-dash.png │ ├── walkthru-dash2.png │ ├── walkthru-extension.png │ ├── walkthru-keySuccess.png │ ├── walkthru-plans.png │ ├── walkthru-testLimit.png │ ├── walkthru-testPass.png │ └── walktrhu-newKey.png ├── cdk ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── cdk.ts ├── cdk.json ├── jest.config.js ├── lib │ ├── api-stack.ts │ └── auth-stack.ts ├── package-lock.json ├── package.json ├── test │ └── cdk.test.ts └── tsconfig.json ├── lambda ├── api_key_pools.js ├── create_key.js ├── delete_key.js ├── get_data.js ├── get_key.js ├── get_keys.js ├── get_plan.js ├── get_plans.js ├── update_key.js └── utils.js ├── react ├── .gitignore ├── .vscode │ └── launch.json ├── README.md ├── db.json ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── components │ ├── Dashboard │ │ ├── ApiTestPanel.js │ │ ├── Dashboard.js │ │ └── OverviewPanel.js │ ├── Keys │ │ ├── KeyDetails.js │ │ ├── KeyService.js │ │ └── KeysList.js │ ├── Navigation │ │ ├── Content.js │ │ ├── EmptyState.js │ │ ├── NavDrawer.js │ │ └── ToolsDrawer.js │ └── Plans │ │ ├── PlanDetails.js │ │ ├── PlanService.js │ │ └── PlansList.js │ ├── config.json │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── reportWebVitals.js │ ├── services │ └── utils.js │ └── setupTests.js └── walktrhu-newKey.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # INSTALL 2 | 3 | ## 1. Prerequisites 4 | 5 | 1. AWS Account 6 | 2. [AWS CLI](https://aws.amazon.com/cli/) configured and installed (or [AWS Cloud9](https://aws.amazon.com/cloud9/)) 7 | 3. [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) installed (version 2.12.0 or later) 8 | 3. Node.js installed 9 | 10 | ## 2. The CDK 11 | 12 | Most of the backend infrastructure is built using the AWS CDK in TypeScript. If you don't already have TypeScript 3.8 or later, install it using `npm`. Before running the CDK, there are a few key pieces of information that need to be copied over from Amplify's build. Primarily the Cognito deployment because it needs to be used as an authorizer for parts of Amazon API Gateway. 13 | 14 | ### 2.1 Deploy Backend with CDK 15 | ```bash 16 | npm install -g typescript 17 | 18 | cd cdk/ 19 | 20 | npm install 21 | 22 | cdk bootstrap # may be able to skip this if you've used CDK before and infrastructure (e.g. CDK staging bucket) is already initialized 23 | 24 | cdk deploy --all 25 | ``` 26 | ## 3. Set up AWS Amplify and local frontend 27 | 28 | As the AWS Amplify Construct for CDK is in preview, we will use the Amplify CLI instead. Note, if you are using SSO instead of IAM Users to access your account, you will want to run `aws configure sso` from the command line first. 29 | 30 | ### 3.1. Install and Configure Amplify CLI 31 | 32 | This creates an Amplify Administrator IAM User. Note that the command line argument will launch the AWS Console 33 | 34 | ```bash 35 | npm install -g @aws-amplify/cli 36 | 37 | amplify configure 38 | # choose same region as the rest of this workshop 39 | # choose user name 40 | # finish creating IAM user in AWS console 41 | # copy AccessKey and SecretAccessKey from console back into the prompt 42 | # chose a profile name that the AmplifyCLI will use for this new IAM user 43 | ``` 44 | 45 | ### 3.2. Use Amplify CLI to configure resources for the web application 46 | 47 | Basic initialization 48 | ```bash 49 | cd $react/ 50 | 51 | amplify init 52 | # enter name for the project 53 | # accept the default configuration 54 | # choose "Profile" as the authorization method, if local -- or choose AWS Access Keys (Cloud9) 55 | # choose the profile name created in "amplify configure" 56 | ``` 57 | 58 | Import Cognito User Pool created by CDK as authentication provider for this project 59 | ```bash 60 | amplify import auth 61 | # Choose "Cognito User Pool only" 62 | # Choose User Pool created by CDK with name starting with UserPool (e.g. UserPool6BA7E5F2-VQFB73BtHTuf) 63 | 64 | amplify push 65 | # yes 66 | ``` 67 | 68 | The deployment may take a few minutes. 69 | 70 | ### 3.3. Testing the frontend with local backend 71 | 72 | Let's start with installing packages for the react application 73 | ```bash 74 | npm install 75 | ``` 76 | 77 | The react application comes with a development server that can be run locally. Review `$react/src/config.json` for configuration of the local environment 78 | ```json 79 | { 80 | "USE_LOCAL_API": true, 81 | "LOCAL_API_BASE": "http://localhost:5100", 82 | "REST_API_BASE": "" 83 | } 84 | ``` 85 | We'll return to setting `REST_API_BASE` later, after the backend is installed via CDK. 86 | 87 | Now in one terminal, start the local API backend. `dev-backend` script is defined in `$react/package.json` and uses port `5100` to run a local json-server to act as local API for testing. 88 | ```bash 89 | npm run dev-backend 90 | ``` 91 | 92 | In a second terminal, start the react server for frontend. We are using the default port `3000` 93 | ```bash 94 | npm start 95 | ``` 96 | 97 | Directing your browser to `http://localhost:3000`, the following login screen should appear. 98 | 99 | ![Amplify Login Screen](/assets/images/amplify-login.png) 100 | 101 | Your Amazon Cognito User Pool created earlier by Amplify will be empty and you will need to sign up a user to access the frontend. 102 | 103 | ![Amplify Create Account](assets/images/amplify-create.png) 104 | 105 | Click on Create Account tab and sign up using your email address and set password (minimum 8 characters). You will receive an email with one time code to verify email address to complete sign-up as user in your Amazon Cognito User Pool. 106 | 107 | At your first login, you will be prompted to set up MFA. Scan QR code with authenticator app to complete setup. 108 | 109 | ![Amplify Create Account](assets/images/amplify-create-mfa-redacted.png) 110 | 111 | After creating a login with Cognito, the application will show the sample data from local API server. You can also review sample data in `$react/db.json`. 112 | 113 | ![Sample Application with sample data](/assets/images/react-local-server.png) 114 | 115 | 116 | ### 3.4 Update the React App to use Amazon API Gateway endpoint 117 | 118 | After the `cdk deploy` command completes from the previous step, you should see in the last few lines of output mention of a multitenantApiEndpoint. It will be in the form of `https://${id}.execute-api.${AWS)_REGION}/amazonaws.com/prod`. Copy the value and set it to the `REST_API_BASE` in `$react/src/config.json`. Also update `USE_LOCAL_API` value to `false`. 119 | 120 | This will update the react application to use the Amazon API Gateway instead of the local development server. 121 | 122 | ### 3.5 Run the React App 123 | 124 | ```bash 125 | cd $react 126 | npm start 127 | ``` 128 | 129 | ### 3.6 Open the App in a Browser and Login 130 | ![Amplify Login Screen](/assets/images/amplify-login.png) 131 | 132 | Navigate to `http://localhost:3000` in your browser to access updated React frontend. You can login use an account you created earlier step. 133 | 134 | Continue to the [WALKTHROUGH](./WALKTHROUGH.md) 135 | 136 | 137 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Multi-Tentant Tiering with Amazon API Gateway and Usage Plans 2 | Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. -------------------------------------------------------------------------------- /POOLING_API_KEYS.md: -------------------------------------------------------------------------------- 1 | # Pooling API Keys 2 | 3 | ## Summary 4 | 5 | This page is intended to be followed after the sample application has been installed (see [INSTALL](./INSTALL.md)), and the user has gotten familiar with the basic functionality (see [WALKTHROUGH](./WALKTHROUGH.md)). 6 | The advanced concept presented on this page is on operational challenge of maintaining thousands of API Keys and [Amazon API Gateway quotas on number of API Keys per account per region](https://docs.aws.amazon.com/apigateway/latest/developerguide/limits.html#api-gateway-execution-service-limits-table). For large number of tenants, some form of pooling mechism based on tiers becomes beneficial. See the associated [blog](https://aws.amazon.com/blogs/startups/) for more details. 7 | 8 | ## Activate Pools of API Keys for Certain Usage Plans Tiers 9 | 10 | Although the sample code has the capacity to pool API Keys for certain usage plans, it does not do so without code modification. Follow these steps to enable that functionality. 11 | 12 | First, delete all your API Keys except for one API Key in the Free Tier, and five API Keys in the Basic Tier. The next step will turn those six keys into pools. 13 | 14 | Second, go to `lambdas/api_key_pools.js` and edit the file as described in the comments. 15 | You will need to use the AWS Console to inspect Usage Plans in the Amazon API Gateway to get the correct ID numbers. 16 | 17 | Here's a screenshot of the Amazon API Gateway from the AWS Console. The ID for the Usage Plan called "BasicPlan" is `vexgx5` in this image, but it will be different for everyone. 18 | ![see Usage Plans in the AWS Console](assets/images/AWSConsole_UsagePlans.png) 19 | 20 | 21 | This is a screenshot for the API Keys. The Id for the API Key in this screenshot is `p5aqzs...`, but it will be different for each deployment. That's the value you want to enter in the `lambdas/api_key_pools.js` file as well. 22 | ![see API Keys in the AWS Console](assets/images/AWSConsole_APIKeys.png) 23 | 24 | 25 | Lastly, re-deploy the lambda functions using CDK. (CDK is intelligent enough to detect changes and deploy just the updates.) 26 | ``` 27 | cd cdk 28 | cdk deploy 29 | ``` 30 | 31 | ## What this change does 32 | 33 | When the data structure in `lambdas/api_key_pools.js` is empty (as it was in the initial deployment), the business logic in the `createKey` and `deleteKey` Lambda functions behave in "siloed" mode, where each request pertains to a unique key. When the data structure is populated, the Lambda functions understand that certain API Keys are pooled resources in specific Usage Plans and will share them across users. In a production system, this could be encoded into a database query or parameter store. 34 | 35 | ## How to verify pooling 36 | 37 | Again, read the blog post for more details, for starters, delete all the keys through the web application. Now go back to the Amazon API Gateway in the AWS Console, you will find six keys persist: 1 on the FreePlan and 5 in the Basic Plan. 38 | 39 | Now create 10 Keys of each type in the sample application. Going back to the Amazon API Gateway in the AWS Console, you should see 10 API Keys on the Test Plan, 10 API Keys on the Premium Plan, the same 5 keys in the Basic plan as before, and the 1 single Key in the Free Pool. 40 | 41 | Next test, create five Keys in the FreePlan. Test each one of these in the Dashboard's testing panel and note the actual UUID in the X-Api-Key header. It should be the same UUID for all five keys. 42 | 43 | ## Why it behaves this way 44 | When requests to create or delete a key hit the lambda functions, they are testing first to see if the Usage Plan is in a fixed pool or not. If it is in a pool, then the entry in Amazon DynamoDB is essentially a reference to a shared resource. If the Usage Plan does not have a pool of keys, then the Lambda Function creates/destroys an API Key with Amazon API Gateway service directly, then records the metadata in Amazon DynamoDB. 45 | 46 | ## Next 47 | 48 | Even though the cost of this sample code is very small, it is still important to clean up when you're done. Look for cleanup instructions in the [README](./README.md) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Applying tiering strategies to manage throttling in a multi-tenant system with Amazon API Gateway Usage Plans 2 | ## Overview 3 | 4 | Amazon API Gateway Usage Plans and API Keys help implement solutions for tiering strategy, managing noisy neighbor effects in a multi-tenant environment. Throttling and quotas help manage and minimize potential impacts by one tenant's ability to affect other tenants experience commonly known as noisy neighbor. 5 | 6 | This repository contains a working demo of throttling REST APIs in Amazon API Gateway in a multi-tenant situation. Throttling is an important strategy to protect backend services from excessive load. API Gateway offers the ability to apply throttling on a per-tenant basis, so that all tenants get their fair share. 7 | 8 | This code sample implements a tiered multi-tenant strategy which is a practical consideration at large scale, because the number API Keys available per account per region is subject to [quota limits](https://docs.aws.amazon.com/apigateway/latest/developerguide/limits.html) and may be significantly less than the total number of tenants. 9 | 10 | ## Solution Architecture 11 | ![Workshop Architecture](/assets/images/architecture.png) 12 | 13 | ## Description 14 | 15 | The sample web application provides administrative functions on how customers can sign up and "purchase" API Keys at different service tiers. The sample app also allows users to call the REST API protected by API Key to test and observe throttle and quota behavior with Usage Plans. 16 | 17 | The web app invokes REST APIs on a single Amazon API Gateway deployment. Calls that perform CRUD operations on API Keys are grouped into `/admin/*` and require authentication with Amazon Cognito. A single REST API, `GET /api/data` is protected by the Usage Plan. 18 | 19 | ## Installation 20 | See [INSTALL](./INSTALL.md) 21 | 22 | 23 | ## Walkthrough 24 | See [WALKTHROUGH](./WALKTHROUGH.md) 25 | 26 | 27 | ## Enable Pooling 28 | See [POOLING_AKI_KEYS](./POOLING_AKI_KEYS.md) 29 | 30 | ## Clean Up 31 | Removing the deployed assets from AWS account is done with the following commands 32 | 33 | ```bash 34 | 35 | cd cdk 36 | cdk destroy --all 37 | 38 | cd react 39 | amplify delete 40 | ``` 41 | 42 | Note that the default behavior of `cdk destroy` is to retain certain stateful resources, such as Amazon DynamoDB Tables. As this is a code sample for training only, the CDK scripts have an explicit `removalPolicy` to override that default behavior and remove the Amazon DynamoDB Tables as well. 43 | 44 | ## Security 45 | 46 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 47 | 48 | ## Code of Conduct 49 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 50 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 51 | opensource-codeofconduct@amazon.com with any additional questions or comments. 52 | 53 | ## License 54 | 55 | This library is licensed under the MIT-0 License. See the LICENSE file. 56 | 57 | SPDX-License-Identifier: MIT-0 58 | OSI Approved :: MIT No Attribution License (MIT-0) 59 | -------------------------------------------------------------------------------- /WALKTHROUGH.md: -------------------------------------------------------------------------------- 1 | # Walkthrough 2 | 3 | ## About this page: 4 | This document assumes that you've already successfully built the sample code (see [INSTALL.md](INSTALL.md)). This walks through the basic functionality of the sample application. It is a very simple UI to vend API Keys and test them. At the end of this page, you should have a good idea how API Keys work, and be able to demonstrate how Amazon API Gateway can switch from HTTP 200 responses when things are allowed, to HTTP 429 responses when Amazon API Gateway is throttling traffic. 5 | 6 | ## Getting Acquainted with the UI 7 | 8 | After logging in the first time, the application looks like this, with four Usage Plans and no API Keys. 9 | 10 | ![Dashboard with 4 Usage Plans and no Keys](assets/images/walkthru-dash.png) 11 | 12 | Navigate to `Usage Plans` on the side panel, we can get more detail about the Usage Plans available. In this example, they have different quotas and throttle characteristics. 13 | 14 | ![List of Usage Plans](assets/images/walkthru-plans.png) 15 | 16 | Purchase a Key from the `Test Plan` by click on `Purchase Key` button as shown below. This particular Usage Plan is useful for demonstration purposes because the quota is very low: 20 calls per day. 17 | 18 | ![API Key Creation Form](assets/images/walktrhu-newKey.png) 19 | 20 | After the key is successfully created, the application lands on the listing of all API Keys available to this user. 21 | 22 | 23 | ![List of API Keys after successful creation](assets/images/walkthru-keySuccess.png) 24 | 25 | Going back to the Dashboard, we see that we now have one key. More importantly, there is a testing utility in the second box of the Dashboard. 26 | 27 | ![Dashboard after API Key created](assets/images/walkthru-dash2.png) 28 | 29 | ## Demonstrating the Quota Limit 30 | 31 | Select the key from the dropdown and press the `GET` button to issue an HTTP GET command. The request, with the X-API-KEY Header shows in the left, the response appears in the right, a sample JSON body. 32 | 33 | ![Dashboard after REST API invoked using API Key](assets/images/walkthru-testPass.png) 34 | 35 | Open up the developer tools within the browser and press the GET button several more times. Eventually the 200 HTTP response will change to a 429 response when the quota is expired. 36 | 37 | ![Using browser developer tools to see when REST API is throttled for exceeding quota](assets/images/walkthru-testLimit.png) 38 | 39 | ## Granting Exceptions 40 | 41 | An exception for this quota can be granted via the AWS Console 42 | 43 | ![AWS Console when granting Usage Extension](assets/images/walkthru-extension.png) 44 | 45 | ## Other Experiments 46 | 47 | 1. Log out, create a second login with a different email, and create a new API Key with the same UsagePlan. You should be able to use that one until Amazon API Gateway throttles that independently. 48 | 49 | 2. You can also try exercising the API on the command line using cURL 50 | ```bash 51 | curl -H "X-Api-key: " https://.execute-api..amazonaws.com/prod/api 52 | ``` 53 | Where ``, `` and `` can be discovered in the API Test panel of the Dashboard. 54 | 55 | 3. For the really curious, try loadtesting one of the other usage plans. Amazon API Gateway will throttle based on best-effort, so don't be suprised if a few extra requests get through on some experiements. 56 | ## Next 57 | Continue to the [POOLING_API_KEYS](./POOLING_API_KEYS.md) -------------------------------------------------------------------------------- /assets/images/AWSConsole_APIKeys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/AWSConsole_APIKeys.png -------------------------------------------------------------------------------- /assets/images/AWSConsole_UsagePlans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/AWSConsole_UsagePlans.png -------------------------------------------------------------------------------- /assets/images/amplify-create-mfa-redacted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/amplify-create-mfa-redacted.png -------------------------------------------------------------------------------- /assets/images/amplify-create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/amplify-create.png -------------------------------------------------------------------------------- /assets/images/amplify-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/amplify-login.png -------------------------------------------------------------------------------- /assets/images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/architecture.png -------------------------------------------------------------------------------- /assets/images/react-local-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/react-local-server.png -------------------------------------------------------------------------------- /assets/images/walkthru-dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/walkthru-dash.png -------------------------------------------------------------------------------- /assets/images/walkthru-dash2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/walkthru-dash2.png -------------------------------------------------------------------------------- /assets/images/walkthru-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/walkthru-extension.png -------------------------------------------------------------------------------- /assets/images/walkthru-keySuccess.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/walkthru-keySuccess.png -------------------------------------------------------------------------------- /assets/images/walkthru-plans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/walkthru-plans.png -------------------------------------------------------------------------------- /assets/images/walkthru-testLimit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/walkthru-testLimit.png -------------------------------------------------------------------------------- /assets/images/walkthru-testPass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/walkthru-testPass.png -------------------------------------------------------------------------------- /assets/images/walktrhu-newKey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/assets/images/walktrhu-newKey.png -------------------------------------------------------------------------------- /cdk/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.js 3 | !jest.config.js 4 | *.d.ts 5 | node_modules 6 | 7 | # CDK asset staging directory 8 | .cdk.staging 9 | cdk.out 10 | -------------------------------------------------------------------------------- /cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project! 2 | 3 | This is a blank project for TypeScript development with CDK. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `cdk deploy` deploy this stack to your default AWS account/region 13 | * `cdk diff` compare deployed stack with current state 14 | * `cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /cdk/bin/cdk.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import { ApiStack } from '../lib/api-stack'; 5 | import { AuthStack } from '../lib/auth-stack'; 6 | import { NagSuppressions, AwsSolutionsChecks } from 'cdk-nag'; 7 | 8 | const app = new cdk.App(); 9 | 10 | const env = { 11 | account: process.env.CDK_DEFAULT_ACCOUNT, 12 | region: process.env.CDK_DEFAULT_REGION 13 | } 14 | const authStack = new AuthStack(app, 'AuthStack', {env}); 15 | const apiStack = new ApiStack(app, 'APIStack', { 16 | env, 17 | cognitoUserPoolId: authStack.userPool.userPoolId 18 | }); 19 | 20 | if (apiStack.node.tryGetContext('AWS_SOLUTIONS_CHECK')) { 21 | cdk.Aspects.of(app).add(new AwsSolutionsChecks()); 22 | NagSuppressions.addStackSuppressions(apiStack, 23 | [ 24 | { id: 'AwsSolutions-APIG4', reason: 'Endpoints set to no authorizer as it is serving the required HEADERS for CORS correctly. It is an auto-generated endpoint by API Gateway with pre-flight options.' } 25 | , { id: 'AwsSolutions-COG4', reason: 'Endpoints set to no authorizer as it is serving the required HEADERS for CORS correctly. It is an auto-generated endpoint by API Gateway with pre-flight options.' } 26 | , { id: 'AwsSolutions-APIG2', reason: 'Backend integration Lambda will validate request input and is this is sample code only.' } 27 | , { id: 'AwsSolutions-IAM5', reason: 'The wildcard permissions used by Lambda functions to manage api keys, usage plans, tags for API Gateway' } 28 | ]) 29 | } 30 | -------------------------------------------------------------------------------- /cdk/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/cdk.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-containers/ecs-service-extensions:enableDefaultLogDriver": true, 26 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 27 | "@aws-cdk/core:target-partitions": [ 28 | "aws", 29 | "aws-cn" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /cdk/lib/api-stack.ts: -------------------------------------------------------------------------------- 1 | import { Stack, StackProps } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { RemovalPolicy } from 'aws-cdk-lib'; 4 | import * as iam from 'aws-cdk-lib/aws-iam'; 5 | import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; 6 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 7 | import * as apigateway from 'aws-cdk-lib/aws-apigateway'; 8 | import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resources'; 9 | import { UserPool } from 'aws-cdk-lib/aws-cognito'; 10 | import * as kms from 'aws-cdk-lib/aws-kms'; 11 | import * as logs from 'aws-cdk-lib/aws-logs'; 12 | 13 | export interface ApiStackProps extends StackProps { 14 | cognitoUserPoolId: string; 15 | } 16 | 17 | export class ApiStack extends Stack { 18 | constructor(scope: Construct, id: string, props: ApiStackProps) { 19 | super(scope, id, props); 20 | 21 | const encryptionKey = new kms.Key(this, 'Key', { 22 | enableKeyRotation: true, 23 | }); 24 | 25 | // DynamoDB Table for Usage Plans and metadata 26 | const plans = new dynamodb.Table(this, 'TieredAPI_Plans', { 27 | partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }, 28 | readCapacity: 1, 29 | writeCapacity: 1, 30 | removalPolicy: RemovalPolicy.DESTROY, 31 | pointInTimeRecovery: true, // best practice 32 | encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED, 33 | encryptionKey, // This will be exposed as table.encryptionKey 34 | }); 35 | 36 | // DynamoDB Table for API Keys and metadata 37 | const keys = new dynamodb.Table(this, 'TieredAPI_Keys', { 38 | partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }, 39 | readCapacity: 1, 40 | writeCapacity: 1, 41 | removalPolicy: RemovalPolicy.DESTROY, 42 | pointInTimeRecovery: true, // best practice 43 | encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED, 44 | encryptionKey, // This will be exposed as table.encryptionKey 45 | }); 46 | 47 | const allowKMSKeyUse = new iam.PolicyStatement({ 48 | effect: iam.Effect.ALLOW, 49 | actions: [ 50 | "kms:Decrypt" 51 | ], 52 | resources: [encryptionKey.keyArn] 53 | 54 | }) 55 | 56 | const apiGatewayPolicy = new iam.PolicyStatement({ 57 | effect: iam.Effect.ALLOW, 58 | actions: [ 59 | "apigateway:DELETE", 60 | "apigateway:PATCH", 61 | "apigateway:POST", 62 | "apigateway:PUT", 63 | "apigateway:GET" 64 | ], 65 | resources: [ 66 | `arn:aws:apigateway:${props.env?.region}::/apikeys/*`, 67 | `arn:aws:apigateway:${props.env?.region}::/apikeys`, 68 | `arn:aws:apigateway:${props.env?.region}::/usageplans/*/keys/*`, 69 | `arn:aws:apigateway:${props.env?.region}::/usageplans/*/keys`, 70 | `arn:aws:apigateway:${props.env?.region}::/tags/*` 71 | ] 72 | }); 73 | 74 | const cloudWatchPolicy = new iam.Policy(this, "TieredAPI_CloudWatchLogsPolicy", { 75 | statements: [ 76 | new iam.PolicyStatement({ 77 | effect: iam.Effect.ALLOW, 78 | actions: [ 79 | "logs:CreateLogGroup", 80 | "logs:CreateLogStream", 81 | "logs:PutLogEvents" 82 | ], 83 | resources: [`arn:aws:logs:${props.env?.region}:${props.env?.account}:*`] 84 | }) 85 | ] 86 | }); 87 | 88 | // Policy that allows basic Reads on new DynamoDB tables (and logging) 89 | const allowDDBReadsAndKMSPolicy = new iam.Policy(this, "TieredAPI_Read_LambdaPolicy", { 90 | statements: [ 91 | new iam.PolicyStatement({ 92 | effect: iam.Effect.ALLOW, 93 | actions: [ 94 | "dynamodb:GetItem", 95 | "dynamodb:Query", 96 | "dynamodb:Scan", 97 | ], 98 | resources: [plans.tableArn, keys.tableArn] 99 | }), 100 | allowKMSKeyUse, 101 | apiGatewayPolicy 102 | ] 103 | }) 104 | 105 | // Policy that allows basic Create on new DynamoDB tables (and logging) 106 | const allowDDBCreateAndKMSPolicy = new iam.Policy(this, "TieredAPI_Create_LambdaPolicy", { 107 | statements: [ 108 | new iam.PolicyStatement({ 109 | effect: iam.Effect.ALLOW, 110 | actions: [ 111 | "dynamodb:PutItem", 112 | "dynamodb:GetItem", 113 | ], 114 | resources: [plans.tableArn, keys.tableArn] 115 | }), 116 | allowKMSKeyUse, 117 | apiGatewayPolicy 118 | ] 119 | }) 120 | 121 | // Policy that allows basic Create on new DynamoDB tables (and logging) 122 | const allowDDBDeleteAndKMSPolicy = new iam.Policy(this, "TieredAPI_Delete_LambdaPolicy", { 123 | statements: [ 124 | new iam.PolicyStatement({ 125 | effect: iam.Effect.ALLOW, 126 | actions: [ 127 | "dynamodb:DeleteItem", 128 | "dynamodb:GetItem", 129 | ], 130 | resources: [plans.tableArn, keys.tableArn] 131 | }), 132 | allowKMSKeyUse, 133 | apiGatewayPolicy 134 | ] 135 | }) 136 | 137 | // Policy that allows basic Create on new DynamoDB tables (and logging) 138 | const allowDDBUpdateAndKMSPolicy = new iam.Policy(this, "TieredAPI_Update_LambdaPolicy", { 139 | statements: [ 140 | new iam.PolicyStatement({ 141 | effect: iam.Effect.ALLOW, 142 | actions: [ 143 | "dynamodb:UpdateItem", 144 | "dynamodb:GetItem", 145 | ], 146 | resources: [plans.tableArn, keys.tableArn] 147 | }), 148 | allowKMSKeyUse, 149 | apiGatewayPolicy 150 | ] 151 | }) 152 | 153 | // Role for Lambda to assume that has new policy 154 | const lambdaCreateRole = new iam.Role(this, "TieredAPI_Create_LambdaRole", { 155 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 156 | description: 'Assumed by Lambda Functions', 157 | inlinePolicies: { allowDDBCreateAndKMSPolicy: allowDDBCreateAndKMSPolicy.document, cloudWatchPolicy: cloudWatchPolicy.document } 158 | }) 159 | 160 | const lambdaReadRole = new iam.Role(this, "TieredAPI_Read_LambdaRole", { 161 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 162 | description: 'Assumed by Lambda Functions', 163 | inlinePolicies: { allowDDBReadsAndKMSPolicy: allowDDBReadsAndKMSPolicy.document, cloudWatchPolicy: cloudWatchPolicy.document } 164 | }) 165 | 166 | const lambdaUpdateRole = new iam.Role(this, "TieredAPI_Update_LambdaRole", { 167 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 168 | description: 'Assumed by Lambda Functions', 169 | inlinePolicies: { allowDDBUpdateAndKMSPolicy: allowDDBUpdateAndKMSPolicy.document, cloudWatchPolicy: cloudWatchPolicy.document } 170 | }) 171 | 172 | const lambdaDeleteRole = new iam.Role(this, "TieredAPI_Delete_LambdaRole", { 173 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 174 | description: 'Assumed by Lambda Functions', 175 | inlinePolicies: { allowDDBDeleteAndKMSPolicy: allowDDBDeleteAndKMSPolicy.document, cloudWatchPolicy: cloudWatchPolicy.document } 176 | }) 177 | 178 | const apiGatewayCloudWatchRole = new iam.Role(this, "TieredAPI_CloudWatchRole", { 179 | assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), 180 | description: 'Assumed by API Gateway', 181 | inlinePolicies: { cloudWatchPolicy: cloudWatchPolicy.document } 182 | }) 183 | 184 | // Lambda for basic health check, this will stay unprotected 185 | const getDataLambda = new lambda.Function(this, 'getData', { 186 | runtime: lambda.Runtime.NODEJS_14_X, 187 | handler: 'get_data.handler', 188 | code: lambda.Code.fromAsset('../lambda'), 189 | role: lambdaReadRole, 190 | deadLetterQueueEnabled: false, 191 | }); 192 | 193 | // getPlans 194 | const getPlansLambda = new lambda.Function(this, 'getPlans', { 195 | runtime: lambda.Runtime.NODEJS_14_X, 196 | handler: 'get_plans.handler', 197 | code: lambda.Code.fromAsset('../lambda'), 198 | role: lambdaReadRole, 199 | environment: { 200 | PLANS_TABLE_NAME: plans.tableName, 201 | KEYS_TABLE_NAME: keys.tableName 202 | }, 203 | deadLetterQueueEnabled: false, 204 | }); 205 | 206 | // getPlan 207 | const getPlanLambda = new lambda.Function(this, 'getPlan', { 208 | runtime: lambda.Runtime.NODEJS_14_X, 209 | handler: 'get_plan.handler', 210 | code: lambda.Code.fromAsset('../lambda'), 211 | role: lambdaReadRole, 212 | environment: { 213 | PLANS_TABLE_NAME: plans.tableName, 214 | KEYS_TABLE_NAME: keys.tableName 215 | }, 216 | deadLetterQueueEnabled: false, 217 | }); 218 | 219 | // getKeys 220 | const getKeysLambda = new lambda.Function(this, 'getKeys', { 221 | runtime: lambda.Runtime.NODEJS_14_X, 222 | handler: 'get_keys.handler', 223 | code: lambda.Code.fromAsset('../lambda'), 224 | role: lambdaReadRole, 225 | environment: { 226 | PLANS_TABLE_NAME: plans.tableName, 227 | KEYS_TABLE_NAME: keys.tableName 228 | }, 229 | deadLetterQueueEnabled: false, 230 | }); 231 | 232 | // createKey 233 | const createKeyLambda = new lambda.Function(this, 'createKey', { 234 | runtime: lambda.Runtime.NODEJS_14_X, 235 | handler: 'create_key.handler', 236 | code: lambda.Code.fromAsset('../lambda'), 237 | role: lambdaCreateRole, 238 | environment: { 239 | PLANS_TABLE_NAME: plans.tableName, 240 | KEYS_TABLE_NAME: keys.tableName 241 | }, 242 | deadLetterQueueEnabled: false, 243 | }); 244 | 245 | // getKey 246 | const getKeyLambda = new lambda.Function(this, 'getKey', { 247 | runtime: lambda.Runtime.NODEJS_14_X, 248 | handler: 'get_key.handler', 249 | code: lambda.Code.fromAsset('../lambda'), 250 | role: lambdaReadRole, 251 | environment: { 252 | PLANS_TABLE_NAME: plans.tableName, 253 | KEYS_TABLE_NAME: keys.tableName 254 | }, 255 | deadLetterQueueEnabled: false, 256 | }); 257 | 258 | // updateKey 259 | const updateKeyLambda = new lambda.Function(this, 'updateKey', { 260 | runtime: lambda.Runtime.NODEJS_14_X, 261 | handler: 'update_key.handler', 262 | code: lambda.Code.fromAsset('../lambda'), 263 | role: lambdaUpdateRole, 264 | environment: { 265 | PLANS_TABLE_NAME: plans.tableName, 266 | KEYS_TABLE_NAME: keys.tableName 267 | }, 268 | deadLetterQueueEnabled: false, 269 | }); 270 | 271 | // deleteKey 272 | const deleteKeyLambda = new lambda.Function(this, 'deleteKey', { 273 | runtime: lambda.Runtime.NODEJS_14_X, 274 | handler: 'delete_key.handler', 275 | code: lambda.Code.fromAsset('../lambda'), 276 | role: lambdaDeleteRole, 277 | environment: { 278 | PLANS_TABLE_NAME: plans.tableName, 279 | KEYS_TABLE_NAME: keys.tableName 280 | }, 281 | deadLetterQueueEnabled: false, 282 | }); 283 | 284 | const logGroup = new logs.LogGroup(this, "MultiTenantSampleAPILogs"); 285 | 286 | // Create the whole REST API Gateway 287 | const apigw = new apigateway.RestApi(this, "MultiTenantSampleAPI", { 288 | defaultCorsPreflightOptions: { // this is useful for debugging as the react app's origin may be localhost. Reconsider for production. 289 | allowOrigins: apigateway.Cors.ALL_ORIGINS, 290 | allowMethods: apigateway.Cors.ALL_METHODS // this is also the default 291 | }, 292 | cloudWatchRole: false, 293 | deployOptions: { 294 | cachingEnabled: false, // Suggest true for production systems 295 | tracingEnabled: false, // Suggest true for production/development systems. 296 | loggingLevel: apigateway.MethodLoggingLevel.INFO, 297 | accessLogDestination: new apigateway.LogGroupLogDestination(logGroup), 298 | accessLogFormat: apigateway.AccessLogFormat.clf(), 299 | }, 300 | }); 301 | 302 | // All these resources are throttled and will require an API Key 303 | const getDataResource = apigw.root.addResource('api'); 304 | const getDataApi = getDataResource.addMethod('GET', new apigateway.LambdaIntegration(getDataLambda), { 305 | apiKeyRequired: true, 306 | }); 307 | 308 | const userPool = UserPool.fromUserPoolId(this, 'UserPool', props.cognitoUserPoolId); 309 | 310 | // All these resources will require Cognito 311 | const auth = new apigateway.CognitoUserPoolsAuthorizer(this, 'UserAuthorizer', { 312 | cognitoUserPools: [userPool] 313 | }); 314 | 315 | const adminResource = apigw.root.addResource('admin'); 316 | const plansResource = adminResource.addResource('plans'); 317 | const getPlansApi = plansResource.addMethod('GET', new apigateway.LambdaIntegration(getPlansLambda), { 318 | authorizer: auth, 319 | authorizationType: apigateway.AuthorizationType.COGNITO, 320 | }); 321 | 322 | const planResource = plansResource.addResource('{id}'); 323 | const getPlanApi = planResource.addMethod('GET', new apigateway.LambdaIntegration(getPlanLambda), { 324 | authorizer: auth, 325 | authorizationType: apigateway.AuthorizationType.COGNITO, 326 | }); 327 | 328 | const keysResource = adminResource.addResource('keys'); 329 | const getKeysApi = keysResource.addMethod('GET', new apigateway.LambdaIntegration(getKeysLambda), { 330 | authorizer: auth, 331 | authorizationType: apigateway.AuthorizationType.COGNITO, 332 | }); 333 | const createKeyApi = keysResource.addMethod('POST', new apigateway.LambdaIntegration(createKeyLambda), { 334 | authorizer: auth, 335 | authorizationType: apigateway.AuthorizationType.COGNITO, 336 | }); 337 | 338 | const keyResource = keysResource.addResource('{id}'); 339 | const getKeyApi = keyResource.addMethod('GET', new apigateway.LambdaIntegration(getKeyLambda), { 340 | authorizer: auth, 341 | authorizationType: apigateway.AuthorizationType.COGNITO, 342 | }); 343 | const updateKeyApi = keyResource.addMethod('PUT', new apigateway.LambdaIntegration(updateKeyLambda), { 344 | authorizer: auth, 345 | authorizationType: apigateway.AuthorizationType.COGNITO, 346 | }); 347 | const deleteKeyApi = keyResource.addMethod('DELETE', new apigateway.LambdaIntegration(deleteKeyLambda), { 348 | authorizer: auth, 349 | authorizationType: apigateway.AuthorizationType.COGNITO, 350 | }); 351 | 352 | //Sample Usage Plans for API Gateway 353 | interface PlanData { 354 | name: string, 355 | description: string, 356 | rateLimit: number, 357 | burstLimit: number, 358 | quotaLimit: number, 359 | quotaOffset: number, 360 | period: apigateway.Period, 361 | price: string 362 | } 363 | 364 | // convenience function: create a usage plan 365 | function createUsagePlan(gw: apigateway.RestApi, plan: PlanData): apigateway.UsagePlan { 366 | return gw.addUsagePlan(plan.name, { 367 | name: plan.name, 368 | description: plan.description, 369 | throttle: { 370 | rateLimit: plan.rateLimit, 371 | burstLimit: plan.burstLimit, 372 | }, 373 | quota: { 374 | limit: plan.rateLimit, 375 | offset: plan.quotaOffset, 376 | period: plan.period 377 | }, 378 | apiStages: [{ stage: gw.deploymentStage }] 379 | }); 380 | } 381 | 382 | function createDynamoSeed(id: string, plan: PlanData): any { 383 | return { 384 | PutRequest: { 385 | Item: { 386 | id: { S: id }, 387 | name: { S: plan.name }, 388 | description: { S: plan.description }, 389 | quota: { S: `${plan.quotaLimit} per ${plan.period}` }, 390 | throttle: { S: `${plan.rateLimit} TPS` }, 391 | price: { N: plan.price } 392 | } 393 | } 394 | } 395 | } 396 | 397 | const usagePlans = { 398 | testPlan: { 399 | name: 'TestPlan', 400 | description: 'A simple plan for demonstration purposes', 401 | rateLimit: 10, 402 | burstLimit: 5, 403 | quotaLimit: 20, 404 | quotaOffset: 0, 405 | period: apigateway.Period.DAY, 406 | price: "0.00" 407 | }, 408 | freePlan: { 409 | name: 'FreePlan', 410 | description: 'Free Service', 411 | rateLimit: 10, 412 | burstLimit: 5, 413 | quotaLimit: 2000, 414 | quotaOffset: 0, 415 | period: apigateway.Period.DAY, 416 | price: "0.00" 417 | }, 418 | basicPlan: { 419 | name: 'BasicPlan', 420 | description: 'Basic paid service', 421 | rateLimit: 100, 422 | burstLimit: 50, 423 | quotaLimit: 5000, 424 | quotaOffset: 0, 425 | period: apigateway.Period.MONTH, 426 | price: "19.99" 427 | }, 428 | premiumPlan: { 429 | name: 'PremiumPlan', 430 | description: 'When only the best will do', 431 | rateLimit: 100, 432 | burstLimit: 50, 433 | quotaLimit: 50000, 434 | quotaOffset: 0, 435 | period: apigateway.Period.MONTH, 436 | price: "69.99" 437 | } 438 | } 439 | 440 | // create some usage plans 441 | const testPlan = createUsagePlan(apigw, usagePlans.testPlan); 442 | const freePlan = createUsagePlan(apigw, usagePlans.freePlan); 443 | const basicPlan = createUsagePlan(apigw, usagePlans.basicPlan); 444 | const premiumPlan = createUsagePlan(apigw, usagePlans.premiumPlan); 445 | 446 | // seed the Plans Table with the new usage plans. 447 | const seedPlansDb = new AwsCustomResource(this, 'seedPlansDb', { 448 | functionName: 'seedPlansDb', 449 | onCreate: { 450 | service: 'DynamoDB', 451 | action: 'batchWriteItem', 452 | parameters: { 453 | RequestItems: { 454 | [plans.tableName]: [ 455 | createDynamoSeed(testPlan.usagePlanId, usagePlans.testPlan), 456 | createDynamoSeed(freePlan.usagePlanId, usagePlans.freePlan), 457 | createDynamoSeed(basicPlan.usagePlanId, usagePlans.basicPlan), 458 | createDynamoSeed(premiumPlan.usagePlanId, usagePlans.premiumPlan), 459 | ] 460 | } 461 | }, 462 | physicalResourceId: PhysicalResourceId.of('seedPlansDb'), // Use the token returned by the call as physical id 463 | }, 464 | role: lambdaCreateRole, 465 | policy: { 466 | statements: [allowKMSKeyUse, new iam.PolicyStatement({ 467 | effect: iam.Effect.ALLOW, 468 | actions: [ 469 | "dynamodb:PutItem", 470 | "dynamodb:BatchWriteItem", 471 | "dynamodb:DescribeTable" 472 | ], 473 | resources: [plans.tableArn, keys.tableArn] 474 | }), 475 | ] 476 | }, 477 | }) 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /cdk/lib/auth-stack.ts: -------------------------------------------------------------------------------- 1 | import { Stack, StackProps, CfnOutput, Duration } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { RemovalPolicy } from 'aws-cdk-lib'; 4 | import * as cognito from "aws-cdk-lib/aws-cognito"; 5 | 6 | export class AuthStack extends Stack { 7 | public readonly userPool: cognito.UserPool; 8 | public readonly client: cognito.UserPoolClient; 9 | 10 | constructor(scope: Construct, id: string, props?: StackProps) { 11 | super(scope, id, props); 12 | 13 | const userPool = new cognito.UserPool(this, "UserPool", { 14 | removalPolicy: RemovalPolicy.DESTROY, 15 | autoVerify: {email: true}, 16 | passwordPolicy:{ 17 | minLength: 8, 18 | requireDigits: true, 19 | requireLowercase: true, 20 | requireSymbols: true, 21 | requireUppercase: true, 22 | tempPasswordValidity: Duration.days(3) 23 | }, 24 | signInAliases: {email: true, username: false}, 25 | selfSignUpEnabled: true, 26 | mfa: cognito.Mfa.REQUIRED, 27 | mfaSecondFactor: { 28 | otp: true, 29 | sms: false, 30 | }, 31 | }); 32 | 33 | // Set the advancedSecurityMode to ENFORCED 34 | const cfnUserPool = userPool.node.findChild('Resource') as cognito.CfnUserPool; 35 | cfnUserPool.userPoolAddOns = { 36 | advancedSecurityMode: 'ENFORCED' 37 | }; 38 | 39 | const client = userPool.addClient("WebClient", { 40 | userPoolClientName: "webClient", 41 | idTokenValidity: Duration.days(1), 42 | accessTokenValidity: Duration.days(1), 43 | authFlows: { 44 | userPassword: true, 45 | userSrp: true, 46 | custom: true, 47 | }, 48 | }); 49 | 50 | this.userPool = userPool; 51 | this.client = client; 52 | 53 | new CfnOutput(this, "CognitoUserPoolId", { 54 | value: userPool.userPoolId, 55 | description: "userPoolId required for frontend settings", 56 | }); 57 | new CfnOutput(this, "CognitoUserPoolWebClientId", { 58 | value: client.userPoolClientId, 59 | description: "clientId required for frontend settings", 60 | }); 61 | } 62 | } -------------------------------------------------------------------------------- /cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk": "bin/cdk.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^26.0.24", 15 | "@types/node": "10.17.27", 16 | "aws-cdk": "^2.17.0", 17 | "cdk-nag": "^2.28.141", 18 | "jest": "^29.7.0", 19 | "ts-jest": "^29.1.4", 20 | "ts-node": "^9.0.0", 21 | "typescript": "~4.3.0" 22 | }, 23 | "dependencies": { 24 | "aws-cdk-lib": "^2.80.0", 25 | "constructs": "^10.0.92", 26 | "source-map-support": "^0.5.21" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Cdk from '../lib/cdk-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Cdk.CdkStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lambda/api_key_pools.js: -------------------------------------------------------------------------------- 1 | // Contains a list of API Keys that are pooled resources among tenants. 2 | // 3 | // e.g. 4 | 5 | // 6 | // In the example above. The Free plan is a globally shared API Key 7 | // And the Basic plan is a hybrid strategy that shards tenants across a couple API Keys. 8 | 9 | 10 | // NOTE: This list of pooled IDs is empty 11 | const apiKeyPools = [ 12 | // { 13 | // planName: "FreePlan" 14 | // planId: "...", 15 | // apiKeys: [ "..." ] 16 | // }, 17 | // { 18 | // planName: "BasicPlan" 19 | // planId: "...", 20 | // apiKeys: [ "...", "...", "...", "...", "..." ] 21 | // } 22 | ]; 23 | 24 | 25 | /** 26 | * 27 | * @param {*} planId 28 | * @returns Associated Pool information from apiKeyPools 29 | */ 30 | exports.findPoolForPlanId = (planId) => { 31 | return apiKeyPools.find((item) => item.planId === planId); 32 | } 33 | -------------------------------------------------------------------------------- /lambda/create_key.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const {findPoolForPlanId} = require("./api_key_pools"); 3 | 4 | const { 5 | parseJwt, 6 | goodResponse, 7 | internalServerErrorResponse, 8 | makeid, 9 | } = require("./utils"); 10 | 11 | const dynamo = new AWS.DynamoDB({ apiVersion: "2012-08-10" }); 12 | var apigateway = new AWS.APIGateway({ apiVersion: "2015-07-09" }); 13 | 14 | exports.handler = function (event, context, callback) { 15 | console.log("Received event:", JSON.stringify(event, null, 2)); 16 | console.log("Received context: ", JSON.stringify(context, null, 2)); 17 | 18 | const plansTable = process.env.PLANS_TABLE_NAME; 19 | const keysTable = process.env.KEYS_TABLE_NAME; 20 | 21 | const httpMethod = event.httpMethod; // e.g. "GET" 22 | const path = event.path; // e.g. "/admin/plans/123456 23 | const resource = event.resource; // e.g. "/admin/plans/{id} 24 | const token = event.headers.Authorization.replace(/^[Bb]earer\s+/, "").trim(); 25 | 26 | if ( 27 | !plansTable || 28 | !keysTable || 29 | !path || 30 | !resource || 31 | !httpMethod || 32 | !token 33 | ) { 34 | console.error( 35 | `HTTP 500: Precondition Fail: '${httpMethod}' '${path}' '${resource}' '${plansTable}' '${keysTable}' token.len=${token.len} ` 36 | ); 37 | callback(null, internalServerErrorResponse()); 38 | return; 39 | } 40 | const jwt = parseJwt(token); 41 | console.log("JWT payload: ", JSON.stringify(jwt, null, 2)); 42 | 43 | const key = JSON.parse(event.body); 44 | const rand = event.requestContext.requestId; 45 | createKey(keysTable, key, plansTable, jwt, rand, callback); 46 | }; 47 | 48 | /** 49 | * POST /admin/keys 50 | */ 51 | function createKey(tableName, key, plansTable, jwt, rand, callback) { 52 | const pool = findPoolForPlanId(key.planId); 53 | // const pool = pools.apiKeyPools.find((item) => item.planId === key.planId); 54 | 55 | if (!pool) { 56 | createSiloedKey(tableName, key, plansTable, jwt, rand, callback); 57 | } else { 58 | createPooledKey(pool, tableName, key, jwt, callback); 59 | } 60 | } 61 | 62 | /** 63 | * POST /admin/keys 64 | */ 65 | function createSiloedKey(tableName, key, plansTable, jwt, rand, callback) { 66 | console.log("createSiloedKey"); 67 | 68 | var apiKeyId = undefined; 69 | 70 | // first make sure we have a valid plan ID 71 | dynamo 72 | .getItem({ 73 | TableName: plansTable, 74 | Key: { 75 | id: { 76 | S: key.planId, 77 | }, 78 | }, 79 | }) 80 | .promise() 81 | .then((data) => { 82 | console.log("Found matching plan: ", data); 83 | // create an API key 84 | return apigateway 85 | .createApiKey({ 86 | name: key.name, 87 | description: key.description, 88 | enabled: key.enabled, 89 | tags: { ownerId: jwt.sub }, 90 | value: rand, 91 | }) 92 | .promise(); 93 | }) 94 | .then((apiKey) => { 95 | console.log("APIGateway created APIKey: ", apiKey); 96 | apiKeyId = apiKey.id; 97 | return apigateway 98 | .createUsagePlanKey({ 99 | keyId: apiKey.id, 100 | keyType: "API_KEY", 101 | usagePlanId: key.planId, 102 | }) 103 | .promise(); 104 | }) 105 | .then((data) => { 106 | console.log("APIGateway regsitered key with usage plan ", data); 107 | // now save do our database. 108 | return dynamo 109 | .putItem({ 110 | TableName: tableName, 111 | Item: { 112 | id: { S: apiKeyId }, 113 | planId: { S: key.planId }, 114 | name: { S: key.name }, 115 | description: { S: key.description }, 116 | enabled: { BOOL: key.enabled }, 117 | owner: { S: jwt.sub }, 118 | value: { S: rand }, 119 | }, 120 | }) 121 | .promise(); 122 | }) 123 | .then((data) => { 124 | console.log("Dynamo data: ", JSON.stringify(data.Item, null, 2)); 125 | const response = goodResponse( 126 | JSON.stringify(AWS.DynamoDB.Converter.unmarshall(data.Item)) 127 | ); 128 | callback(null, response); 129 | }) 130 | .catch((reason) => { 131 | console.error(reason); 132 | callback(null, internalServerErrorResponse()); 133 | }); 134 | } 135 | 136 | function createPooledKey(pool, tableName, key, jwt, callback) { 137 | 138 | 139 | // first, choose a key from the pool. 140 | // a more sophisticated implementation would balance the load, 141 | // but for demo purposes, random suffices. 142 | var randomKeyFromPool = pool.apiKeys[Math.floor(Math.random() * pool.apiKeys.length)]; // note a production system can do much better than random. 143 | // first make sure we have a valid key ID 144 | 145 | 146 | console.log("createPooledKey", randomKeyFromPool); 147 | 148 | dynamo 149 | .getItem({ 150 | TableName: tableName, 151 | Key: { 152 | id: { 153 | S: randomKeyFromPool, 154 | }, 155 | }, 156 | }) 157 | .promise() 158 | .then((data) => { 159 | console.log("FoundPooledKey: ", JSON.stringify(data,0,2)); 160 | 161 | const newId = makeid(8); 162 | return dynamo 163 | .putItem({ 164 | TableName: tableName, 165 | Item: { 166 | id: { S: newId }, 167 | planId: { S: key.planId }, 168 | name: { S: key.name }, 169 | description: { S: key.description }, 170 | enabled: { BOOL: key.enabled }, 171 | owner: { S: jwt.sub }, 172 | value: data.Item.value, 173 | }, 174 | }) 175 | .promise(); 176 | }) 177 | .then((data) => { 178 | console.log("Dynamo data: ", JSON.stringify(data.Item, null, 2)); 179 | const response = goodResponse( 180 | JSON.stringify(AWS.DynamoDB.Converter.unmarshall(data.Item)) 181 | ); 182 | callback(null, response); 183 | }) 184 | .catch((reason) => { 185 | console.error(reason); 186 | callback(null, internalServerErrorResponse()); 187 | }); 188 | } 189 | -------------------------------------------------------------------------------- /lambda/delete_key.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | 3 | const { findPoolForPlanId } = require("./api_key_pools"); 4 | 5 | const { 6 | parseJwt, 7 | goodResponse, 8 | internalServerErrorResponse, 9 | } = require("./utils"); 10 | 11 | const dynamo = new AWS.DynamoDB({ apiVersion: "2012-08-10" }); 12 | var apigateway = new AWS.APIGateway({ apiVersion: "2015-07-09" }); 13 | 14 | exports.handler = function (event, context, callback) { 15 | console.log("Received event:", JSON.stringify(event, null, 2)); 16 | console.log("Received context: ", JSON.stringify(context, null, 2)); 17 | 18 | const plansTable = process.env.PLANS_TABLE_NAME; 19 | const keysTable = process.env.KEYS_TABLE_NAME; 20 | 21 | const httpMethod = event.httpMethod; // e.g. "GET" 22 | const path = event.path; // e.g. "/admin/plans/123456 23 | const resource = event.resource; // e.g. "/admin/plans/{id} 24 | const token = event.headers.Authorization.replace(/^[Bb]earer\s+/, "").trim(); 25 | 26 | callback(null, goodResponse("YES")); 27 | 28 | if ( 29 | !plansTable || 30 | !keysTable || 31 | !path || 32 | !resource || 33 | !httpMethod || 34 | !token 35 | ) { 36 | console.error( 37 | `HTTP 500: Precondition Fail: '${httpMethod}' '${path}' '${resource}' '${plansTable}' '${keysTable}' token.len=${token.len} ` 38 | ); 39 | callback(null, internalServerErrorResponse()); 40 | return; 41 | } 42 | const jwt = parseJwt(token); 43 | console.log("JWT payload: ", JSON.stringify(jwt, null, 2)); 44 | 45 | deleteKeyById(keysTable, event.pathParameters.id, jwt, callback); 46 | }; 47 | 48 | /** 49 | * DELETE /admin/keys/{id} 50 | */ 51 | function deleteKeyById(tableName, id, jwt, callback) { 52 | 53 | dynamo 54 | .getItem({ 55 | TableName: tableName, 56 | Key: { 57 | id: { 58 | S: id, 59 | }, 60 | }, 61 | }) 62 | .promise() 63 | .then((data) => { 64 | console.log("DynamoFind ", JSON.stringify(data, 0, 2)) 65 | // store the usagePlanId for later 66 | const usagePlanId = data.Item.planId.S; 67 | 68 | const pool = findPoolForPlanId(usagePlanId); 69 | 70 | if (!pool) { 71 | deleteSiloedKeyById(tableName, id, usagePlanId, jwt, callback); 72 | } else { 73 | deletePooledKeyById(tableName, id, pool, jwt, callback); 74 | } 75 | }); 76 | } 77 | 78 | /** 79 | * DELETE /admin/keys/{id} 80 | */ 81 | function deleteSiloedKeyById(tableName, id, usagePlanId, jwt, callback) { 82 | console.log("Delete Siloed Key ", id, usagePlanId); 83 | 84 | dynamo 85 | .deleteItem({ 86 | TableName: tableName, 87 | Key: { 88 | id: { 89 | S: id, 90 | }, 91 | }, 92 | // ConditionExpression: "#owner = :o", 93 | // ExpressionAttributeNames: { 94 | // "#owner": "owner", 95 | // }, 96 | // ExpressionAttributeValues: { 97 | // ":o": { 98 | // S: jwt.sub, 99 | // }, 100 | // }, 101 | }) 102 | .promise() 103 | .then((data) => { 104 | console.log("Dynamo delete: ", JSON.stringify(data, null, 2)); 105 | return apigateway 106 | .deleteUsagePlanKey({ 107 | keyId: id, 108 | usagePlanId: usagePlanId, 109 | }) 110 | .promise(); 111 | }) 112 | .then((data) => { 113 | console.log("APIGW DeleteUsageKey Result: ", JSON.stringify(data, null, 2)); 114 | return apigateway 115 | .deleteApiKey({ 116 | apiKey: id, 117 | }) 118 | .promise(); 119 | }) 120 | .then((data) => { 121 | console.log("APIGW DeleteApiKey Result: ", JSON.stringify(data, null, 2)); 122 | const response = goodResponse( 123 | JSON.stringify(AWS.DynamoDB.Converter.unmarshall(data)) 124 | ); 125 | callback(null, response); 126 | }) 127 | .catch((err) => { 128 | console.error( 129 | "HTTP 500: Dynamo responded: ", 130 | JSON.stringify(err, null, 2) 131 | ); 132 | callback(null, internalServerErrorResponse()); 133 | }); 134 | } 135 | 136 | // This deletes the tenant-specific entry in DynamoDB, but does not 137 | // delete the pooled ApiKey 138 | function deletePooledKeyById(tableName, id, pool, jwt, callback) { 139 | console.log("Delete Pooled Key", id, JSON.stringify(pool,0,2)); 140 | 141 | dynamo 142 | .deleteItem({ 143 | TableName: tableName, 144 | Key: { 145 | id: { 146 | S: id, 147 | }, 148 | }, 149 | // ConditionExpression: "#owner = :o", 150 | // ExpressionAttributeNames: { 151 | // "#owner": "owner", 152 | // }, 153 | // ExpressionAttributeValues: { 154 | // ":o": { 155 | // S: jwt.sub, 156 | // }, 157 | // }, 158 | }) 159 | .promise() 160 | .then((data) => { 161 | console.log("Dynamo data: ", JSON.stringify(data, null, 2)); 162 | const response = goodResponse( 163 | JSON.stringify(AWS.DynamoDB.Converter.unmarshall(data)) 164 | ); 165 | callback(null, response); 166 | }) 167 | .catch((err) => { 168 | console.error( 169 | "HTTP 500: Dynamo responded: ", 170 | JSON.stringify(err, null, 2) 171 | ); 172 | callback(null, internalServerErrorResponse()); 173 | }); 174 | } 175 | -------------------------------------------------------------------------------- /lambda/get_data.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | exports.handler = function(event, context, callback) { 4 | const response = { 5 | items: [ 6 | { 7 | item: "grapes", 8 | category: "fruit", 9 | price: 3.99, 10 | unit: "lb" 11 | }, 12 | { 13 | item: "grape juice", 14 | category: "beverage", 15 | price: 5.99, 16 | unit: "qt" 17 | } 18 | ] 19 | } 20 | callback(null,{ 21 | "isBase64Encoded": false, 22 | "statusCode": 200, 23 | "headers": { 24 | "Content-Type": "application/json", 25 | "Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", 26 | "Access-Control-Allow-Origin": "*" 27 | }, 28 | "body": JSON.stringify(response) 29 | }); 30 | } 31 | 32 | -------------------------------------------------------------------------------- /lambda/get_key.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const { parseJwt, goodResponse, internalServerErrorResponse } = require('./utils'); 3 | 4 | const dynamo = new AWS.DynamoDB({apiVersion: '2012-08-10'}); 5 | 6 | exports.handler = function(event, context, callback) { 7 | console.log('Received event:', JSON.stringify(event, null, 2)); 8 | console.log('Received context: ', JSON.stringify(context, null, 2)); 9 | 10 | const plansTable = process.env.PLANS_TABLE_NAME; 11 | const keysTable = process.env.KEYS_TABLE_NAME; 12 | 13 | const httpMethod = event.httpMethod; // e.g. "GET" 14 | const path = event.path; // e.g. "/admin/plans/123456 15 | const resource = event.resource; // e.g. "/admin/plans/{id} 16 | const token = event.headers.Authorization.replace(/^[Bb]earer\s+/,'').trim(); 17 | 18 | if (!plansTable || !keysTable || !path || !resource || !httpMethod || !token ) { 19 | console.error(`HTTP 500: Precondition Fail: '${httpMethod}' '${path}' '${resource}' '${plansTable}' '${keysTable}' token.len=${token.len} `); 20 | callback(null,internalServerErrorResponse()); 21 | return; 22 | } 23 | const jwt = parseJwt(token) 24 | console.log('JWT payload: ', JSON.stringify(jwt, null, 2)); 25 | 26 | const key = JSON.parse(event.body); 27 | const rand = event.requestContext.requestId; 28 | getKeyById(keysTable, event.pathParameters.id, jwt, callback); 29 | } 30 | 31 | /** 32 | * GET /admin/keys/{id} 33 | */ 34 | function getKeyById(tableName, id, jwt, callback) { 35 | dynamo.getItem({ 36 | TableName: tableName, 37 | Key:{ 38 | "id": { 39 | S: id 40 | } 41 | } 42 | }) 43 | .promise().then((data)=>{ 44 | console.log("Dynamo data: ",JSON.stringify(data.Item, null, 2)); 45 | if (data.Item.owner.S !== jwt.sub) { 46 | throw Error("Not Found"); 47 | } 48 | const response = goodResponse(JSON.stringify(AWS.DynamoDB.Converter.unmarshall(data.Item))); 49 | callback(null,response); 50 | }) 51 | .catch((err)=>{ 52 | console.error("HTTP 500: Dynamo responded: ", JSON.stringify(err, null, 2)); 53 | callback(null,internalServerErrorResponse()); 54 | }); 55 | 56 | } 57 | -------------------------------------------------------------------------------- /lambda/get_keys.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const { parseJwt, goodResponse, internalServerErrorResponse } = require('./utils'); 3 | 4 | const dynamo = new AWS.DynamoDB({apiVersion: '2012-08-10'}); 5 | 6 | exports.handler = function(event, context, callback) { 7 | console.log('Received event:', JSON.stringify(event, null, 2)); 8 | console.log('Received context: ', JSON.stringify(context, null, 2)); 9 | 10 | const plansTable = process.env.PLANS_TABLE_NAME; 11 | const keysTable = process.env.KEYS_TABLE_NAME; 12 | 13 | const httpMethod = event.httpMethod; // e.g. "GET" 14 | const path = event.path; // e.g. "/admin/plans/123456 15 | const resource = event.resource; // e.g. "/admin/plans/{id} 16 | const token = event.headers.Authorization.replace(/^[Bb]earer\s+/,'').trim(); 17 | 18 | if (!plansTable || !keysTable || !path || !resource || !httpMethod || !token ) { 19 | console.error(`HTTP 500: Precondition Fail: '${httpMethod}' '${path}' '${resource}' '${plansTable}' '${keysTable}' token.len=${token.len} `); 20 | callback(null,internalServerErrorResponse()); 21 | return; 22 | } 23 | const jwt = parseJwt(token) 24 | console.log('JWT payload: ', JSON.stringify(jwt, null, 2)); 25 | 26 | getKeys(keysTable, jwt, callback); 27 | } 28 | 29 | /** 30 | * GET /admin/keys 31 | */ 32 | function getKeys(tableName, jwt, callback) { 33 | dynamo.scan({ 34 | TableName : tableName, 35 | FilterExpression : "#owner = :o", 36 | ExpressionAttributeNames: { 37 | "#owner": "owner" 38 | }, 39 | ExpressionAttributeValues: { 40 | ":o": { 41 | S: jwt.sub 42 | } 43 | } 44 | }).promise().then((data)=>{ 45 | console.log("Dynamo data: ",JSON.stringify(data.Items, null, 2)); 46 | const response = goodResponse(JSON.stringify(data.Items.map((item)=>AWS.DynamoDB.Converter.unmarshall(item)))); 47 | callback(null,response); 48 | }).catch((err)=>{ 49 | console.error("HTTP 500: Dynamo responded: ", JSON.stringify(err, null, 2)); 50 | callback(null,internalServerErrorResponse()); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /lambda/get_plan.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const { parseJwt, goodResponse, internalServerErrorResponse } = require('./utils'); 3 | 4 | const dynamo = new AWS.DynamoDB({apiVersion: '2012-08-10'}); 5 | 6 | exports.handler = function(event, context, callback) { 7 | console.log('Received event:', JSON.stringify(event, null, 2)); 8 | console.log('Received context: ', JSON.stringify(context, null, 2)); 9 | 10 | const plansTable = process.env.PLANS_TABLE_NAME; 11 | const keysTable = process.env.KEYS_TABLE_NAME; 12 | 13 | const httpMethod = event.httpMethod; // e.g. "GET" 14 | const path = event.path; // e.g. "/admin/plans/123456 15 | const resource = event.resource; // e.g. "/admin/plans/{id} 16 | const token = event.headers.Authorization.replace(/^[Bb]earer\s+/,'').trim(); 17 | 18 | if (!plansTable || !keysTable || !path || !resource || !httpMethod || !token ) { 19 | console.error(`HTTP 500: Precondition Fail: '${httpMethod}' '${path}' '${resource}' '${plansTable}' '${keysTable}' token.len=${token.len} `); 20 | callback(null,internalServerErrorResponse()); 21 | return; 22 | } 23 | const jwt = parseJwt(token) 24 | console.log('JWT payload: ', JSON.stringify(jwt, null, 2)); 25 | 26 | const id = event.pathParameters.id; 27 | getPlanById(plansTable, id, callback); 28 | } 29 | 30 | /** 31 | * GET /admin/plans/:id 32 | */ 33 | function getPlanById(tableName, id, callback) { 34 | 35 | dynamo.getItem({ 36 | TableName: tableName, 37 | Key:{ 38 | "id": { 39 | S: id 40 | } 41 | } 42 | }).promise().then((data) => { 43 | console.log("Dynamo data: ",JSON.stringify(data.Item, null, 2)); 44 | const response = goodResponse(JSON.stringify(AWS.DynamoDB.Converter.unmarshall(data.Item))); 45 | callback(null,response); 46 | }) 47 | .catch((err) => { 48 | console.error("HTTP 500: Dynamo responded: ", JSON.stringify(err, null, 2)); 49 | callback(null,internalServerErrorResponse()); 50 | }); 51 | 52 | }; 53 | 54 | -------------------------------------------------------------------------------- /lambda/get_plans.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const { parseJwt, goodResponse, internalServerErrorResponse } = require('./utils'); 3 | 4 | const dynamo = new AWS.DynamoDB({apiVersion: '2012-08-10'}); 5 | 6 | exports.handler = function(event, context, callback) { 7 | console.log('Received event:', JSON.stringify(event, null, 2)); 8 | console.log('Received context: ', JSON.stringify(context, null, 2)); 9 | 10 | const plansTable = process.env.PLANS_TABLE_NAME; 11 | const keysTable = process.env.KEYS_TABLE_NAME; 12 | 13 | const httpMethod = event.httpMethod; // e.g. "GET" 14 | const path = event.path; // e.g. "/admin/plans/123456 15 | const resource = event.resource; // e.g. "/admin/plans/{id} 16 | const token = event.headers.Authorization.replace(/^[Bb]earer\s+/,'').trim(); 17 | 18 | if (!plansTable || !keysTable || !path || !resource || !httpMethod || !token ) { 19 | console.error(`HTTP 500: Precondition Fail: '${httpMethod}' '${path}' '${resource}' '${plansTable}' '${keysTable}' token.len=${token.len} `); 20 | callback(null,internalServerErrorResponse()); 21 | return; 22 | } 23 | const jwt = parseJwt(token) 24 | console.log('JWT payload: ', JSON.stringify(jwt, null, 2)); 25 | 26 | getPlans(plansTable, callback); 27 | } 28 | 29 | /** 30 | * GET /admin/plans 31 | */ 32 | function getPlans(tableName, callback) { 33 | 34 | dynamo.scan({ 35 | TableName : tableName, 36 | }).promise().then((data)=>{ 37 | console.log("Dynamo data: ",JSON.stringify(data.Items, null, 2)); 38 | const response = goodResponse(JSON.stringify(data.Items.map((item)=>AWS.DynamoDB.Converter.unmarshall(item)))); 39 | callback(null,response); 40 | }) 41 | .catch((err)=>{ 42 | console.error("HTTP 500: Dynamo responded: ", JSON.stringify(err, null, 2)); 43 | callback(null,internalServerErrorResponse()); 44 | }); 45 | 46 | }; 47 | -------------------------------------------------------------------------------- /lambda/update_key.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const { parseJwt, goodResponse, internalServerErrorResponse } = require('./utils'); 3 | 4 | const dynamo = new AWS.DynamoDB({apiVersion: '2012-08-10'}); 5 | 6 | exports.handler = function(event, context, callback) { 7 | console.log('Received event:', JSON.stringify(event, null, 2)); 8 | console.log('Received context: ', JSON.stringify(context, null, 2)); 9 | 10 | const plansTable = process.env.PLANS_TABLE_NAME; 11 | const keysTable = process.env.KEYS_TABLE_NAME; 12 | 13 | const httpMethod = event.httpMethod; // e.g. "GET" 14 | const path = event.path; // e.g. "/admin/plans/123456 15 | const resource = event.resource; // e.g. "/admin/plans/{id} 16 | const token = event.headers.Authorization.replace(/^[Bb]earer\s+/,'').trim(); 17 | 18 | if (!plansTable || !keysTable || !path || !resource || !httpMethod || !token ) { 19 | console.error(`HTTP 500: Precondition Fail: '${httpMethod}' '${path}' '${resource}' '${plansTable}' '${keysTable}' token.len=${token.len} `); 20 | callback(null,internalServerErrorResponse()); 21 | return; 22 | } 23 | const jwt = parseJwt(token); 24 | const item = JSON.parse(event.body); 25 | console.log('JWT payload: ', JSON.stringify(jwt, null, 2)); 26 | 27 | updateKeyById(keysTable, item, jwt, callback); 28 | } 29 | 30 | /** 31 | * PUT /admin/keys/{id} 32 | */ 33 | function updateKeyById(tableName, item, jwt, callback) { 34 | dynamo.updateItem({ 35 | TableName: tableName, 36 | Key: {"id": { S: item.id }}, 37 | ExpressionAttributeNames: { 38 | "#N": "name", 39 | "#D": "description", 40 | "#E": "enabled", 41 | "#O": "owner" 42 | }, 43 | ExpressionAttributeValues: { 44 | ":n": {S: item.name}, 45 | ":d": {S: item.description}, 46 | ":e": {BOOL: item.enabled}, 47 | ":o": { S: jwt.sub } 48 | }, 49 | UpdateExpression: "SET #N = :n, #D = :d, #E = :e", 50 | ConditionExpression : "#O = :o", 51 | 52 | }).promise().then((data) => { 53 | console.log("Dynamo data: ",JSON.stringify(data.Item, null, 2)); 54 | const response = goodResponse(JSON.stringify(AWS.DynamoDB.Converter.unmarshall(data.Item))); 55 | callback(null,response); 56 | }) 57 | .catch((err) => { 58 | console.error("HTTP 500: Dynamo responded: ", JSON.stringify(err, null, 2)); 59 | callback(null,internalServerErrorResponse()); 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /lambda/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Given a JWT Token (sans 'Bearer prefix'), return JSON of the data inside 3 | * NOTE: does not cryptographically validate the token. That is assumed upstream. 4 | */ 5 | exports.parseJwt = (token) => { 6 | const base64Url = token.split('.')[1]; 7 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 8 | const jsonPayload = decodeURIComponent(Buffer.from(base64,'base64').toString().split('').map(function(c) { 9 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 10 | }).join('')); 11 | 12 | return JSON.parse(jsonPayload); 13 | }; 14 | 15 | 16 | /** 17 | * HTTP 200 OK response 18 | */ 19 | exports.goodResponse = (body) => { 20 | return { 21 | "statusCode": 200, 22 | "headers": { 23 | "Content-Type": "application/json", 24 | "Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", 25 | "Access-Control-Allow-Methods": "*", 26 | "Access-Control-Allow-Origin": "*" 27 | }, 28 | "isBase64Encoded": false, 29 | "body": body 30 | }; 31 | } 32 | 33 | /** 34 | * HTTP 500 Internal Server Error 35 | */ 36 | exports.internalServerErrorResponse = () => { 37 | return { 38 | "statusCode": 500, 39 | "headers": { 40 | "Content-Type": "application/json", 41 | "Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", 42 | "Access-Control-Allow-Origin": "*" 43 | }, 44 | "isBase64Encoded": false, 45 | "body": "Internal Server Error" 46 | } 47 | } 48 | 49 | 50 | exports.makeid = (length) => { 51 | var result = ''; 52 | var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 53 | var charactersLength = characters.length; 54 | for ( var i = 0; i < length; i++ ) { 55 | result += characters.charAt(Math.floor(Math.random() * 56 | charactersLength)); 57 | } 58 | return result; 59 | } -------------------------------------------------------------------------------- /react/.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 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | src/aws-exports.js 26 | src/config.json 27 | 28 | amplify/ 29 | 30 | #amplify-do-not-edit-begin 31 | amplify/\#current-cloud-backend 32 | amplify/.config/local-* 33 | amplify/logs 34 | amplify/mock-data 35 | amplify/mock-api-resources 36 | amplify/backend/amplify-meta.json 37 | amplify/backend/.temp 38 | build/ 39 | dist/ 40 | node_modules/ 41 | aws-exports.js 42 | awsconfiguration.json 43 | amplifyconfiguration.json 44 | amplifyconfiguration.dart 45 | amplify-build-config.json 46 | amplify-gradle-config.json 47 | amplifytools.xcconfig 48 | .secret-* 49 | **.sample 50 | #amplify-do-not-edit-end 51 | -------------------------------------------------------------------------------- /react/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /react/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 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /react/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "plans": [ 3 | { 4 | "id": "0", 5 | "name": "Test", 6 | "description": "Not advertised to end-customers", 7 | "quota": "10 per day", 8 | "throttle": "1 TPS", 9 | "price": 0 10 | }, 11 | { 12 | "id": "1", 13 | "name": "Basic", 14 | "description": "Provides basic level of service for developers.", 15 | "quota": "1000 per week", 16 | "throttle": "5 TPS", 17 | "price": 10 18 | }, 19 | { 20 | "id": "2", 21 | "name": "Premium", 22 | "description": "Provides premium service for production workloads.", 23 | "quota": "1000 per day", 24 | "throttle": "40 TPS", 25 | "price": 1000 26 | } 27 | ], 28 | "keys": [ 29 | { 30 | "id": "0", 31 | "planId": "0", 32 | "name": "TestKey", 33 | "description": "test 1", 34 | "enabled": false 35 | }, 36 | { 37 | "id": "1", 38 | "planId": "0", 39 | "name": "Test Key2", 40 | "description": "test 2", 41 | "enabled": true 42 | }, 43 | { 44 | "id": "2", 45 | "planId": "1", 46 | "name": "Basic Key", 47 | "description": "", 48 | "enabled": false 49 | } 50 | ], 51 | "api": { 52 | "items": [] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-gateway-multitenant-tiering-usageplans", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@aws-amplify/ui-react": "^5.2.0", 7 | "@awsui/collection-hooks": "^1.0.21", 8 | "@awsui/components-react": "^3.0.433", 9 | "@awsui/global-styles": "^1.0.10", 10 | "@testing-library/jest-dom": "^5.16.1", 11 | "@testing-library/react": "^12.1.4", 12 | "@testing-library/user-event": "^13.5.0", 13 | "aws-amplify": "^5.3.18", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-router-dom": "^6.2.2", 17 | "web-vitals": "^2.1.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject", 24 | "dev-backend": "json-server --watch db.json --port 5100" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "json-server": "^0.17.0", 46 | "react-scripts": "^5.0.0", 47 | "webpack-dev-server": "^5.0.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /react/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/react/public/favicon.ico -------------------------------------------------------------------------------- /react/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /react/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/react/public/logo192.png -------------------------------------------------------------------------------- /react/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/api-gateway-multitenant-tiering-usageplans/9d441961f783a0bba1df61ffed545c77382ac34c/react/public/logo512.png -------------------------------------------------------------------------------- /react/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 | -------------------------------------------------------------------------------- /react/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /react/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 | -------------------------------------------------------------------------------- /react/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import Amplify from "aws-amplify"; 4 | import { Authenticator } from "@aws-amplify/ui-react"; 5 | import "@aws-amplify/ui-react/styles.css"; 6 | 7 | import { 8 | AppLayout, 9 | Flashbar, 10 | TopNavigation, 11 | } from "@awsui/components-react"; 12 | import "@awsui/global-styles/index.css"; 13 | 14 | import awsconfig from "./aws-exports"; 15 | import NavDrawer from "./components/Navigation/NavDrawer"; 16 | import ToolsDrawer from "./components/Navigation/ToolsDrawer"; 17 | import Content from "./components/Navigation/Content"; 18 | 19 | Amplify.configure(awsconfig); 20 | 21 | export default function App() { 22 | const [notifications, setNotifications] = useState([]); 23 | const i18nStrings = { 24 | overflowMenuTriggerText: "More", 25 | }; 26 | 27 | return ( 28 | 29 | {({ signOut, user }) => ( 30 |
31 |
32 | 44 |
45 | } 47 | navigation={} 48 | tools={} 49 | content={ 50 | 51 | } 52 | headerSelector="#h" 53 | /> 54 |
55 | )} 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /react/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /react/src/components/Dashboard/ApiTestPanel.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | Box, 4 | ColumnLayout, 5 | Container, 6 | Header, 7 | SpaceBetween, 8 | Button, 9 | Select, 10 | TextContent, 11 | } from "@awsui/components-react"; 12 | 13 | import config from "../../config.json"; 14 | const useLocal = config.USE_LOCAL_API; 15 | const localUrlBase = config.LOCAL_API_BASE; 16 | const remoteUrlBase = config.REST_API_BASE; 17 | 18 | const url = useLocal ? localUrlBase : remoteUrlBase; 19 | 20 | export default function ApiTestPanel({ keys }) { 21 | const [req, setReq] = useState(""); 22 | const [resp, setResp] = useState(""); 23 | const [keyOptions, setKeyOptions] = useState([]); 24 | const [selectedKey, setSelectedKey] = useState(); 25 | const [keyValue, setKeyValue] = useState(); 26 | 27 | useEffect(() => { 28 | setKeyOptions( 29 | keys.map((item) => ({ 30 | label: item.name, 31 | value: item.value, 32 | })) 33 | ); 34 | }, [keys]) 35 | 36 | function handleKeyChange(event) { 37 | setSelectedKey(event.detail.selectedOption); 38 | setKeyValue(event.detail.selectedOption.value) 39 | } 40 | 41 | function handleGet() { 42 | setReq(`GET ${url}/api\nX-Api-Key: ${keyValue}`); 43 | 44 | fetch(`${url}/api`, { 45 | method: 'GET', 46 | headers: { 47 | 'x-api-key': keyValue, 48 | Accept: 'application/json', 49 | 'Content-Type': 'application/json', 50 | } 51 | }).then(data => data.json()) 52 | .then(data => setResp(JSON.stringify(data))) 53 | .catch((reason) => { setResp("ERROR\n" + reason); console.log(reason)}); 54 | } 55 | 56 | function handleClear() { 57 | setReq(""); 58 | setResp(""); 59 | } 60 | 61 | return ( 62 | API Test} 64 | > 65 | 66 | 67 | {url}/api 68 | setId(event.detail.value)} 137 | /> 138 | 139 | 143 | setPlanId(event.detail.value)} 147 | /> 148 | 149 | 153 | setName(event.detail.value)} 167 | /> 168 | 169 | 173 |