├── .gitignore ├── .prettierrc.json ├── .vscode ├── launch.json └── settings.json ├── .yarnrc.yml ├── CHANGELOG ├── CHANGELOG.ja.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.ja.md ├── README.md ├── THIRD-PARTY-LICENSES ├── _templates └── redcap │ └── config │ ├── config.ejs.t │ └── prompt.cjs ├── buildspec └── redcap-build.yml ├── containers └── redcap-docker-apache │ ├── Dockerfile │ ├── apache2 │ ├── cert │ │ ├── apache-selfsigned.crt │ │ └── apache-selfsigned.key │ └── sites-available │ │ └── 000-default.conf │ ├── scripts │ ├── redcap_configure.sh │ └── setup_app.sh │ └── sql │ └── redcapConfig.default.sql ├── docs ├── architecture │ └── architecture.drawio ├── en │ ├── autoscaling.md │ ├── build.md │ ├── cron.md │ ├── dbreplica.md │ ├── devenv.md │ ├── docker.md │ ├── filestorage.md │ ├── guardduty.md │ ├── iamdb.md │ ├── index.md │ ├── loadtest.md │ ├── multilang.md │ ├── ptp.md │ ├── salt.md │ ├── ses.md │ └── waf.md ├── images │ ├── architecture.png │ ├── codeBuild.png │ ├── genConfigSQL.png │ └── stackOutput.png └── ja │ ├── autoscaling.md │ ├── build.md │ ├── cron.md │ ├── dbreplica.md │ ├── devenv.md │ ├── docker.md │ ├── filestorage.md │ ├── guardduty.md │ ├── iamdb.md │ ├── index.md │ ├── loadtest.md │ ├── multilang.md │ ├── ptp.md │ ├── salt.md │ ├── ses.md │ └── waf.md ├── eslint.config.js ├── loadtest └── locust │ ├── Compose.yaml │ └── locustfile.py ├── package.json ├── packages ├── REDCap │ ├── languages │ │ ├── Japanese.12.5.17.ini │ │ ├── Japanese.13.4.5.ini │ │ ├── Japanese.13.8.1.ini │ │ ├── Japanese.ini │ │ └── Spanish.ini │ └── releases │ │ └── redcap13.7.2.zip └── functions │ ├── package.json │ ├── src │ ├── createSesCredentials.ts │ ├── startProjectBuild.ts │ └── stateMachineExec.ts │ ├── sst-env.d.ts │ └── tsconfig.json ├── prototyping.d.ts ├── prototyping ├── cdkNag │ ├── NagConsoleLogger.ts │ └── Suppressions.ts ├── cfn │ └── AppRunnerCustomDomain.yaml ├── constructs │ ├── AppRunner.ts │ ├── AuroraServerlessV2.ts │ ├── CodeBuildProject.ts │ ├── EcsFargate.ts │ ├── NetworkVpc.ts │ ├── RedCapAwsAccessUser.ts │ ├── SimpleEmailService.ts │ └── Waf.ts ├── extensions │ └── Helpers.ts └── overrides │ ├── BucketProps.ts │ └── RemovalPolicy.ts ├── sst.config.ts ├── stacks ├── Backend.ts ├── Backend │ ├── AppRunnerHostedZones.ts │ ├── DomainConfiguration.ts │ ├── RedCapService.ts │ └── WafExtraRules.ts ├── BuildImage.ts ├── Database.ts ├── EC2Server.ts ├── Network.ts ├── Route53NSRecords.ts └── Security.ts ├── stages.sample.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | **/yarn-error.log 4 | .yarn 5 | 6 | # sst 7 | .sst 8 | .build 9 | .sst*.mjs 10 | stages.ts 11 | 12 | # misc 13 | .DS_Store 14 | __pycache__ 15 | 16 | # local env files 17 | .env*.local 18 | 19 | # cdk 20 | cdk.context.json 21 | cdk-*-outputs.json 22 | deployLambdaResponse.json 23 | 24 | # REDCap config sql 25 | **/redcapConfig.sql 26 | 27 | # Docs 28 | *.bkp 29 | *.dtmp -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": true, 4 | "singleQuote": true, 5 | "arrowParens": "avoid", 6 | "useTabs": false, 7 | "tabWidth": 2, 8 | "printWidth": 100, 9 | "vueIndentScriptAndStyle": false, 10 | "bracketSameLine": false 11 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug SST Start", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", 9 | "runtimeArgs": ["start", "--increase-timeout"], 10 | "console": "integratedTerminal", 11 | "skipFiles": ["/**"], 12 | "env": {} 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "search.exclude": { 5 | "**/.sst": true 6 | }, 7 | "typescript.tsdk": "node_modules/typescript/lib", 8 | "eslint.enable": true, 9 | "biome.enabled": false, 10 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | [JA](./CHANGELOG.ja.md) | EN 2 | 3 | # CHANGELOG 4 | 5 | ## v1.1.1 6 | 7 | - Add `preferredMaintenanceWindow` configuration for Amazon Aurora. 8 | 9 | ## v1.1.0 10 | 11 | - Upgrade to Amazon Aurora engine 3_08_0 with support to scale to 0 ACUs 12 | - Upgrade packages sst 2.48.5 13 | - Minor code refactoring and format 14 | - Recommended NodeJS version >= v22.11.0 LTS 15 | - New configuration settings: `db` for custom database options and `cronMinutes` to configure REDCap's scheduler in minutes. 16 | - `cronSecret` will now default to a random digit string. 17 | 18 | ### Upgrade procedure to v1.1.0 19 | 20 | 1. Execute `yarn install` to upgrade packages 21 | 2. If you are using a custom database configuration in `stage.db`, please update it to this new type, here is an example: 22 | 23 | ```ts 24 | db: { 25 | dbSnapshotId: undefined, 26 | maxAllowedPacket: '4194304', 27 | dbReaders: 1, 28 | scaling: { 29 | maxCapacityAcu: 2, 30 | minCapacityAcu: 0, 31 | }, 32 | }, 33 | ``` 34 | 35 | Note that `dbReaders` setting is now moved inside the `db` property. 36 | 37 | Using `minCapacityAcu: 0` will allow the database to scale to 0 ACUs automatically after a period of inactivity. You can read more about in this [blog](https://aws.amazon.com/blogs/database/introducing-scaling-to-0-capacity-with-amazon-aurora-serverless-v2/) 38 | 39 | 3. Check you changes `yarn diff --stage ` 40 | 4. Deploy to apply changes `yarn deploy --stage ` 41 | 42 | ## v1.0.11 43 | 44 | - EC2Server stack is always configured with a DELETE retention policy, independent of the stage and mode. 45 | - Disable public IP assignment for EC2 instance in the EC2Server stack. 46 | - Upgrade packages sst 2.45.1 47 | 48 | ## v1.0.10 49 | 50 | - Disable CORS configuration for all buckets. #68 51 | - Update the CDK removal policy for stages. `dev` stage/mode is `destroy` for all resources, `prod` stage is set to `retain`, cdk default for other stages. #66 52 | 53 | ## v1.0.9 54 | 55 | - Fixed bug on multiple stage deployment related to the logGroupName for EC2 #79 56 | - Package dependency updates 57 | 58 | ## v1.0.8 59 | 60 | - Added `opcache` for PHP 61 | - Package dependency updates 62 | - Docs: Update the architecture image with better names for the private and isolated subnets. 63 | 64 | ## v1.0.7 65 | 66 | - Docker base image upgraded to php8.2 67 | - Upgrade packages sst 2.43.3 and other dependencies 68 | - Add `allowedCountries` for AWS WAF to limit access by country in `stages.ts`, e.g.`allowedCountries: ['JP']` 69 | - Add `generalLogRetention` for general retention period configuration in `stage.ts` for Amazon ECS Fargate, Amazon Aurora RDS, Amazon VPC logs and AWS Lambda, e.g `generalLogRetention: 'one_year'`, 70 | - Add `bounceNotificationEmail` stage config to receive bounce notifications from Amazon SES. 71 | 72 | ## v1.0.6 73 | 74 | - Allow database deployment with zero or more readers. 75 | - Allow database deployment from snapshot Id. 76 | - Upgrade to SST 2.42.0. 77 | - Fixed an issue that always executed the initial database settings. 78 | 79 | ## v1.0.5 80 | 81 | - Bug fixes when creating a new deployment with replica enabled 82 | - Update documentation regarding dev env. 83 | 84 | ## v1.0.4 85 | 86 | - Package dependency updates 87 | - Fix the usage of Amazon Aurora READ replica 88 | - Update documentation regarding database replica 89 | 90 | ## v1.0.3 91 | 92 | - Ensure REDCap database settings update when S3 IAM user is updated/changed 93 | 94 | ## v1.0.2 95 | 96 | - Package dependency updates 97 | - README updates to deploy on AWS Route 53 with subdomains. 98 | 99 | ## v1.0.1 100 | 101 | - Add support to deploy REDCap instances in `Amazon ECS on AWS Fargate` as an alternative to AWS App Runner. This is for users that require REDCap to execute user request that are longer than 120 seconds. 102 | 103 | ### Upgrade procedure to v1.0.1 104 | 105 | This **ONLY** applies if you have deployed any previous version and you are using the automatically created AWS App Runner Custom Link Domain. e.g you have deployed with the stage settings `hostInRoute53: true,` and `domain: ''` and you access your REDCap installation with your own domain name. 106 | 107 | In this case, this project will have deployed an `A Record` in your Route 53 Hosted Zone that needs to be replaced and managed by CDK. The procedure is the following: 108 | 109 | 1. Run `yarn install` to install package updates. 110 | 2. In you `stages.ts` file, comment or delete your `domain: ` and enter a different email/value for `email: ` 111 | 3. Deploy the v1.0.1 release 112 | 4. Revert the `stages.ts` changes (add your domain and previous email) 113 | 5. Deploy again to complete the changes 114 | 115 | **Important:** These steps will cause disruption in your linked custom domain and new email notifications. 116 | 117 | ## v1.0.0 118 | 119 | - Add new stack that deploys a temporary EC2 instance for large REDCap requests / workloads. 120 | - Refactor IAM auth for RDS on CDK. 121 | - Database authentication uses IAM as default. 122 | - Docker image now will pull all the secrets for the application at container start. 123 | - Add WAF rule allow for `NoUserAgent_HEADER` for `/survey` check. 124 | - `redcapConfig.sql` will not be re-executed on new instances spawn or re-deployments. This is to avoid post deployment configurations reset. 125 | 126 | ### Breaking changes 127 | 128 | - Aurora Serverless deployment V1 for `dev` stages is now replaced with Aurora Serverless V2. This is ONLY for `dev` stages, e.g `yarn dev --stage your_stage`. Do a full database backup and restore the data in the new instance if required. 129 | 130 | ### Upgrade procedure to v1.0.0 131 | 132 | 1. Create a database backup to prevent data loss if needed. 133 | 134 | 2. Run `yarn install` to install package updates. 135 | 136 | 3. In your AWS console, go to AWS App Runner and select your application. Navigate to the Configuration tab and change Deployment settings to `Manual`. Wait until the change takes effect. This change will be automatically reverted after this update is completed. 137 | 138 | 4. For your stage configuration in `stages.ts`, add the following parameter `deployTag: 'upgrade-v010'`. You can use other tag value if you wish. 139 | 140 | 5. Run `yarn deploy --stage ` 141 | 142 | 6. Remove the added configuration parameter `deployTag` from stages.ts. 143 | 144 | ## v0.9.0 145 | 146 | - Replace postfix with msmtp integrated with Amazon SES 147 | - Start apache2 in non-root, www-data. 148 | - Allow higher port configuration for apache2 149 | 150 | ## v0.8.0 151 | 152 | - Refactor how to start docker apache and email service 153 | - Allow configuration of php timezone in stage configuration file 154 | 155 | ## v0.7.0 156 | 157 | - Allowed to link App Runner domain with A (for a single domain) and CNAME (for domains with subdomain) records 158 | - Set an initial value for REDCap config `project_contact_email`, configured from `gen redcap config` 159 | - Default stage settings to use domain without subdomain. 160 | - Package upgrades 161 | 162 | ## v0.6.0 163 | 164 | - Set `max_allowed_packet` as the default in the database parameter group 165 | - Add a WAF custom rule for protecting root path instead of the code 166 | 167 | ## v0.5.0 168 | 169 | - Set some parameter group as the default to improve REDCap performance 170 | - Set JST as timezone in REDCap 171 | - Add a WAF custom rule for protecting root path 172 | 173 | ## v0.4.0 174 | 175 | - Remove AWS App Runner autoscaling name, will be auto-generated 176 | - Add option to link NS records with same or external AWS account based on NS records returned by the deploy. 177 | -------------------------------------------------------------------------------- /CHANGELOG.ja.md: -------------------------------------------------------------------------------- 1 | JP | [EN](./CHANGELOG) 2 | 3 | # CHANGELOG 4 | 5 | ## v1.1.1 6 | 7 | - Amazon Aurora の `preferredMaintenanceWindow` 設定を追加します。 8 | 9 | ## v1.1.0 10 | 11 | - Amazon Aurora エンジンのバージョン 3_08_0 にアップグレードし、0 ACUまで拡張できるようサポートを追加 12 | - パッケージ sst のバージョンを 2.48.5 にアップグレードする 13 | - 軽微なコードのリファクタリングと書式設定を行う 14 | - 推奨されるNodeJSのバージョンは、v22.11.0 LTS以上です。 15 | - 新しい設定項目: `db`はカスタムデータベースオプション、`cronMinutes`はREDCapのスケジューラを分単位で設定するためのものです。 16 | - `cronSecret`はランダムな数字の文字列にデフォルト設定されるようになりました。 17 | 18 | ### v1.1.0へのアップグレード手順 19 | 20 | 1. `yarn install` を実行してパッケージをアップグレードします。 21 | 22 | 2. `stage.db` でカスタムデータベース設定を使用している場合は、新しいタイプに更新してください。以下は例です: 23 | 24 | ```ts 25 | db: { 26 | dbSnapshotId: undefined, 27 | maxAllowedPacket: '4194304', 28 | dbReaders: 1, 29 | scaling: { 30 | maxCapacityAcu: 2, 31 | minCapacityAcu: 0, 32 | }, 33 | }, 34 | ``` 35 | 36 | `dbReaders`設定はdbプロパティに移動されたことに注意してください。 37 | 38 | `minCapacityAcu: 0`を使用すると、非アクティブ期間後にデータベースが自動的に0 ACUにスケーリングされるようになります。この[ブログ](https://aws.amazon.com/blogs/database/introducing-scaling-to-0-capacity-with-amazon-aurora-serverless-v2/)でさらに詳しく読むことができます。 39 | 40 | 3. 変更を確認します: `yarn diff --stage ` 41 | 42 | 4. 変更を適用するためにデプロイします: `yarn deploy --stage ` 43 | 44 | ## v1.0.11 45 | 46 | - EC2Serverスタックは、ステージやモードに関わらず、常に「DELETE」リテンションポリシーで構成されます。 47 | - EC2Serverスタック内のEC2インスタンスに対するPublic IPアドレスの割り当てを無効化しました。 48 | - パッケージsstを2.45.1にアップグレードしました。 49 | 50 | ## v1.0.10 51 | 52 | - すべてのバケットに対してCORS設定を無効にする。 #68 53 | - ステージのCDK削除ポリシーを更新する。`dev`ステージ/モードでは、すべてのリソースが`destroy`に設定され、`prod`ステージは`retain`に設定され、その他のステージではCDKのデフォルト値が使用される。 #66 54 | 55 | ## v1.0.9 56 | 57 | - EC2 の logGroupName に関連する複数ステージのデプロイメントのバグを修正しました #79 58 | - パッケージ依存関係の更新 59 | 60 | ## v1.0.8 61 | 62 | - PHP に `opcache` を追加しました 63 | - パッケージ依存関係の更新 64 | - ドキュメント: プライベート サブネットと分離サブネットの適切な名前を使用してアーキテクチャ イメージを更新します。 65 | 66 | ## v1.0.7 67 | 68 | - Docker ベースイメージを php8.2 にアップグレードしました 69 | - パッケージ sst 2.43.3 およびその他の依存関係をアップグレードしました 70 | - AWS WAF の `allowedCountries` を追加して、`stages.ts` で国別にアクセスを制限できるようにしました (例: `allowedCountries: ['JP']`) 71 | - `stage.ts` に、Amazon ECS Fargate、Amazon Aurora RDS、Amazon VPC ログ、AWS Lambda でのログの保持期間を一括で設定するための`generalLogRetention` を追加しました(例: `generalLogRetention: 'one_year'`) 72 | - `stages.ts` に Amazon SES からバウンス通知を受信するための`bounceNotificationEmail` 設定を追加しました 73 | 74 | ## v1.0.6 75 | 76 | - データベースは 0 個以上のリーダーを使用して展開できます。 77 | - データベースのデプロイはスナップショット ID から行うことができます。 78 | - SST 2.42.0 にアップグレードします。 79 | - データベースの初期設定が常に実行される問題を修正しました。 80 | 81 | ## v1.0.5 82 | 83 | - レプリカを有効にして新しいデプロイメントを作成するときのバグ修正 84 | - 開発環境に関するドキュメントを更新。 85 | 86 | ## v.1.0.4 87 | 88 | - パッケージのアップデート 89 | - Amazon Aurora READ レプリカの使用法を修正 90 | - データベース レプリカに関するドキュメントを更新 91 | 92 | ## v1.0.3 93 | 94 | - AWS S3 IAM ユーザーが更新されたときに REDCap データベース設定も更新されるようにする 95 | 96 | ## v1.0.2 97 | 98 | - パッケージのアップデート 99 | - サブドメインを使用して AWS Route 53 にデプロイするための README の更新。 100 | 101 | ## v1.0.1 102 | 103 | - Amazon ECS on AWS Fargate へのREDCapインスタンスのデプロイをサポートするために追加しました。これは、ユーザーのリクエストを120秒以上実行する必要があるユーザー向けです。 104 | 105 | ### v1.0.1へのアップグレード手順 106 | 107 | これは、以前のバージョンをデプロイし、自動的に作成されたAWS App Runnerカスタムリンクドメインを使用している場合に**のみ**適用されます。 例えば、`hostInRoute53: true`および`domain: ''`でステージ設定をデプロイし、独自のドメイン名でREDCapにアクセスしている場合です。 108 | 109 | この場合、CDKによって管理および置換する必要があるRoute 53ホストゾーンにデプロイされたAレコードがあります。 110 | 111 | アップグレードの手順は次のとおりです。 112 | 113 | 1. パッケージの更新をインストールするために`yarn install`を実行します。 114 | 115 | 2. `stages.ts`ファイルで`domain: `をコメントアウトまたは削除し、`email: `に異なるメール/値を入力します。 116 | 117 | 3. v1.0.1リリースをデプロイします。 118 | 119 | 4. `stages.ts`の変更を元に戻します(ドメインと以前のメールを追加)。 120 | 121 | 5. 変更を完了するために再度デプロイします。 122 | 123 | 重要: これらの手順により、リンクされたカスタムドメインと新しいメール通知に中断が発生します。 124 | 125 | ## v1.0.0 126 | 127 | - 大規模なREDCapリクエスト/ワークロード用の一時EC2インスタンスをデプロイする新しいスタックを追加しました。 128 | 129 | - CDKでのRDSのIAM認証をリファクタリングしました。 130 | 131 | - データベース認証ではIAMをデフォルトで使用するように変更しました。 132 | 133 | - コンテナの起動時にすべてのシークレットをプルするように変更しました。 134 | 135 | - `/survey` チェックのため`にNoUserAgent_HEADER`のWAFルールを許可しました。 136 | 137 | - `redcapConfig.sql`は、新しいインスタンスのスポーンや再デプロイ時に再実行されなくなりました。 これは、デプロイ後の構成のリセットを回避するためです。 138 | 139 | ### 破壊的変更 140 | 141 | - dev ステージの Aurora Serverless デプロイ V1 は、現在 Aurora Serverless V2 に置き換えられています。 これはdevステージのみに適用されます。例: yarn dev --stage your_stage。 必要に応じて、完全なデータベースバックアップを実行し、新しいインスタンスでデータを復元してください。 142 | 143 | ### v1.0.0 へのアップグレード手順 144 | 145 | 1. データ損失を防ぐために必要に応じてデータベースのバックアップを作成します。 146 | 147 | 2. パッケージの更新をインストールするために`yarn install`を実行します。 148 | 149 | 3. AWSコンソールで、AWS App Runnerに移動し、アプリケーションを選択します。 [構成] タブに移動し、デプロイ設定を`Manual`に変更します。 変更が有効になるまで待ちます。 この変更は、この更新が完了した後に自動的に元に戻されます。 150 | 151 | 4. `stages.ts`のステージ構成に、次のパラメーター `deployTag: 'upgrade-v010'` を追加します。 別のタグ値を使用できます。 152 | 153 | 5. `yarn deploy --stage `を実行します。 154 | 155 | 6. `stages.ts`から追加した構成パラメーター `deployTag` を削除します。 156 | 157 | ## v0.9.0 158 | 159 | - Amazon SESと統合されたpostfixをmsmtpに置き換えました。 160 | 161 | - 非rootのwww-dataでapache2を起動するようにしました。 162 | 163 | - 80以外のポートでApache2が起動できるよう変更しました。 164 | 165 | ## v0.8.0 166 | 167 | - Dockerfileで、Apache2およびMTAの起動方法をリファクタリングしました。 168 | 169 | - `stages.ts`ファイルでphpタイムゾーンの構成を許可しました。 170 | 171 | ## v0.7.0 172 | 173 | - Aレコード(単一ドメインの場合)およびCNAME(サブドメインを持つドメインの場合)レコードでApp Runnerドメインをリンクできるようになりました。 174 | 175 | - `gen redcap config`から構成される`project_contact_email`のREDCap構成の初期値を設定しました。 176 | 177 | - デフォルトのステージ設定をサブドメインなしのドメインで使用するようにしました。 178 | 179 | - パッケージのアップグレードを行いました。 180 | 181 | ## v0.6.0 182 | 183 | - データベースパラメータグループで`max_allowed_packet`をデフォルトとして設定しました。 184 | 185 | - コードの代わりにルートパスを保護するためのカスタムWAFルールを追加しました。 186 | 187 | ## v0.5.0 188 | 189 | - REDCapのパフォーマンスを向上させるために、いくつかのパラメータグループをデフォルトとして設定しました。 190 | 191 | - REDCapのタイムゾーンをJSTに設定しました。 192 | 193 | - ルートパスを保護するためのWAFカスタムルールを追加しました。 194 | 195 | ## v0.4.0 196 | 197 | - AWS App Runnerのオートスケーリング名を削除しました。自動生成されます。 198 | 199 | - NSレコードで返されたものに基づいて、同じまたは外部のAWSアカウントでNSレコードをリンクするオプションを追加しました。- 200 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 4 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 5 | with any additional questions or comments. 6 | -------------------------------------------------------------------------------- /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 | ## Reporting Bugs/Feature Requests 10 | 11 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 12 | 13 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 14 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 15 | 16 | * A reproducible test case or series of steps 17 | * The version of our code being used 18 | * Any modifications you've made relevant to the bug 19 | * Anything unusual about your environment or deployment 20 | 21 | ## Contributing via Pull Requests 22 | 23 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 24 | 25 | 1. You are working against the latest source on the *main* branch. 26 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 27 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 28 | 29 | To send us a pull request, please: 30 | 31 | 1. Fork the repository. 32 | 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. 33 | 3. Ensure local tests pass. 34 | 4. Commit to your fork using clear commit messages. 35 | 5. Send us a pull request, answering any default questions in the pull request interface. 36 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 37 | 38 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 39 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 40 | 41 | ## Finding contributions to work on 42 | 43 | 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. 44 | 45 | ## Code of Conduct 46 | 47 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 48 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 49 | with any additional questions or comments. 50 | 51 | ## Security issue notifications 52 | 53 | 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. 54 | 55 | ## Licensing 56 | 57 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | REDCap deployment on AWS with serverless services 2 | Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /_templates/redcap/config/config.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: containers/redcap-docker-apache/sql/redcapConfig.sql 3 | --- 4 | 5 | -- REDCAP CONFIG 6 | UPDATE redcap_config SET value = '<%= enable_api %>' WHERE field_name = 'api_enabled'; 7 | UPDATE redcap_config SET value = '<%= auto_report_stats %>' WHERE field_name = 'auto_report_stats'; 8 | UPDATE redcap_config SET value = '<%= language %>' WHERE field_name = 'language_global'; 9 | 10 | <% if (contact_email) { %> 11 | UPDATE redcap_config SET value = '<%= contact_email %>' WHERE field_name = 'from_email'; 12 | UPDATE redcap_config SET value = '<%= contact_email %>' WHERE field_name = 'homepage_contact_email'; 13 | UPDATE redcap_config SET value = '<%= contact_email %>' WHERE field_name = 'project_contact_email'; 14 | <% } %> 15 | 16 | 17 | <% if (base_url) { %> 18 | UPDATE redcap_config SET value = 'https://<%= base_url %>' WHERE field_name = 'redcap_base_url'; 19 | <% } %> 20 | 21 | UPDATE redcap_config SET value = 'table' WHERE field_name = 'auth_meth_global'; 22 | 23 | -- REDCAP USERS 24 | INSERT IGNORE INTO redcap_auth (username, password, legacy_hash, temp_pwd) VALUES ('site_admin', MD5('<%= password %>'), '1', '1'); 25 | UPDATE redcap_user_information SET super_user = '0' WHERE username = 'site_admin'; 26 | UPDATE redcap_user_information SET user_firstname = '<%= siteadmin_firstname %>' WHERE username = 'site_admin'; 27 | UPDATE redcap_user_information SET user_lastname = '<%= siteadmin_lastname %>' WHERE username = 'site_admin'; 28 | UPDATE redcap_user_information SET user_email = '<%= siteadmin_email %>' WHERE username = 'site_admin'; 29 | 30 | <% if (use_s3 == '1') { %> 31 | -- S3 Integration 32 | UPDATE redcap_config set value = '2' where field_name = 'edoc_storage_option'; 33 | UPDATE redcap_config SET value = 'APPLICATION_BUCKET_NAME' WHERE field_name = 'amazon_s3_bucket'; 34 | UPDATE redcap_config SET value = 'REDCAP_IAM_USER_ACCESS_KEY' WHERE field_name = 'amazon_s3_key'; 35 | UPDATE redcap_config SET value = 'REDCAP_IAM_USER_SECRET' WHERE field_name = 'amazon_s3_secret'; 36 | UPDATE redcap_config SET value = 'REGION' WHERE field_name = 'amazon_s3_endpoint'; 37 | <% } %> 38 | -------------------------------------------------------------------------------- /_templates/redcap/config/prompt.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | module.exports = [ 8 | { 9 | type: 'password', 10 | name: 'password', 11 | message: 'REDCap site-admin temp-password?', 12 | }, 13 | { 14 | type: 'input', 15 | name: 'siteadmin_firstname', 16 | message: 'REDCap site-admin first name?', 17 | }, 18 | { 19 | type: 'input', 20 | name: 'siteadmin_lastname', 21 | message: 'REDCap site-admin last name?', 22 | }, 23 | { 24 | type: 'input', 25 | name: 'siteadmin_email', 26 | message: 'REDCap site-admin email?', 27 | }, 28 | { 29 | type: 'select', 30 | name: 'enable_api', 31 | message: 'Enable REDCap API?', 32 | initial: '0', 33 | choices: [ 34 | { name: '1', message: 'Yes', value: '1' }, 35 | { name: '0', message: 'No', value: '0' }, 36 | ], 37 | }, 38 | { 39 | type: 'select', 40 | name: 'language', 41 | message: 'REDCap global language?', 42 | initial: 'English', 43 | choices: [ 44 | { name: 'English', message: 'English', value: 'English' }, 45 | { name: 'Japanese', message: 'Japanese', value: 'Japanese' }, 46 | ], 47 | }, 48 | { 49 | type: 'select', 50 | name: 'auto_report_stats', 51 | message: 'Enable REDCap Report stats?', 52 | initial: '0', 53 | choices: [ 54 | { name: '1', message: 'Yes', value: '1' }, 55 | { name: '0', message: 'No', value: '0' }, 56 | ], 57 | }, 58 | { 59 | type: 'select', 60 | name: 'use_s3', 61 | message: 'Use S3 bucket as storage for REDCap?', 62 | initial: '1', 63 | choices: [ 64 | { name: '1', message: 'Yes', value: '1' }, 65 | { name: '0', message: 'No', value: '0' }, 66 | ], 67 | }, 68 | { 69 | type: 'input', 70 | name: 'contact_email', 71 | message: 'REDCap from/homepage contact email?', 72 | }, 73 | { 74 | type: 'input', 75 | name: 'base_url', 76 | message: 'REDCap base URL (example: redcap.subdomain.com)?', 77 | }, 78 | ]; 79 | -------------------------------------------------------------------------------- /buildspec/redcap-build.yml: -------------------------------------------------------------------------------- 1 | version: '0.2' 2 | phases: 3 | pre_build: 4 | commands: 5 | - 'aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com' 6 | build: 7 | commands: 8 | - 'docker build --cache-from $ECR_REPOSITORY_URI:$IMAGE_TAG --build-arg LANG_S3_URI=$LANG_S3_URI --build-arg REDCAP_S3_URI=$REDCAP_S3_URI --build-arg AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION --build-arg PORT=$PORT --build-arg AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI -t $ECR_REPOSITORY_URI:$IMAGE_TAG -t $ECR_REPOSITORY_URI:latest .' 9 | post_build: 10 | commands: 11 | - 'docker push $ECR_REPOSITORY_URI:$IMAGE_TAG' 12 | - 'docker push $ECR_REPOSITORY_URI:latest' 13 | -------------------------------------------------------------------------------- /containers/redcap-docker-apache/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/php:8.2-apache 2 | 3 | ARG PORT 4 | ARG LANG_S3_URI 5 | ARG REDCAP_S3_URI 6 | ARG AWS_DEFAULT_REGION 7 | ARG DEBIAN_FRONTEND=noninteractive 8 | ARG AWS_CONTAINER_CREDENTIALS_RELATIVE_URI 9 | 10 | # Required packages setup 11 | RUN apt-get update -qq && \ 12 | apt-get install -yq --no-install-recommends \ 13 | ca-certificates jq ghostscript \ 14 | libzip-dev libpng-dev libjpeg-dev \ 15 | libmagickwand-dev libxml2 libxml2-dev librsvg2-dev librsvg2-bin \ 16 | unzip curl \ 17 | default-mysql-client msmtp msmtp-mta libsasl2-modules \ 18 | && apt clean && rm -rf /var/lib/apt/lists/* 19 | 20 | RUN pecl install imagick apcu \ 21 | && docker-php-ext-enable imagick apcu \ 22 | && docker-php-ext-install gd zip mysqli opcache 23 | 24 | RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "/tmp/awscliv2.zip"\ 25 | && unzip -qq /tmp/awscliv2.zip -d /tmp/ \ 26 | && /tmp/aws/install -i /usr/local/aws-cli -b /usr/local/bin \ 27 | && rm /tmp/awscliv2.zip && rm -rf /tmp/aws 28 | 29 | # Composer PHP SDK 30 | COPY --from=composer /usr/bin/composer /usr/local/bin/composer 31 | RUN mkdir -p /usr/local/share/redcap/aws && composer require aws/aws-sdk-php --working-dir=/usr/local/share/redcap/aws 32 | 33 | # RDS certificate 34 | RUN curl "https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem" -o "/usr/local/share/redcap/global-bundle.pem" 35 | 36 | # Fix ImageMagick policy for REDCap 37 | RUN sed -i '//{d;}' /etc/ImageMagick-6/policy.xml 38 | 39 | # Apache config 40 | COPY apache2/sites-available/000-default.conf /etc/apache2/sites-available/000-default.conf 41 | COPY apache2/cert/apache-selfsigned.key /etc/ssl/certs/apache-selfsigned.key 42 | COPY apache2/cert/apache-selfsigned.crt /etc/ssl/certs/apache-selfsigned.crt 43 | 44 | RUN echo "ServerName 127.0.0.1" >> /etc/apache2/apache2.conf 45 | RUN sed -i "s/Listen 80/Listen ${PORT}/" /etc/apache2/ports.conf && sed -i "s/Listen 443/Listen 8081/" /etc/apache2/ports.conf 46 | RUN sed -i "s/\*:80/\*:${PORT}/" /etc/apache2/sites-available/000-default.conf && sed -i "s/\*:443/\*:8081/" /etc/apache2/sites-available/default-ssl.conf 47 | RUN sed -i '/SSLCertificateFile.*snakeoil\.pem/c\SSLCertificateFile \/etc\/ssl\/certs\/apache-selfsigned.crt' /etc/apache2/sites-available/default-ssl.conf && sed -i '/SSLCertificateKeyFile.*snakeoil\.key/cSSLCertificateKeyFile /etc/ssl/certs/apache-selfsigned.key\' /etc/apache2/sites-available/default-ssl.conf 48 | RUN chown www-data:www-data /etc/ssl/certs/apache-selfsigned.key /etc/ssl/certs/apache-selfsigned.crt 49 | RUN a2enmod ssl && a2enmod socache_shmcb && a2ensite default-ssl && a2ensite 000-default 50 | 51 | # Container start scripts 52 | COPY scripts/ /etc/redcap-entry/ 53 | RUN chown www-data -R /etc/redcap-entry && chmod a+x /etc/redcap-entry/*.sh && /etc/redcap-entry/setup_app.sh 54 | RUN touch /etc/msmtprc && chown www-data /etc/msmtprc 55 | 56 | # Run apache as www-data 57 | USER www-data 58 | 59 | # Redcap database setup script 60 | COPY sql/ /etc/redcap-entry/ 61 | 62 | # Configure redcap - db, mail, etc. 63 | ENTRYPOINT ["/etc/redcap-entry/redcap_configure.sh"] 64 | 65 | # Start apache 66 | CMD ["apache2-foreground"] -------------------------------------------------------------------------------- /containers/redcap-docker-apache/apache2/cert/apache-selfsigned.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEdzCCAt8CFFVV8KMtK6+YDdtBQy7xeus0ilJMMA0GCSqGSIb3DQEBCwUAMHgx 3 | CzAJBgNVBAYTAkpQMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl 4 | cm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMMCTEyNy4wLjAuMTEdMBsGCSqG 5 | SIb3DQEJARYOdXNlckAxMjcuMC4wLjEwHhcNMjQwMjA4MDYwNzQ4WhcNMjUwMjA3 6 | MDYwNzQ4WjB4MQswCQYDVQQGEwJKUDETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8G 7 | A1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDDAkxMjcuMC4w 8 | LjExHTAbBgkqhkiG9w0BCQEWDnVzZXJAMTI3LjAuMC4xMIIBojANBgkqhkiG9w0B 9 | AQEFAAOCAY8AMIIBigKCAYEAs5HwyyMiXWxdbw0K8uWiVQBMyYukfvHRPZntjI2d 10 | onhz09hKiiHjvcIsoPacsuABvCSPtTVxTTSkWDKw97YMwHWPszc7HLjuq8yNiC4W 11 | X6kGu37/U/+/NejvoBmWYODIQxYHs9dUm3D6j33wBEhcJcVEx3W8RxcVXxxEcxJJ 12 | DV6x8aoyRr55m1zQFFU+7KnH/PVvG169jD+d4JkIEXddaj/BHDKx7aXqQqNjlTA3 13 | Bx0zaCHMmjAW4Jn5/9FJ5qSJW2B4na+2TZO5xTgM1ueUMkq6uIztmeRODIKc0Bia 14 | SrSS1Dke7grtOyLhpnVtVwF9ZpCpEBOwB83o5+xX0WQotdtJ2KWrVEoW3XFSw9Bc 15 | ZRJBilsMHYYVuFFvOJhF00MfNIJz+EXRcCz0A4HoE/QVykJRsTpz5zKPIBFs6ipZ 16 | xGTb7LkbPMiI4+oa3mHJWoT3wpkkATYK6mlB5vudru+l0X8/bfurdxQRNWzaea0N 17 | P53skSacIzy3UlHTNFmBb9uDAgMBAAEwDQYJKoZIhvcNAQELBQADggGBABmBBAmr 18 | Ug0LpNjXnS2jorxLhyIB3ne/vMGdAmL3bUa9ZB1rOcABw3l0WfdAoSifYfJAXcv4 19 | sl+pMS9BVtVigXB7cEV++8cjn152UjTGceAHx8H4sOOuDFeAAzg688hhjqF0nHBm 20 | qc0p17N1gkdiIVWh02MbxlXAT8GWqe4OZw/16B/dMPin9c0oo8tzPxASSZ2jbSLU 21 | FQ+5Igwq+Swx/csxve1V/r8OyEZVOOCrxO8obrkrPXjDC81sxSoYCUEJAkt5aCA1 22 | PiX3B8jMff1lX8JNa3+ItyEmzjDdzAE5NCjJlZ8ooBd5T/3C8gxRDDolPcgBOAJG 23 | RtIjAM1cdeeoz10swewdZLgZUFQHkqP6RQqtu58r4IqXBKNwqSQvHr92NpkHZNVT 24 | O12HeQV9qRpqrbb23d043/cl+GzWr0hFB7tGF4jfihxCD+GMPvTFIgg5kGh5hc5Z 25 | dTlzmgRGp7zsMhbwhy1A3UzIuuxH2VvGNNBy6X5szQ17tKcnI4nHw1tY0Q== 26 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /containers/redcap-docker-apache/apache2/cert/apache-selfsigned.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQCzkfDLIyJdbF1v 3 | DQry5aJVAEzJi6R+8dE9me2MjZ2ieHPT2EqKIeO9wiyg9pyy4AG8JI+1NXFNNKRY 4 | MrD3tgzAdY+zNzscuO6rzI2ILhZfqQa7fv9T/7816O+gGZZg4MhDFgez11SbcPqP 5 | ffAESFwlxUTHdbxHFxVfHERzEkkNXrHxqjJGvnmbXNAUVT7sqcf89W8bXr2MP53g 6 | mQgRd11qP8EcMrHtpepCo2OVMDcHHTNoIcyaMBbgmfn/0UnmpIlbYHidr7ZNk7nF 7 | OAzW55QySrq4jO2Z5E4MgpzQGJpKtJLUOR7uCu07IuGmdW1XAX1mkKkQE7AHzejn 8 | 7FfRZCi120nYpatUShbdcVLD0FxlEkGKWwwdhhW4UW84mEXTQx80gnP4RdFwLPQD 9 | gegT9BXKQlGxOnPnMo8gEWzqKlnEZNvsuRs8yIjj6hreYclahPfCmSQBNgrqaUHm 10 | +52u76XRfz9t+6t3FBE1bNp5rQ0/neyRJpwjPLdSUdM0WYFv24MCAwEAAQKCAYBC 11 | OmIv2Z50DGKNcacHHNB5PyoS73DU7QT6DkqBmz13TauSh2Q+e+9N7k6dczcp9dpN 12 | 9MIX2EUYb4DpkpCYW8lqNjGwrH8dwcstC71ra2wPDf0Qq+8poNp53JZ8WtOOmXji 13 | 3T4sAxAOYGXZBF7AhZuOxqnuUqsFIStdr8RDGIxe5P0GH3p5gwjA10NbLHGPwbKj 14 | xjWbR57rGg91ZZuHLZoDdM4ZQ01CU/4JY893l5fEBO4Pyt92QqQ9ZCzDQAJckXQF 15 | Wc1bN40Q74QbzFIHc4ZPVMo2ok20bL24MmXiPSq8RElvy4NmPUVEg73Z9K+wli0v 16 | jh1DpKfhoEeqYJIxmPwG1knilnKl4qT3f04KEbc087YfJbviI9GqZ8miIIFLkYAI 17 | 3SGhzXt6OoVeYpq4ctWaRGAS4eZ0z4Uj2rTA1VNCIupbZAk325WAF4vKl9cAFNsZ 18 | kULsSKyH7R3TQEmOek2YCfxFzVt7vYCOGN8ehgViWYIMJzUoPCS06zSG3vj7CfkC 19 | gcEA2BWoC3tB3pwXzaUTQPisvyhwIqQJPC+hxlLegNAufNEALEVvrL47EfOZ8fSN 20 | j5LKmzlmBAXXy+voTFzV4KpbtxeUEQIOYOv8uQFlzOWtuVH+lXG8o+9r2BS1QxCn 21 | 3NpAYfmM0RY4reXuEsq8P7UUf+Sj0DJv34KRwbfMBRXLnm+5f3RrfsO1m/XZ7nSR 22 | DIThQrsa1efZaO0KN0Dyr+3bLfKz+X+lOdVHfViAmZ1fst33L2Ghx8sBq9vJbkUs 23 | ylzvAoHBANS9kBzjGcyj7g/CiAINjr1FdX8kzYt8n219PQbWqUgXDJzn4kk/GiBc 24 | s6+FFSUtNG74x5k8VaF7h4WlFh6+ID5yMUkB1HdaIcmlgu6YOhNrKRLCSBKyuMPI 25 | 0NuyQ2rk1YT79eWPxcDeawuAPn1/jtHWmGskEXksJqN1l+sbGYNWnwzfo9QKQTtW 26 | 6XaGCL2MZfNBMjGXHaQUb9u5TcDu9arUYa+AqQkdkiFMcJbVCRCBbIE4/aANAk8I 27 | GMUkYL3SrQKBwDiEKYifO1Iy91LVCx0iLWRt+i5FQxkXyDMr94Avcwk4TNhHbPb8 28 | ZkzCrxAGi4Pyu8UvlQwWTyPJ1t8qJNJ3HDfeFd8A76vP7TCiOfMGW5Kt7G0/6zvh 29 | Yg6JFAOvdoggGVjGwVrqefaZvdPybJwpC2yL64CHwJTv/JlzLgxib/hHdnfshjUI 30 | kRZyjgZ9PHbOxnACqfkqg8WaweJDvXXgO0RgR0xJY3il3OXe4PvMmnwY7A7bdUnh 31 | QdWmTZ/mvdlLxwKBwQCftxlktpL98eyeZFubvvX03xrRM54lJJDEsIuKgMpiVvuf 32 | KO/YMcm5lh9InM89M+zzi06+mm9nZshd64zp0699clnSB8+tMzu+mcXsvtiLD56s 33 | eLOHZioUwsUay4CV1er/hfIcQI4kuFcBMWy453Uf0M5pUZDUufLgMT62wYer9PI/ 34 | xf7HCFPk6uEnnIUfWTKJJ985H8yfDMDV4w6e1EgX0o7sJdnCADNfUHYOpy3A+Imv 35 | kkvHRzz+fIOsLh17JZkCgcBWN0mHTz8PFSGOIFSPvge+KUud8pui43WTF9/+lHmG 36 | EUHZ0u9jJ1kyDnze5hZLa0izw1b56sHqACYs8sOpRANPdyHpuXdcFWhqB0wlV3Us 37 | L0BbpgE+wFlIhbWq1gfb9FMGvTMVAg1GG4bj2Hmtz5COL9rjplBG9zJMUWwPIbk8 38 | 536H42doAw55W1LG3HMzUNa4/riRcQXObQK7Dt6Fp7z0OwerWfa97ErZTFsHx5MJ 39 | pUpy6FmqvSurNBkvIqL5qok= 40 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /containers/redcap-docker-apache/apache2/sites-available/000-default.conf: -------------------------------------------------------------------------------- 1 | 2 | LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined 3 | LogFormat "%{X-Forwarded-For}i %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" proxy 4 | SetEnvIf X-Forwarded-For "^.*\..*\..*\..*" forwarded 5 | CustomLog /dev/stdout combined env=!forwarded 6 | CustomLog /dev/stdout proxy env=forwarded 7 | ErrorLog /dev/stdout 8 | -------------------------------------------------------------------------------- /containers/redcap-docker-apache/scripts/redcap_configure.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 5 | # Licensed under the Amazon Software License http://aws.amazon.com/asl/ 6 | 7 | set -e 8 | 9 | DB_SECRET="$(aws secretsmanager get-secret-value --secret-id ${DB_SECRET_ID} --query 'SecretString' --output text --region ${AWS_REGION})" 10 | DB_SALT="$(aws secretsmanager get-secret-value --secret-id ${DB_SALT_SECRET_ID} --query 'SecretString' --output text --region ${AWS_REGION})" 11 | SES_CREDENTIALS="$(aws secretsmanager get-secret-value --secret-id ${SES_CREDENTIALS_SECRET_ID} --query 'SecretString' --output text --region ${AWS_REGION})" 12 | S3_SECRET="$(aws secretsmanager get-secret-value --secret-id ${S3_SECRET_ID} --query 'SecretString' --output text --region ${AWS_REGION})" 13 | 14 | export RDS_HOSTNAME="$(echo $DB_SECRET | jq -r .host)" 15 | export RDS_USERNAME="$(echo $DB_SECRET | jq -r .username)" 16 | export RDS_PASSWORD="$(echo $DB_SECRET | jq -r .password)" 17 | export RDS_DBNAME="$(echo $DB_SECRET | jq -r .dbname)" 18 | export RDS_PORT="$(echo $DB_SECRET | jq -r .port)" 19 | 20 | export SES_USERNAME="$(echo $SES_CREDENTIALS | jq -r .username)" 21 | export SES_PASSWORD="$(echo $SES_CREDENTIALS | jq -r .password)" 22 | 23 | export S3_ACCESS_KEY="$(echo $S3_SECRET | jq -r .AccessKeyId)" 24 | export S3_SECRET_ACCESS_KEY="$(echo $S3_SECRET | jq -r .SecretAccessKey)" 25 | 26 | # DB_SALT in alphanumeric 27 | export DB_SALT_ALPHA=$(echo -n "$DB_SALT" | sha256sum | cut -d' ' -f1) 28 | 29 | if [ "$USE_IAM_DB_AUTH" = 'true' ]; then 30 | echo "- Using IAM auth" 31 | mysql -h ${RDS_HOSTNAME} -u ${RDS_USERNAME} -D ${RDS_DBNAME} --password=${RDS_PASSWORD} -e " 32 | CREATE USER IF NOT EXISTS 'redcap_user'@'%' IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'; 33 | ALTER USER IF EXISTS 'redcap_user'@'%' IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'; 34 | GRANT ALL PRIVILEGES ON \`${RDS_DBNAME}\`.* TO 'redcap_user'@'%'; 35 | GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'redcap_user'@'%'; 36 | FLUSH PRIVILEGES; 37 | " 38 | echo "include '/usr/local/share/redcap/redcap_connect_iam.php';" >>/var/www/html/database.php 39 | else 40 | echo "- Using base auth" 41 | echo "include '/usr/local/share/redcap/redcap_connect_base.php';" >>/var/www/html/database.php 42 | fi 43 | 44 | # EMAIL Setting for REDCap 45 | if [ -z "$SES_USERNAME" ]; then 46 | echo "Credentials not set, smtp will not be configured" 47 | else 48 | cat </etc/msmtprc 49 | defaults 50 | tls on 51 | tls_starttls on 52 | tls_trust_file /etc/ssl/certs/ca-certificates.crt 53 | syslog on 54 | 55 | account default 56 | host email-smtp.${AWS_REGION}.amazonaws.com 57 | port 587 58 | auth on 59 | user $SES_USERNAME 60 | password $SES_PASSWORD 61 | from ${SMTP_EMAIL:=localhost} 62 | EOF 63 | fi 64 | 65 | # REDCap initial DB SETUP 66 | if [ -f "/var/www/html/install.php" ]; then 67 | echo " - Executing install.php" 68 | cd /var/www/html 69 | php -r '$_GET["auto"]=1; $_GET["sql"]=1 ; $_SERVER["REQUEST_METHOD"] = "POST"; $_SERVER["PHP_SELF"]= "install.php"; require_once("install.php");' 70 | fi 71 | 72 | # REDCap replica config for Aurora serverless V2 73 | if [ ! -z "$READ_REPLICA_HOSTNAME" ]; then 74 | echo "- Using replica" 75 | echo "include '/usr/local/share/redcap/redcap_connect_replica.php';" >>/var/www/html/database.php 76 | mysql -h ${RDS_HOSTNAME} -u ${RDS_USERNAME} -D ${RDS_DBNAME} --password=${RDS_PASSWORD} -e " 77 | UPDATE IGNORE redcap_config SET value = '1' WHERE field_name = 'read_replica_enable'; 78 | " 79 | REDCAP_VERSION=$(ls /var/www/html/ -1 | grep -E "^redcap_v") 80 | # Disable the lag check as this does not apply to Amazon RDS Aurora Serverless V2 81 | sed -i 's/$bypassReadReplicaLagCheck=false)/$bypassReadReplicaLagCheck=true)/g' /var/www/html/$REDCAP_VERSION/Config/init_functions.php 82 | fi 83 | 84 | DB_S3_ACCESS_KEY=$(mysql redcap -h ${RDS_HOSTNAME} -u ${RDS_USERNAME} -p${RDS_PASSWORD} -se "select value from redcap_config where field_name='amazon_s3_key'") 85 | 86 | # Force new database configuration 87 | # DB_S3_ACCESS_KEY='key' 88 | 89 | if [ "$DB_S3_ACCESS_KEY" = "$S3_ACCESS_KEY" ]; then 90 | echo '- REDCap initial settings already configured, skipping' 91 | else 92 | # REDCap SQL Initialization Configuration 93 | REDCAP_CONFIG_SQL=/etc/redcap-entry/redcapConfig.sql 94 | 95 | if [ -f "$REDCAP_CONFIG_SQL" ]; then 96 | echo " - Using provided redcapConfig.sql for REDCap settings" 97 | else 98 | echo " - Using default REDCap DB settings" 99 | REDCAP_CONFIG_SQL=/etc/redcap-entry/redcapConfig.default.sql 100 | fi 101 | 102 | echo ' - Configuring REDCap DB settings...' 103 | ESCAPED_S3_SECRET_ACCESS_KEY=$(printf '%s\n' "$S3_SECRET_ACCESS_KEY" | sed -e 's/[\/&]/\\&/g') 104 | sed -i "s/APPLICATION_BUCKET_NAME/$S3_BUCKET/g" $REDCAP_CONFIG_SQL 105 | sed -i "s/REDCAP_IAM_USER_ACCESS_KEY/$S3_ACCESS_KEY/g" $REDCAP_CONFIG_SQL 106 | sed -i "s/REDCAP_IAM_USER_SECRET/$ESCAPED_S3_SECRET_ACCESS_KEY/g" $REDCAP_CONFIG_SQL 107 | sed -i "s/REGION/$AWS_REGION/g" $REDCAP_CONFIG_SQL 108 | mysql -h ${RDS_HOSTNAME} -u ${RDS_USERNAME} -D redcap --password=${RDS_PASSWORD} <$REDCAP_CONFIG_SQL 109 | echo ' - Done' 110 | fi 111 | 112 | exec "$@" 113 | -------------------------------------------------------------------------------- /containers/redcap-docker-apache/scripts/setup_app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 5 | # Licensed under the Amazon Software License http://aws.amazon.com/asl/ 6 | 7 | ## REDCap package setup 8 | aws s3 cp $REDCAP_S3_URI /tmp/redcapPackage.zip 9 | unzip -qq /tmp/redcapPackage.zip -d /tmp/ 10 | cp -rp /tmp/redcap/* /var/www/html/ && rm -rf /tmp/redcap 11 | chown -R www-data:www-data /var/www/html 12 | 13 | ## REDCap languages 14 | aws s3 cp $LANG_S3_URI /tmp/redcapLanguages.zip 15 | unzip -qq /tmp/redcapLanguages.zip -d /tmp/languages 16 | cp /tmp/languages/*.ini /var/www/html/languages/. 2>/dev/null 17 | 18 | ## Setup PHP variables 19 | cat </dev/null 20 | max_input_vars=100000 21 | upload_max_filesize=1000M 22 | post_max_size=1000M 23 | session.cookie_secure=On 24 | error_reporting=E_ALL & ~E_DEPRECATED & ~E_STRICT 25 | error_log=/dev/stdout 26 | EOF 27 | 28 | ## Setup opcache 29 | cat </dev/null 30 | opcache.enable=1 31 | opcache.revalidate_freq=0 32 | opcache.validate_timestamps=0 33 | opcache.max_accelerated_files=10000 34 | opcache.memory_consumption=192 35 | opcache.max_wasted_percentage=10 36 | opcache.interned_strings_buffer=16 37 | EOF 38 | 39 | 40 | ### Set Timezone for REDCap 41 | echo "date.timezone = \${PHP_TIMEZONE}" >>/usr/local/etc/php/conf.d/redcap-php-overrides.ini 42 | 43 | # PHP database configuration: iam auth base 44 | cat </dev/null 45 | createToken(\$hostname . ":{\$port}", \$region, \$username); 70 | } catch (AwsException \$e) {} 71 | 72 | EOF 73 | 74 | # PHP database configuration: standard auth with single user secret rotation 75 | cat </dev/null 76 | getenv('AWS_REGION'), 93 | ]); 94 | 95 | \$result = \$client->getSecretValue([ 96 | 'SecretId' => getenv('DB_SECRET_NAME'), 97 | ]); 98 | 99 | if (isset(\$result['SecretString'])) { 100 | \$secret = \$result['SecretString']; 101 | } else { 102 | \$secret = base64_decode(\$result['SecretBinary']); 103 | } 104 | \$secretArray = json_decode(\$secret, true); 105 | \$password = \$secretArray['password']; 106 | 107 | } catch (AwsException \$e) {} 108 | 109 | EOF 110 | 111 | # PHP database configuration: replica config 112 | cat </dev/null 113 | > /usr/local/etc/php/conf.d/redcap-php-overrides.ini 131 | # mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" 132 | -------------------------------------------------------------------------------- /containers/redcap-docker-apache/sql/redcapConfig.default.sql: -------------------------------------------------------------------------------- 1 | -- DEFAULT VALUES - Run config from: $yarn run gen redcap config 2 | -- REDCAP CONFIG 3 | UPDATE redcap_config SET value = '0' WHERE field_name = 'api_enabled'; 4 | UPDATE redcap_config SET value = '0' WHERE field_name = 'auto_report_stats'; 5 | UPDATE redcap_config SET value = 'noreply@mydomain.com' WHERE field_name = 'from_email'; 6 | UPDATE redcap_config SET value = 'noreply@mydomain.com' WHERE field_name = 'homepage_contact_email'; 7 | UPDATE redcap_config SET value = 'noreply@mydomain.com' WHERE field_name = 'project_contact_email'; 8 | UPDATE redcap_config SET value = 'English' WHERE field_name = 'language_global'; 9 | UPDATE redcap_config SET value = 'https://mydomain.com' WHERE field_name = 'redcap_base_url'; 10 | UPDATE redcap_config SET value = 'table' WHERE field_name = 'auth_meth_global'; 11 | 12 | -- REDCAP USERS 13 | INSERT IGNORE INTO redcap_auth (username, password, legacy_hash, temp_pwd) VALUES ('site_admin', MD5('changeme'), '1', '1'); 14 | UPDATE redcap_user_information SET super_user = '0' WHERE username = 'site_admin'; -------------------------------------------------------------------------------- /docs/en/autoscaling.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/autoscaling.md) | EN 2 | 3 | # App Runner AutoScaling Setting 4 | 5 | In this project, we use AWS App Runner to host REDCap. This section describes scaling configuration in App Runner. 6 | 7 | ## App Runner AutoScaling Parameters 8 | 9 | | Parameter | Property in stage | Description | How to decide | 10 | | --------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 11 | | Max Concurrency | `appRunnerConcurrency` | The maximum number of concurrent requests that an instance processes. When the number of concurrent requests exceeds this quota, App Runner scales up the service. | To adjust this parameter, you should do load testing. Please refer [Load Testing for concurrency](./loadtest.md). | 12 | | Max Size | `appRunnerMaxSize` | The maximum number of instances that your service can scale up to. This is the highest number of instances that can concurrently handle your service's traffic. | If you want to be able to support high loads of traffic, you can start with a high value and adjust it down after you detect peak loads. This will allow you to do cost control on the system to not scale up to unpredicted traffic. For example: DoS attack or users abusing the system. Also see [Load Testing for concurrency](./loadtest.md) for how much traffic can be handled by how many instances. | 13 | | Min Size | `appRunnerMinSize` | The minimum number of instances that App Runner can provision for your service. The service always has at least this number of provisioned instances. Some of these instances actively handle traffic. The remainder of them are part of the cost-effective compute capacity reserve, which is ready to be quickly activated. | MinSize depends if you allow your peak time users to have errors and wait until the scale-up process is completed. For a production system and unknown load is risky to put this at 1. Is better to start with a minSize: 2-3 and reduce it to one after the load and peaks are known. MinSize puts instances in warm state and you pay only for the allocated memory. | 14 | 15 | For more information, please refer the link below. 16 | 17 | 18 | ### App Runner Pricing Mechanism 19 | 20 | App Runner is based on two types of pricing. 21 | 22 | For provisioned instances which don't handle any traffic, it costs only for Memory usage. 23 | For active instances which handle traffic, it costs for both of CPU and Memory usage. 24 | 25 | The unit pricing depends on region. For example, in ap-northeast-1(Tokyo), 0.009 USD/GB for Memory usage and 0.081 USD/vCPU for CPU usage. 26 | Unit pricing for Memory doesn't depend on whether the instance handles traffic or not. 27 | 28 | For more information, please refer the link below. 29 | 30 | 31 | ### How to estimate the concurrency value 32 | 33 | To estimate this value, you should monitor your traffic and how your instances are performing in terms of memory and cpu. If you don't know your traffic and workload patterns, you can execute a simple load balancing test provided in this project. [Load testing](../loadtest.md) 34 | 35 | #### Default concurrency value 36 | 37 | For a 2vCPU/4GB configuration instance on AWS App Runner, you can setup the concurrency to a value of `10`. This was estimated by doing a load test to the `index.php` page with one instance. 38 | The simulation created `60` users, achieved and average of `12` req/sec with an average latency of `1` second and a reported average concurrency of `14` in the AWS Console App Runner service. In this state the instance processing (CPU) is at max, but is still able to process request with the mentioned latency. If your usage of REDCap has heavy processing tasks/request you might need to consider to lower the concurrency. 39 | 40 | For a more approximated test, we recommend that you simulate a request closer to your most used REDCap use case, like for example answering a form. 41 | -------------------------------------------------------------------------------- /docs/en/build.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/build.md) | EN 2 | 3 | # Building process 4 | 5 | This project will deploy the required assets and AWS services to run REDCap. However, the App Runner service requires a docker image with everything configured to execute. The creation of this image is done in another AWS service called AWS Codebuild. 6 | 7 | Once the architecture and REDCap application code is uploaded to your AWS account (AWS S3), an AWS Lambda function triggers the Codebuild that creates the container image. After the process is successful, the image is pushed to Amazon Elastic Container Repository (ECR). 8 | 9 | In this section, the process of building is described step by step. 10 | 11 | ## Architecture provisioning 12 | 13 | First, you must run `yarn deploy --stage ` command. 14 | This command actually runs `sst deploy` and, `cdk deploy` is executed within it. The `cdk deploy` command is one of AWS CDK(Cloud Development Kit) commands that starts the provisioning. All subsequent steps will be triggered as part of this process. 15 | 16 | ## Build executed by AWS CodeBuild 17 | 18 | Once `deploy` is executed, an AWS CodeBuild project is created. This is to perform a series of procedures to create a Docker image for REDCap. After that, AWS CDK's Trigger (with a AWS Lambda function) executes AWS CodeBuild's `StartBuild`. This creates a Docker Image and pushes it to ECR with the `latest` and REDCap's version tags. 19 | 20 | ## Automated deployment to App Runner 21 | 22 | App Runner can automatically deploy new images pushed to ECR. In this project this setting is enabled. This means that when the image is updated, AWS App Runner will deploy that image using a blue/green strategy. 23 | 24 | ### How can we re-run build process? 25 | 26 | You can re-run the build process after re-deploy via AWS Management Console or AWS CLI: 27 | 28 | #### 1. AWS Management Console 29 | 30 | In the AWS console go to [CodeBuild - Tokyo region](https://ap-northeast-1.console.aws.amazon.com/codesuite/codebuild/projects) (or the region you have deployed). In this page, you can find your build (with project name and stage) and select it, and then click the `Start build` button. 31 | 32 | ![codeBuild](../images/codeBuild.png) 33 | 34 | #### 2. AWS CLI 35 | 36 | Confirm AWS CLI is installed in your computer first. 37 | 38 | ```sh 39 | aws --version 40 | ``` 41 | 42 | Run the command which is outputted as `UpdateDeploymentCommand` after deployment. 43 | 44 | ```sh 45 | ✔ Deployed: 46 | Network 47 | BuildImage 48 | UpdateDeploymentCommand: aws lambda invoke --function-name --region --profile deployLambdaResponse.json 49 | ... 50 | ``` 51 | 52 | **NOTE**: If you are running in the `dev` environment, e.g `sst dev ...`, make sure this command is running before executing the aws lambda invoke. 53 | -------------------------------------------------------------------------------- /docs/en/cron.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/cron.md) | EN 2 | 3 | # REDCap cronjob setup 4 | 5 | In REDCap this cronjob is configured to run every minute inside your REDCap deployment. This is not optimal if you have your installation scaled to multiple instances/servers (more than 1), as all the servers will be executing this command. To prevent this, this process has been externalized and it's remotely called. 6 | 7 | Thanks to a feature in REDCap, you can trigger this procedure by executing your service endpoint, for example: . This endpoint has by default un-authorized access, but we have added a shared secret to prevent public execution. 8 | 9 | ## Setup Amazon EventBridge 10 | 11 | This service will allow us to `schedule` an HTTPS call to our endpoint in a secure way. The implementation details of this are in the [Backend.ts](../../stacks/Backend.ts) file 12 | 13 | We first create a connection object, that is required for the ApiDestination constructor. This connection has a dummy basic authorization that is not used by the REDCap server, but required by this constructor. 14 | 15 | ```ts 16 | const connection = new aws_events.Connection(stack, 'redcap-connection', { 17 | authorization: aws_events.Authorization.basic( 18 | 'nouser', 19 | SecretValue.unsafePlainText('nopassword'), 20 | ), 21 | }); 22 | ``` 23 | 24 | The destination is your service URL with an additional custom secret that only EventBridge and WAF knows. 25 | 26 | ```ts 27 | const destination = new aws_events.ApiDestination(stack, 'redcap-destination', { 28 | connection, 29 | endpoint: `${ServiceUrl}/cron.php?secret=${searchString}`, 30 | httpMethod: HttpMethod.GET, 31 | }); 32 | ``` 33 | 34 | We schedule the call to happen every one minute 35 | 36 | ```ts 37 | const rule = new aws_events.Rule(stack, 'redcap-cron', { 38 | schedule: aws_events.Schedule.rate(Duration.minutes(1)), 39 | targets: [new ApiDestination(destination)], 40 | }); 41 | ``` 42 | 43 | ## About WAF and filtered Ips 44 | 45 | You can setup WAF with filtered IPs limiting access to your REDCap setup. However, we need to allow access to Amazon EventBridge to always access the URL from public access (it's not possible to determine the IP of the EventBridge call). The AWS WAF rule implementation for this is here [WafExtraRules.ts](../../stacks/Backend/WafExtraRules.ts) 46 | 47 | In practice, the cron.php endpoint is protected in AWS WAF with a shared secret in EventBridge. If an Internet user tries to execute this endpoint, WAF will validate the secret parameter and deny the request to the service. This is also in place to prevent a potential DoS attack. The secret value is auto-generated and updated every time you execute a deploy. 48 | -------------------------------------------------------------------------------- /docs/en/dbreplica.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/dbreplica.md) | EN 2 | 3 | # REDCap database read replica 4 | 5 | By default, the deployed Amazon RDS Aurora V2 will create a single reader instance in Multi-AZ configuration (2-zones). REDCap is configured automatically to use this replica using an environmental variable called `READ_REPLICA_HOSTNAME` that is passed via CDK. 6 | 7 | This is performed in the [setup_app.sh](/containers/redcap-docker-apache/scripts/setup_app.sh) and in [redcap_configure.sh](/containers/redcap-docker-apache/scripts/redcap_configure.sh) 8 | -------------------------------------------------------------------------------- /docs/en/devenv.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/devenv.md) | EN 2 | 3 | # Setup development Environment 4 | 5 | This environment has a few differences when is deployed. 6 | 7 | > Do not put any sensible data on this environment 8 | 9 | ## Architecture differences vs production 10 | 11 | ### SST Console 12 | 13 | [SST](https://docs.sst.dev/learn) is a framework that works with CDK. While using `development` mode, you can access the SST console that is a webapp connected to your AWS deployed resources for this environment. From here, you can query your REDCap's database, deploy new versions of REDCap (via lambda functions) and testing. 14 | 15 | ### Hot reload support 16 | 17 | CDK code changes (saving in the editor) will trigger an update in your architecture. 18 | 19 | ## SETUP 20 | 21 | 1. In your `stages.ts` create a new stage configuration called `dev`. For example: You can start with a config with the min number of App Runner instances. 22 | 23 | ```ts 24 | const dev: RedCapConfig = { 25 | ...baseOptions, 26 | redCapS3Path: 'redcap/redcap13.7.2.zip', 27 | domain: 'redcap.mydomain.dev', 28 | cronSecret: 'mysecret', 29 | appRunnerConcurrency: 25, 30 | appRunnerMaxSize: 2, 31 | appRunnerMinSize: 1, 32 | cpu: Cpu.TWO_VCPU, 33 | memory: Memory.FOUR_GB, 34 | }; 35 | 36 | ... 37 | 38 | export { prod, stag, dev }; 39 | ``` 40 | 41 | 2. Deploy in a new account (recommended). Even though you could deploy as many environments in one AWS account, its recommended that your `dev` and `production` are in separated accounts. With regard to setup a profile for your `dev` account, plase see [here](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) 42 | 43 | 3. Install dependencies 44 | 45 | ```sh 46 | yarn install 47 | ``` 48 | 49 | 4. Start dev env 50 | 51 | ```sh 52 | yarn dev --stage dev 53 | ``` 54 | 55 | 5. Look at your terminal output, SST will provide an access link to the console. e.g 56 | -------------------------------------------------------------------------------- /docs/en/docker.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/docker.md) | EN 2 | 3 | # REDCap container 4 | 5 | For AWS App Runner, we must use a container image. The process of building this image is done in a Codebuild project where the asset is uploaded to Amazon ECR and automatically deployed to App Runner. 6 | 7 | This process is split into two steps: Build and Start. 8 | 9 | ## Build 10 | 11 | The [Docker file](../../containers/redcap-docker-apache/Dockerfile) contain the image definition. The image is based on a public image from [docker hub](https://hub.docker.com/_/php) with a REDCap compatible PHP version 8.1. 12 | 13 | This definition also has required packages like `libmagick` that are required for REDCap to work. Also, the official AWS CLI is included to perform S3 operations, like fetching your REDCap installation file from S3 after is uploaded by CDK or yourself. 14 | 15 | After all the dependencies are installed a bash [script](../../containers/redcap-docker-apache/scripts/setup_app.sh) is executed. This is a script that performs the following tasks: 16 | 17 | 1. Fetch the REDCap installation file from S3 18 | 2. Copy any language file for REDCap 19 | 3. Configure PHP and variables 20 | 21 | ## Start 22 | 23 | This is the entry point for the container to start the application and execute the final configurations. A final configuration will be like fetching any kind of secret, like database password or credentials. As a best practice, any kind of secret should not be recorded in the build process. 24 | 25 | The script will do: 26 | 27 | 1. Setup database credentials and replica (if configured) 28 | 2. Configure Postfix for email with Amazon SES 29 | 3. Execute `install.php` to initialize REDCap (post execution of this have no impact) 30 | 4. Execute the `redcapConfig.sql` that contains your REDCap settings (email, domain name, etc) in the tables `redcap_config`, `redcap_auth` and `redcap_user_information` 31 | 5. Start postfix and apache services. 32 | -------------------------------------------------------------------------------- /docs/en/filestorage.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/filestorage.md) | EN 2 | 3 | # REDCap File Storage 4 | 5 | ## Amazon S3 integration 6 | 7 | By default containers don't persist the data for stateless applications at scale, but containerized applications require data persistant using the storage when the container terminates. 8 | 9 | The containers running in AWS AppRunner do not have any additional storage attached besides the ephemeral 3GB. The recommendation here is to enable REDCap's Amazon S3 integration that requires IAM access credentials and enablement in the `redcap_config` table. Automatically generated SQL to enable the integration can be obtained by running `yarn gen redcap config` as follows. 10 | 11 | ``` 12 | Use S3 bucket as storage for REDCap? ・ Yes 13 | ``` 14 | 15 | ### Versioning in the bucket 16 | 17 | Versioning in an Amazon S3 bucket enables you to preserve, retrieve and restore the every version of all of files in your buckets. Enabled versioing can help your appliation recover objects from accidental deletion or overwrite. You can enable versioning for any environment, but will be enabled by default when deploying prod environment. 18 | -------------------------------------------------------------------------------- /docs/en/guardduty.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/guardduty.md) | EN 2 | 3 | # Enable Amazon GuardDuty 4 | 5 | Amazon GuardDuty is a security monitoring service that continuously monitors and detects potential threats with machine learning, discover of unusual pattern and intelligent threat detection. From the perspective of security, we recommnend to enable it in your AWS account. 6 | 7 | > ### Warn 8 | > 9 | > The Enable setting is just required once per your AWS Account. 10 | > Therefore, no need to enable it if someone enable it in your environment. 11 | 12 | It is disabled by default, so please uncomment the security stack in [sst.config.ts](../../sst.config.ts) as follows when you run the deployment. 13 | 14 | ```sst.config.ts 15 | /****** Stacks ******/ 16 | app.stack(Network); 17 | app.stack(BuildImage); 18 | app.stack(Database); 19 | app.stack(Backend); 20 | // Optional - enables AWS Guard-duty 21 | app.stack(Security); <- Uncomment 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/en/iamdb.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/iamdb.md) | EN 2 | 3 | # IAM database authentication for MySQL 4 | 5 | For `deploy` mode or the production environment the database is configured with IAM authentication between AWS AppRunner and Amazon RDS running MySQL. This means that there is no password configured for the `redcap_user` that is created for the application, instead the application will get a token for each new database connection. This enables SSL/TLS communication, greater security and centralized access control via IAM. Only AWS App Runner role is allowed to get a token to connect the database. 6 | 7 | The configuration for this is in the [Backend](../../stacks/Backend.ts) stack, by passing a variable `USE_IAM_DB_AUTH: 'true',` to AWS App Runner construct. This will enable a series of tasks in the [setup_app.sh](../../containers/redcap-docker-apache/scripts/setup_app.sh) script. 8 | 9 | 1. Create `redcap_user` that is enabled to authenticate with IAM 10 | 11 | 2. Configure `database.php` with a new PHP script that will initially fetch the token 12 | 13 | ```php 14 | require 'vendor/autoload.php'; 15 | use Aws\Credentials\CredentialProvider; 16 | \$provider = CredentialProvider::defaultProvider(); 17 | \$RdsAuthGenerator = new Aws\Rds\AuthTokenGenerator(\$provider); 18 | ... 19 | \$password = \$RdsAuthGenerator->createToken(\$hostname . ":3306", \$region, \$username); 20 | \$db_ssl_ca = "/usr/local/share/global-bundle.pem"; 21 | \$db_ssl_verify_server_cert = true; 22 | ... 23 | ``` 24 | 25 | If you disable this option, database authentication will be retrieved and set by AWS SDK for PHP from credentials stored in AWS Secrets Manager. 26 | -------------------------------------------------------------------------------- /docs/en/index.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/index.md) | EN 2 | 3 | # Docs - REDCap on AWS 4 | 5 | ## Get started 6 | 7 | [1. Quick start guide](../../README.md) 8 | 9 | ## Dev 10 | 11 | [2. Setup Development environment](./devenv.md) 12 | 13 | ## App security 14 | 15 | [3. Web application firewall](./waf.md) 16 | 17 | ## Database 18 | 19 | [4. Database IAM authorization](./iamdb.md) 20 | 21 | [5. REDCap database SALT setup](./salt.md) 22 | 23 | [6. REDCap database read replica](./dbreplica.md) 24 | 25 | ## Move to production 26 | 27 | [7. Load testing with Locust](./loadtest.md) 28 | 29 | [8. Path to production](./ptp.md) 30 | 31 | [9. AppRunner AutoScaling Setting](./autoscaling.md) 32 | 33 | [10. Move out of the Amazon SES sandbox](./ses.md) 34 | 35 | ## REDCap building and configuring 36 | 37 | [11. REDCap container](./docker.md) 38 | 39 | [12. Process of Build](./build.md) 40 | 41 | [13. Add multi-language support](./multilang.md) 42 | 43 | [14. REDCap file storage](./filestorage.md) 44 | 45 | [15. REDCap cronjob setup](./cron.md) 46 | -------------------------------------------------------------------------------- /docs/en/loadtest.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/loadtest.md) | EN 2 | 3 | # Load testing with Locust 4 | 5 | In this project, a simple load test with Docker Compose is included [here](../../loadtest/locust/) 6 | 7 | To run it, you must have docker installed in your machine. 8 | 9 | Then you must do: 10 | 11 | 1. Go to the locust folder 12 | 13 | ```sh 14 | cd ./loadtest/locust 15 | ``` 16 | 17 | 2. Start Locust 18 | 19 | ```sh 20 | docker-compose up 21 | ``` 22 | 23 | 3. Open the tool in your browser: 24 | 25 | Inside the tool, you can start a new test adding the endpoint of your REDCap installation you wish to test. 26 | 27 | ## Considerations to execute the load test 28 | 29 | 1. AWS WAF is configured with a default of 3000 request. You can disable WAF or increase this number to perform the test. 30 | 31 | 2. REDCap's internal system has a number of request per client, this can be disabled in the configuration panel. 32 | -------------------------------------------------------------------------------- /docs/en/multilang.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/multilang.md) | EN 2 | 3 | # Add multi-language support 4 | 5 | You can provide or download language files from [REDCap Consortium Language Library](https://redcap.vanderbilt.edu/plugins/redcap_consortium/language_library.php). These are zip files, so you need to unzip. The language file will be in `.ini` format. Place the unzipped `.ini` file into `packages/redcap/languages`. If you want to use multiple languages, you can place multiple files. Be careful to follow the name convention required by REDCap, e.g `Japanese.ini` for Japanese. 6 | 7 | After the initial deployment, if you need to add or remove languages, you need to follow the update process described bellow. (Updating REDCap) 8 | -------------------------------------------------------------------------------- /docs/en/ptp.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/ptp.md) | EN 2 | 3 | # Path to production 4 | 5 | 1. If you don't know the number of users and load on the system, be sure to start with reasonable App Runner scaling values that allow the service to scale. Later you can tune this values to save some cost, like minimizing `minSize` for `warm` instances. Each instance you reduce is around 7 USD. `maxSize` is also important for cost control and budget, that can be tuned with time and some system usage. 6 | 7 | 2. REDCap is very CPU intensive. The default setting is to use 2 vCPU for each instance, but under REDCap operations that can take a lot of CPU time, it might be recommended to scale to a 4vCPU/8GB configuration. This is also something to monitor after some system usage. 8 | 9 | 3. Upgrading REDCap. The project intentionally configures this a series of steps to avoid a system downtime or data loss. We recommend to assign a person to execute this steps each time REDCap needs to be upgraded. 10 | 11 | 4. The main data storage of REDCap are the database and S3. Amazon RDS Aurora V2 has several backup and disaster recovery options enabled for the `prod` deployment, like the 24 hours backtracking. On the other side, S3 has a great reliability, but it does not make backup or keep history of your files by default. By default (while deploying `prod`) in this project, `versioning` will be enabled. You can disable it in the [Backend.ts](../../stacks/Backend.ts) construct when creating the REDCap's application bucket: 12 | 13 | ```ts 14 | ...bucketProps(app.stage === 'prod'), // versioning enabled 15 | ``` 16 | 17 | ```ts 18 | ...bucketProps(false), // versioning disabled 19 | ``` 20 | 21 | 5. During this time (January 2024), the CDK and Cloudformation do not support the creation of custom domain for App Runner. In this project, a workaround is implemented to support this feature. When the support is release (), it is recommended to refactor this feature. 22 | 23 | 6. SST is an open source project and they improve it by collecting anonymous data of your machine information (not AWS related). However you can disable it: 24 | 25 | 7. Keep SST up to date. To upgrade SST and CDK execute the following: 26 | 27 | 1. `yarn sst update --stage ` any stage will work 28 | 29 | 2. `yarn install` 30 | 31 | 8. There are a few endpoints in REDCap that are accessed without authorization, like `install.php`, `upgrade.php`, `cron.php`. The last one, `cron.php` is already protected by WAF and EventBridge with a secret that only these two entities know. This endpoint triggers many functions, so it is a good candidate to protect first in the prototype. However, if you are not using WAF, it is recommended to add the same strategy to protect any other public endpoints that trigger some server action that should not be public. 32 | 33 | 9. Activate Amazon GuardDuty for Amazon S3, Amazon RDS and AWS Lambda services used in this project. The CDK automatically is enabling GuardDuty service, but you have to manually check to enable it these three specific services in the AWS Console. When CloudFormation support is added (), you can enable these services via CDK. 34 | 35 | 10. REDCap's IAM user access keys should be rotated for S3 and email services with Amazon SES. The required configuration for REDCap is to provided AWS access keys for these services, it is recommended to move this authorization to IAM base access policies. [More info - IAM](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html) 36 | 37 | 11. Database authentication using IAM instead of user and password. Removing password access increase your security posture to prevent that this password is leaked. For production, it is recommended to test if this approach would work with REDCap's connection code base. This approach is generally transparent or with minimal changes to the application. [More info - IAM DB Auth](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html). 38 | 39 | 12. REDCap's code base review. The system now has the ability to allow outbound external communications, this means that REDCap's servers can communicate with an Internet service. Some features of REDCap depends on this to work, like short-url, so this is why this is the default. However, sharing the stats or allowing your server to communicate to external servers is a possible backdoor to leak your secret credentials stored in the database. This is why is important to track code changes and code validation that REDCap's code is preventing that these secrets are never transmitted. 40 | 41 | 13. Aurora database is deployed with default settings that works for most applications and changing these settings takes advance knowledge of MySQL and Aurora. REDCap configuration check will throw a warning regarding some parameters, but we recommend to change these if you really see performance issues. You can visit to these docs for more information [Best practices on Aurora](https://aws.amazon.com/blogs/database/best-practices-for-amazon-aurora-mysql-database-configuration/) and [Create parameter group on CDK](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_rds.ParameterGroupProps.html) 42 | 43 | If you want to prevent the warning messages, you can add the code below to [Database.ts](../../stacks/Database.ts) and configure it according to REDCap. However, this is not recommended by Amazon Aurora, and if there is no problem, it is recommended to use Amazon Aurora's default values. 44 | When you change the parameter group, please restart the DB to reflect the settings in the DB after changing the settings and deploying. Please refer to [here](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/USER_RebootCluster.html) for details on restarting. 45 | 46 | ```ts 47 | parameterGroupParameters: { 48 | // Avoid the REDCap system warning. Please change to the required value 49 | max_allowed_packet: '1073741824', 50 | read_rnd_buffer_size: '262144', 51 | sort_buffer_size: '2097152', 52 | }, 53 | ``` 54 | 55 | Alternatively, you can configure your `stages.ts` in the `db` object to set the `maxAllowedPacket: '1073741824'` to the 56 | -------------------------------------------------------------------------------- /docs/en/salt.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/salt.md) | EN 2 | 3 | # REDCap database salt 4 | 5 | REDCap installation requires a database salt, a generated string that will be used to create encrypted content. This value, must be kept in secret and is critical for database or installation recovery. 6 | 7 | > The project is configured to RETAIN this secret even if you delete/deprovision your REDCap installation. This allows you to recover in case of a disaster. 8 | 9 | ## Secret creation 10 | 11 | CDK will automatically create a random string secret and store this in AWS Secret manager. 12 | 13 | ## Secret sharing 14 | 15 | The secret is passed via ENV VARIABLE to the executing container in AWS App Runner. Before starting the services, this value is normalized to be letters and number (as REDCap recommendation) by using a hash function in [setup_app.sh](../../containers/redcap-docker-apache/scripts/setup_app.sh) 16 | 17 | ```sh 18 | DB_SALT_ALPHA=$(echo -n "$DB_SALT" | sha256sum | cut -d' ' -f1) 19 | ``` 20 | 21 | Later this value is passed to the `database.php` for REDCap usage. 22 | -------------------------------------------------------------------------------- /docs/en/ses.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/ses.md) | EN 2 | 3 | # Moving out of the Amazon SES Sandbox in your production environment 4 | 5 | Amazon SES is placed in the sandbox in all new AWS Account to prevent fraud and abuse. In the sandbox, Amazon SES has some restrictions such as the maximum number per second and per 24 hour period. Please see the details as [the official document](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html). 6 | 7 | For that reason, you need to request that Amazon SES in your account move out of the sandbox in the production environment. You can request it using either AWS management console or AWS CLI. Please check how to request as [the document](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html). 8 | -------------------------------------------------------------------------------- /docs/en/waf.md: -------------------------------------------------------------------------------- 1 | [JP](../ja/waf.md) | EN 2 | 3 | # AWS WAF 4 | 5 | AWS WAF is a web application firewall that protect your applications from common web exploits and uses security rules to protect your traffic. This is enabled in every environment you deploy. 6 | 7 | ### IP filtering 8 | 9 | To improve your security posture, we recommend limiting the access to your REDCap application from a list of knows IPs (e.g. our campus CIDR) with AWS WAF. To add one or more CIDR addresses configure the `allowedIps` parameter in the `stages.ts` file. 10 | 11 | ```ts 12 | allowedIps: ['118.1.0.0/24'], 13 | ``` 14 | 15 | ### AWS Managed rules 16 | 17 | AWS WAF managed rules protect your applications against common application vulnerabilities or other unwanted traffic. This project implemented the following rules from `Baseline rule groups`, `IP reputation rule groups` and `Use-case specific rule groups` in [Waf.ts](../../prototyping/constructs/Waf.ts). Please check the [AWS WAF rules documentation](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rules.html) if you need. 18 | 19 | - AWSManagedRulesCommonRuleSet 20 | - AWSManagedRulesKnownBadInputsRuleSet 21 | - AWSManagedRulesSQLiRuleSet 22 | - AWSManagedRulesAmazonIpReputationList 23 | 24 | ### Custom rules 25 | 26 | #### Rate control 27 | 28 | A rate-based rule counts incoming requests and rate limits requests when they are coming at too fast a rate. The rate-based rule in [Waf.ts](../../prototyping/constructs/Waf.ts) prevents clients to flood your service if they execute more than 3000 request in 30 seconds. 29 | 30 | #### Secret base access control 31 | 32 | This is a control access for `cron.php` in REDCap. Please check [cron documentation](../en/cron.md) 33 | 34 | #### Example to add new rules 35 | 36 | If you want to restrict access using combination of URL-path based and rate limit, you can write: 37 | 38 | ```ts 39 | // This waf rule is an example of a rate limit on the specific path of url. In practice, it does not make much sense because REDCap will render the login UI at any URL when you are not logged in. 40 | { 41 | name: 'rate-limit-specific-url', 42 | rule: { 43 | name: 'rate-limit-specific-url', 44 | priority: 50, 45 | statement: { 46 | rateBasedStatement: { 47 | limit: 100, 48 | aggregateKeyType: 'IP', 49 | scopeDownStatement: { 50 | byteMatchStatement: { 51 | fieldToMatch: { 52 | uriPath: {}, 53 | }, 54 | positionalConstraint: 'EXACTLY', 55 | searchString: '/', 56 | textTransformations: [ 57 | { 58 | type: 'NONE', 59 | priority: 0, 60 | }, 61 | ], 62 | }, 63 | }, 64 | }, 65 | }, 66 | action: { 67 | block: {}, 68 | }, 69 | visibilityConfig: { 70 | sampledRequestsEnabled: true, 71 | cloudWatchMetricsEnabled: true, 72 | metricName: 'rate-limit-specific-url', 73 | }, 74 | }, 75 | }, 76 | ``` 77 | 78 | This is an example of an IP-based rate limit of 100 per 5 minutes for the `/` path. 79 | This is useful for more robust rate limiting on login pages or some important pages, but not really useful in practice, since REDCap will display the login screen for any URL that is not logged in. 80 | -------------------------------------------------------------------------------- /docs/images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/redcap-on-aws/becf009c37b174b177cbeee0279ce75aff9b93a5/docs/images/architecture.png -------------------------------------------------------------------------------- /docs/images/codeBuild.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/redcap-on-aws/becf009c37b174b177cbeee0279ce75aff9b93a5/docs/images/codeBuild.png -------------------------------------------------------------------------------- /docs/images/genConfigSQL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/redcap-on-aws/becf009c37b174b177cbeee0279ce75aff9b93a5/docs/images/genConfigSQL.png -------------------------------------------------------------------------------- /docs/images/stackOutput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/redcap-on-aws/becf009c37b174b177cbeee0279ce75aff9b93a5/docs/images/stackOutput.png -------------------------------------------------------------------------------- /docs/ja/autoscaling.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/autoscaling.md) 2 | 3 | # App Runner のオートスケーリングの設定 4 | 5 | このプロジェクトでは、REDCap をホスト環境として AWS App Runner を使用します。このページでは App Runner のスケーリングに関する設定について解説します。 6 | 7 | ## App Runner のオートスケーリングに関するパラメーター 8 | 9 | | パラメーター | stage ファイルでの名称 | 説明 | 決定の仕方 | 10 | | --------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 11 | | Max Concurrency | `appRunnerConcurrency` | 1つのインスタンスが処理する並行するリクエストの上限数。 同時リクエスト数がこの設定値を上回った場合、App Runnerはサービスをスケールアウトします。 | このパラメーターを適切に設定するには負荷試験を行う必要があります。 詳しくは [負荷試験](#負荷試験) を参考にしてください。 | 12 | | Max Size | `appRunnerMaxSize` | スケールアウトできるインスタンス数の上限を設定します。これは、トラフィックを同時に処理するインスタンスの最大数です。 | 高負荷のトラフィックに対応できるようにしたい場合は、高い値から始めて、ピーク負荷を検出した後に調整することをお勧めします。これにより、DoS 攻撃のような予期しないトラフィックが来てもスケールアップしないように、システムのコスト管理を行うことができます。 どのくらいのインスタンス数でどれくらいのリクエストが処理できるのかについては、[負荷試験](#負荷試験) もご参照ください。 | 13 | | Min Size | `appRunnerMinSize` | App Runner がサービス用にプロビジョニングできるインスタンスの最小数。 サービスには常に少なくともこの数のプロビジョニングされたインスタンスがあります。 これらのインスタンスの一部はトラフィックをアクティブに処理します。 残りは費用対効果の高いコンピューティングキャパシティリザーブの一部であり、トラフィックに応じてすぐに利用可能になります。 | MinSizeは、ピーク時にユーザーにエラーを発生させて、スケールアッププロセスが完了するまで待たせるかどうかによって異なります。 実稼働システムで、負荷が不明な場合、これを 1 に設定するのは危険です。 2-3 から始めて、負荷とピークがわかったら 1 に減らすのが良いでしょう。 MinSize はインスタンスをウォーム状態にします。お支払いいただくのは割り当てられたメモリの分のみです。 | 14 | 15 | 詳しくは以下のリンクを参照ください。 16 | https://docs.aws.amazon.com/apprunner/latest/dg/manage-autoscaling.html 17 | 18 | ### App Runner の料金体系 19 | 20 | App Runner は 2 種類の価格設定に基づいています。 21 | 22 | トラフィックを処理しないプロビジョニングされたインスタンスの場合、コストはメモリ使用量に対してのみ発生します。 23 | トラフィックを処理するアクティブなインスタンスの場合、CPU 使用量とメモリ使用量の両方にコストがかかります。 24 | 25 | 単価は地域によって異なります。 たとえば、ap-northeast-1 (東京) では、メモリ使用量が 0.009 USD/GB、CPU 使用量が 0.081 USD/vCPU です。 26 | メモリの単価は、インスタンスがトラフィックを処理するかどうかに関係なく一定です。 27 | 28 | 詳細については、以下のリンクを参照してください。 29 | https://aws.amazon.com/apprunner/pricing/ 30 | 31 | ### 負荷試験 32 | 33 | 適切なパラメータ設定のため、私たちは負荷試験を行いました。 34 | 35 | #### 環境 36 | 37 | 負荷試験は [locast](https://locust.io/) を用いて行なっています。 38 | これは Python 製のメジャーな負荷試験ツールです。 39 | 40 | 負荷試験は REDCap Ver.13.7.2 で行なっています。 41 | 42 | #### 結果 43 | 44 | 2vCPUs と 4GB RAM のインスタンスを用いた結果が以下です。 45 | 46 | | ユーザー数 | RPS | 同時リクエスト数 | CPU 使用率 | メモリ使用率 | ステータス | エンドポイント | レイテンシー(平均) | コメント | 47 | | ---------- | --- | ---------------- | ---------- | ------------ | ---------- | -------------- | ------------------ | ------------------------------------ | 48 | | 30 | 8 | 4 | 1000 | 220 | ok | / | | 70 concurrency will crash the server | 49 | | 60 | 9 | 29 | 1888 | 640 | FAIL | / | 750 | 29 concurrency you have cpu 200% | 50 | | 125 | 13 | 74 | 2000 | 1300 | FAIL | / | 5000 | | 51 | | 90 | 15 | 47 | 2000 | 988 | FAIL | / | 2900 | | 52 | 53 | 1 つのインスタンスで 30 人のユーザーをサポートするには、2 つの vCPU と 4GB の RAM で問題ありません。 54 | 40 人のユーザーが約 10rps でアクセスすると、CPU 使用率は 140 %に達します。これは、同時実行数としては 2~4 になります。 55 | 60 人のユーザーがいる場合、CPU 使用率は 200% になり、同時実行数は 30 に急増します。 この時点でスケールアウトしても遅いです。 56 | 57 | 上記の結果から、vCPU の数が`2`に設定され、メモリが`4GB` に設定されている場合は、`最大同時実行数`を`25`程度に設定することをお勧めします。 58 | -------------------------------------------------------------------------------- /docs/ja/build.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/build.md) 2 | 3 | # ビルドプロセス 4 | 5 | 本プロジェクトでは、REDCap アプリケーションを Docker コンテナとして動作させています。 6 | AWS アカウントに REDCap のアプリケーションコードがアップロードされると、それが動作するコンテナイメージを作成し、Amazon Elastic Container Repository(ECR)に保存します。 7 | AWS App Runner がそれをもとにコンテナを実行することで、REDCap アプリケーションが動作します。 8 | このページでは、ビルドプロセスの詳細について順を追って説明します。 9 | 10 | ## deploy コマンドの実行 11 | 12 | まず、ユーザーは`yarn deploy --stage `コマンドを実行します。 13 | このコマンドは、実際には`sst deploy`を実行し、その中で`cdk deploy`が実行されます。`cdk deploy`コマンドは、AWS CDK(Cloud Development Kit)のコマンドで、定義された一連の AWS リソースをデプロイすることを指示します。この後の工程は全てこのプロセスの一部としてトリガーします。 14 | 15 | ## AWS CodeBuild によるビルドの実行 16 | 17 | `deploy` が実行されると、その中で AWS CodeBuild プロジェクトが作成されます。これは、REDCap 用の Docker Image を作成するための一連の手続きを実行するためのものです。その後、AWS CDK の Trigger(内部では AWS Lambda が動作します)が、AWS CodeBuild の`StartBuild`を実行します。これにより、Docker Image が作成され、ECR に `latest`タグで Push されます。 18 | 19 | ## App Runner への自動デプロイ 20 | 21 | このプロジェクトでデプロイされる App Runner では、`autoDeployment` プロパティを有効に設定しています。この機能を有効化することで、デプロイ元となっている ECR レポジトリの `latest` が更新された際、それを検知して自動的に再度デプロイを行います。 22 | 23 | ### ビルドプロセスの再実行 24 | 25 | AWS マネジメントコンソールまたは AWS CLI を用いてビルドプロセスを再実行することができます。 26 | 27 | #### 1. AWS マネジメントコンソール 28 | 29 | AWS マネジメントコンソールを用いて再実行を行う場合、[CodeBuild](https://ap-northeast-1.console.aws.amazon.com/codesuite/codebuild/projects)にアクセスします。ページ内でデプロイされたプロジェクトを見つけて選択し、上の`Start build`ボタンを押します。 30 | 31 | ![codeBuild](../images/codeBuild.png) 32 | 33 | #### 2. AWS CLI 34 | 35 | 初めに AWS CLI がインストールされていることを確認してください。 36 | 37 | ```sh 38 | aws --version 39 | ``` 40 | 41 | デプロイ完了時に `UpdateDeploymentCommand`として表示されるコマンドを実行してください。 42 | 43 | ```sh 44 | ✔ Deployed: 45 | Network 46 | BuildImage 47 | UpdateDeploymentCommand: aws lambda invoke --function-name --region --profile deployLambdaResponse.json 48 | ... 49 | ``` 50 | 51 | **注**: `dev` 環境 (例: `sst dev ...`) で実行している場合は、AWS lambda 呼び出しを実行する前にコマンドが実行されていることを確認してください。 52 | -------------------------------------------------------------------------------- /docs/ja/cron.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/cron.md) 2 | 3 | # REDCap cronジョブ セットアップ 4 | 5 | REDCapでは、このcronジョブはREDCapデプロイの際に毎分実行されるよう設定されています。 6 | REDCapのインストールを複数のインスタンスやサーバ(1台以上)にスケールする場合、全てのサーバーでcronジョブコマンドが実行されるため、本設定は最適ではありません。 7 | これを防ぐために、本プロセスを切り出して、外部から呼び出すようにしています。 8 | 9 | REDCapの機能により、サービスエンドポイント(例えば、)を実行することでジョブ実行できます。デフォルトでは、このエンドポイントは認可なしでアクセスできてしまいますが、共用シークレットを追加することで外部からの実行を防止しています。 10 | 11 | ## Amazon EventBridgeのセットアップ 12 | 13 | Amazon EventBridgeを利用することで、安全にエンドポイントに対してHTTPリクエストで `schedule`できます。本実装の詳細は[Backend.ts](../../stacks/Backend.ts)ファイルをご確認ください。 14 | 15 | まず、`ApiDestination`コンストラクタに必要な`Connection`オブジェクトを作成します。このオブジェクトにはダミーのベーシック認証があり、REDCapサーバーには不要ですが、コンストラクタの作成には必要です。 16 | 17 | ```ts 18 | const connection = new aws_events.Connection(stack, 'redcap-connection', { 19 | authorization: aws_events.Authorization.basic( 20 | 'nouser', 21 | SecretValue.unsafePlainText('nopassword'), 22 | ), 23 | }); 24 | ``` 25 | 26 | 宛先は、EventBridgeとWAFのみが知っている追加のカスタムシークレットを含むサービスURLになります。 27 | 28 | ```ts 29 | const destination = new aws_events.ApiDestination(stack, 'redcap-destination', { 30 | connection, 31 | endpoint: `${ServiceUrl}/cron.php?secret=${searchString}`, 32 | httpMethod: HttpMethod.GET, 33 | }); 34 | ``` 35 | 36 | 1分毎に実行するよう以下のようにスケジュール設定しています。 37 | 38 | ```ts 39 | const rule = new aws_events.Rule(stack, 'redcap-cron', { 40 | schedule: aws_events.Schedule.rate(Duration.minutes(1)), 41 | targets: [new ApiDestination(destination)], 42 | }); 43 | ``` 44 | 45 | ## WAFとIPフィルタリング 46 | 47 | WAFの設定によりREDCapアクセスを特定のIPのみにフィルタリングできます。しかし、Amazon EventBridgeが常に外部からのURLにアクセスできるようにアクセス許可が必要です(EventBridgeのIPを特定はできません)。そのため、AWS WAFのルールを[WafExtraRules.ts](../../stacks/Backend/WafExtraRules.ts)のように実装しています。 48 | 49 | 実際、`cron.php`エンドポイントは、たとえIPフィルタリングを有効化していても常に公開されていますが、共有シークレットを介したEventBridgeアクセスのWAFルールにより保護されています。仮にインターネットのユーザーがこのエンドポイントを実行した場合、WAFがシークレットパラメータを検証し、リクエストを拒否します。また、この設定は、潜在的なDoS攻撃を防ぐ観点でも設定しています。 50 | 51 | シークレットはデプロイ実行のたびに自動生成され、アップデートされます。 52 | -------------------------------------------------------------------------------- /docs/ja/dbreplica.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/dbreplica.md) 2 | 3 | # REDCap database リードレプリカ 4 | 5 | デフォルトでは、デプロイされた Amazon RDS Aurora V2 は、マルチ AZ 構成 (2 ゾーン) で単一の読み取り専用インスタンスを作成します。REDCap は、CDK 経由で渡される `READ_REPLICA_HOSTNAME` という環境変数を使用して、このレプリカを自動的に使用するように構成されています。 6 | 7 | これは [setup_app.sh](/containers/redcap-docker-apache/scripts/setup_app.sh) と [redcap_configure.sh](/containers/redcap-docker-apache/scripts/redcap_configure.sh) で実行されます。 8 | -------------------------------------------------------------------------------- /docs/ja/devenv.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/devenv.md) 2 | 3 | # 開発環境のセットアップ 4 | 5 | この開発環境はデプロイ時に本番環境といくつかの違いがあります。 6 | 7 | > 本環境で機微なデータは利用しないでください 8 | 9 | ## 本番環境とのアーキテクチャの違い 10 | 11 | ### SST Console 12 | 13 | [SST](https://docs.sst.dev/learn) はCDKで動作するフレームワークです。`development`利用時は、ご自身のAWS環境ににデプロイされたリソースに接続されたウェブアプリケーションであるSST Consoleにアクセスできます。SST ConsoleからREDCapデータベースへのクエリやREDCapの新しいバージョンのデプロイ(Lambda経由)やテストが可能です。 14 | 15 | ### ホットリロードサポート 16 | 17 | CDKのコード変更により(エディターで保存した際)、AWS上にデプロイされているアーキテクチャのアップデートが実行されます 18 | 19 | ## セットアップ 20 | 21 | 1. `stages.ts`で、`dev`と呼ばれる新しいステージ設定を作成します。 以下は、App Runnerを最小インスタンス数で設定した例になります。 22 | 23 | ```ts 24 | const dev: RedCapConfig = { 25 | ...baseOptions, 26 | redCapS3Path: 'redcap/redcap13.7.2.zip', 27 | domain: 'redcap.domain.dev', 28 | cronSecret: 'mysecret', 29 | appRunnerConcurrency: 25, 30 | appRunnerMaxSize: 2, 31 | appRunnerMinSize: 1, 32 | cpu: Cpu.TWO_VCPU, 33 | memory: Memory.FOUR_GB, 34 | }; 35 | 36 | ... 37 | 38 | export { prod, stag, dev }; 39 | ``` 40 | 41 | 2. (推奨)新規のAWSアカウントにデプロイします。一つのAWSアカウントに複数の環境をデプロイすることができますが、`dev`と`production`はアカウントを分離(異なるAWSアカウント)してデプロイすることを推奨します。 `dev`アカウントのAWS profile設定に関しては、[こちら](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html)を参照ください。 42 | 3. 依存関係のインストール 43 | 44 | ```sh 45 | yarn install 46 | ``` 47 | 48 | 4. 開発環境の起動 49 | 50 | ```sh 51 | yarn dev --stage dev 52 | ``` 53 | 54 | 5. ターミナルの出力結果を確認すると、SST Consoleのアクセスリンクが表示されます。例) 55 | -------------------------------------------------------------------------------- /docs/ja/docker.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/docker.md) 2 | 3 | # REDCap コンテナ 4 | 5 | AWS App Runnerはコンテナイメージが必要です。このイメージのビルドプロセスはCodeBuildプロジェクトで実行しており、アセットのAmazon ECRへのアップロードとApp Runnerへ自動的にデプロイされるよう設定しています 6 | 7 | このプロセスはBuildとStartの2ステップに分かれています。 8 | 9 | ## Build 10 | 11 | [Docker file](../../containers/redcap-docker-apache/Dockerfile)はイメージの定義(イメージを作成する設計図)です。このイメージは、 REDCap互換のPHPバージョン8.1を使用した [docker hub](https://hub.docker.com/_/php)のパブリックイメージに基づきます。 12 | 13 | この定義はREDCapが動作するために必要な`libmagick`や`postfix`といった必須パッケージも含まれます。また、CDKやマニュアルでREDCapパッケージをS3にアップロード後、S3からREDCapインストールファイルなどを取得するといったS3を操作するためのAWS CLIも含まれています。 14 | 15 | 全ての依存関係がインストールされた後に、Bash [script](../../containers/redcap-docker-apache/scripts/setup_app.sh)が実行されます。このスクリプトでは以下のタスクが実行されます。 16 | 17 | 1. S3からREDCapインストールファイルを取得 18 | 2. REDCapの言語ファイルをコピー 19 | 3. PHPと変数の設定 20 | 21 | ## Start 22 | 23 | これはコンテナがアプリケーションを開始し、最終設定を実行するエントリーポイントです。最終設定では、データベースのパスワードやクレデンシャルなどのシークレットを取得します。ベストプラクティスとして、ビルドプロセスではシークレットを記録しないでください。 24 | 25 | ここでは以下のタスクを実行します。 26 | 27 | 1. データベースのクレデンシャルと(設定されていれば)レプリカのセットアップ 28 | 2. Amazon SESを利用したEmailのPostfix設定 29 | 3. `install.php`を実行してREDCapを初期化(post execution of this have no impact) 30 | 4. `redcapConfig.sql`を実行してデータベーステーブルの`redcap_config`、`redcap_auth`や`redcap_user_information`といったREDCapを設定(Emailやドメイン名など) 31 | 5. PostfixとApacheサービスの起動 32 | -------------------------------------------------------------------------------- /docs/ja/filestorage.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/filestorage.md) 2 | 3 | # REDCap File Storage 4 | 5 | ## Amazon S3との統合 6 | 7 | デフォルトでは、ステートレスな大規模なアプリケーションのデータを永続化しませんが、コンテナアプリケーションでは、コンテナを削除してもストレージを使用してデータを永続化する必要があります。 8 | 9 | AWS App Runnerで稼働しているコンテナには、3GBのエフェメラルな(一時的な)ストレージ以外に追加のストレージはありません。従って、ここでの推奨事項は、REDCapとAmazon S3の統合を有効化することです。Amazon S3統合にあたって、IAMアクセスの認証情報と`redcap_config`テーブルの有効化が必要です。統合を有効化するSQLは、以下のように `yarn gen redcap config`を実行することで自動生成されます。 10 | 11 | ``` 12 | Use S3 bucket as storage for REDCap? ・ Yes 13 | ``` 14 | 15 | ### バケットのバージョニング 16 | 17 | Amazon S3バケットのバージョニングにより、バケット内の全てのファイルの全てのバージョンを保存、取得、復元できます。バージョニングの有効化は、誤って削除または上書きされたオブジェクトを復元するのに役立ちます。バージョニングは全ての環境で可能ですが、デフォルトでは本番環境のデプロイ時に有効化するようにしています。 18 | -------------------------------------------------------------------------------- /docs/ja/guardduty.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/guardduty.md) 2 | 3 | # Amazon GuardDutyの有効化 4 | 5 | Amazon GuardDutyは、機械学習、異常検出や脅威インテリジェンスを使用して継続的に脅威を監視及び検出するサービスです。セキュリティの観点からAWSアカウントで有効化することを推奨しています。 6 | 7 | > ### 注意 8 | > 9 | > 有効化設定はAWSアカウント毎に一度だけ必要です。 10 | > そのため、既にご利用環境で有効化されている場合は設定不要です。 11 | 12 | デフォルトでは有効化されていないため、有効化の際は[sst.config.ts](../../sst.config.ts)のSecurity stackを以下のようにコメントアウトしてデプロイしてください。 13 | 14 | ```sst.config.ts 15 | /****** Stacks ******/ 16 | app.stack(Network); 17 | app.stack(BuildImage); 18 | app.stack(Database); 19 | app.stack(Backend); 20 | // Optional - enables AWS Guard-duty 21 | app.stack(Security); <- コメントアウト 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/ja/iamdb.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/iamdb.md) 2 | 3 | # データベースの IAM 認証 4 | 5 | `deploy` モードまたは本番環境では、データベースは AWS AppRunner と Amazon RDS を IAM 認証を用いて接続します。つまり、アプリケーション用に作成された `redcap_user` にはパスワードが設定されておらず、代わりにアプリケーションは新しいデータベース接続ごとにトークンを取得します。これにより、SSL/TLS 通信、セキュリティの強化、IAM による一元的なアクセス制御が可能になります。 データベースに接続するためのトークンを取得できるのは、AWS App Runner 用のロールだけです。 6 | 7 | この設定は [Backend](../../stacks/Backend.ts) で、AppRunner コンストラクトに `USE_IAM_DB_AUTH: 'true' を指定することで有効化されます。これにより、[setup_app.sh](../../containers/redcap-docker-apache/scripts/setup_app.sh) で一連の処理が有効になります。 8 | 9 | 1. IAM 認証を有効化した `redcap_user` を作成します。 10 | 11 | 2. トークンを取得するための追加の PHP スクリプトで `database.php` を設定します。 12 | 13 | ```php 14 | require 'vendor/autoload.php'; 15 | use Aws\Credentials\CredentialProvider; 16 | \$provider = CredentialProvider::defaultProvider(); 17 | \$RdsAuthGenerator = new Aws\Rds\AuthTokenGenerator(\$provider); 18 | ... 19 | \$password = \$RdsAuthGenerator->createToken(\$hostname . ":3306", \$region, \$username); 20 | \$db_ssl_ca = "/usr/local/share/global-bundle.pem"; 21 | \$db_ssl_verify_server_cert = true; 22 | ... 23 | ``` 24 | 25 | このオプションを無効にすると、データベース認証は AWS Secrets Manager に保存されたクレデンシャルを AWS SDK for PHP で取得し、設定されるようになります。 26 | -------------------------------------------------------------------------------- /docs/ja/index.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/index.md) 2 | 3 | # Docs - REDCap on AWS 4 | 5 | ## Get started 6 | 7 | [1. クイックスタートガイド](../../README.ja.md) 8 | 9 | ## Dev 10 | 11 | [2. 開発環境のセットアップ](./devenv.md) 12 | 13 | ## App security 14 | 15 | [3. Web application firewall](./waf.md) 16 | 17 | ## Database 18 | 19 | [4. Database IAM authorization](./iamdb.md) 20 | 21 | [5. REDCap database ソルトセットアップ](./salt.md) 22 | 23 | [6. REDCap database リードレプリカセットアップ](./dbreplica.md) 24 | 25 | ## Move to production 26 | 27 | [7. Locust を用いた負荷試験](./loadtest.md) 28 | 29 | [8. 本番環境への移行](./ptp.md) 30 | 31 | [9. AppRunner のオートスケーリングの設定](./autoscaling.md) 32 | 33 | [10. Amazon SESのサンドボックス外への移動](./ses.md) 34 | 35 | ## REDCap building and configuring 36 | 37 | [11. REDCap コンテナ](./docker.md) 38 | 39 | [12. ビルドプロセス](./build.md) 40 | 41 | [13. 多言語対応](./multilang.md) 42 | 43 | [14. REDCap file storage](./filestorage.md) 44 | 45 | [15. REDCap cronjob セットアップ](./cron.md) 46 | -------------------------------------------------------------------------------- /docs/ja/loadtest.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/loadtest.md) 2 | 3 | # Locust での負荷試験 4 | 5 | このプロジェクトには、Docker Compose を使った簡単な[負荷テスト](../../loadtest/locust/)が含まれています 6 | 7 | これを実行する前に、コンピューターに Docker がインストールされていることを確認してください。 8 | 9 | そして、次のコマンドを実行します。 10 | 11 | 1. `locust` ディレクトリに移動 12 | 13 | ```sh 14 | cd ./loadtest/locust 15 | ``` 16 | 17 | 2. Locust を起動 18 | 19 | ```sh 20 | docker-compose up 21 | ``` 22 | 23 | 3. ブラウザで にアクセスしツールを開く 24 | 25 | ツール内では、テストしたいREDCapのエンドポイントを追加して新しいテストを開始できます。 26 | 27 | ## 負荷試験を実行する際の考慮事項 28 | 29 | 1. AWS WAF はデフォルトで 3000 リクエストを上限に設定されています。 WAF を無効にするか、この数を増やしてテストを実行できます。 30 | 31 | 2. REDCap の内部システムにはクライアントごとに多数のリクエストがありますが、これは設定から無効にできます。 32 | -------------------------------------------------------------------------------- /docs/ja/multilang.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/multilang.md) 2 | 3 | # 多言語対応 4 | 5 | [REDCap Consortium Language Library](https://redcap.vanderbilt.edu/plugins/redcap_consortium/language_library.php) から、対応するバージョン、言語の Language File をダウンロードし、解凍してください。解凍後は`.ini`形式となります。 6 | 7 | 解凍済みの`.ini`ファイルを`packages/REDCap/languages`に配置します。複数言語を利用したい際は、複数配置することが可能です。 8 | 9 | この操作を事前に行うことにより、デプロイ後、REDCap の設定画面から言語変更がが可能になります。 10 | 11 | **(注意) 言語の設定変更は、REDCap の設定画面からも行うことができますが、必ず上記の方法で設定してください。REDCap の設定で行うと、正しく反映されない場合があります。** 12 | -------------------------------------------------------------------------------- /docs/ja/ptp.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/ptp.md) 2 | 3 | # 本番環境への移行 4 | 5 | 1. ユーザー数とシステムへの負荷が不確定な状態では、まずは App Runner が適切にスケールするためのパラメータを設定することから始めましょう。あとで`minSize`をより小さな値にするなど、この値を変更することでコストの最適化ができます。1 インスタンスあたりおよそ 7USD の削減になります。`MaxSize`はコストと予算の管理の観点からも重要です。こちらもシステムの負荷に応じて調整します。 6 | 7 | 2. REDCap は CPU を多く消費するソフトウェアです。デフォルト設定では、各インスタンスあたり 2vCPU を使用しますが、4vCPU/8 GB のインスタンスにスケールアップする方が良い場合もあります。これは、システム運用開始後にメトリクスを監視して決定します。 8 | 9 | 3. REDCap をアップグレードします。このプロジェクトでは、システムのダウンタイムやデータ損失を防ぐために、これを一連の手順で構成しています。 REDCap のアップグレードが必要な際にこの手順を実行する担当者を割り当てることをお勧めします。 10 | 11 | 4. REDCapの主なデータストレージはデータベースとS3です。 Amazon RDS Aurora では、5 時間のタイムウィンドウバックトラックなど、いくつかのバックアップオプションとディザスタリカバリの機能がデフォルトで有効になっています。 一方、S3は信頼性に優れていますが、デフォルトではファイルのバックアップや履歴の保持は行いません。 このプロジェクトでは、デフォルトで `versioning` が有効になっています。 これは、[Backend.ts](../../stacks/Backend.ts) で無効にできます。: 12 | 13 | ```ts 14 | ...bucketProps(app.stage === 'prod', false), // 二つ目のパラメーターを false に設定します。 15 | ``` 16 | 17 | 5. 現時点では (2024年1月)、CDK と CloudFormation は App Runner 用のカスタムドメインの作成をサポートしていません。 このプロジェクトでは、この機能をサポートするための回避策が実装されています。サポートがリリース () された場合は、この機能をリファクタリングすることをお勧めします。 18 | 19 | 6. SSTはオープンソースプロジェクトであり、改善のためにマシン情報 (AWS とは無関係に) の匿名データを収集します。 ただし、次の手順で無効にできます。 20 | 21 | 7. SST を最新バージョンに保ちましょう。 SST と CDK をアップグレードするには、以下を実行します。 22 | 23 | 1. `yarn sst update --stage ` ステージ名は何でも構いません。 24 | 25 | 2. `yarn install` 26 | 27 | 8. REDCap には、`install.php`, `upgrade.php`, `cron.php` のように、許可なくアクセスされるエンドポイントがいくつかあります。 最後の `cron.php `は既にWAFとEventBridgeによってこの2つのリソースだけが知っている秘密の値で保護されています。このエンドポイントは多くの関数をトリガーするので、初めに保護すべきです。ただし、WAFを使用していない場合は、サーバーアクションをトリガーする他のパブリックエンドポイントを保護するために、同じ手法を取り入れることをお勧めします。 28 | 29 | 9. このプロジェクトで使用している Amazon S3、Amazon RDS、AWS Lambda の Amazon GuardDuty を有効化してください。 CDK は GuardDuty サービスを自動的に有効にしますが、これら 3 つのサービスを AWS コンソールで手動で有効にする必要があります。 CloudFormation サポートが追加されると ()、CDK を介してこれらのサービスを有効にすることができます。 30 | 31 | 10. REDCapの IAMユーザー Access keyは、Amazon S3やAmazonSESを用いたEmailサービスのためにローテーションする必要があります。 REDCapに必要な設定は、これらのサービスにアクセスキーを渡すことです。そのため、この認可をIAMベースのアクセスポリシーに移行することを推奨します。詳細は [こちら](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html)を参照ください。 32 | 33 | 11. データベース認証はユーザー/パスワードではなく、IAMを利用しています。パスワードでのアクセスを利用しないことで、パスワードの漏洩を防ぐといったセキュリティを強化できます。本番環境では、本設定がREDCapの接続に問題がないかをテストすることを推奨します。本アプローチは、通常透過的で、アプリケーションへの変更が最小限ですみます。データベースのIAM認証については、[こちら](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html)を参照ください。 34 | 35 | 12. REDCapのコードベースレビューを実施してください。このシステムはアウトバウンドの外部通信が許可されており、REDCapサーバーがインターネットサービスと通信できます。REDCapの一部機能(例えば短縮URL)はこの設定により動作するため、デフォルトで設定されています。しかし、統計情報を共有したり、外部サービスとの通信を許可することは、データベースに格納している秘匿な資格情報が漏洩するバックドアになる可能性があります。そのため、コードの変更を追跡し、REDCapのコードがこれらの秘匿情報を送信していないか検証することが重要です。 36 | 37 | 13. Amazon Auroraは、ほとんどのアプリケーションで動作するデフォルト設定でデプロイされており、これらの設定を変更するには、MySQL と Aurora に関する高度な知識が必要です。REDCap の Configuration Check では、いくつかのパラメータに関して警告が表示されますが、パフォーマンスに問題がある場合は、これらのパラメータを変更することをお勧めします。詳しくは、[Amazon Aurora MySQL データベース設定のベストプラクティス](https://aws.amazon.com/blogs/database/best-practices-for-amazon-aurora-mysql-database-configuration/)と[ParameterGroup - AWS CDK](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_rds.ParameterGroupProps.html)をご覧ください。 38 | 39 | もし、警告表示を防ぎたい場合、[Database.ts](../../stacks/Database.ts)に以下のコードを追加し、REDCapに合わせた設定を行うことで表示を防ぐことができます。ただし、これは Amazon Aurora としては推奨されないものであり、問題がない場合は Amazon Aurora の規定値で運用することをおすすめします。 40 | パラメーターグループの変更を行なった際は、設定値を変更してデプロイした後に、設定をDBに反映するためDBの再起動を実施してください。再起動については[こちら](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/USER_RebootCluster.html)を参照ください。 41 | 42 | ```ts 43 | parameterGroupParameters: { 44 | // Avoid the REDCap system warning. Please change to the required value 45 | max_allowed_packet: '1073741824', 46 | read_rnd_buffer_size: '262144', 47 | sort_buffer_size: '2097152', 48 | }, 49 | ``` 50 | 51 | 別の方法として、`stages.ts`ファイルの`db`オブジェクトで`maxAllowedPacket: '1073741824'`を設定することができます。 52 | -------------------------------------------------------------------------------- /docs/ja/salt.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/salt.md) 2 | 3 | # REDCap database ソルト 4 | 5 | REDCap のインストールには、データベースのソルトが必要で、これは暗号化されたコンテンツの作成に使用する生成された文字列です。この値は、秘匿情報であり、データベースまたはインストールのリカバリの際に重要な値です。 6 | 7 | > たとえREDCapのインストールを削除またはデプロビジョニングした場合でも、プロジェクトはこのシークレットを保持するよう設定されています。これにより、災害発生時でも復旧が可能になります。 8 | 9 | ## シークレットの作成 10 | 11 | AWS CDKはランダムな文字列のシークレットを自動生成し、AWS Secrets Managerにシークレットを格納します。 12 | 13 | ## シークレットの受け渡し 14 | 15 | シークレットは環境変数によりAWS App Runnerで実行中のコンテナに渡されます。REDCapの起動前に、この値は[setup_app.sh](../../containers/redcap-docker-apache/scripts/setup_app.sh)のハッシュ関数を使用して、文字と数字(REDCap推奨に従い)に正規化されます。 16 | 17 | ```sh 18 | DB_SALT_ALPHA=$(echo -n "$DB_SALT" | sha256sum | cut -d' ' -f1) 19 | ``` 20 | 21 | この値は後ほどREDCapで利用するために `database.php`に渡されます。 22 | -------------------------------------------------------------------------------- /docs/ja/ses.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/ses.md) 2 | 3 | # 本番環境でのAmazon SESサンドボックス外への移動 4 | 5 | 不正利用や悪用防止の観点から、全ての新しいAWSアカウントではAmazon SESはサンドボックに配置されます。サンドボックス環境では、Amazon SESは制限がかかっています。例えば、秒間あたりまたは、24時間あたりの最大メッセージ数。制限の詳細については、[公式ドキュメント](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html)を参照ください。 6 | 7 | 上記の理由から、本番環境利用時は、ご自身のアカウントのAmazon SESをサンドボックス外に移動するリクエストを行なってください。リクエストに関しては、AWSマネージメントコンソールまたは、AWS CLIから実施可能です。リクエストの詳細については、[本ドキュメント](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html)を確認ください。 8 | -------------------------------------------------------------------------------- /docs/ja/waf.md: -------------------------------------------------------------------------------- 1 | JP | [EN](../en/waf.md) 2 | 3 | # AWS WAF 4 | 5 | AWS WAFは一般的な攻撃からウェブアプリケーションを保護するウェブアプリケーションファイアウォールで、セキュリティルールを利用してトラフィックを保護します。これはデプロイした全ての環境で有効化されます。 6 | 7 | ### IPフィルタリング 8 | 9 | セキュリティ強化のため、特定のIPリスト(例えば、キャンパスのCIDR)からのみアクセス可能といった、REDCapアプリケーションへのアクセス制限の設定を推奨します。`stages.ts`ファイルの `allowedIps`に以下のように設定することで、一つ以上のCIDRアドレスを追加できます。 10 | 11 | ```ts 12 | allowedIps: ['118.1.0.0/24'], 13 | ``` 14 | 15 | ### AWS WAF マネージドルール 16 | 17 | AWS WAFのマネージドルールで一般的なアプリケーションの脆弱性や他の不要なトラフィックからアプリケーションを保護できます。 本プロジェクトでは、[Waf.ts](../../prototyping/constructs/Waf.ts)で`Baseline rule groups`、`IP reputation rule groups`と`Use-case specific rule groups`から下記のルールを実装しています。必要に応じて[AWS WAF rules documentation](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rules.html)をご確認ください。 18 | 19 | - AWSManagedRulesCommonRuleSet 20 | - AWSManagedRulesKnownBadInputsRuleSet 21 | - AWSManagedRulesSQLiRuleSet 22 | - AWSManagedRulesAmazonIpReputationList 23 | 24 | ### カスタムコントロール 25 | 26 | #### レートコントロール 27 | 28 | レートベースのルールは、受信リクエストをカウントし、リクエスト速度があまりにも速い場合は、レート制限をリクエストします。[Waf.ts](../../prototyping/constructs/Waf.ts)のレートベースのルールで、クライアントが30秒間に3000を超えるリクエストを実行した場合に、サービスが正常に動作できるよう保護します。 29 | 30 | #### シークレットベースのアクセスコントロール 31 | 32 | これはREDCapアプリケーションの`cron.php`へのアクセスコントロールです。詳細は [REDCap cronジョブ セットアップ](./cron.md)をご確認ください。 33 | 34 | #### 新しいルールを導入する場合のコード例 35 | 36 | もし、URL パスベースとレートベースの組み合わせでルールを適用したい場合は次のようにします。 37 | 38 | ```ts 39 | // This waf rule is an example of a rate limit on the specific path of url. In practice, it does not make much sense because REDCap will render the login UI at any URL when you are not logged in. 40 | { 41 | name: 'rate-limit-specific-url', 42 | rule: { 43 | name: 'rate-limit-specific-url', 44 | priority: 50, 45 | statement: { 46 | rateBasedStatement: { 47 | limit: 100, 48 | aggregateKeyType: 'IP', 49 | scopeDownStatement: { 50 | byteMatchStatement: { 51 | fieldToMatch: { 52 | uriPath: {}, 53 | }, 54 | positionalConstraint: 'EXACTLY', 55 | searchString: '/', 56 | textTransformations: [ 57 | { 58 | type: 'NONE', 59 | priority: 0, 60 | }, 61 | ], 62 | }, 63 | }, 64 | }, 65 | }, 66 | action: { 67 | block: {}, 68 | }, 69 | visibilityConfig: { 70 | sampledRequestsEnabled: true, 71 | cloudWatchMetricsEnabled: true, 72 | metricName: 'rate-limit-specific-url', 73 | }, 74 | }, 75 | }, 76 | ``` 77 | 78 | これは `/` パスに対して、IP ベースで 5 分あたり 100 のレートリミットをかける例です。 79 | ログインページなどでより強固なレートリミットをかけたい場合に有効ですが、REDCapではどのURLであっても未ログイン状態ではログイン画面を表示してしまうので実際にはあまり意味がありません。 80 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | 4 | export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended); 5 | -------------------------------------------------------------------------------- /loadtest/locust/Compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | master: 5 | image: locustio/locust 6 | ports: 7 | - '8089:8089' 8 | volumes: 9 | - ./:/mnt/locust 10 | command: -f /mnt/locust/locustfile.py --master -H http://master:8089 11 | 12 | worker1: 13 | image: locustio/locust 14 | volumes: 15 | - ./:/mnt/locust 16 | command: -f /mnt/locust/locustfile.py --worker --master-host master 17 | 18 | worker2: 19 | image: locustio/locust 20 | volumes: 21 | - ./:/mnt/locust 22 | command: -f /mnt/locust/locustfile.py --worker --master-host master 23 | 24 | worker3: 25 | image: locustio/locust 26 | volumes: 27 | - ./:/mnt/locust 28 | command: -f /mnt/locust/locustfile.py --worker --master-host master 29 | -------------------------------------------------------------------------------- /loadtest/locust/locustfile.py: -------------------------------------------------------------------------------- 1 | from locust import HttpUser, TaskSet, task, between, FastHttpUser 2 | 3 | 4 | def index(l): 5 | l.client.get("/") 6 | 7 | 8 | def stats(l): 9 | l.client.get("/stats/requests") 10 | 11 | 12 | class UserTasks(TaskSet): 13 | # one can specify tasks like this 14 | tasks = [index] 15 | 16 | # but it might be convenient to use the @task decorator 17 | # @task 18 | # def page404(self): 19 | # self.client.get("/does_not_exist") 20 | 21 | 22 | class WebsiteUser(FastHttpUser): 23 | """ 24 | User class that does requests to the locust web server running on localhost 25 | """ 26 | 27 | host = "http://127.0.0.1:8089" 28 | wait_time = between(2, 5) 29 | tasks = [UserTasks] 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redcap-on-aws", 3 | "version": "1.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "sst dev", 8 | "build": "sst build", 9 | "deploy": "sst deploy", 10 | "removeRoute53NS": "sst remove --stage route53NS", 11 | "destroy": "npm run removeRoute53NS && sst remove", 12 | "console": "sst console", 13 | "diff": "sst diff", 14 | "disableTelemetry": "sst telemetry disable", 15 | "typecheck": "tsc --noEmit", 16 | "test": "sst bind -- vitest run", 17 | "gen": "hygen" 18 | }, 19 | "devDependencies": { 20 | "@tsconfig/node22": "^22.0.1", 21 | "@types/lodash": "^4.17.9", 22 | "@types/node": "^20.14.10", 23 | "aws-cdk-lib": "2.179.0", 24 | "constructs": "10.3.0", 25 | "eslint": "^9.18.0", 26 | "eslint-config-prettier": "^9.1.0", 27 | "globals": "^15.14.0", 28 | "hygen": "^6.2.11", 29 | "prettier": "^3.3.3", 30 | "sst": "2.48.5", 31 | "typescript": "^5.1.6", 32 | "typescript-eslint": "^8.21.0" 33 | }, 34 | "workspaces": [ 35 | "packages/*" 36 | ], 37 | "dependencies": { 38 | "@aws-cdk/aws-apprunner-alpha": "2.179.0-alpha.0", 39 | "cdk-nag": "^2.28.195", 40 | "csv-parse": "^5.5.6", 41 | "moment": "^2.30.1", 42 | "node-gyp": "^9.4.1" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/REDCap/releases/redcap13.7.2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/redcap-on-aws/becf009c37b174b177cbeee0279ce75aff9b93a5/packages/REDCap/releases/redcap13.7.2.zip -------------------------------------------------------------------------------- /packages/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sst/functions", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "test": "sst bind vitest", 7 | "typecheck": "tsc -noEmit" 8 | }, 9 | "devDependencies": { 10 | "@types/aws-lambda": "^8.10.145", 11 | "@types/node": "^20.14.10", 12 | "sst": "2.48.5" 13 | }, 14 | "dependencies": { 15 | "@aws-sdk/client-codebuild": "^3.658.0", 16 | "@aws-sdk/client-iam": "^3.658.0", 17 | "@aws-sdk/client-secrets-manager": "^3.658.0", 18 | "@aws-sdk/client-sfn": "^3.658.0", 19 | "deasync": "^0.1.30" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/functions/src/createSesCredentials.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { 8 | AccessKey, 9 | CreateAccessKeyCommand, 10 | IAMClient, 11 | ListAccessKeysCommand, 12 | } from '@aws-sdk/client-iam'; 13 | import { 14 | GetSecretValueCommand, 15 | SecretsManagerClient, 16 | UpdateSecretCommand, 17 | } from '@aws-sdk/client-secrets-manager'; 18 | import { Handler } from 'aws-lambda'; 19 | import { Buffer } from 'buffer'; 20 | import { createHmac } from 'crypto'; 21 | import { get, size } from 'lodash'; 22 | 23 | const region = process.env.AWS_REGION; 24 | async function updateSecret(SecretId: string, SecretString: string) { 25 | const smClient = new SecretsManagerClient({ region }); 26 | const command = new UpdateSecretCommand({ 27 | SecretId, 28 | SecretString, 29 | }); 30 | try { 31 | const response = await smClient.send(command); 32 | return response; 33 | } catch (error) { 34 | console.error(error); 35 | } 36 | } 37 | 38 | async function getSecret(SecretId: string) { 39 | const smClient = new SecretsManagerClient({ region }); 40 | const command = new GetSecretValueCommand({ 41 | SecretId, 42 | }); 43 | try { 44 | const response = await smClient.send(command); 45 | return response; 46 | } catch (error) { 47 | console.error(error); 48 | } 49 | } 50 | 51 | async function createAccessKey(): Promise { 52 | const iamClient = new IAMClient({ region }); 53 | const command = new CreateAccessKeyCommand({ 54 | UserName: process.env.SES_USERNAME, 55 | }); 56 | 57 | try { 58 | const response = await iamClient.send(command); 59 | return response.AccessKey; 60 | } catch (error) { 61 | console.error(error); 62 | } 63 | } 64 | 65 | async function hasAccessKeys() { 66 | const iamClient = new IAMClient({ region }); 67 | const command = new ListAccessKeysCommand({ 68 | UserName: process.env.SES_USERNAME, 69 | }); 70 | try { 71 | const response = await iamClient.send(command); 72 | return size(get(response, 'AccessKeyMetadata')) > 0; 73 | } catch (error) { 74 | console.error(error); 75 | } 76 | } 77 | 78 | export const handler: Handler = async () => { 79 | try { 80 | const hasKeys = await hasAccessKeys(); 81 | 82 | if (!hasKeys) { 83 | return { 84 | statusCode: 400, 85 | body: 'Selected user does not have credentials to configure', 86 | }; 87 | } 88 | 89 | let accessKey: AccessKey | undefined; 90 | 91 | if (process.env.TRANSFORM_CREDENTIALS_ARN) { 92 | const credentialsSecret = await getSecret(process.env.TRANSFORM_CREDENTIALS_ARN); 93 | accessKey = JSON.parse(credentialsSecret?.SecretString || '') || undefined; 94 | } else { 95 | accessKey = await createAccessKey(); 96 | } 97 | 98 | if (accessKey?.SecretAccessKey && accessKey.AccessKeyId) { 99 | // Create ses smtp credentials 100 | const smtpPassword = calculateSesSmtpPassword( 101 | accessKey.SecretAccessKey, 102 | process.env.AWS_REGION || 'ap-northeast-1', 103 | ); 104 | 105 | const response = JSON.stringify({ 106 | username: accessKey.AccessKeyId, 107 | password: smtpPassword, 108 | }); 109 | 110 | if (process.env.SES_USER_PASSWORD_ARN) { 111 | await updateSecret(process.env.SES_USER_PASSWORD_ARN, response); 112 | } 113 | console.log('SES password secret updated'); 114 | return { 115 | statusCode: 200, 116 | body: response, 117 | }; 118 | } 119 | return { 120 | statusCode: 400, 121 | body: 'Error creating SES credentials', 122 | }; 123 | } catch (e) { 124 | console.log(e); 125 | } 126 | }; 127 | 128 | export const sign = (key: string[], message: string): string[] => { 129 | const hmac = createHmac('sha256', Buffer.from(key.map(a => a.charCodeAt(0)))).update(message); 130 | return hmac.digest('binary').toString().split(''); 131 | }; 132 | 133 | /** 134 | * https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html#smtp-credentials-convert 135 | */ 136 | export const calculateSesSmtpPassword = (secretAccessKey: string, region: string): string => { 137 | const date = '11111111'; 138 | const service = 'ses'; 139 | const terminal = 'aws4_request'; 140 | const message = 'SendRawEmail'; 141 | const version = [0x04]; 142 | 143 | let signature = sign(`AWS4${secretAccessKey}`.split(''), date); 144 | signature = sign(signature, region); 145 | signature = sign(signature, service); 146 | signature = sign(signature, terminal); 147 | signature = sign(signature, message); 148 | 149 | const signatureAndVersion = version.slice(); // copy of array 150 | 151 | signature.forEach((a: string) => signatureAndVersion.push(a.charCodeAt(0))); 152 | 153 | return Buffer.from(signatureAndVersion).toString('base64'); 154 | }; 155 | -------------------------------------------------------------------------------- /packages/functions/src/startProjectBuild.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { 8 | BatchGetBuildsCommand, 9 | CodeBuildClient, 10 | ListBuildsForProjectCommand, 11 | StartBuildCommand, 12 | } from '@aws-sdk/client-codebuild'; 13 | import { Handler } from 'aws-lambda'; 14 | import { filter, isEmpty, size } from 'lodash'; 15 | 16 | async function getBuilds(client: CodeBuildClient) { 17 | // Get the list builds 18 | const props = { 19 | projectName: process.env.CODEBUILD_PROJECT_NAME, 20 | nextToken: undefined as string | undefined, 21 | }; 22 | const listProjectBuildCommand = new ListBuildsForProjectCommand(props); 23 | 24 | const ids = []; 25 | 26 | // Get the details of each build 27 | const listBuildResponse = await client.send(listProjectBuildCommand); 28 | ids.push(listBuildResponse.ids); 29 | 30 | while (listBuildResponse.nextToken) { 31 | props.nextToken = listBuildResponse.nextToken; 32 | const listProjectBuildCommand = new ListBuildsForProjectCommand(props); 33 | const nextListBuildResponse = await client.send(listProjectBuildCommand); 34 | ids.push(nextListBuildResponse.ids); 35 | } 36 | 37 | const batchBuildCommand = new BatchGetBuildsCommand({ 38 | ids: listBuildResponse.ids, 39 | }); 40 | 41 | if (isEmpty(listBuildResponse.ids)) { 42 | return []; 43 | } 44 | 45 | const batchBuildResponse = await client.send(batchBuildCommand); 46 | return batchBuildResponse.builds || []; 47 | } 48 | 49 | export const handler: Handler = async () => { 50 | try { 51 | let buildStartedId: string | undefined = undefined; 52 | let buildFinished = false; 53 | let canStartBuild = true; 54 | 55 | const client = new CodeBuildClient({ region: process.env.AWS_REGION }); 56 | 57 | const startBuildCommand = new StartBuildCommand({ 58 | projectName: process.env.CODEBUILD_PROJECT_NAME, 59 | }); 60 | 61 | const builds = await getBuilds(client); 62 | // We can start the build if no builds are found, first deploy. 63 | if (isEmpty(builds)) { 64 | const buildCommandResponse = await client.send(startBuildCommand); 65 | if (buildCommandResponse.$metadata.httpStatusCode === 200) 66 | buildStartedId = buildCommandResponse.build?.id; 67 | } else { 68 | // Check if we can start the build 69 | builds.forEach(build => { 70 | if (build.buildStatus === 'IN_PROGRESS') { 71 | canStartBuild = false; 72 | buildStartedId = build.id; 73 | } 74 | }); 75 | 76 | if (canStartBuild) { 77 | const buildCommandResponse = await client.send(startBuildCommand); 78 | if (buildCommandResponse.$metadata.httpStatusCode === 200) 79 | buildStartedId = buildCommandResponse.build?.id; 80 | } 81 | } 82 | 83 | if (buildStartedId) { 84 | while (buildFinished === false) { 85 | await new Promise(r => setTimeout(r, 10000)); 86 | const builds = await getBuilds(client); 87 | 88 | const hasBuildJob = size(filter(builds, b => b.id === buildStartedId)); 89 | 90 | if (hasBuildJob <= 0) { 91 | return new Error('Build is no longer in scope'); 92 | } 93 | 94 | builds.forEach(build => { 95 | if (build.id === buildStartedId) { 96 | if (build.buildStatus === 'IN_PROGRESS') { 97 | buildFinished = false; 98 | } else { 99 | buildFinished = true; 100 | return true; 101 | } 102 | } 103 | }); 104 | } 105 | } 106 | } catch (e) { 107 | console.error('Lambda build had an error'); 108 | throw e; 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /packages/functions/src/stateMachineExec.ts: -------------------------------------------------------------------------------- 1 | import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn'; 2 | export async function handler() { 3 | const client = new SFNClient({ region: process.env.AWS_REGION }); 4 | const input = { 5 | stateMachineArn: process.env.SFN_ARN, 6 | }; 7 | const command = new StartExecutionCommand(input); 8 | const response = await client.send(command); 9 | return response; 10 | } 11 | -------------------------------------------------------------------------------- /packages/functions/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 2 | /// 3 | -------------------------------------------------------------------------------- /packages/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "baseUrl": "." 7 | } 8 | } -------------------------------------------------------------------------------- /prototyping.d.ts: -------------------------------------------------------------------------------- 1 | import { Cpu, Memory } from '@aws-cdk/aws-apprunner-alpha'; 2 | import { Duration } from 'aws-cdk-lib'; 3 | import { DatabaseClusterProps } from 'aws-cdk-lib/aws-rds'; 4 | import { ServiceProps } from 'sst/constructs'; 5 | import { ConfigOptions } from 'sst/project'; 6 | 7 | export interface ProtoConfigOptions extends ConfigOptions { 8 | allowedIps?: string[]; 9 | allowedCountries?: string[]; // WAF allowed country list, ISO 3166-2. 10 | } 11 | 12 | export interface RedCapConfig extends ProtoConfigOptions { 13 | generalLogRetention?: ServiceProps['logRetention']; // Optional general log retention period for 14 | bounceNotificationEmail?: string; 15 | phpTimezone?: string; 16 | redCapS3Path?: string; // if specified, target an s3 path 17 | redCapLocalVersion?: string; // if specified, refer the local package file 18 | domain?: string; 19 | subdomain?: string; 20 | hostInRoute53: boolean | string; // if string, this will perform a lookup in Route 53 for the provided domain name. If true, it will create a new Hosted Zone in Route 53. 21 | email?: string; // used for AWS appRunner notifications and SES if no domain is provided. 22 | appRunnerConcurrency?: number; 23 | appRunnerMaxSize?: number; 24 | appRunnerMinSize?: number; 25 | autoDeploymentsEnabled?: boolean; 26 | cpu?: Cpu; 27 | memory?: Memory; 28 | cronSecret?: string; // protect cron.php endpoint with a secret parameter https://endpoint/cron.php?secret= 29 | cronMinutes?: number; // cron execution in minutes, a value of zero means disabled 30 | port?: number; 31 | deployTag?: string; // forces a new AppRunner deployment and tags ECR docker image with this value 32 | rebuildImage?: boolean; 33 | ec2ServerStack?: { 34 | // an EC2 server running the REDCap docker image, used for long running server requests 35 | ec2StackDuration: Duration; // after this time, the EC2 stack will be destroyed 36 | }; 37 | ecs?: { 38 | //Override AppRunner deployment and use Amazon ECS Fargate 39 | memory: ServiceProps['memory']; 40 | cpu: ServiceProps['cpu']; 41 | scaling: ServiceProps['scaling']; 42 | }; 43 | db?: { 44 | // The number of additional aurora readers, by default, 1 reader is added. Use 0 to use single writer/reader 45 | dbReaders?: number; 46 | dbSnapshotId?: string; 47 | maxAllowedPacket?: string; 48 | scaling?: { 49 | minCapacityAcu: number; 50 | maxCapacityAcu: number; 51 | }; 52 | preferredMaintenanceWindow?: DatabaseClusterProps['preferredMaintenanceWindow']; 53 | }; 54 | } 55 | 56 | export interface DomainAppsConfig extends ConfigOptions { 57 | apps: Array; 58 | domain: string; 59 | profile: string; 60 | region: string; 61 | } 62 | 63 | interface DomainAppConfig { 64 | name: string; 65 | nsRecords: Array; 66 | } 67 | -------------------------------------------------------------------------------- /prototyping/cdkNag/NagConsoleLogger.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import chalk from 'chalk'; 8 | import * as fs from 'fs'; 9 | import path from 'path'; 10 | 11 | import { 12 | INagLogger, 13 | NagLoggerErrorData, 14 | NagLoggerNonComplianceData, 15 | NagLoggerSuppressedData, 16 | NagLoggerSuppressedErrorData, 17 | } from 'cdk-nag'; 18 | import { parse } from 'csv-parse/sync'; 19 | import { filter, size } from 'lodash'; 20 | 21 | export class NagConsoleLogger implements INagLogger { 22 | private nagFiles = { 23 | total: 0, 24 | warning: 0, 25 | error: 0, 26 | }; 27 | 28 | private sstPath = '.sst/dist'; 29 | private logSuppressed = false; 30 | 31 | public hasErrors = false; 32 | 33 | showSuppressed() { 34 | this.logSuppressed = true; 35 | } 36 | 37 | onCompliance(): void {} 38 | onNonCompliance(data: NagLoggerNonComplianceData): void { 39 | if (data.ruleLevel === 'Warning') 40 | console.log( 41 | chalk.yellow( 42 | `\n[${data.ruleLevel} ${data.ruleId}] - (${data.resource.cfnResourceType}) ${data.resource.node.path}`, 43 | ), 44 | ); 45 | else { 46 | console.log( 47 | chalk.red( 48 | `\n[NonCompliance ${data.ruleLevel} ${data.ruleId}] - (${data.resource.cfnResourceType}) ${data.resource.node.path} ${data.ruleInfo}`, 49 | ), 50 | ); 51 | this.hasErrors = true; 52 | process.exit(); 53 | } 54 | } 55 | onSuppressed(data: NagLoggerSuppressedData): void { 56 | if (this.logSuppressed) { 57 | console.log( 58 | chalk.cyan( 59 | `\n[Suppressed ${data.ruleLevel} ${data.ruleId}] - (${data.resource.cfnResourceType}) ${data.resource.node.path} - ${data.suppressionReason}`, 60 | ), 61 | ); 62 | } 63 | } 64 | onError(data: NagLoggerErrorData): void { 65 | console.log( 66 | chalk.red( 67 | `\n[${data.ruleLevel} ${data.ruleId}] - (${data.resource.cfnResourceType}) ${data.resource.node.path}`, 68 | ), 69 | ); 70 | this.hasErrors = true; 71 | process.exit(); 72 | } 73 | onSuppressedError(data: NagLoggerSuppressedErrorData): void { 74 | if (this.logSuppressed) { 75 | console.log( 76 | chalk.cyan( 77 | `\n[Suppressed ${data.ruleLevel} ${data.ruleId}] - (${data.resource.cfnResourceType}) ${data.resource.node.path} - ${data.errorSuppressionReason}`, 78 | ), 79 | ); 80 | } 81 | } 82 | onNotApplicable(): void {} 83 | 84 | // Logs to a console table the offenses found with RuleId and RuleInfo by category. 85 | nagFilesToConsoleTable(stage: string = '') { 86 | const files = this.findCsvFilesInDirectory(`./${this.sstPath}/`, '.csv', stage); 87 | const log = console.table; 88 | const headers = { 89 | RuleID: '', 90 | ResourceID: '', 91 | Compliance: '', 92 | ExceptionReason: '', 93 | RuleLevel: '', 94 | RuleInfo: '', 95 | }; 96 | 97 | files.forEach(file => { 98 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 99 | const offenses: any[] = []; 100 | 101 | const csv = fs.readFileSync(`./${file}`); 102 | 103 | const rows = parse(csv.toString(), { 104 | delimiter: ',', 105 | fromLine: 2, 106 | autoParse: true, 107 | }); 108 | 109 | rows.forEach((row: Array) => { 110 | const entry = {}; 111 | Object.keys(headers).forEach((header, idx) => { 112 | Object.assign(entry, { [header]: row[idx].trim() }); 113 | }); 114 | offenses.push(entry); 115 | }); 116 | 117 | const nonComplianceError = filter(offenses, { 118 | Compliance: 'Non-Compliant', 119 | RuleLevel: 'Error', 120 | }); 121 | 122 | const nonComplianceWarn = filter(offenses, { 123 | Compliance: 'Non-Compliant', 124 | RuleLevel: 'Warning', 125 | }); 126 | 127 | this.nagFiles.error += size(nonComplianceError) > 0 ? 1 : 0; 128 | this.nagFiles.warning += size(nonComplianceWarn) > 0 ? 1 : 0; 129 | 130 | if (size(nonComplianceError) > 0) { 131 | this.hasErrors = true; 132 | console.log(chalk.bgRed(`Error: ${file} `)); 133 | log(nonComplianceError, ['RuleID', 'RuleInfo', 'ResourceID']); 134 | } 135 | 136 | if (size(nonComplianceWarn) > 0) { 137 | console.log(chalk.bgYellow(`Warnings: ${file}: `)); 138 | log(nonComplianceWarn, ['RuleID', 'RuleInfo', 'ResourceID']); 139 | } 140 | }); 141 | 142 | console.log( 143 | chalk.cyan(`cdk-nag files found: ${this.nagFiles.total}, `), 144 | chalk.red(`w/Error: ${this.nagFiles.error}, `), 145 | chalk.yellow(`w/Warning: ${this.nagFiles.warning}`), 146 | ); 147 | } 148 | 149 | // Returns an array of all cdk-nag csv files in a given directory 150 | findCsvFilesInDirectory(startPath: string, filter: string, stage: string): Array { 151 | if (!fs.existsSync(startPath)) { 152 | console.log('no dir ', startPath); 153 | return []; 154 | } 155 | const files = fs.readdirSync(startPath); 156 | const results = []; 157 | for (let i = 0; i < files.length; i++) { 158 | const filename = path.join(startPath, files[i]); 159 | const stat = fs.lstatSync(filename); 160 | const regexp = new RegExp(`^${this.sstPath}/AwsSolutions-${stage}-`); 161 | if (stat.isDirectory()) { 162 | this.findCsvFilesInDirectory(filename, filter, stage); 163 | } else if (regexp.test(filename) && filename.endsWith(filter)) { 164 | results.push(filename); 165 | } 166 | } 167 | this.nagFiles.total = results.length; 168 | return results; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /prototyping/constructs/AppRunner.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import * as ecr from 'aws-cdk-lib/aws-ecr'; 8 | 9 | import { 10 | Cpu, 11 | ImageConfiguration, 12 | Memory, 13 | Service, 14 | Source, 15 | VpcConnector, 16 | } from '@aws-cdk/aws-apprunner-alpha'; 17 | import { 18 | RemovalPolicy, 19 | aws_ec2, 20 | aws_events, 21 | aws_events_targets, 22 | aws_iam, 23 | aws_sns, 24 | cloudformation_include, 25 | } from 'aws-cdk-lib'; 26 | import { 27 | CfnAutoScalingConfiguration, 28 | CfnAutoScalingConfigurationProps, 29 | CfnService, 30 | } from 'aws-cdk-lib/aws-apprunner'; 31 | import { Construct } from 'constructs'; 32 | import { getAppRunnerHostedZone } from '../../stacks/Backend/AppRunnerHostedZones'; 33 | import { App, Stack } from 'sst/constructs'; 34 | import { 35 | AliasRecordTargetConfig, 36 | IAliasRecordTarget, 37 | IPublicHostedZone, 38 | RecordSet, 39 | RecordTarget, 40 | RecordType, 41 | } from 'aws-cdk-lib/aws-route53'; 42 | 43 | export interface AppRunnerProps { 44 | app: App; 45 | stack: Stack; 46 | domain?: string; 47 | subdomain?: string; 48 | publicHostedZone?: IPublicHostedZone; 49 | instanceRole?: aws_iam.Role; 50 | accessRole?: aws_iam.Role; 51 | autoDeploymentsEnabled?: boolean; 52 | notificationEmail?: string; 53 | network: { 54 | vpc: aws_ec2.Vpc; 55 | subnetType: aws_ec2.SubnetType; 56 | securityGroups?: aws_ec2.ISecurityGroup[]; 57 | }; 58 | appName: string; 59 | service: { 60 | config?: { 61 | cpu?: Cpu; 62 | port?: ImageConfiguration['port']; 63 | memory?: Memory; 64 | environmentSecrets?: ImageConfiguration['environmentSecrets']; 65 | environmentVariables?: ImageConfiguration['environmentVariables']; 66 | }; 67 | image: { 68 | repositoryName: string; 69 | tag?: string; 70 | }; 71 | healthCheck?: { 72 | path?: string; 73 | }; 74 | }; 75 | scalingConfiguration?: CfnAutoScalingConfigurationProps; 76 | } 77 | 78 | export class AppRunner extends Construct { 79 | public readonly service; 80 | public readonly customUrl; 81 | 82 | constructor(scope: Construct, id: string, props: AppRunnerProps) { 83 | super(scope, id); 84 | 85 | // Create VPC Connector 86 | let vpcConnector; 87 | 88 | if (props.network) { 89 | vpcConnector = new VpcConnector(this, 'vpc-connector', { 90 | vpc: props.network.vpc, 91 | vpcSubnets: props.network.vpc.selectSubnets({ subnetType: props.network.subnetType }), 92 | securityGroups: props.network.securityGroups, 93 | }); 94 | } 95 | 96 | // Create access role if there's no access role in props 97 | let accessRole; 98 | if (props.accessRole) accessRole = props.accessRole; 99 | else { 100 | accessRole = new aws_iam.Role(this, `apprunner-accessRole`, { 101 | assumedBy: new aws_iam.ServicePrincipal('build.apprunner.amazonaws.com'), 102 | }); 103 | accessRole.addToPolicy( 104 | new aws_iam.PolicyStatement({ 105 | actions: [ 106 | 'ecr:DescribeImages', 107 | 'wafv2:ListResourcesForWebACL', 108 | 'wafv2:GetWebACLForResource', 109 | 'wafv2:AssociateWebACL', 110 | 'wafv2:DisassociateWebACL', 111 | 'apprunner:ListAssociatedServicesForWebAcl', 112 | 'apprunner:DescribeWebAclForService', 113 | 'apprunner:AssociateWebAcl', 114 | 'apprunner:DisassociateWebAcl', 115 | ], 116 | resources: ['*'], 117 | }), 118 | ); 119 | } 120 | 121 | // Create App Runner service 122 | this.service = new Service(this, 'apprunner-service', { 123 | source: Source.fromEcr({ 124 | imageConfiguration: props.service.config, 125 | repository: ecr.Repository.fromRepositoryName( 126 | this, 127 | `${props.appName}-reponame`, 128 | props.service.image.repositoryName, 129 | ), 130 | tagOrDigest: props.service.image.tag || 'latest', 131 | }), 132 | autoDeploymentsEnabled: props.autoDeploymentsEnabled || false, 133 | vpcConnector, 134 | accessRole, 135 | instanceRole: props.instanceRole, 136 | cpu: props.service.config?.cpu || Cpu.TWO_VCPU, 137 | memory: props.service.config?.memory || Memory.FOUR_GB, 138 | }); 139 | 140 | // Configure auto scaling 141 | const autoScalingConfiguration = new CfnAutoScalingConfiguration( 142 | this, 143 | `AppRunnerScalingConfig`, 144 | props.scalingConfiguration, 145 | ); 146 | const cfnService = this.service.node.defaultChild as CfnService; 147 | cfnService.autoScalingConfigurationArn = 148 | autoScalingConfiguration.attrAutoScalingConfigurationArn; 149 | 150 | // Configure health check 151 | if (props.service.healthCheck?.path) { 152 | cfnService.healthCheckConfiguration = { 153 | protocol: 'HTTP', 154 | path: props.service.healthCheck.path, 155 | }; 156 | } 157 | 158 | this.service.applyRemovalPolicy(RemovalPolicy.DESTROY); 159 | 160 | // Configure notification email about App Runner 161 | if (props.notificationEmail) { 162 | const rule = new aws_events.Rule(this, 'apprunner-rule', { 163 | eventPattern: { 164 | source: ['aws.apprunner'], 165 | detail: { 166 | serviceName: [this.service.serviceName], 167 | operationStatus: [ 168 | 'PauseServiceCompletedSuccessfully', 169 | 'PauseServiceFailed', 170 | 'ResumeServiceCompletedSuccessfully', 171 | 'ResumeServiceFailed', 172 | 'UpdateServiceCompletedSuccessfully', 173 | 'UpdateServiceFailed', 174 | 'DeploymentCompletedSuccessfully', 175 | 'DeploymentFailed', 176 | ], 177 | }, 178 | }, 179 | }); 180 | 181 | const snsTopic = new aws_sns.Topic(this, 'apprunner-topic'); 182 | 183 | rule.addTarget(new aws_events_targets.SnsTopic(snsTopic)); 184 | new aws_sns.Subscription(this, 'apprunner-sub', { 185 | endpoint: props.notificationEmail, 186 | protocol: aws_sns.SubscriptionProtocol.EMAIL, 187 | topic: snsTopic, 188 | }); 189 | } 190 | 191 | // Workaround to link AppRunner to custom domain 192 | if (props.domain && props.publicHostedZone) { 193 | const DomainName = props.subdomain ? `${props.subdomain}.${props.domain}` : props.domain; 194 | const ARHostedZone = getAppRunnerHostedZone(props.stack.region); 195 | 196 | const customDomainCfn = new cloudformation_include.CfnInclude( 197 | this, 198 | `${props.app.stage}-${props.app.name}-apprunner-custom-domain`, 199 | { 200 | templateFile: './prototyping/cfn/AppRunnerCustomDomain.yaml', 201 | parameters: { 202 | DomainName, 203 | AppRunnerHostedZones: getAppRunnerHostedZone(props.stack.region), 204 | ServiceUrl: this.service.serviceUrl, 205 | ServiceArn: this.service.serviceArn, 206 | DNSDomainId: props.publicHostedZone.hostedZoneId, 207 | }, 208 | }, 209 | ); 210 | 211 | if (ARHostedZone) { 212 | const record: IAliasRecordTarget = { 213 | bind: (): AliasRecordTargetConfig => ({ 214 | dnsName: this.service.serviceUrl, 215 | hostedZoneId: ARHostedZone, 216 | }), 217 | }; 218 | 219 | const recordSet = new RecordSet(this, 'apprunner-arecord-alias', { 220 | recordType: RecordType.A, 221 | target: RecordTarget.fromAlias(record), 222 | zone: props.publicHostedZone, 223 | deleteExisting: true, 224 | }); 225 | 226 | customDomainCfn.node.addDependency(recordSet); 227 | } 228 | 229 | customDomainCfn.node.addDependency(this.service); 230 | this.customUrl = DomainName; 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /prototyping/constructs/AuroraServerlessV2.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { isEmpty, isNumber } from 'lodash'; 8 | 9 | import { Duration, RemovalPolicy, Stack, aws_ec2, aws_iam, aws_rds } from 'aws-cdk-lib'; 10 | import { IVpc } from 'aws-cdk-lib/aws-ec2'; 11 | 12 | import { Construct } from 'constructs'; 13 | import { Config } from 'sst/constructs'; 14 | import { IClusterInstance } from 'aws-cdk-lib/aws-rds'; 15 | import { NagSuppressions } from 'cdk-nag'; 16 | import { RetentionDays } from 'aws-cdk-lib/aws-logs'; 17 | 18 | import { createHash } from 'crypto'; 19 | 20 | type ScalingConfiguration = { 21 | minCapacityAcu: number; 22 | maxCapacityAcu: number; 23 | }; 24 | 25 | type AuroraProps = { 26 | engine: RdsV2Engines['engine']; 27 | vpc: aws_ec2.IVpc; 28 | scaling: ScalingConfiguration; 29 | migrations?: string; 30 | dbUserName?: string; 31 | enabledProxy?: boolean; 32 | defaultDatabaseName?: string | undefined; 33 | backupRetentionInDays?: number; 34 | removalPolicy?: RemovalPolicy; 35 | migrateOneDown?: boolean; 36 | parameterGroupParameters?: aws_rds.ParameterGroupProps['parameters']; 37 | disableKeyRotation?: boolean; 38 | rotateSecretAfterDays?: Duration; 39 | readers?: number; 40 | snapshotIdentifier?: string; 41 | logRetention?: Lowercase; 42 | preferredMaintenanceWindow?: aws_rds.DatabaseClusterProps['preferredMaintenanceWindow']; 43 | }; 44 | 45 | type RdsV2Engines = { 46 | engine: 'mysql8.0' | 'postgresql13.10' | 'postgresql14.7' | 'postgresql15.2'; 47 | }; 48 | 49 | /** 50 | * @summary Constructs a new instance of the AuroraServerlessV2 class. 51 | * @param {cdk.App} scope - represents the scope for all the resources. 52 | * @param {string} id - this is a a scope-unique id. 53 | * @param {AuroraProps} props - user provided props for the construct 54 | */ 55 | export class AuroraServerlessV2 extends Construct { 56 | public readonly aurora: aws_rds.DatabaseCluster; 57 | public readonly proxyRole: aws_iam.Role | undefined; 58 | public readonly proxy: aws_rds.DatabaseProxy | undefined; 59 | private parameterGroup: aws_rds.ParameterGroup | undefined; 60 | 61 | vpc: IVpc; 62 | RDS_V2_SECRET_ARN!: Config.Parameter; 63 | RDS_PROXY_ENDPOINT!: Config.Parameter; 64 | 65 | constructor(scope: Construct, id: string, props: AuroraProps) { 66 | super(scope, id); 67 | 68 | this.vpc = props.vpc; 69 | 70 | // Check whether isolated subnets which you chose or not 71 | if (isEmpty(props.vpc.isolatedSubnets)) { 72 | throw new Error('You must specify the isolated subnets'); 73 | } 74 | 75 | // Create parameter group if there's no parameter group in props 76 | if (props.parameterGroupParameters) { 77 | this.parameterGroup = new aws_rds.ParameterGroup(this, 'AuroraV2ParameterGroup', { 78 | engine: this.getEngine(props.engine), 79 | parameters: props.parameterGroupParameters, 80 | }); 81 | } 82 | 83 | let readers: Array | undefined = [ 84 | aws_rds.ClusterInstance.serverlessV2('ReaderClusterInstance1', { 85 | autoMinorVersionUpgrade: true, 86 | publiclyAccessible: false, 87 | caCertificate: aws_rds.CaCertificate.RDS_CA_RSA2048_G1, 88 | }), 89 | ]; 90 | 91 | if (isNumber(props.readers)) 92 | if (props.readers > 0) { 93 | readers = []; 94 | for (let index = 1; index <= props.readers; index++) { 95 | readers.push( 96 | aws_rds.ClusterInstance.serverlessV2(`ReaderClusterInstance${index}`, { 97 | autoMinorVersionUpgrade: true, 98 | publiclyAccessible: false, 99 | caCertificate: aws_rds.CaCertificate.RDS_CA_RSA2048_G1, 100 | }), 101 | ); 102 | } 103 | } else if (props.readers === 0) { 104 | readers = undefined; 105 | } 106 | 107 | const logRetention = props.logRetention || 'ONE_YEAR'; 108 | 109 | let databaseProps: aws_rds.DatabaseClusterProps | aws_rds.DatabaseClusterFromSnapshotProps = { 110 | vpc: props.vpc, 111 | vpcSubnets: { 112 | subnets: props.vpc.isolatedSubnets, 113 | }, 114 | engine: this.getEngine(props.engine), 115 | cloudwatchLogsExports: 116 | props.engine === 'mysql8.0' ? ['error', 'general', 'slowquery', 'audit'] : ['postgresql'], // Export all available MySQL-based logs 117 | defaultDatabaseName: props.defaultDatabaseName || 'sampleDB', 118 | cloudwatchLogsRetention: 119 | RetentionDays[logRetention.toUpperCase() as keyof typeof RetentionDays], 120 | iamAuthentication: true, 121 | writer: aws_rds.ClusterInstance.serverlessV2('WriterClusterInstance', { 122 | autoMinorVersionUpgrade: true, 123 | publiclyAccessible: false, 124 | caCertificate: aws_rds.CaCertificate.RDS_CA_RSA2048_G1, 125 | }), 126 | readers, 127 | serverlessV2MinCapacity: props.scaling.minCapacityAcu, 128 | serverlessV2MaxCapacity: props.scaling.maxCapacityAcu, 129 | backup: { 130 | retention: props.backupRetentionInDays 131 | ? Duration.days(props.backupRetentionInDays) 132 | : Duration.days(30), 133 | }, 134 | credentials: aws_rds.Credentials.fromGeneratedSecret(props.dbUserName || 'dbadmin', {}), 135 | backtrackWindow: Duration.hours(24), 136 | parameterGroup: this.parameterGroup, 137 | storageEncrypted: true, 138 | removalPolicy: props.removalPolicy, 139 | preferredMaintenanceWindow: props.preferredMaintenanceWindow, 140 | }; 141 | 142 | // Create Aurora Cluster 143 | if (props.snapshotIdentifier) { 144 | console.info(`\nAurora serverless: Deploying from snapshot: ${props.snapshotIdentifier}\n`); 145 | 146 | databaseProps = { 147 | ...databaseProps, 148 | credentials: undefined, 149 | snapshotIdentifier: props.snapshotIdentifier, 150 | snapshotCredentials: aws_rds.SnapshotCredentials.fromGeneratedSecret( 151 | props.dbUserName || 'dbadmin', 152 | ), 153 | }; 154 | 155 | this.aurora = new aws_rds.DatabaseClusterFromSnapshot( 156 | this, 157 | `ServerlessAuroraDatabaseFromSnapshot-${createHash('sha1').update(props.snapshotIdentifier).digest('hex')}`, 158 | databaseProps, 159 | ); 160 | 161 | NagSuppressions.addResourceSuppressions( 162 | this.aurora, 163 | [ 164 | { 165 | id: 'AwsSolutions-SMG4', 166 | reason: 167 | 'Related to https://github.com/aws/aws-cdk/issues/28761 that creates an additional secret.', 168 | }, 169 | ], 170 | true, 171 | ); 172 | } else { 173 | this.aurora = new aws_rds.DatabaseCluster(this, 'ServerlessAuroraDatabase', databaseProps); 174 | } 175 | 176 | // Secret Rotation 177 | if (!props.disableKeyRotation) 178 | this.aurora.addRotationSingleUser({ 179 | automaticallyAfter: props.rotateSecretAfterDays, 180 | }); 181 | 182 | // Setup IAM authentication 183 | if (this.aurora.secret) { 184 | this.RDS_V2_SECRET_ARN = new Config.Parameter(this, 'RDS_V2_SECRET_ARN', { 185 | value: this.aurora.secret.secretArn, 186 | }); 187 | if (props.enabledProxy) { 188 | this.proxy = this.aurora.addProxy('dbproxy', { 189 | secrets: [this.aurora.secret], 190 | vpc: props.vpc, 191 | iamAuth: true, 192 | dbProxyName: `${id}-dbclusterv2-dbproxy`, 193 | }); 194 | 195 | this.proxyRole = new aws_iam.Role(this, 'DBProxyRole', { 196 | assumedBy: new aws_iam.AccountPrincipal(Stack.of(this).account), 197 | }); 198 | this.proxy.grantConnect(this.proxyRole, props.dbUserName); 199 | 200 | this.RDS_PROXY_ENDPOINT = new Config.Parameter(this, 'RDS_PROXY_ENDPOINT', { 201 | value: this.proxy.endpoint, 202 | }); 203 | } 204 | } 205 | } 206 | 207 | private getEngine(engine: RdsV2Engines['engine']) { 208 | if (engine === 'mysql8.0') { 209 | return aws_rds.DatabaseClusterEngine.auroraMysql({ 210 | version: aws_rds.AuroraMysqlEngineVersion.VER_3_08_0, 211 | }); 212 | } else if (engine === 'postgresql13.10') { 213 | return aws_rds.DatabaseClusterEngine.auroraPostgres({ 214 | version: aws_rds.AuroraPostgresEngineVersion.VER_13_10, 215 | }); 216 | } else if (engine === 'postgresql14.7') { 217 | return aws_rds.DatabaseClusterEngine.auroraPostgres({ 218 | version: aws_rds.AuroraPostgresEngineVersion.VER_14_7, 219 | }); 220 | } else if (engine === 'postgresql15.2') { 221 | return aws_rds.DatabaseClusterEngine.auroraPostgres({ 222 | version: aws_rds.AuroraPostgresEngineVersion.VER_15_2, 223 | }); 224 | } 225 | throw new Error( 226 | `The specified "engine" is not supported in this package. Only mysql8.0 (3_08_0), postgresql13.10, postgresql14.7, and postgresql15.2 engines are currently supported.`, 227 | ); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /prototyping/constructs/CodeBuildProject.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { Project, ProjectProps } from 'aws-cdk-lib/aws-codebuild'; 8 | import { Function } from 'sst/constructs'; 9 | import { Duration, RemovalPolicy, triggers } from 'aws-cdk-lib'; 10 | import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; 11 | import { Key } from 'aws-cdk-lib/aws-kms'; 12 | import { Construct } from 'constructs'; 13 | import { RetentionDays } from 'aws-cdk-lib/aws-logs'; 14 | 15 | interface LambdaTriggerProps { 16 | handler: string; 17 | name: string; 18 | rebuild: boolean; 19 | logRetention?: Lowercase; 20 | executeBefore?: Construct[]; 21 | executeAfter?: Construct[]; 22 | } 23 | export class CodeBuildProject extends Construct { 24 | public readonly project; 25 | public readonly props; 26 | constructor(scope: Construct, id: string, props: ProjectProps) { 27 | super(scope, id); 28 | this.props = props; 29 | 30 | // setup encryption key 31 | const key = new Key(this, 'CodeBuildProjectKey', { 32 | enableKeyRotation: true, 33 | removalPolicy: RemovalPolicy.DESTROY, 34 | }); 35 | 36 | // create CodeBuild project 37 | this.project = new Project(this, 'buildJob', { 38 | encryptionKey: key, 39 | ...props, 40 | }); 41 | } 42 | 43 | // create Trigger construct to run CodeBuild Project 44 | public addLambdaTrigger(props: LambdaTriggerProps): string { 45 | const environment = { 46 | CODEBUILD_PROJECT_NAME: this.project.projectName || '', 47 | TIMESTAMP: props.rebuild ? Date.now().toString() : 'NA', 48 | }; 49 | 50 | const buildJobFunc = new Function(this, `${props.name}-lambda-trigger`, { 51 | handler: props.handler, 52 | timeout: '15 minutes', 53 | logRetention: props.logRetention || 'one_year', 54 | environment, 55 | }); 56 | 57 | buildJobFunc.addToRolePolicy( 58 | new PolicyStatement({ 59 | actions: [ 60 | 'codebuild:StartBuild', 61 | 'codebuild:ListBuildsForProject', 62 | 'codebuild:BatchGetBuilds', 63 | ], 64 | resources: [this.project.projectArn], 65 | }), 66 | ); 67 | 68 | const after = props.executeAfter; 69 | after?.push(buildJobFunc); 70 | 71 | new triggers.Trigger(this, `${props.name}-trigger`, { 72 | handler: buildJobFunc, 73 | timeout: Duration.minutes(10), 74 | executeOnHandlerChange: true, 75 | executeAfter: after, 76 | executeBefore: props.executeBefore, 77 | }); 78 | 79 | return buildJobFunc.functionName; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /prototyping/constructs/NetworkVpc.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { RemovalPolicy, aws_ec2 } from 'aws-cdk-lib'; 8 | import { 9 | FlowLogDestination, 10 | FlowLogTrafficType, 11 | InterfaceVpcEndpointAwsService, 12 | IpAddresses, 13 | SubnetType, 14 | Vpc, 15 | VpcProps, 16 | } from 'aws-cdk-lib/aws-ec2'; 17 | import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; 18 | import { Construct } from 'constructs'; 19 | import { isEmpty } from 'lodash'; 20 | 21 | export class NetworkVpc extends Construct { 22 | public readonly vpc: Vpc; 23 | public readonly endpoints: { [x: string]: aws_ec2.InterfaceVpcEndpoint } = {}; 24 | public readonly eipAllocationForNat: string[] | undefined; 25 | 26 | constructor( 27 | scope: Construct, 28 | id: string, 29 | props: { 30 | maxAzs: number; 31 | cidr: string; 32 | cidrMask: number; 33 | publicSubnet?: boolean; 34 | isolatedSubnet?: boolean; 35 | natSubnet?: boolean; 36 | vpcEndpoints?: Array; 37 | vpcEndpointS3?: boolean; 38 | vpcEndpointDynamoDb?: boolean; 39 | logRetention?: Lowercase; 40 | }, 41 | ) { 42 | super(scope, id); 43 | 44 | const logRetention = props.logRetention || 'TWO_MONTHS'; 45 | 46 | // Vpc logging 47 | const cwLogs = new LogGroup(this, 'vpc-logs', { 48 | logGroupName: `/${id}/vpc-logs/`, 49 | retention: RetentionDays[logRetention.toUpperCase() as keyof typeof RetentionDays], 50 | removalPolicy: RemovalPolicy.DESTROY, 51 | }); 52 | 53 | // configure subnets following props value 54 | const subnetConfiguration: VpcProps['subnetConfiguration'] = []; 55 | 56 | if (props.publicSubnet) { 57 | subnetConfiguration.push({ 58 | cidrMask: props.cidrMask, 59 | name: 'public-subnet', 60 | subnetType: SubnetType.PUBLIC, 61 | }); 62 | } 63 | 64 | if (props.natSubnet) { 65 | subnetConfiguration.push({ 66 | cidrMask: props.cidrMask, 67 | name: 'private-subnet', 68 | subnetType: SubnetType.PRIVATE_WITH_EGRESS, 69 | }); 70 | } 71 | 72 | if (props.isolatedSubnet) { 73 | subnetConfiguration.push({ 74 | cidrMask: props.cidrMask, 75 | name: 'isolated-subnet', 76 | subnetType: SubnetType.PRIVATE_ISOLATED, 77 | }); 78 | } 79 | 80 | if (isEmpty(subnetConfiguration)) { 81 | throw new Error('No subnet configuration enabled'); 82 | } 83 | 84 | // Create VPC - Private and public subnets 85 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 86 | const vpcTempProps: any = { 87 | ipAddresses: IpAddresses.cidr(props.cidr), 88 | vpcName: `${id}-vpc`, 89 | subnetConfiguration, 90 | maxAzs: props.maxAzs, 91 | flowLogs: { 92 | s3: { 93 | destination: FlowLogDestination.toCloudWatchLogs(cwLogs), 94 | trafficType: FlowLogTrafficType.ALL, 95 | }, 96 | }, 97 | }; 98 | 99 | // setup NAT Gateway 100 | if (props.natSubnet) { 101 | this.eipAllocationForNat = []; 102 | const eipAllocationIds: string[] = []; 103 | 104 | for (let i = 0; i < props.maxAzs; i++) { 105 | const eip = new aws_ec2.CfnEIP(this, `${id}-nat-eip${i}`, {}); 106 | this.eipAllocationForNat.push(eip.attrPublicIp); 107 | eipAllocationIds.push(eip.attrAllocationId); 108 | } 109 | 110 | vpcTempProps.natGatewayProvider = aws_ec2.NatProvider.gateway({ 111 | eipAllocationIds, 112 | }); 113 | } 114 | 115 | const vpcProps: aws_ec2.VpcProps = vpcTempProps; 116 | 117 | this.vpc = new Vpc(this, 'vpc', vpcProps); 118 | 119 | // Add vpc endpoints 120 | const interfaceEndpointOptions = { 121 | vpc: this.vpc, 122 | privateDnsEnable: true, 123 | }; 124 | 125 | // create VPC endpoints 126 | props.vpcEndpoints?.forEach(vpcEndpoint => { 127 | this.endpoints[`${vpcEndpoint.shortName}`] = this.vpc.addInterfaceEndpoint( 128 | `${vpcEndpoint.shortName}-vep`, 129 | { 130 | service: vpcEndpoint, 131 | ...interfaceEndpointOptions, 132 | }, 133 | ); 134 | }); 135 | 136 | // Vpc endpoints for s3 and dynamodb 137 | if (props.vpcEndpointDynamoDb) 138 | this.vpc.addGatewayEndpoint('DynamoDbEndpoint', { 139 | service: aws_ec2.GatewayVpcEndpointAwsService.DYNAMODB, 140 | }); 141 | if (props.vpcEndpointS3) { 142 | this.vpc.addGatewayEndpoint('S3Endpoint', { 143 | service: aws_ec2.GatewayVpcEndpointAwsService.S3, 144 | }); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /prototyping/constructs/RedCapAwsAccessUser.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { SecretValue, aws_iam } from 'aws-cdk-lib'; 8 | import { AccessKey, Group, User } from 'aws-cdk-lib/aws-iam'; 9 | import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; 10 | import { Construct } from 'constructs'; 11 | 12 | export class RedCapAwsAccessUser extends Construct { 13 | public readonly secret: Secret; 14 | public readonly user: User; 15 | public readonly userGroup: Group; 16 | constructor( 17 | scope: Construct, 18 | id: string, 19 | props: { 20 | user?: aws_iam.User; 21 | group?: aws_iam.Group; 22 | userName?: string; 23 | groupName: string; 24 | }, 25 | ) { 26 | super(scope, id); 27 | 28 | // create IAM user 29 | this.user = 30 | props.user ?? 31 | new aws_iam.User(this, 'redcap_user', { 32 | userName: props.userName, 33 | }); 34 | 35 | // create IAM user group 36 | this.userGroup = 37 | props.group ?? 38 | new aws_iam.Group(this, 'redcap-group', { 39 | groupName: props.groupName, 40 | }); 41 | 42 | this.userGroup.addUser(this.user); 43 | 44 | // create access key and store in Secrets Manager 45 | const accessKey = new AccessKey(this, 'AccessKey', { user: this.user }); 46 | 47 | this.secret = new Secret(this, 'Secret', { 48 | secretObjectValue: { 49 | AccessKeyId: SecretValue.unsafePlainText(accessKey.accessKeyId), 50 | SecretAccessKey: accessKey.secretAccessKey, 51 | }, 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /prototyping/constructs/SimpleEmailService.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { Duration, RemovalPolicy, aws_iam, aws_secretsmanager, triggers } from 'aws-cdk-lib'; 8 | import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; 9 | import { IPublicHostedZone } from 'aws-cdk-lib/aws-route53'; 10 | import { 11 | ConfigurationSet, 12 | EmailIdentity, 13 | EmailSendingEvent, 14 | EventDestination, 15 | Identity, 16 | } from 'aws-cdk-lib/aws-ses'; 17 | import { Topic } from 'aws-cdk-lib/aws-sns'; 18 | import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions'; 19 | 20 | import { Construct } from 'constructs'; 21 | import { Function } from 'sst/constructs'; 22 | import { Suppressions } from '../cdkNag/Suppressions'; 23 | 24 | export interface SimpleEmailServiceProps { 25 | group?: aws_iam.Group; 26 | /** 27 | * The IAM user for Amazon SES 28 | */ 29 | user: aws_iam.User; 30 | /** 31 | * Secret containing an IAM user AccessKeyId and a SecretAccessKey in SecretManager to be transformed to SES credentials. 32 | * Default: new IAM credentials will be created. 33 | */ 34 | transformCredentials?: aws_secretsmanager.Secret; 35 | publicHostedZone?: IPublicHostedZone; 36 | domain?: string; 37 | subdomain?: string; 38 | email?: string; 39 | bounceNotificationEmail?: string; 40 | } 41 | 42 | export class SimpleEmailService extends Construct { 43 | public readonly sesUserCredentials: aws_secretsmanager.Secret; 44 | constructor(scope: Construct, id: string, props: SimpleEmailServiceProps) { 45 | super(scope, id); 46 | 47 | let identity; 48 | let mailFromDomain; 49 | 50 | // check props 51 | if (props.publicHostedZone && props.domain) { 52 | throw new Error('SES can be configured with public hosted zone or a domain'); 53 | } 54 | 55 | if ((props.publicHostedZone || props.domain) && props.email) { 56 | throw new Error('SES can be configured with one public hosted zone, domain or email'); 57 | } 58 | 59 | let configSet: ConfigurationSet | undefined = undefined; 60 | 61 | if (props.bounceNotificationEmail) { 62 | const bounceTopic = new Topic(this, 'BounceTopic', { 63 | topicName: `${id}-bounce-notifications`, 64 | }); 65 | 66 | bounceTopic.addSubscription(new EmailSubscription(props.bounceNotificationEmail)); 67 | 68 | configSet = new ConfigurationSet(this, 'configSet', {}); 69 | configSet.addEventDestination('bounceSns', { 70 | destination: EventDestination.snsTopic(bounceTopic), 71 | events: [EmailSendingEvent.BOUNCE], 72 | }); 73 | 74 | Suppressions.SimpleEmailServiceSuppressions(bounceTopic); 75 | } 76 | 77 | // create Identity 78 | if (props.publicHostedZone) { 79 | identity = Identity.publicHostedZone(props.publicHostedZone); 80 | mailFromDomain = `mail.${props.publicHostedZone.zoneName}`; 81 | } else if (props.domain) { 82 | const domain = props.subdomain ? `${props.subdomain}.${props.domain}` : props.domain; 83 | identity = Identity.domain(domain); 84 | mailFromDomain = `mail.${domain}`; 85 | } else if (props.email) { 86 | new EmailIdentity(this, `${id}-identity`, { 87 | identity: Identity.email(props.email), 88 | configurationSet: configSet, 89 | }); 90 | } 91 | 92 | if (identity) { 93 | new EmailIdentity(this, `${id}-identity`, { 94 | identity, 95 | mailFromDomain, 96 | configurationSet: configSet, 97 | }); 98 | } 99 | 100 | const user = props.user; 101 | 102 | const policy = new aws_iam.Policy(this, 'user-policy', { 103 | statements: [ 104 | new aws_iam.PolicyStatement({ 105 | effect: aws_iam.Effect.ALLOW, 106 | actions: ['ses:SendRawEmail', 'ses:SendEmail'], 107 | resources: ['*'], 108 | }), 109 | ], 110 | }); 111 | 112 | if (props.group) { 113 | props.group.attachInlinePolicy(policy); 114 | } else { 115 | user.attachInlinePolicy(policy); 116 | } 117 | 118 | // store ses password in Secrets Manager 119 | this.sesUserCredentials = new aws_secretsmanager.Secret(scope, 'ses-user-password', { 120 | removalPolicy: RemovalPolicy.DESTROY, 121 | }); 122 | 123 | // create lambda function to get SES credential 124 | const getCredentialsFunction = new Function(scope, 'get-credentials', { 125 | environment: { 126 | SES_USERNAME: user.userName || 'redcap-smtp-user', 127 | SES_USER_PASSWORD_ARN: this.sesUserCredentials.secretArn, 128 | TRANSFORM_CREDENTIALS_ARN: props.transformCredentials?.secretArn || '', 129 | }, 130 | handler: 'packages/functions/src/createSesCredentials.handler', 131 | }); 132 | 133 | getCredentialsFunction.addToRolePolicy( 134 | new PolicyStatement({ 135 | actions: ['iam:CreateAccessKey', 'iam:ListAccessKeys'], 136 | resources: [user.userArn], 137 | }), 138 | ); 139 | 140 | getCredentialsFunction.addToRolePolicy( 141 | new PolicyStatement({ 142 | actions: [ 143 | 'secretsmanager:GetSecretValue', 144 | 'secretsmanager:UpdateSecret', 145 | 'secretsmanager:PutSecretValue', 146 | ], 147 | resources: [this.sesUserCredentials.secretArn], 148 | }), 149 | ); 150 | 151 | if (props.transformCredentials) 152 | getCredentialsFunction.addToRolePolicy( 153 | new PolicyStatement({ 154 | actions: ['secretsmanager:GetSecretValue'], 155 | resources: [props.transformCredentials.secretArn], 156 | }), 157 | ); 158 | 159 | // execute get credential function after once deployed 160 | new triggers.Trigger(scope, 'create-credentials-ses', { 161 | handler: getCredentialsFunction, 162 | timeout: Duration.minutes(1), 163 | invocationType: triggers.InvocationType.REQUEST_RESPONSE, 164 | }); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /prototyping/constructs/Waf.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { concat, isEmpty, uniqBy } from 'lodash'; 8 | 9 | import * as cdk from 'aws-cdk-lib'; 10 | import * as wafv2 from 'aws-cdk-lib/aws-wafv2'; 11 | 12 | import { Construct } from 'constructs'; 13 | 14 | export interface WafRule { 15 | name: string; 16 | rule: wafv2.CfnWebACL.RuleProperty; 17 | } 18 | 19 | export class Waf extends Construct { 20 | public readonly waf: wafv2.CfnWebACL; 21 | constructor( 22 | scope: Construct, 23 | id: string, 24 | props: { 25 | useCloudFront?: boolean; 26 | webACLResourceArn?: string; 27 | extraRules?: Array; 28 | allowedIps: Array; 29 | }, 30 | ) { 31 | super(scope, id); 32 | 33 | let ipset = null; 34 | const distScope = props.useCloudFront ? 'CLOUDFRONT' : 'REGIONAL'; 35 | 36 | if (!isEmpty(props.allowedIps)) { 37 | ipset = new wafv2.CfnIPSet(this, `${id}-ipset`, { 38 | addresses: props.allowedIps, 39 | ipAddressVersion: 'IPV4', 40 | scope: distScope, 41 | description: 'Webapp allowed IPV4', 42 | name: `${id}-webapp-ip-list`, 43 | }); 44 | } 45 | 46 | this.waf = new WAF(this, `${id}-WAFv2`, ipset, distScope, props.extraRules); 47 | 48 | if (!props.useCloudFront && props.webACLResourceArn) { 49 | // Create an association, not needed for cloudfront 50 | new WebACLAssociation(this, `${id}-acl-Association`, { 51 | resourceArn: props.webACLResourceArn, 52 | webAclArn: this.waf.attrArn, 53 | }); 54 | } 55 | } 56 | } 57 | 58 | let wafRules: WafRule[] = [ 59 | // Rate Filter 60 | { 61 | name: 'rate-filter', 62 | rule: { 63 | name: 'rate-filter', 64 | priority: 30, 65 | statement: { 66 | rateBasedStatement: { 67 | limit: 3000, 68 | aggregateKeyType: 'IP', 69 | }, 70 | }, 71 | action: { 72 | block: {}, 73 | }, 74 | visibilityConfig: { 75 | sampledRequestsEnabled: true, 76 | cloudWatchMetricsEnabled: true, 77 | metricName: 'rate-filter', 78 | }, 79 | }, 80 | }, 81 | // AWS IP Reputation list includes known malicious actors/bots and is regularly updated 82 | { 83 | name: 'AWS-AWSManagedRulesAmazonIpReputationList', 84 | rule: { 85 | name: 'AWS-AWSManagedRulesAmazonIpReputationList', 86 | priority: 200, 87 | statement: { 88 | managedRuleGroupStatement: { 89 | vendorName: 'AWS', 90 | name: 'AWSManagedRulesAmazonIpReputationList', 91 | }, 92 | }, 93 | overrideAction: { 94 | none: {}, 95 | }, 96 | visibilityConfig: { 97 | sampledRequestsEnabled: true, 98 | cloudWatchMetricsEnabled: true, 99 | metricName: 'AWSManagedRulesAmazonIpReputationList', 100 | }, 101 | }, 102 | }, 103 | // Common Rule Set aligns with major portions of OWASP Core Rule Set 104 | { 105 | name: 'AWS-AWSManagedRulesCommonRuleSet', 106 | rule: { 107 | name: 'AWS-AWSManagedRulesCommonRuleSet', 108 | priority: 300, 109 | statement: { 110 | managedRuleGroupStatement: { 111 | vendorName: 'AWS', 112 | name: 'AWSManagedRulesCommonRuleSet', 113 | // Excluding generic RFI body rule for sns notifications 114 | // https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html 115 | excludedRules: [ 116 | { name: 'GenericRFI_BODY' }, 117 | { name: 'SizeRestrictions_BODY' }, 118 | { name: 'CrossSiteScripting_BODY' }, 119 | { name: 'NoUserAgent_HEADER' }, 120 | ], 121 | }, 122 | }, 123 | overrideAction: { 124 | none: {}, 125 | }, 126 | visibilityConfig: { 127 | sampledRequestsEnabled: true, 128 | cloudWatchMetricsEnabled: true, 129 | metricName: 'AWS-AWSManagedRulesCommonRuleSet', 130 | }, 131 | }, 132 | }, 133 | { 134 | name: 'AWS-AWSManagedRulesKnownBadInputsRuleSet', 135 | rule: { 136 | name: 'AWS-AWSManagedRulesKnownBadInputsRuleSet', 137 | priority: 400, 138 | statement: { 139 | managedRuleGroupStatement: { 140 | vendorName: 'AWS', 141 | name: 'AWSManagedRulesKnownBadInputsRuleSet', 142 | }, 143 | }, 144 | overrideAction: { 145 | none: {}, 146 | }, 147 | visibilityConfig: { 148 | sampledRequestsEnabled: true, 149 | cloudWatchMetricsEnabled: true, 150 | metricName: 'AWS-AWSManagedRulesKnownBadInputsRuleSet', 151 | }, 152 | }, 153 | }, 154 | { 155 | name: 'AWS-AWSManagedRulesSQLiRuleSet', 156 | rule: { 157 | name: 'AWS-AWSManagedRulesSQLiRuleSet', 158 | priority: 500, 159 | statement: { 160 | managedRuleGroupStatement: { 161 | vendorName: 'AWS', 162 | name: 'AWSManagedRulesSQLiRuleSet', 163 | }, 164 | }, 165 | overrideAction: { 166 | none: {}, 167 | }, 168 | visibilityConfig: { 169 | sampledRequestsEnabled: true, 170 | cloudWatchMetricsEnabled: true, 171 | metricName: 'AWS-AWSManagedRulesSQLiRuleSet', 172 | }, 173 | }, 174 | }, 175 | ]; 176 | 177 | export class WAF extends wafv2.CfnWebACL { 178 | constructor( 179 | scope: Construct, 180 | id: string, 181 | ipset: cdk.aws_wafv2.CfnIPSet | null, 182 | distScope: string, 183 | extraRules?: Array, 184 | ) { 185 | if (extraRules && !isEmpty(extraRules)) { 186 | wafRules = uniqBy(concat(wafRules, extraRules), 'name'); 187 | } 188 | if (ipset) { 189 | wafRules.push({ 190 | name: 'ip-filter', 191 | rule: { 192 | name: 'ip-filter', 193 | priority: 40, 194 | statement: { 195 | andStatement: { 196 | statements: [ 197 | { 198 | notStatement: { 199 | statement: { 200 | ipSetReferenceStatement: { 201 | arn: ipset.attrArn, 202 | }, 203 | }, 204 | }, 205 | }, 206 | { 207 | notStatement: { 208 | statement: { 209 | byteMatchStatement: { 210 | searchString: 'cron.php', 211 | fieldToMatch: { 212 | uriPath: {}, 213 | }, 214 | textTransformations: [ 215 | { 216 | priority: 0, 217 | type: 'NONE', 218 | }, 219 | ], 220 | positionalConstraint: 'ENDS_WITH', 221 | }, 222 | }, 223 | }, 224 | }, 225 | ], 226 | }, 227 | }, 228 | action: { 229 | block: { 230 | customResponse: { 231 | responseCode: 403, 232 | customResponseBodyKey: 'response', 233 | }, 234 | }, 235 | }, 236 | visibilityConfig: { 237 | sampledRequestsEnabled: true, 238 | cloudWatchMetricsEnabled: true, 239 | metricName: 'ip-filter', 240 | }, 241 | }, 242 | }); 243 | } 244 | super(scope, id, { 245 | defaultAction: { allow: {} }, 246 | visibilityConfig: { 247 | cloudWatchMetricsEnabled: true, 248 | metricName: `${id}-metric`, 249 | sampledRequestsEnabled: false, 250 | }, 251 | customResponseBodies: { 252 | response: { 253 | contentType: 'TEXT_HTML', 254 | content: '
Access denied
', 255 | }, 256 | }, 257 | scope: distScope, 258 | name: `${id}-waf`, 259 | rules: wafRules.map(wafRule => wafRule.rule), 260 | }); 261 | } 262 | } 263 | 264 | export class WebACLAssociation extends wafv2.CfnWebACLAssociation { 265 | constructor(scope: Construct, id: string, props: wafv2.CfnWebACLAssociationProps) { 266 | super(scope, id, { 267 | resourceArn: props.resourceArn, 268 | webAclArn: props.webAclArn, 269 | }); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /prototyping/extensions/Helpers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | export class Helpers { 8 | static extractRedCapTag(s: string): string | null { 9 | try { 10 | const match = s.match(/redcap(\d+\.\d+\.\d+)\.zip/); 11 | return match ? match[1] : null; 12 | } catch (e) { 13 | console.error(e); 14 | throw e; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /prototyping/overrides/BucketProps.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { Duration, RemovalPolicy } from 'aws-cdk-lib'; 8 | import { BlockPublicAccess, BucketEncryption, BucketProps, StorageClass } from 'aws-cdk-lib/aws-s3'; 9 | import { App, Bucket } from 'sst/constructs'; 10 | import { NagSuppressions } from 'cdk-nag'; 11 | 12 | export function bucketProps(app?: App, logBucket?: Bucket): BucketProps { 13 | const isProd = app?.stage === 'prod'; 14 | 15 | if (logBucket) 16 | NagSuppressions.addResourceSuppressions(logBucket?.cdk.bucket, [ 17 | { 18 | id: 'AwsSolutions-S1', 19 | reason: 'Is the log bucket', 20 | }, 21 | ]); 22 | 23 | return { 24 | removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY, 25 | encryption: BucketEncryption.S3_MANAGED, 26 | blockPublicAccess: BlockPublicAccess.BLOCK_ALL, 27 | autoDeleteObjects: !isProd, 28 | enforceSSL: true, 29 | versioned: isProd, 30 | serverAccessLogsBucket: logBucket?.cdk.bucket ?? undefined, 31 | lifecycleRules: [ 32 | { 33 | transitions: [ 34 | { 35 | storageClass: StorageClass.INFREQUENT_ACCESS, 36 | transitionAfter: Duration.days(90), 37 | }, 38 | { 39 | storageClass: StorageClass.INTELLIGENT_TIERING, 40 | transitionAfter: Duration.days(180), 41 | }, 42 | ], 43 | }, 44 | ], 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /prototyping/overrides/RemovalPolicy.ts: -------------------------------------------------------------------------------- 1 | import { CfnResource, RemovalPolicy } from 'aws-cdk-lib'; 2 | 3 | export class OverrideEc2ServerRemovalPolicy { 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | visit(node: any) { 6 | if (node instanceof CfnResource) { 7 | if (node.stack.stackName.includes('EC2Server')) { 8 | node.applyRemovalPolicy(RemovalPolicy.DESTROY); 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | // Read stages from stages.ts -- use yarn sst deploy --stage 8 | import * as stage from './stages'; 9 | 10 | import { Aspects, Tags } from 'aws-cdk-lib'; 11 | import { AwsSolutionsChecks } from 'cdk-nag'; 12 | import { SSTConfig } from 'sst'; 13 | import { NagConsoleLogger } from './prototyping/cdkNag/NagConsoleLogger'; 14 | import { Backend } from './stacks/Backend'; 15 | import { BuildImage } from './stacks/BuildImage'; 16 | import { Database } from './stacks/Database'; 17 | import { EC2Server } from './stacks/EC2Server'; 18 | import { Network } from './stacks/Network'; 19 | import { Route53NSRecords } from './stacks/Route53NSRecords'; 20 | import { OverrideEc2ServerRemovalPolicy } from './prototyping/overrides/RemovalPolicy'; 21 | import { get } from 'lodash'; 22 | 23 | export default { 24 | config(_input) { 25 | return stage[_input.stage as keyof typeof stage]; 26 | }, 27 | stacks(app) { 28 | const logger = new NagConsoleLogger(); 29 | const ec2ServerStack = get(stage, [app.stage, 'ec2ServerStack']); 30 | 31 | if (app.mode === 'deploy') logger.showSuppressed(); 32 | 33 | if (app.mode !== 'remove') 34 | Aspects.of(app).add( 35 | new AwsSolutionsChecks({ 36 | verbose: true, 37 | additionalLoggers: [logger], 38 | }), 39 | ); 40 | 41 | // Enable tags 42 | Tags.of(app).add('deployment', `${app.stage}-${app.region}`); 43 | 44 | // Assets removal policy: for dev stage and mode is destroy, prod is retain 45 | if (app.stage === 'dev' || app.mode === 'dev') { 46 | app.setDefaultRemovalPolicy('destroy'); 47 | } else if (app.stage === 'prod' && app.mode === 'deploy') app.setDefaultRemovalPolicy('retain'); 48 | 49 | /****** Stacks ******/ 50 | if (app.stage === 'route53NS') { 51 | app.stack(Route53NSRecords); 52 | } else { 53 | app.stack(Network); 54 | app.stack(BuildImage); 55 | app.stack(Database); 56 | app.stack(Backend); 57 | if (ec2ServerStack) { 58 | Aspects.of(app.stack(EC2Server)).add(new OverrideEc2ServerRemovalPolicy()); 59 | } 60 | } 61 | }, 62 | } satisfies SSTConfig; 63 | -------------------------------------------------------------------------------- /stacks/Backend.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import * as stage from '../stages'; 8 | 9 | import { Cpu, Memory } from '@aws-cdk/aws-apprunner-alpha'; 10 | import { Fn, RemovalPolicy, aws_secretsmanager } from 'aws-cdk-lib'; 11 | import { assign, get, isEmpty, random } from 'lodash'; 12 | 13 | // SST 14 | import { Bucket, StackContext, use } from 'sst/constructs'; 15 | 16 | // Stack dependency 17 | import { BuildImage } from './BuildImage'; 18 | import { Database } from './Database'; 19 | import { Network } from './Network'; 20 | 21 | // Construct and other assets 22 | import { RedCapAwsAccessUser } from '../prototyping/constructs/RedCapAwsAccessUser'; 23 | import { 24 | SimpleEmailService, 25 | SimpleEmailServiceProps, 26 | } from '../prototyping/constructs/SimpleEmailService'; 27 | import { Waf } from '../prototyping/constructs/Waf'; 28 | import { getRedcapCronRule, getCountryLimitRule } from './Backend/WafExtraRules'; 29 | 30 | // Nag suppressions 31 | import { Suppressions } from '../prototyping/cdkNag/Suppressions'; 32 | import { bucketProps } from '../prototyping/overrides/BucketProps'; 33 | import { DomainConfiguration } from './Backend/DomainConfiguration'; 34 | import { RedcapService } from './Backend/RedCapService'; 35 | 36 | const { createHmac } = await import('node:crypto'); 37 | 38 | export function Backend({ stack, app }: StackContext) { 39 | const { networkVpc } = use(Network); 40 | const { dbAllowedSg, auroraClusterV2 } = use(Database); 41 | const repository = use(BuildImage); 42 | 43 | if (!auroraClusterV2.aurora.secret) { 44 | throw new Error('No database secret found'); 45 | } 46 | 47 | // Config 48 | const domain = get(stage, [stack.stage, 'domain']); 49 | const subdomain = get(stage, [stack.stage, 'subdomain']); 50 | const hostInRoute53: boolean | string = get(stage, [stack.stage, 'hostInRoute53'], true); 51 | const phpTimezone = get(stage, [stack.stage, 'phpTimezone']); 52 | const cronSecret = get(stage, [stack.stage, 'cronSecret'], random(0, 10).toString()); 53 | const allowedIps = get(stage, [stack.stage, 'allowedIps'], []); 54 | const allowedCountries = get(stage, [stack.stage, 'allowedCountries'], undefined); 55 | const ecsConfig = get(stage, [stack.stage, 'ecs']); 56 | const email = get(stage, [stack.stage, 'email']); 57 | const bounceNotificationEmail = get(stage, [stack.stage, 'bounceNotificationEmail']); 58 | const port = get(stage, [stack.stage, 'port']); 59 | const tag = get(stage, [stack.stage, 'deployTag'], 'latest'); 60 | const generalLogRetention = get(stage, [stack.stage, 'generalLogRetention'], undefined); 61 | const cronMinutes = get(stage, [stack.stage, 'cronMinutes'], undefined); 62 | 63 | // IAM user and group to access AWS S3 service (file system) 64 | const redCapS3AccessUser = new RedCapAwsAccessUser(stack, `${app.stage}-${app.name}-s3-access`, { 65 | groupName: `${app.stage}-${app.name}-groupS3`, 66 | }); 67 | 68 | // IAM user and group to access AWS SES service (email) 69 | const redCapSESAccessUser = new RedCapAwsAccessUser( 70 | stack, 71 | `${app.stage}-${app.name}-ses-access`, 72 | { 73 | groupName: `${app.stage}-${app.name}-groupSES`, 74 | }, 75 | ); 76 | 77 | // Route53 DNS and Amazon SES validation 78 | const sesProps: SimpleEmailServiceProps = { 79 | user: redCapSESAccessUser.user, 80 | group: redCapSESAccessUser.userGroup, 81 | transformCredentials: redCapSESAccessUser.secret, 82 | bounceNotificationEmail: bounceNotificationEmail, 83 | }; 84 | 85 | const domainConfig = new DomainConfiguration({ 86 | app, 87 | domain, 88 | hostInRoute53, 89 | stack, 90 | subdomain, 91 | }); 92 | 93 | if (!domain && !email) throw new Error('No identify found to deploy Amazon SES'); 94 | 95 | const publicHostedZone = domainConfig.publicHostedZone; 96 | 97 | // SES configuration 98 | if (publicHostedZone) { 99 | assign(sesProps, { publicHostedZone }); 100 | } else { 101 | assign(sesProps, { email }); 102 | } 103 | 104 | const ses = new SimpleEmailService(stack, `${app.stage}-${app.name}-redcap-ses`, { 105 | ...sesProps, 106 | }); 107 | 108 | // DB salt secret 109 | const dbSalt = new aws_secretsmanager.Secret(stack, `${app.stage}-${app.name}-dbsalt`, { 110 | description: 111 | 'REDCap db salt secret, value must be hashed to sha256 before passing it to database.php', 112 | removalPolicy: app.stage === 'prod' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY, 113 | }); 114 | 115 | // REDCap S3 integration for file storage 116 | const redcapApplicationBucketLogs = new Bucket(stack, 'appBucket-logs', { 117 | cdk: { 118 | bucket: { 119 | ...bucketProps(app), 120 | }, 121 | }, 122 | cors: false, 123 | }); 124 | const redcapApplicationBucket = new Bucket(stack, 'appBucket', { 125 | cdk: { 126 | bucket: { 127 | ...bucketProps(app, redcapApplicationBucketLogs), 128 | }, 129 | }, 130 | cors: false, 131 | }); 132 | 133 | redcapApplicationBucket.cdk.bucket.grantReadWrite(redCapS3AccessUser.userGroup); 134 | 135 | // AWS WAF: CRON SHARED SECRET 136 | const searchString = createHmac('sha256', cronSecret) 137 | .update(cronSecret.split('').reverse().join('')) 138 | .digest('hex'); 139 | 140 | // AWS WAF: CRON RULE 141 | const extraRules = [getRedcapCronRule(searchString, 20)]; 142 | 143 | // AWS WAF: COUNTRY ALLOW IF IN LIST 144 | if (!isEmpty(allowedCountries)) { 145 | const countryRules = getCountryLimitRule(allowedCountries, 10); 146 | if (countryRules) extraRules.push(countryRules); 147 | } 148 | 149 | const waf = new Waf(stack, `${app.stage}-${app.name}-appwaf`, { 150 | allowedIps, 151 | extraRules, 152 | }); 153 | 154 | const environmentVariables = { 155 | S3_BUCKET: redcapApplicationBucket.bucketName, 156 | USE_IAM_DB_AUTH: 'true', 157 | DB_SECRET_NAME: auroraClusterV2.aurora.secret.secretName, 158 | SMTP_EMAIL: email, 159 | DB_SECRET_ID: auroraClusterV2.aurora.secret.secretArn, 160 | DB_SALT_SECRET_ID: dbSalt.secretArn, 161 | SES_CREDENTIALS_SECRET_ID: ses.sesUserCredentials.secretArn, 162 | S3_SECRET_ID: redCapS3AccessUser.secret.secretArn, 163 | PHP_TIMEZONE: phpTimezone || 'UTC', 164 | }; 165 | 166 | if (auroraClusterV2 && auroraClusterV2.aurora.clusterReadEndpoint.hostname) { 167 | assign(environmentVariables, { 168 | READ_REPLICA_HOSTNAME: auroraClusterV2.aurora.clusterReadEndpoint.hostname, 169 | }); 170 | } 171 | 172 | const service = new RedcapService(stack, app, { 173 | databaseCluster: auroraClusterV2.aurora, 174 | domain, 175 | subdomain, 176 | publicHostedZone, 177 | waf, 178 | secrets: { 179 | dbSalt, 180 | dbSecret: auroraClusterV2.aurora.secret, 181 | redCapS3AccessUser, 182 | ses, 183 | }, 184 | environmentVariables, 185 | vpc: networkVpc.vpc, 186 | servicePort: port, 187 | logRetention: generalLogRetention, 188 | repository, 189 | searchString, 190 | cronMinutes, 191 | }); 192 | 193 | if (ecsConfig) { 194 | // Deploy with ECS backend 195 | service.ecsDeploy({ 196 | cpu: get(ecsConfig, 'cpu', '2 vCPU'), 197 | memory: get(ecsConfig, 'memory', '4 GB'), 198 | scaling: get(ecsConfig, 'scaling', { maxContainers: 2, minContainers: 1 }), 199 | tag, 200 | }); 201 | } else { 202 | // Deploy with AppRunner backend 203 | service.appRunnerDeploy({ 204 | autoDeploymentsEnabled: get(stage, [stack.stage, 'autoDeploymentsEnabled'], true), 205 | cpu: get(stage, [stack.stage, 'cpu'], Cpu.TWO_VCPU), 206 | memory: get(stage, [stack.stage, 'memory'], Memory.FOUR_GB), 207 | notificationEmail: email, 208 | securityGroups: [dbAllowedSg], 209 | tag, 210 | scalingConfiguration: { 211 | maxConcurrency: get(stage, [stack.stage, 'appRunnerConcurrency'], 10), 212 | maxSize: get(stage, [stack.stage, 'appRunnerMaxSize'], 2), 213 | minSize: get(stage, [stack.stage, 'appRunnerMinSize'], 1), 214 | }, 215 | }); 216 | } 217 | // Additional outputs 218 | if (publicHostedZone && publicHostedZone.hostedZoneNameServers) 219 | stack.addOutputs({ 220 | NameServers: Fn.join(',', publicHostedZone.hostedZoneNameServers), 221 | }); 222 | 223 | stack.addOutputs({ 224 | AppRunnerServiceUrl: service.AppRunnerServiceUrl || '', 225 | CustomServiceUrl: service.CustomServiceUrl || '', 226 | EcsServiceUrl: service.EcsServiceUrl || '', 227 | }); 228 | 229 | // Suppress cdk nag offenses. 230 | Suppressions.SesSuppressions(ses); 231 | Suppressions.WebWafSuppressions(waf); 232 | Suppressions.RedCapAwsAccessUserSuppressions([redCapS3AccessUser, redCapSESAccessUser]); 233 | Suppressions.DBSecretSalt(dbSalt); 234 | 235 | if (service.appRunnerService) Suppressions.AppRunnerSuppressions(service.appRunnerService, app); 236 | if (service.ecsService) Suppressions.ECSSuppressions(service.ecsService); 237 | 238 | return { 239 | repository, 240 | dbSalt, 241 | sesUserCredentials: ses.sesUserCredentials, 242 | s3UserCredentials: redCapS3AccessUser.secret, 243 | environmentVariables, 244 | }; 245 | } 246 | -------------------------------------------------------------------------------- /stacks/Backend/AppRunnerHostedZones.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | //Source: https://docs.aws.amazon.com/general/latest/gr/apprunner.html 8 | export function getAppRunnerHostedZone(region: string) { 9 | const map = new Map([ 10 | ['us-east-2', 'Z0224347AD7KVHMLOX31'], 11 | ['us-east-1', 'Z01915732ZBZKC8D32TPT'], 12 | ['us-west-2', 'Z02243383FTQ64HJ5772Q'], 13 | ['ap-southeast-1', 'Z09819469CZ3KQ8PWMCL'], 14 | ['ap-southeast-2', 'Z03657752RA8799S0TI5I'], 15 | ['ap-northeast-1', 'Z08491812XW6IPYLR6CCA'], 16 | ['eu-central-1', 'Z0334911C2FDI2Q9M4FZ'], 17 | ['eu-west-1', 'Z087551914Z2PCAU0QHMW'], 18 | ]); 19 | if (region) return map.get(region); 20 | return map.get('us-east-1'); 21 | } 22 | -------------------------------------------------------------------------------- /stacks/Backend/DomainConfiguration.ts: -------------------------------------------------------------------------------- 1 | import { IPublicHostedZone, PublicHostedZone, ZoneDelegationRecord } from 'aws-cdk-lib/aws-route53'; 2 | import { isString } from 'lodash'; 3 | import { App, Stack } from 'sst/constructs'; 4 | 5 | export interface DomainProps { 6 | domain: string; 7 | subdomain?: string; 8 | hostInRoute53: boolean | string; 9 | stack: Stack; 10 | app: App; 11 | } 12 | 13 | export class DomainConfiguration { 14 | public readonly props; 15 | public publicHostedZone: IPublicHostedZone | undefined; 16 | constructor(props: DomainProps) { 17 | this.props = props; 18 | 19 | const { hostInRoute53, subdomain } = this.props; 20 | 21 | if (!this.props.domain) { 22 | return; 23 | } 24 | 25 | if (isString(hostInRoute53)) { 26 | if (!subdomain) this.awsAccountDefaultHostedZone(); 27 | else if (isString(subdomain)) this.awsAccountWithSubdomain(); 28 | } else if (hostInRoute53 === true) { 29 | this.createNewHostedZone(); 30 | } 31 | } 32 | 33 | private createNewHostedZone() { 34 | const zoneName = this.props.subdomain 35 | ? `${this.props.subdomain}.${this.props.domain}` 36 | : this.props.domain; 37 | this.publicHostedZone = new PublicHostedZone( 38 | this.props.stack, 39 | `${this.props.app.stage}-${this.props.app.name}-hostedzone`, 40 | { 41 | zoneName, 42 | }, 43 | ); 44 | } 45 | 46 | private awsAccountDefaultHostedZone() { 47 | if (!isString(this.props.hostInRoute53)) { 48 | throw new Error('hostInRoute53 is not a string'); 49 | } 50 | this.publicHostedZone = PublicHostedZone.fromLookup( 51 | this.props.stack, 52 | `${this.props.app.stage}-${this.props.app.name}-hostedzone`, 53 | { 54 | domainName: this.props.hostInRoute53.toString(), 55 | }, 56 | ); 57 | } 58 | 59 | private awsAccountWithSubdomain() { 60 | const rootHz = PublicHostedZone.fromLookup( 61 | this.props.stack, 62 | `${this.props.app.stage}-${this.props.app.name}-root-hostedzone`, 63 | { 64 | domainName: this.props.hostInRoute53.toString(), 65 | }, 66 | ); 67 | 68 | const newHZ = new PublicHostedZone( 69 | this.props.stack, 70 | `${this.props.app.stage}-${this.props.app.name}-hostedzone`, 71 | { 72 | zoneName: `${this.props.subdomain}.${this.props.domain}`, 73 | }, 74 | ); 75 | 76 | new ZoneDelegationRecord( 77 | this.props.stack, 78 | `${this.props.app.stage}-${this.props.app.name}-delegation-records`, 79 | { 80 | nameServers: newHZ.hostedZoneNameServers!, 81 | zone: rootHz, 82 | deleteExisting: true, 83 | recordName: `${this.props.subdomain}.${this.props.domain}`, 84 | }, 85 | ); 86 | 87 | this.publicHostedZone = newHZ; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /stacks/Backend/RedCapService.ts: -------------------------------------------------------------------------------- 1 | import { App, Stack } from 'sst/constructs'; 2 | import { EcsFargate } from '../../prototyping/constructs/EcsFargate'; 3 | import { SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; 4 | import { Repository } from 'aws-cdk-lib/aws-ecr'; 5 | import { DatabaseCluster } from 'aws-cdk-lib/aws-rds'; 6 | import { AppRunner } from '../../prototyping/constructs/AppRunner'; 7 | import { Duration, SecretValue, aws_ec2, aws_events } from 'aws-cdk-lib'; 8 | import { ISecret } from 'aws-cdk-lib/aws-secretsmanager'; 9 | import { SimpleEmailService } from '../../prototyping/constructs/SimpleEmailService'; 10 | import { RedCapAwsAccessUser } from '../../prototyping/constructs/RedCapAwsAccessUser'; 11 | import { Waf, WebACLAssociation } from '../../prototyping/constructs/Waf'; 12 | import { IGrantable } from 'aws-cdk-lib/aws-iam'; 13 | import { CfnAutoScalingConfigurationProps } from 'aws-cdk-lib/aws-apprunner'; 14 | import { Connection, HttpMethod } from 'aws-cdk-lib/aws-events'; 15 | import { ApiDestination } from 'aws-cdk-lib/aws-events-targets'; 16 | import { Cpu, Memory } from '@aws-cdk/aws-apprunner-alpha'; 17 | import { ServiceProps } from 'sst/constructs'; 18 | import { IPublicHostedZone } from 'aws-cdk-lib/aws-route53'; 19 | import { isNumber } from 'lodash'; 20 | 21 | export class RedcapService { 22 | private common; 23 | private stack; 24 | private app; 25 | private connection: Connection; 26 | public ecsService?: EcsFargate; 27 | public appRunnerService?: AppRunner; 28 | public EcsServiceUrl?: string | undefined; 29 | public AppRunnerServiceUrl?: string | undefined; 30 | public CustomServiceUrl?: string | undefined; 31 | 32 | constructor( 33 | stack: Stack, 34 | app: App, 35 | common: { 36 | domain: string; 37 | subdomain: string; 38 | publicHostedZone?: IPublicHostedZone; 39 | waf: Waf; 40 | secrets: { 41 | dbSecret: ISecret; 42 | dbSalt: ISecret; 43 | ses: SimpleEmailService; 44 | redCapS3AccessUser: RedCapAwsAccessUser; 45 | }; 46 | databaseCluster: DatabaseCluster; 47 | vpc: Vpc; 48 | servicePort: number; 49 | repository: Repository; 50 | environmentVariables: Record; 51 | searchString: string; 52 | cronMinutes?: number; 53 | logRetention?: ServiceProps['logRetention']; //Only for ECS, AppRunner has no logRetention setting 54 | }, 55 | ) { 56 | this.common = common; 57 | this.app = app; 58 | this.stack = stack; 59 | this.connection = this.createEventConnection(); 60 | } 61 | 62 | private grantSecretsReadAndConnect(grantee: IGrantable) { 63 | this.common.secrets.dbSecret.grantRead(grantee); 64 | this.common.secrets.dbSalt.grantRead(grantee); 65 | this.common.secrets.ses.sesUserCredentials.grantRead(grantee); 66 | this.common.secrets.redCapS3AccessUser.secret.grantRead(grantee); 67 | this.common.databaseCluster.grantConnect(grantee, 'redcap_user'); 68 | } 69 | 70 | private associateWaf(resourceArn: string, serviceType: string) { 71 | let id = 'apprunner-redcap'; 72 | if (serviceType === 'ecs-redcap') id = 'ecs-redcap'; 73 | new WebACLAssociation(this.stack, id, { 74 | webAclArn: this.common.waf.waf.attrArn, 75 | resourceArn, 76 | }); 77 | } 78 | 79 | private setupCronJob(serviceType: string) { 80 | let prefixId = 'ecs-service'; 81 | let url = this.CustomServiceUrl; //requires a valid https connection with SSL 82 | if (serviceType === 'apprunner') { 83 | url = this.AppRunnerServiceUrl; 84 | prefixId = 'apprunner-service'; 85 | } 86 | 87 | const destination = new aws_events.ApiDestination(this.stack, `${prefixId}-destination`, { 88 | connection: this.connection, 89 | endpoint: `${url}/cron.php?secret=${this.common.searchString}`, 90 | httpMethod: HttpMethod.GET, 91 | description: `Call cron on REDCap deployment ${serviceType}`, 92 | }); 93 | 94 | let schedule: aws_events.Schedule | undefined = aws_events.Schedule.rate(Duration.minutes(1)); 95 | 96 | if (isNumber(this.common.cronMinutes)) { 97 | if (this.common.cronMinutes > 0) 98 | schedule = aws_events.Schedule.rate(Duration.minutes(this.common.cronMinutes)); 99 | else schedule = undefined; 100 | } 101 | if (schedule) 102 | new aws_events.Rule(this.stack, `${prefixId}-cron`, { 103 | schedule, 104 | targets: [new ApiDestination(destination)], 105 | }); 106 | } 107 | 108 | private createEventConnection() { 109 | return new aws_events.Connection(this.stack, 'redcap-connection', { 110 | // Auth not in use, REDCap does not have any auth requirement for this. However, this constructs requires it. 111 | // To protect this, we add a AWS WAF rule above. 112 | authorization: aws_events.Authorization.basic( 113 | 'redcap-cron-user', 114 | SecretValue.unsafePlainText('nopassword'), 115 | ), 116 | description: 'Connection to REDCap cronjob', 117 | }); 118 | } 119 | 120 | public ecsDeploy(config: { 121 | cpu?: ServiceProps['cpu']; 122 | memory?: ServiceProps['memory']; 123 | scaling?: ServiceProps['scaling']; 124 | tag: string; 125 | }) { 126 | const domainName = this.common.subdomain 127 | ? `${this.common.subdomain}.${this.common.domain}` 128 | : this.common.domain; 129 | 130 | this.CustomServiceUrl = `https://${domainName}`; 131 | 132 | this.ecsService = new EcsFargate(this.stack, `${this.app.stage}-${this.app.name}-ecs-service`, { 133 | app: this.app, 134 | network: { 135 | vpc: this.common.vpc, 136 | subnetType: aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, 137 | servicePort: this.common.servicePort || 8080, 138 | }, 139 | logRetention: this.common.logRetention, 140 | cpu: config.cpu || '2 vCPU', 141 | memory: config.memory || '4 GB', 142 | scaling: config.scaling, 143 | repository: this.common.repository, 144 | tag: config.tag || 'latest', 145 | domain: this.common.domain, 146 | subdomain: this.common.subdomain, 147 | environmentVariables: this.common.environmentVariables, 148 | databaseCluster: this.common.databaseCluster, 149 | containerInsights: true, 150 | publicHostedZone: this.common.publicHostedZone, 151 | certificate: { 152 | fromDns: { 153 | domainName, 154 | }, 155 | }, 156 | }); 157 | 158 | const ecsTaskRole = this.ecsService.service?.cdk?.fargateService?.taskDefinition.taskRole; 159 | const loadBalancerArn = this.ecsService.service?.cdk?.applicationLoadBalancer?.loadBalancerArn; 160 | 161 | if (ecsTaskRole) this.grantSecretsReadAndConnect(ecsTaskRole); 162 | if (loadBalancerArn) this.associateWaf(loadBalancerArn, 'ecs-service'); 163 | 164 | this.EcsServiceUrl = `https://${this.ecsService.url}`; 165 | this.setupCronJob('ecs'); 166 | } 167 | 168 | public appRunnerDeploy(config: { 169 | notificationEmail: string; 170 | securityGroups: Array; 171 | autoDeploymentsEnabled: boolean; 172 | cpu: Cpu; 173 | memory: Memory; 174 | tag: string; 175 | scalingConfiguration: CfnAutoScalingConfigurationProps; 176 | }) { 177 | const { 178 | notificationEmail, 179 | securityGroups, 180 | autoDeploymentsEnabled, 181 | cpu, 182 | memory, 183 | tag, 184 | scalingConfiguration, 185 | } = config; 186 | 187 | this.appRunnerService = new AppRunner( 188 | this.stack, 189 | `${this.app.stage}-${this.app.name}-service`, 190 | { 191 | stack: this.stack, 192 | app: this.app, 193 | publicHostedZone: this.common.publicHostedZone, 194 | domain: this.common.domain, 195 | subdomain: this.common.subdomain, 196 | appName: `${this.app.stage}-${this.app.name}`, 197 | notificationEmail: notificationEmail, 198 | network: { 199 | vpc: this.common.vpc, 200 | subnetType: aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, 201 | securityGroups: securityGroups, 202 | }, 203 | autoDeploymentsEnabled, 204 | service: { 205 | config: { 206 | port: this.common.servicePort || 8080, 207 | cpu, 208 | memory, 209 | environmentVariables: this.common.environmentVariables, 210 | }, 211 | image: { 212 | repositoryName: this.common.repository.repositoryName, 213 | tag, 214 | }, 215 | }, 216 | scalingConfiguration, 217 | }, 218 | ); 219 | 220 | this.grantSecretsReadAndConnect(this.appRunnerService.service); 221 | this.associateWaf(this.appRunnerService.service.serviceArn, 'apprunner'); 222 | 223 | this.AppRunnerServiceUrl = `https://${this.appRunnerService.service.serviceUrl}`; 224 | if (this.appRunnerService.customUrl) { 225 | this.CustomServiceUrl = `https://${this.appRunnerService.customUrl}`; 226 | } 227 | this.setupCronJob('apprunner'); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /stacks/Backend/WafExtraRules.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { isEmpty } from 'lodash'; 8 | import { WafRule } from '../../prototyping/constructs/Waf'; 9 | 10 | // get waf rule for REDCap cron job 11 | export function getRedcapCronRule(searchString: string, priorityRule?: number) { 12 | const redcapCronRule: WafRule = { 13 | name: 'REDCAP-cronjob', 14 | rule: { 15 | name: 'cron-execution', 16 | priority: priorityRule ?? 20, 17 | statement: { 18 | andStatement: { 19 | statements: [ 20 | { 21 | byteMatchStatement: { 22 | searchString: 'cron.php', 23 | fieldToMatch: { 24 | uriPath: {}, 25 | }, 26 | textTransformations: [ 27 | { 28 | priority: 0, 29 | type: 'NONE', 30 | }, 31 | ], 32 | positionalConstraint: 'ENDS_WITH', 33 | }, 34 | }, 35 | { 36 | notStatement: { 37 | statement: { 38 | byteMatchStatement: { 39 | searchString, 40 | fieldToMatch: { 41 | singleQueryArgument: { 42 | Name: 'secret', 43 | }, 44 | }, 45 | textTransformations: [ 46 | { 47 | priority: 0, 48 | type: 'NONE', 49 | }, 50 | ], 51 | positionalConstraint: 'EXACTLY', 52 | }, 53 | }, 54 | }, 55 | }, 56 | ], 57 | }, 58 | }, 59 | action: { 60 | block: {}, 61 | }, 62 | visibilityConfig: { 63 | sampledRequestsEnabled: true, 64 | cloudWatchMetricsEnabled: true, 65 | metricName: 'redcap-cron-exec', 66 | }, 67 | }, 68 | }; 69 | 70 | return redcapCronRule; 71 | } 72 | 73 | // get waf rule for REDCap country limit 74 | export function getCountryLimitRule(allowedCountries?: string[], priorityRule?: number) { 75 | if (!allowedCountries || isEmpty(allowedCountries)) return undefined; 76 | 77 | allowedCountries.forEach(country => { 78 | if (!country) throw new Error('Invalid country in list'); 79 | }); 80 | 81 | const redcapCountryRule: WafRule = { 82 | name: 'country-list', 83 | rule: { 84 | visibilityConfig: { 85 | sampledRequestsEnabled: true, 86 | cloudWatchMetricsEnabled: true, 87 | metricName: 'country-list', 88 | }, 89 | name: 'country-list', 90 | priority: priorityRule || 10, 91 | action: { 92 | block: { 93 | customResponse: { 94 | responseCode: 403, 95 | customResponseBodyKey: 'response', 96 | }, 97 | }, 98 | }, 99 | statement: { 100 | andStatement: { 101 | statements: [ 102 | { 103 | notStatement: { 104 | statement: { 105 | geoMatchStatement: { 106 | countryCodes: allowedCountries, 107 | }, 108 | }, 109 | }, 110 | }, 111 | { 112 | notStatement: { 113 | statement: { 114 | byteMatchStatement: { 115 | searchString: 'cron.php', 116 | fieldToMatch: { 117 | uriPath: {}, 118 | }, 119 | textTransformations: [ 120 | { 121 | priority: 0, 122 | type: 'NONE', 123 | }, 124 | ], 125 | positionalConstraint: 'ENDS_WITH', 126 | }, 127 | }, 128 | }, 129 | }, 130 | ], 131 | }, 132 | }, 133 | }, 134 | }; 135 | return redcapCountryRule; 136 | } 137 | -------------------------------------------------------------------------------- /stacks/BuildImage.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { get, last, split, toLower } from 'lodash'; 8 | import { StackContext, use } from 'sst/constructs'; 9 | 10 | import { RemovalPolicy } from 'aws-cdk-lib'; 11 | import { 12 | BuildSpec, 13 | Cache, 14 | LinuxBuildImage, 15 | LocalCacheMode, 16 | Source, 17 | } from 'aws-cdk-lib/aws-codebuild'; 18 | import { Repository } from 'aws-cdk-lib/aws-ecr'; 19 | import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; 20 | import { Key } from 'aws-cdk-lib/aws-kms'; 21 | 22 | import { Suppressions } from '../prototyping/cdkNag/Suppressions'; 23 | import { CodeBuildProject } from '../prototyping/constructs/CodeBuildProject'; 24 | 25 | import { Asset } from 'aws-cdk-lib/aws-s3-assets'; 26 | import { Helpers } from '../prototyping/extensions/Helpers'; 27 | import { Network } from './Network'; 28 | 29 | import * as stage from '../stages'; 30 | 31 | export function BuildImage({ stack, app }: StackContext) { 32 | const { networkVpc } = use(Network); 33 | 34 | const profile = get(stage, [stack.stage, 'profile'], 'default'); 35 | 36 | // Main REDCap docker port 37 | const port = get(stage, [stack.stage, 'port']); 38 | 39 | // Use local redcap file as deployment 40 | const redCapLocalVersion = get(stage, [stack.stage, 'redCapLocalVersion']); 41 | 42 | const generalLogRetention = get(stage, [stack.stage, 'generalLogRetention'], undefined); 43 | 44 | // Use a remote S3 location to deploy redcap 45 | let redCapS3Path = get(stage, [stack.stage, 'redCapS3Path']); 46 | let redcapTag = get(stage, [stack.stage, 'deployTag'], Helpers.extractRedCapTag(redCapS3Path)); 47 | 48 | const rebuild = get(stage, [stack.stage, 'rebuildImage'], false); 49 | 50 | // Validation check 51 | if (redCapS3Path && redCapLocalVersion) { 52 | throw new Error( 53 | 'You must define only one REDCap install source, redCapLocalVersion (redcap.zip) or an redCapS3Path (bucket_name/path/redcap.zip)', 54 | ); 55 | } else if (!redCapS3Path && !redCapLocalVersion) { 56 | throw new Error( 57 | 'No REDCap install source found, define redCapLocalVersion or redCapS3Path in your stage file', 58 | ); 59 | } 60 | 61 | if (redCapLocalVersion) { 62 | const redcapPackage = new Asset(stack, `${app.stage}-${app.name}-redcapPackage`, { 63 | path: `packages/REDCap/releases/${redCapLocalVersion}.zip`, 64 | exclude: ['.DS_Store'], 65 | }); 66 | 67 | redcapTag = get( 68 | stage, 69 | [stack.stage, 'deployTag'], 70 | Helpers.extractRedCapTag(`${redCapLocalVersion}.zip`), 71 | ); 72 | 73 | redCapS3Path = redcapPackage.s3ObjectUrl; 74 | } else { 75 | redCapS3Path = `s3://${redCapS3Path}`; 76 | } 77 | 78 | const redcapS3Arn = `arn:aws:s3:::${last(split(redCapS3Path, 's3://'))}`; 79 | 80 | // AWS ECR Repository creation 81 | const repository = new Repository(stack, `${app.stage}-${app.name}-ecr`, { 82 | imageScanOnPush: true, 83 | autoDeleteImages: true, 84 | encryptionKey: new Key(stack, `redcap-kms-key`, { 85 | enableKeyRotation: true, 86 | removalPolicy: RemovalPolicy.DESTROY, 87 | }), 88 | removalPolicy: RemovalPolicy.DESTROY, 89 | repositoryName: toLower(`${app.stage}-${app.name}-repository`), 90 | }); 91 | 92 | // Language assets 93 | const redcapLanguages = new Asset(stack, `${app.stage}-${app.name}-redcapLanguages`, { 94 | path: `packages/REDCap/languages`, 95 | exclude: ['.DS_Store'], 96 | }); 97 | 98 | // Deployment assets 99 | const buildAsset = new Asset(stack, `asset`, { 100 | path: 'containers/redcap-docker-apache', 101 | exclude: ['.DS_Store'], 102 | }); 103 | 104 | // Codebuild project to build REDCap image and push to ECR 105 | const codeBuild = new CodeBuildProject(stack, `${app.stage}-${app.name}-codeBuildProject`, { 106 | projectName: `${app.stage}-${app.name}-build`, 107 | vpc: networkVpc.vpc, 108 | cache: Cache.local(LocalCacheMode.DOCKER_LAYER), 109 | source: Source.s3({ 110 | bucket: buildAsset.bucket, 111 | path: buildAsset.s3ObjectKey, 112 | }), 113 | environment: { 114 | privileged: true, 115 | buildImage: LinuxBuildImage.STANDARD_7_0, 116 | }, 117 | environmentVariables: { 118 | ECR_REPOSITORY_URI: { 119 | value: repository.repositoryUri, 120 | }, 121 | IMAGE_TAG: { 122 | value: redcapTag!, 123 | }, 124 | REDCAP_S3_URI: { 125 | value: redCapS3Path, 126 | }, 127 | LANG_S3_URI: { 128 | value: redcapLanguages.s3ObjectUrl, 129 | }, 130 | AWS_ACCOUNT_ID: { 131 | value: stack.account, 132 | }, 133 | PORT: { 134 | value: port || '8080', 135 | }, 136 | }, 137 | buildSpec: BuildSpec.fromAsset('./buildspec/redcap-build.yml'), 138 | }); 139 | 140 | const project = codeBuild.project; 141 | 142 | repository.grantPullPush(project); 143 | buildAsset.bucket.grantRead(project); 144 | 145 | project.addToRolePolicy( 146 | new PolicyStatement({ 147 | actions: [ 148 | 'ecr:GetAuthorizationToken', 149 | 'ecr-public:GetAuthorizationToken', 150 | 'sts:GetServiceBearerToken', 151 | ], 152 | resources: ['*'], 153 | }), 154 | ); 155 | 156 | if (redcapS3Arn) 157 | project.addToRolePolicy( 158 | new PolicyStatement({ 159 | actions: ['s3:GetObject', 's3:ListBucket'], 160 | resources: [redcapS3Arn], 161 | }), 162 | ); 163 | 164 | const lambdaBuild = codeBuild.addLambdaTrigger({ 165 | handler: 'packages/functions/src/startProjectBuild.handler', 166 | name: 'redcap-build', 167 | rebuild: rebuild, 168 | logRetention: generalLogRetention, 169 | executeAfter: [codeBuild], 170 | executeBefore: [], 171 | }); 172 | 173 | stack.addOutputs({ 174 | UpdateDeploymentCommand: `aws lambda invoke --function-name ${lambdaBuild} --region ${stack.region} --profile ${profile} deployLambdaResponse.json`, 175 | }); 176 | 177 | Suppressions.BuildImageSuppressions(codeBuild); 178 | 179 | return repository; 180 | } 181 | -------------------------------------------------------------------------------- /stacks/Database.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { Duration, aws_ec2 } from 'aws-cdk-lib'; 8 | import { StackContext, use } from 'sst/constructs'; 9 | import { Suppressions } from '../prototyping/cdkNag/Suppressions'; 10 | import { AuroraServerlessV2 } from '../prototyping/constructs/AuroraServerlessV2'; 11 | import { Network } from './Network'; 12 | import { get } from 'lodash'; 13 | 14 | import * as stage from '../stages'; 15 | import { RedCapConfig } from '../prototyping'; 16 | import moment from 'moment'; 17 | 18 | export function Database({ stack, app }: StackContext) { 19 | const { networkVpc } = use(Network); 20 | 21 | const dbAllowedSg = new aws_ec2.SecurityGroup(stack, `${app.stage}-${app.name}-apprunner-db-sg`, { 22 | vpc: networkVpc.vpc, 23 | allowAllOutbound: true, 24 | }); 25 | 26 | const dbConfig = get(stage, [stack.stage, 'db']) as RedCapConfig['db']; 27 | 28 | if (!dbConfig) 29 | console.warn( 30 | 'WARNING: db config is absent in stages.ts, using Amazon Aurora defaults settings', 31 | ); 32 | 33 | // Maintenance window check 34 | if (dbConfig?.preferredMaintenanceWindow) { 35 | const window = dbConfig.preferredMaintenanceWindow.split('-'); 36 | 37 | if ( 38 | window.length !== 2 || 39 | !moment(window[0], 'ddd:HH:mm', true).isValid() || 40 | !moment(window[1], 'ddd:HH:mm', true).isValid() 41 | ) 42 | throw new Error( 43 | `Database preferredMaintenanceWindow configuration is invalid, Example: 'Sun:23:45-Mon:00:15'`, 44 | ); 45 | } 46 | 47 | const readers = dbConfig?.dbReaders ?? undefined; 48 | const scaling = dbConfig?.scaling ?? { 49 | maxCapacityAcu: 2, 50 | minCapacityAcu: 0.5, 51 | }; 52 | const maxAllowedPacket = dbConfig?.maxAllowedPacket ?? '4194304'; 53 | const snapshotIdentifier = dbConfig?.dbSnapshotId ?? undefined; 54 | const preferredMaintenanceWindow = dbConfig?.preferredMaintenanceWindow; 55 | const logRetention = get(stage, [stack.stage, 'generalLogRetention'], undefined); 56 | 57 | const auroraClusterV2 = new AuroraServerlessV2(stack, 'RDSV2', { 58 | engine: 'mysql8.0', 59 | defaultDatabaseName: 'redcap', 60 | dbUserName: 'dbadmin', 61 | vpc: networkVpc.vpc, 62 | scaling: { 63 | minCapacityAcu: scaling.minCapacityAcu, 64 | maxCapacityAcu: scaling.maxCapacityAcu, 65 | }, 66 | enabledProxy: false, 67 | logRetention, 68 | rotateSecretAfterDays: Duration.days(120), 69 | parameterGroupParameters: { 70 | max_allowed_packet: maxAllowedPacket, 71 | }, 72 | readers, 73 | snapshotIdentifier, 74 | preferredMaintenanceWindow, 75 | }); 76 | 77 | stack.exportValue(auroraClusterV2.aurora.clusterResourceIdentifier); 78 | stack.exportValue(auroraClusterV2.aurora.connections.securityGroups[0].securityGroupId); 79 | 80 | auroraClusterV2?.aurora.connections.allowDefaultPortFrom(dbAllowedSg); 81 | Suppressions.RDSV2Suppressions(auroraClusterV2); 82 | 83 | return { dbAllowedSg, auroraClusterV2 }; 84 | } 85 | -------------------------------------------------------------------------------- /stacks/EC2Server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | import { aws_ec2 } from 'aws-cdk-lib'; 3 | import { 4 | Instance, 5 | InstanceClass, 6 | InstanceSize, 7 | InstanceType, 8 | MachineImage, 9 | SubnetType, 10 | UserData, 11 | } from 'aws-cdk-lib/aws-ec2'; 12 | import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'; 13 | import { LogGroup } from 'aws-cdk-lib/aws-logs'; 14 | import { 15 | DefinitionBody, 16 | LogLevel, 17 | StateMachine, 18 | Wait, 19 | WaitTime, 20 | } from 'aws-cdk-lib/aws-stepfunctions'; 21 | import { CallAwsService } from 'aws-cdk-lib/aws-stepfunctions-tasks'; 22 | import { Trigger } from 'aws-cdk-lib/triggers'; 23 | 24 | import { get } from 'lodash'; 25 | import { Function, StackContext, use } from 'sst/constructs'; 26 | 27 | import { Suppressions } from '../prototyping/cdkNag/Suppressions'; 28 | import { Backend } from './Backend'; 29 | import { BuildImage } from './BuildImage'; 30 | import { Database } from './Database'; 31 | import { Network } from './Network'; 32 | 33 | import * as stage from '../stages'; 34 | 35 | function generateEnvUserData(envVars: { [key: string]: string }, profiledPath: string): string { 36 | const envExports = Object.keys(envVars) 37 | .map(key => 38 | envVars[key].includes('--output json') 39 | ? `export ${key}=${envVars[key]}` 40 | : `export ${key}=\\"${envVars[key]}\\"`, 41 | ) 42 | .join('\\n'); 43 | return ` 44 | touch ${profiledPath} 45 | chmod +x ${profiledPath} 46 | echo -e "${envExports}" > ${profiledPath} 47 | `; 48 | } 49 | 50 | export function EC2Server({ stack, app }: StackContext) { 51 | const ec2Stack = get(stage, [stack.stage, 'ec2ServerStack']); 52 | const ec2StackDuration = get(ec2Stack, 'ec2StackDuration'); 53 | 54 | if (!ec2Stack) { 55 | Suppressions.SSTEmptyStackSuppressions(stack); 56 | return; 57 | } 58 | 59 | const repository = use(BuildImage); 60 | const { networkVpc } = use(Network); 61 | const { dbSalt, s3UserCredentials, sesUserCredentials, environmentVariables } = use(Backend); 62 | const { dbAllowedSg, auroraClusterV2 } = use(Database); 63 | 64 | const userData = UserData.forLinux({ shebang: '#!/bin/bash' }); 65 | 66 | const profiledPath = '/etc/profile.d/cdk_variables.sh'; 67 | const dockerEnv = `-e AWS_REGION='${app.region}'\ 68 | -e USE_CERT='1' \ 69 | -e USE_IAM_DB_AUTH='true' \ 70 | -e DB_SECRET_ID=\$DB_SECRET_ID \ 71 | -e DB_SALT_SECRET_ID=\$DB_SALT_SECRET_ID \ 72 | -e DB_SECRET_NAME=\$DB_SECRET_NAME \ 73 | -e S3_BUCKET=\$S3_BUCKET \ 74 | -e S3_SECRET_ID=\$S3_SECRET_ID \ 75 | -e SES_CREDENTIALS_SECRET_ID=\$SES_CREDENTIALS_SECRET_ID \ 76 | -e PHP_TIMEZONE=\$PHP_TIMEZONE \ 77 | -e SMTP_EMAIL=\$SMTP_EMAIL`; 78 | 79 | userData.addCommands( 80 | 'sudo dnf update -y', 81 | 'sudo dnf install -y docker', 82 | 'sudo service docker start', 83 | 'sudo usermod -a -G docker ec2-user', 84 | `aws ecr get-login-password --region ${app.region} | docker login --username AWS --password-stdin ${app.account}.dkr.ecr.${app.region}.amazonaws.com`, 85 | `docker pull ${repository.repositoryUri}`, 86 | generateEnvUserData(environmentVariables, profiledPath), 87 | `sudo su && runuser -l ec2-user -c 'docker run --rm -d --sig-proxy=false -p 8081:8081 ${dockerEnv} ${repository.repositoryUri}'`, 88 | ); 89 | 90 | const ec2ServerInstance = new Instance(stack, `EC2ServerInstance`, { 91 | instanceType: InstanceType.of(InstanceClass.BURSTABLE3, InstanceSize.MEDIUM), 92 | machineImage: MachineImage.latestAmazonLinux2023(), 93 | vpc: networkVpc.vpc, 94 | vpcSubnets: networkVpc.vpc.selectSubnets({ 95 | subnetType: SubnetType.PRIVATE_WITH_EGRESS, 96 | }), 97 | userData, 98 | userDataCausesReplacement: true, 99 | securityGroup: dbAllowedSg, 100 | ssmSessionPermissions: true, 101 | detailedMonitoring: true, 102 | associatePublicIpAddress: false, 103 | blockDevices: [ 104 | { 105 | deviceName: '/dev/sda1', 106 | mappingEnabled: true, 107 | volume: aws_ec2.BlockDeviceVolume.ebs(20, { 108 | deleteOnTermination: true, 109 | encrypted: true, 110 | volumeType: aws_ec2.EbsDeviceVolumeType.GP2, 111 | }), 112 | }, 113 | ], 114 | }); 115 | 116 | // Allow secrets read for EC2 instance 117 | auroraClusterV2.aurora.secret?.grantRead(ec2ServerInstance.role); 118 | dbSalt.grantRead(ec2ServerInstance.role); 119 | s3UserCredentials.grantRead(ec2ServerInstance.role); 120 | sesUserCredentials.grantRead(ec2ServerInstance.role); 121 | repository.grantPull(ec2ServerInstance.role); 122 | 123 | // Allow RDS IAM connect 124 | auroraClusterV2.aurora.grantConnect(ec2ServerInstance.role, 'redcap_user'); 125 | 126 | const wait = new Wait(stack, `wait`, { 127 | time: WaitTime.duration(ec2StackDuration), 128 | }); 129 | 130 | const stackArn = `arn:aws:cloudformation:${stack.region}:${stack.account}:stack/${stack.stackName}/*`; 131 | 132 | const deleteStack = new CallAwsService(stack, `deleteStack`, { 133 | service: 'cloudFormation', 134 | action: 'deleteStack', 135 | iamResources: [stackArn], 136 | parameters: { 137 | StackName: stack.stackName, 138 | }, 139 | }); 140 | 141 | deleteStack.addRetry({ 142 | maxAttempts: 3, 143 | }); 144 | 145 | const def = DefinitionBody.fromChainable(wait.next(deleteStack)); 146 | 147 | const terminateStateMachine = new StateMachine(stack, `deleteStackStateMachine`, { 148 | definitionBody: def, 149 | logs: { 150 | destination: new LogGroup(stack, `deleteStackSfnLogGroup`), 151 | level: LogLevel.ALL, 152 | }, 153 | tracingEnabled: true, 154 | }); 155 | 156 | const stateMachineExecHandler = new Function(stack, `stateMachineExecHandler`, { 157 | handler: 'packages/functions/src/stateMachineExec.handler', 158 | environment: { 159 | SFN_ARN: terminateStateMachine.stateMachineArn, 160 | }, 161 | }); 162 | 163 | stateMachineExecHandler.addToRolePolicy( 164 | new PolicyStatement({ 165 | actions: ['states:StartExecution'], 166 | effect: Effect.ALLOW, 167 | resources: [terminateStateMachine.stateMachineArn], 168 | }), 169 | ); 170 | 171 | new Trigger(stack, 'terminateEC2Trigger', { 172 | handler: stateMachineExecHandler, 173 | }); 174 | 175 | const profile = get(stage, [stack.stage, 'profile'], 'default'); 176 | 177 | stack.addOutputs({ 178 | ssmPortForward: `aws ssm start-session --target ${ec2ServerInstance.instanceId} \ 179 | --document-name AWS-StartPortForwardingSession \ 180 | --parameters '{"portNumber":["8081"],"localPortNumber":["8081"]}' --region ${app.region} --profile ${profile}`, 181 | }); 182 | 183 | Suppressions.EC2ServerSuppressions( 184 | ec2ServerInstance, 185 | terminateStateMachine, 186 | stateMachineExecHandler, 187 | ); 188 | } 189 | -------------------------------------------------------------------------------- /stacks/Network.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { InterfaceVpcEndpointAwsService } from 'aws-cdk-lib/aws-ec2'; 8 | import { StackContext } from 'sst/constructs'; 9 | import { Suppressions } from '../prototyping/cdkNag/Suppressions'; 10 | import { NetworkVpc } from '../prototyping/constructs/NetworkVpc'; 11 | import { get } from 'lodash'; 12 | 13 | import * as stage from '../stages'; 14 | 15 | export function Network({ stack, app }: StackContext) { 16 | const iface = InterfaceVpcEndpointAwsService; 17 | const networkVpc = new NetworkVpc(stack, `${app.stage}-${app.name}-vpc`, { 18 | cidr: '10.0.0.0/16', 19 | cidrMask: 24, 20 | publicSubnet: true, 21 | isolatedSubnet: true, 22 | natSubnet: true, 23 | vpcEndpoints: [iface.SECRETS_MANAGER], 24 | vpcEndpointS3: true, 25 | maxAzs: 2, 26 | logRetention: get(stage, [stack.stage, 'generalLogRetention'], undefined), 27 | }); 28 | 29 | networkVpc.vpc.publicSubnets.forEach(publicSubnet => stack.exportValue(publicSubnet.subnetId)); 30 | Suppressions.NetworkVpcSuppressions(networkVpc); 31 | return { networkVpc }; 32 | } 33 | -------------------------------------------------------------------------------- /stacks/Route53NSRecords.ts: -------------------------------------------------------------------------------- 1 | import * as stage from '../stages'; 2 | 3 | import { get, isEmpty, trim } from 'lodash'; 4 | import { aws_route53 } from 'aws-cdk-lib'; 5 | import { StackContext } from 'sst/constructs'; 6 | import { Suppressions } from '../prototyping/cdkNag/Suppressions'; 7 | 8 | interface AppRecords { 9 | name: string; 10 | nsRecords: Array; 11 | } 12 | 13 | interface Route53NSRecordProps { 14 | domain: string; 15 | apps: Array; 16 | } 17 | 18 | export function Route53NSRecords({ stack }: StackContext) { 19 | const apps = get(stage, [stack.stage, 'apps'], null); 20 | const domain = trim(get(stage, [stack.stage, 'domain'], null)); 21 | 22 | if (isEmpty(apps) || !domain || domain === '') { 23 | return; 24 | } 25 | 26 | const config: Route53NSRecordProps = { 27 | apps, 28 | domain, 29 | }; 30 | 31 | const zone = aws_route53.PublicHostedZone.fromLookup(stack, `${config.domain}-zone`, { 32 | domainName: config.domain, 33 | }); 34 | 35 | if (config.domain && !isEmpty(config.apps)) { 36 | config.apps.forEach(app => { 37 | new aws_route53.NsRecord(stack, `ns-${config.domain}-${app.name}`, { 38 | zone, 39 | values: app.nsRecords, 40 | recordName: `${app.name}.${zone.zoneName}`, 41 | }); 42 | }); 43 | } 44 | 45 | Suppressions.Route53NS(zone); 46 | } 47 | -------------------------------------------------------------------------------- /stacks/Security.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0 4 | * Licensed under the Amazon Software License http://aws.amazon.com/asl/ 5 | */ 6 | 7 | import { CfnDetector } from 'aws-cdk-lib/aws-guardduty'; 8 | 9 | import { StackContext } from 'sst/constructs'; 10 | import { Suppressions } from '../prototyping/cdkNag/Suppressions'; 11 | 12 | export function Security({ stack }: StackContext) { 13 | const detector = new CfnDetector(stack, `guard-duty`, { 14 | enable: true, 15 | dataSources: { 16 | s3Logs: { 17 | enable: true, 18 | }, 19 | }, 20 | }); 21 | Suppressions.SecurityDetectorSuppressions(detector); 22 | } 23 | -------------------------------------------------------------------------------- /stages.sample.ts: -------------------------------------------------------------------------------- 1 | import { Cpu, Memory } from '@aws-cdk/aws-apprunner-alpha'; 2 | import { Duration } from 'aws-cdk-lib'; 3 | import { DomainAppsConfig, ProtoConfigOptions, RedCapConfig } from './prototyping'; 4 | 5 | const baseOptions: ProtoConfigOptions = { 6 | name: 'REDCap', 7 | profile: 'your_aws_profile', 8 | region: 'ap-northeast-1', 9 | allowedIps: ['192.0.3.0/24'], 10 | allowedCountries: ['JP'], //(ISO) 3166 11 | }; 12 | 13 | const dev: RedCapConfig = { 14 | ...baseOptions, 15 | hostInRoute53: false, 16 | phpTimezone: 'Asia/Tokyo', 17 | redCapS3Path: 'redcap-binaries/redcap13.7.2.zip', 18 | cronSecret: 'mysecret', 19 | cronMinutes: 1, // a value of 0 means disabled 20 | email: 'email@mydomain.com', 21 | port: 8080, 22 | db: { 23 | dbSnapshotId: undefined, 24 | maxAllowedPacket: '1073741824', 25 | preferredMaintenanceWindow: 'Sun:23:45-Mon:00:15', 26 | dbReaders: 0, // disable readers for dev envs 27 | scaling: { 28 | maxCapacityAcu: 2, 29 | minCapacityAcu: 0, 30 | }, 31 | }, 32 | // Uncomment to use ECS as backend instead of appRunner 33 | // ecs: { 34 | // memory: '4 GB', 35 | // cpu: '4 vCPU', 36 | // scaling: { 37 | // maxContainers: 3, 38 | // minContainers: 1, 39 | // requestsPerContainer: 100, 40 | // cpuUtilization: 90, 41 | // }, 42 | // }, 43 | }; 44 | 45 | const prod: RedCapConfig = { 46 | ...baseOptions, 47 | phpTimezone: 'Asia/Tokyo', 48 | redCapLocalVersion: 'redcap13.7.2', 49 | domain: 'redcap.mydomain.com', 50 | hostInRoute53: true, 51 | email: 'email@mydomain.com', 52 | appRunnerConcurrency: 10, 53 | appRunnerMaxSize: 10, 54 | appRunnerMinSize: 2, 55 | cronSecret: 'prodsecret', 56 | cronMinutes: 1, 57 | cpu: Cpu.FOUR_VCPU, 58 | memory: Memory.EIGHT_GB, 59 | ec2ServerStack: { 60 | ec2StackDuration: Duration.hours(3), 61 | }, 62 | db: { 63 | maxAllowedPacket: '1073741824', 64 | preferredMaintenanceWindow: 'Sun:23:45-Mon:00:15', 65 | }, 66 | bounceNotificationEmail: 'email+bounce@mydomain.com', 67 | }; 68 | 69 | const stag: RedCapConfig = { 70 | ...baseOptions, 71 | redCapS3Path: 'redcap-binaries/redcap13.7.2.zip', 72 | domain: 'redcap.mydomain.com', 73 | phpTimezone: 'Asia/Tokyo', 74 | hostInRoute53: true, 75 | appRunnerConcurrency: 10, 76 | appRunnerMaxSize: 5, 77 | appRunnerMinSize: 1, 78 | rebuildImage: false, 79 | cronSecret: 'stagsecret', 80 | cronMinutes: 1, 81 | cpu: Cpu.FOUR_VCPU, 82 | memory: Memory.EIGHT_GB, 83 | }; 84 | 85 | // Optional: External NameServer configuration with AppRunner stage, example: 86 | // const route53NS: DomainAppsConfig = { 87 | // ...baseOptions, 88 | // profile: 'your_aws_profile', 89 | // region: 'your_aws_region', 90 | // domain: 'redcap.mydomain.com', 91 | // apps: [ 92 | // { 93 | // name: 'redcap', 94 | // nsRecords: [ 95 | // 'ns-sample.co.uk', 96 | // 'ns-sample.net', 97 | // 'ns-sample.org', 98 | // 'ns-sample.com', 99 | // ], 100 | // }, 101 | // ], 102 | // }; 103 | 104 | // Default route53NS config, no records are created. 105 | const route53NS: DomainAppsConfig = { 106 | ...baseOptions, 107 | profile: 'your_aws_profile', 108 | region: 'ap-northeast-1', 109 | domain: '', 110 | apps: [], 111 | }; 112 | 113 | export { dev, prod, route53NS, stag }; 114 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "exclude": [ 4 | "packages" 5 | ], 6 | "compilerOptions": { 7 | "module": "esnext", 8 | "moduleResolution": "node" 9 | } 10 | } --------------------------------------------------------------------------------