@medplum/cdk 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,767 @@
1
+ 'use strict';
2
+
3
+ var awsCdkLib = require('aws-cdk-lib');
4
+ var fs = require('fs');
5
+ var path = require('path');
6
+ var awsEcr = require('aws-cdk-lib/aws-ecr');
7
+ var constructs = require('constructs');
8
+ var cdkServerlessClamscan = require('cdk-serverless-clamscan');
9
+
10
+ // Based on https://gist.github.com/statik/f1ac9d6227d98d30c7a7cec0c83f4e64
11
+ const awsManagedRules = [
12
+ // Common Rule Set aligns with major portions of OWASP Core Rule Set
13
+ // https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-baseline.html
14
+ {
15
+ name: 'AWS-AWSManagedRulesCommonRuleSet',
16
+ priority: 10,
17
+ statement: {
18
+ managedRuleGroupStatement: {
19
+ vendorName: 'AWS',
20
+ name: 'AWSManagedRulesCommonRuleSet',
21
+ // Excluding generic RFI body rule for sns notifications
22
+ // https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html
23
+ excludedRules: [
24
+ { name: 'NoUserAgent_HEADER' },
25
+ { name: 'UserAgent_BadBots_HEADER' },
26
+ { name: 'SizeRestrictions_QUERYSTRING' },
27
+ { name: 'SizeRestrictions_Cookie_HEADER' },
28
+ { name: 'SizeRestrictions_BODY' },
29
+ { name: 'SizeRestrictions_URIPATH' },
30
+ { name: 'EC2MetaDataSSRF_BODY' },
31
+ { name: 'EC2MetaDataSSRF_COOKIE' },
32
+ { name: 'EC2MetaDataSSRF_URIPATH' },
33
+ { name: 'EC2MetaDataSSRF_QUERYARGUMENTS' },
34
+ { name: 'GenericLFI_QUERYARGUMENTS' },
35
+ { name: 'GenericLFI_URIPATH' },
36
+ { name: 'GenericLFI_BODY' },
37
+ { name: 'RestrictedExtensions_URIPATH' },
38
+ { name: 'RestrictedExtensions_QUERYARGUMENTS' },
39
+ { name: 'GenericRFI_QUERYARGUMENTS' },
40
+ { name: 'GenericRFI_BODY' },
41
+ { name: 'GenericRFI_URIPATH' },
42
+ { name: 'CrossSiteScripting_COOKIE' },
43
+ { name: 'CrossSiteScripting_QUERYARGUMENTS' },
44
+ { name: 'CrossSiteScripting_BODY' },
45
+ { name: 'CrossSiteScripting_URIPATH' },
46
+ ],
47
+ },
48
+ },
49
+ overrideAction: {
50
+ count: {},
51
+ },
52
+ visibilityConfig: {
53
+ sampledRequestsEnabled: true,
54
+ cloudWatchMetricsEnabled: true,
55
+ metricName: 'AWS-AWSManagedRulesCommonRuleSet',
56
+ },
57
+ },
58
+ // AWS IP Reputation list includes known malicious actors/bots and is regularly updated
59
+ // https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-ip-rep.html
60
+ {
61
+ name: 'AWS-AWSManagedRulesAmazonIpReputationList',
62
+ priority: 20,
63
+ statement: {
64
+ managedRuleGroupStatement: {
65
+ vendorName: 'AWS',
66
+ name: 'AWSManagedRulesAmazonIpReputationList',
67
+ excludedRules: [{ name: 'AWSManagedIPReputationList' }, { name: 'AWSManagedReconnaissanceList' }],
68
+ },
69
+ },
70
+ overrideAction: {
71
+ count: {},
72
+ },
73
+ visibilityConfig: {
74
+ sampledRequestsEnabled: true,
75
+ cloudWatchMetricsEnabled: true,
76
+ metricName: 'AWSManagedRulesAmazonIpReputationList',
77
+ },
78
+ },
79
+ // Blocks common SQL Injection
80
+ // https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-use-case.html#aws-managed-rule-groups-use-case-sql-db
81
+ {
82
+ name: 'AWSManagedRulesSQLiRuleSet',
83
+ priority: 30,
84
+ visibilityConfig: {
85
+ sampledRequestsEnabled: true,
86
+ cloudWatchMetricsEnabled: true,
87
+ metricName: 'AWSManagedRulesSQLiRuleSet',
88
+ },
89
+ overrideAction: {
90
+ count: {},
91
+ },
92
+ statement: {
93
+ managedRuleGroupStatement: {
94
+ vendorName: 'AWS',
95
+ name: 'AWSManagedRulesSQLiRuleSet',
96
+ excludedRules: [
97
+ { name: 'SQLi_QUERYARGUMENTS' },
98
+ { name: 'SQLiExtendedPatterns_QUERYARGUMENTS' },
99
+ { name: 'SQLi_BODY' },
100
+ { name: 'SQLiExtendedPatterns_BODY' },
101
+ { name: 'SQLi_COOKIE' },
102
+ { name: 'SQLi_URIPATH' },
103
+ ],
104
+ },
105
+ },
106
+ },
107
+ // Blocks attacks targeting LFI(Local File Injection) for linux systems
108
+ // https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-use-case.html#aws-managed-rule-groups-use-case-linux-os
109
+ {
110
+ name: 'AWSManagedRuleLinux',
111
+ priority: 40,
112
+ visibilityConfig: {
113
+ sampledRequestsEnabled: true,
114
+ cloudWatchMetricsEnabled: true,
115
+ metricName: 'AWSManagedRuleLinux',
116
+ },
117
+ overrideAction: {
118
+ count: {},
119
+ },
120
+ statement: {
121
+ managedRuleGroupStatement: {
122
+ vendorName: 'AWS',
123
+ name: 'AWSManagedRulesLinuxRuleSet',
124
+ excludedRules: [{ name: 'LFI_URIPATH' }, { name: 'LFI_QUERYSTRING' }, { name: 'LFI_COOKIE' }],
125
+ },
126
+ },
127
+ },
128
+ ];
129
+
130
+ /**
131
+ * Based on: https://github.com/aws-samples/http-api-aws-fargate-cdk/blob/master/cdk/singleAccount/lib/fargate-vpclink-stack.ts
132
+ *
133
+ * RDS config: https://docs.aws.amazon.com/cdk/api/latest/docs/aws-rds-readme.html
134
+ */
135
+ class BackEnd extends constructs.Construct {
136
+ constructor(scope, config) {
137
+ super(scope, 'BackEnd');
138
+ const name = config.name;
139
+ // VPC
140
+ let vpc;
141
+ if (config.vpcId) {
142
+ // Lookup VPC by ARN
143
+ vpc = awsCdkLib.aws_ec2.Vpc.fromLookup(this, 'VPC', { vpcId: config.vpcId });
144
+ }
145
+ else {
146
+ // VPC Flow Logs
147
+ const vpcFlowLogs = new awsCdkLib.aws_logs.LogGroup(this, 'VpcFlowLogs', {
148
+ logGroupName: '/medplum/flowlogs/' + name,
149
+ removalPolicy: awsCdkLib.RemovalPolicy.DESTROY,
150
+ });
151
+ // Create VPC
152
+ vpc = new awsCdkLib.aws_ec2.Vpc(this, 'VPC', {
153
+ maxAzs: config.maxAzs,
154
+ flowLogs: {
155
+ cloudwatch: {
156
+ destination: awsCdkLib.aws_ec2.FlowLogDestination.toCloudWatchLogs(vpcFlowLogs),
157
+ trafficType: awsCdkLib.aws_ec2.FlowLogTrafficType.ALL,
158
+ },
159
+ },
160
+ });
161
+ }
162
+ // Bot Lambda Role
163
+ const botLambdaRole = new awsCdkLib.aws_iam.Role(this, 'BotLambdaRole', {
164
+ assumedBy: new awsCdkLib.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
165
+ });
166
+ // RDS
167
+ let rdsCluster = undefined;
168
+ let rdsSecretsArn = config.rdsSecretsArn;
169
+ if (!rdsSecretsArn) {
170
+ rdsCluster = new awsCdkLib.aws_rds.DatabaseCluster(this, 'DatabaseCluster', {
171
+ engine: awsCdkLib.aws_rds.DatabaseClusterEngine.auroraPostgres({
172
+ version: awsCdkLib.aws_rds.AuroraPostgresEngineVersion.VER_12_9,
173
+ }),
174
+ credentials: awsCdkLib.aws_rds.Credentials.fromGeneratedSecret('clusteradmin'),
175
+ defaultDatabaseName: 'medplum',
176
+ storageEncrypted: true,
177
+ instances: config.rdsInstances,
178
+ instanceProps: {
179
+ vpc: vpc,
180
+ vpcSubnets: {
181
+ subnetType: awsCdkLib.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS,
182
+ },
183
+ instanceType: config.rdsInstanceType ? new awsCdkLib.aws_ec2.InstanceType(config.rdsInstanceType) : undefined,
184
+ enablePerformanceInsights: true,
185
+ },
186
+ backup: {
187
+ retention: awsCdkLib.Duration.days(7),
188
+ },
189
+ cloudwatchLogsExports: ['postgresql'],
190
+ instanceUpdateBehaviour: awsCdkLib.aws_rds.InstanceUpdateBehaviour.ROLLING,
191
+ });
192
+ rdsSecretsArn = rdsCluster.secret.secretArn;
193
+ }
194
+ // Redis
195
+ // Important: For HIPAA compliance, you must specify TransitEncryptionEnabled as true, an AuthToken, and a CacheSubnetGroup.
196
+ const redisSubnetGroup = new awsCdkLib.aws_elasticache.CfnSubnetGroup(this, 'RedisSubnetGroup', {
197
+ description: 'Redis Subnet Group',
198
+ subnetIds: vpc.privateSubnets.map((subnet) => subnet.subnetId),
199
+ });
200
+ const redisSecurityGroup = new awsCdkLib.aws_ec2.SecurityGroup(this, 'RedisSecurityGroup', {
201
+ vpc,
202
+ description: 'Redis Security Group',
203
+ allowAllOutbound: false,
204
+ });
205
+ const redisPassword = new awsCdkLib.aws_secretsmanager.Secret(this, 'RedisPassword', {
206
+ generateSecretString: {
207
+ secretStringTemplate: '{}',
208
+ generateStringKey: 'password',
209
+ excludeCharacters: '@%*()_+=`~{}|[]\\:";\'?,./',
210
+ },
211
+ });
212
+ const redisCluster = new awsCdkLib.aws_elasticache.CfnReplicationGroup(this, 'RedisCluster', {
213
+ engine: 'Redis',
214
+ engineVersion: '6.x',
215
+ cacheNodeType: 'cache.t2.medium',
216
+ replicationGroupDescription: 'RedisReplicationGroup',
217
+ authToken: redisPassword.secretValueFromJson('password').toString(),
218
+ transitEncryptionEnabled: true,
219
+ atRestEncryptionEnabled: true,
220
+ multiAzEnabled: true,
221
+ cacheSubnetGroupName: redisSubnetGroup.ref,
222
+ numNodeGroups: 1,
223
+ replicasPerNodeGroup: 1,
224
+ securityGroupIds: [redisSecurityGroup.securityGroupId],
225
+ });
226
+ redisCluster.node.addDependency(redisPassword);
227
+ const redisSecrets = new awsCdkLib.aws_secretsmanager.Secret(this, 'RedisSecrets', {
228
+ generateSecretString: {
229
+ secretStringTemplate: JSON.stringify({
230
+ host: redisCluster.attrPrimaryEndPointAddress,
231
+ port: redisCluster.attrPrimaryEndPointPort,
232
+ password: redisPassword.secretValueFromJson('password').toString(),
233
+ tls: {},
234
+ }),
235
+ generateStringKey: 'unused',
236
+ },
237
+ });
238
+ redisSecrets.node.addDependency(redisPassword);
239
+ redisSecrets.node.addDependency(redisCluster);
240
+ // ECS Cluster
241
+ const cluster = new awsCdkLib.aws_ecs.Cluster(this, 'Cluster', {
242
+ vpc: vpc,
243
+ });
244
+ // Task Policies
245
+ const taskRolePolicies = new awsCdkLib.aws_iam.PolicyDocument({
246
+ statements: [
247
+ // CloudWatch Logs: Create streams and put events
248
+ new awsCdkLib.aws_iam.PolicyStatement({
249
+ effect: awsCdkLib.aws_iam.Effect.ALLOW,
250
+ actions: ['logs:CreateLogStream', 'logs:PutLogEvents'],
251
+ resources: ['arn:aws:logs:*'],
252
+ }),
253
+ // Secrets Manager: Read only access to secrets
254
+ // https://docs.aws.amazon.com/mediaconnect/latest/ug/iam-policy-examples-asm-secrets.html
255
+ new awsCdkLib.aws_iam.PolicyStatement({
256
+ effect: awsCdkLib.aws_iam.Effect.ALLOW,
257
+ actions: [
258
+ 'secretsmanager:GetResourcePolicy',
259
+ 'secretsmanager:GetSecretValue',
260
+ 'secretsmanager:DescribeSecret',
261
+ 'secretsmanager:ListSecrets',
262
+ 'secretsmanager:ListSecretVersionIds',
263
+ ],
264
+ resources: ['arn:aws:secretsmanager:*'],
265
+ }),
266
+ // Parameter Store: Read only access
267
+ // https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-access.html
268
+ new awsCdkLib.aws_iam.PolicyStatement({
269
+ effect: awsCdkLib.aws_iam.Effect.ALLOW,
270
+ actions: ['ssm:GetParametersByPath', 'ssm:GetParameters', 'ssm:GetParameter', 'ssm:DescribeParameters'],
271
+ resources: ['arn:aws:ssm:*'],
272
+ }),
273
+ // SES: Send emails
274
+ // https://docs.aws.amazon.com/ses/latest/dg/sending-authorization-policy-examples.html
275
+ new awsCdkLib.aws_iam.PolicyStatement({
276
+ effect: awsCdkLib.aws_iam.Effect.ALLOW,
277
+ actions: ['ses:SendEmail', 'ses:SendRawEmail'],
278
+ resources: ['arn:aws:ses:*'],
279
+ }),
280
+ // S3: Read and write access to buckets
281
+ // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazons3.html
282
+ new awsCdkLib.aws_iam.PolicyStatement({
283
+ effect: awsCdkLib.aws_iam.Effect.ALLOW,
284
+ actions: ['s3:ListBucket', 's3:GetObject', 's3:PutObject', 's3:DeleteObject'],
285
+ resources: ['arn:aws:s3:::*'],
286
+ }),
287
+ // IAM: Pass role to innvoke lambda functions
288
+ // https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html
289
+ new awsCdkLib.aws_iam.PolicyStatement({
290
+ effect: awsCdkLib.aws_iam.Effect.ALLOW,
291
+ actions: ['iam:ListRoles', 'iam:GetRole', 'iam:PassRole'],
292
+ resources: [botLambdaRole.roleArn],
293
+ }),
294
+ // Lambda: Create, read, update, delete, and invoke functions
295
+ // https://docs.aws.amazon.com/lambda/latest/dg/access-control-identity-based.html
296
+ new awsCdkLib.aws_iam.PolicyStatement({
297
+ effect: awsCdkLib.aws_iam.Effect.ALLOW,
298
+ actions: [
299
+ 'lambda:CreateFunction',
300
+ 'lambda:GetFunction',
301
+ 'lambda:GetFunctionConfiguration',
302
+ 'lambda:UpdateFunctionCode',
303
+ 'lambda:UpdateFunctionConfiguration',
304
+ 'lambda:ListLayerVersions',
305
+ 'lambda:GetLayerVersion',
306
+ 'lambda:InvokeFunction',
307
+ ],
308
+ resources: ['arn:aws:lambda:*'],
309
+ }),
310
+ ],
311
+ });
312
+ // Task Role
313
+ const taskRole = new awsCdkLib.aws_iam.Role(this, 'TaskExecutionRole', {
314
+ assumedBy: new awsCdkLib.aws_iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
315
+ description: 'Medplum Server Task Execution Role',
316
+ inlinePolicies: {
317
+ TaskExecutionPolicies: taskRolePolicies,
318
+ },
319
+ });
320
+ // Task Definitions
321
+ const taskDefinition = new awsCdkLib.aws_ecs.FargateTaskDefinition(this, 'TaskDefinition', {
322
+ memoryLimitMiB: config.serverMemory,
323
+ cpu: config.serverCpu,
324
+ taskRole: taskRole,
325
+ });
326
+ // Log Groups
327
+ const logGroup = new awsCdkLib.aws_logs.LogGroup(this, 'LogGroup', {
328
+ logGroupName: '/ecs/medplum/' + name,
329
+ removalPolicy: awsCdkLib.RemovalPolicy.DESTROY,
330
+ });
331
+ const logDriver = new awsCdkLib.aws_ecs.AwsLogDriver({
332
+ logGroup: logGroup,
333
+ streamPrefix: 'Medplum',
334
+ });
335
+ // Task Containers
336
+ let serverImage = undefined;
337
+ // Pull out the image name and tag from the image URI if it's an ECR image
338
+ const ecrImageUriRegex = new RegExp(`^${config.accountNumber}\\.dkr\\.ecr\\.${config.region}\\.amazonaws\\.com/(.*)[:@](.*)$`);
339
+ const nameTagMatches = config.serverImage.match(ecrImageUriRegex);
340
+ const serverImageName = nameTagMatches?.[1];
341
+ const serverImageTag = nameTagMatches?.[2];
342
+ if (serverImageName && serverImageTag) {
343
+ // Creating an ecr repository image will automatically grant fine-grained permissions to ecs to access the image
344
+ const ecrRepo = awsEcr.Repository.fromRepositoryArn(this, 'ServerImageRepo', `arn:aws:ecr:${config.region}:${config.accountNumber}:repository/${serverImageName}`);
345
+ serverImage = awsCdkLib.aws_ecs.ContainerImage.fromEcrRepository(ecrRepo, serverImageTag);
346
+ }
347
+ else {
348
+ // Otherwise, use the standard container image
349
+ serverImage = awsCdkLib.aws_ecs.ContainerImage.fromRegistry(config.serverImage);
350
+ }
351
+ const serviceContainer = taskDefinition.addContainer('MedplumTaskDefinition', {
352
+ image: serverImage,
353
+ command: [config.region === 'us-east-1' ? `aws:/medplum/${name}/` : `aws:${config.region}:/medplum/${name}/`],
354
+ logging: logDriver,
355
+ });
356
+ serviceContainer.addPortMappings({
357
+ containerPort: config.apiPort,
358
+ hostPort: config.apiPort,
359
+ });
360
+ // Security Groups
361
+ const fargateSecurityGroup = new awsCdkLib.aws_ec2.SecurityGroup(this, 'ServiceSecurityGroup', {
362
+ allowAllOutbound: true,
363
+ securityGroupName: 'MedplumSecurityGroup',
364
+ vpc: vpc,
365
+ });
366
+ // Fargate Services
367
+ const fargateService = new awsCdkLib.aws_ecs.FargateService(this, 'FargateService', {
368
+ cluster: cluster,
369
+ taskDefinition: taskDefinition,
370
+ assignPublicIp: false,
371
+ vpcSubnets: {
372
+ subnetType: awsCdkLib.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS,
373
+ },
374
+ desiredCount: config.desiredServerCount,
375
+ securityGroups: [fargateSecurityGroup],
376
+ });
377
+ // Load Balancer Target Group
378
+ const targetGroup = new awsCdkLib.aws_elasticloadbalancingv2.ApplicationTargetGroup(this, 'TargetGroup', {
379
+ vpc: vpc,
380
+ port: config.apiPort,
381
+ protocol: awsCdkLib.aws_elasticloadbalancingv2.ApplicationProtocol.HTTP,
382
+ healthCheck: {
383
+ path: '/healthcheck',
384
+ interval: awsCdkLib.Duration.seconds(30),
385
+ timeout: awsCdkLib.Duration.seconds(3),
386
+ healthyThresholdCount: 2,
387
+ unhealthyThresholdCount: 5,
388
+ },
389
+ targets: [fargateService],
390
+ });
391
+ // Load Balancer
392
+ const loadBalancer = new awsCdkLib.aws_elasticloadbalancingv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
393
+ vpc: vpc,
394
+ internetFacing: true,
395
+ http2Enabled: true,
396
+ });
397
+ if (config.loadBalancerLoggingEnabled) {
398
+ // Load Balancer logging
399
+ loadBalancer.logAccessLogs(awsCdkLib.aws_s3.Bucket.fromBucketName(this, 'LoggingBucket', config.loadBalancerLoggingBucket), config.loadBalancerLoggingPrefix);
400
+ }
401
+ // HTTPS Listener
402
+ // Forward to the target group
403
+ loadBalancer.addListener('HttpsListener', {
404
+ port: 443,
405
+ certificates: [
406
+ {
407
+ certificateArn: config.apiSslCertArn,
408
+ },
409
+ ],
410
+ sslPolicy: awsCdkLib.aws_elasticloadbalancingv2.SslPolicy.FORWARD_SECRECY_TLS12_RES_GCM,
411
+ defaultAction: awsCdkLib.aws_elasticloadbalancingv2.ListenerAction.forward([targetGroup]),
412
+ });
413
+ // WAF
414
+ const waf = new awsCdkLib.aws_wafv2.CfnWebACL(this, 'BackEndWAF', {
415
+ defaultAction: { allow: {} },
416
+ scope: 'REGIONAL',
417
+ name: `${config.stackName}-BackEndWAF`,
418
+ rules: awsManagedRules,
419
+ visibilityConfig: {
420
+ cloudWatchMetricsEnabled: true,
421
+ metricName: `${config.stackName}-BackEndWAF-Metric`,
422
+ sampledRequestsEnabled: false,
423
+ },
424
+ });
425
+ // Create an association between the load balancer and the WAF
426
+ const wafAssociation = new awsCdkLib.aws_wafv2.CfnWebACLAssociation(this, 'LoadBalancerAssociation', {
427
+ resourceArn: loadBalancer.loadBalancerArn,
428
+ webAclArn: waf.attrArn,
429
+ });
430
+ // Grant RDS access to the fargate group
431
+ if (rdsCluster) {
432
+ rdsCluster.connections.allowDefaultPortFrom(fargateSecurityGroup);
433
+ }
434
+ // Grant Redis access to the fargate group
435
+ redisSecurityGroup.addIngressRule(fargateSecurityGroup, awsCdkLib.aws_ec2.Port.tcp(6379));
436
+ // Route 53
437
+ const zone = awsCdkLib.aws_route53.HostedZone.fromLookup(this, 'Zone', {
438
+ domainName: config.domainName.split('.').slice(-2).join('.'),
439
+ });
440
+ // Route53 alias record for the load balancer
441
+ const record = new awsCdkLib.aws_route53.ARecord(this, 'LoadBalancerAliasRecord', {
442
+ recordName: config.apiDomainName,
443
+ target: awsCdkLib.aws_route53.RecordTarget.fromAlias(new awsCdkLib.aws_route53_targets.LoadBalancerTarget(loadBalancer)),
444
+ zone: zone,
445
+ });
446
+ // SSM Parameters
447
+ const databaseSecrets = new awsCdkLib.aws_ssm.StringParameter(this, 'DatabaseSecretsParameter', {
448
+ tier: awsCdkLib.aws_ssm.ParameterTier.STANDARD,
449
+ parameterName: `/medplum/${name}/DatabaseSecrets`,
450
+ description: 'Database secrets ARN',
451
+ stringValue: rdsSecretsArn,
452
+ });
453
+ const redisSecretsParameter = new awsCdkLib.aws_ssm.StringParameter(this, 'RedisSecretsParameter', {
454
+ tier: awsCdkLib.aws_ssm.ParameterTier.STANDARD,
455
+ parameterName: `/medplum/${name}/RedisSecrets`,
456
+ description: 'Redis secrets ARN',
457
+ stringValue: redisSecrets.secretArn,
458
+ });
459
+ const botLambdaRoleParameter = new awsCdkLib.aws_ssm.StringParameter(this, 'BotLambdaRoleParameter', {
460
+ tier: awsCdkLib.aws_ssm.ParameterTier.STANDARD,
461
+ parameterName: `/medplum/${name}/botLambdaRoleArn`,
462
+ description: 'Bot lambda execution role ARN',
463
+ stringValue: botLambdaRole.roleArn,
464
+ });
465
+ // Debug
466
+ console.log('ARecord', record.domainName);
467
+ console.log('DatabaseSecretsParameter', databaseSecrets.parameterArn);
468
+ console.log('RedisSecretsParameter', redisSecretsParameter.parameterArn);
469
+ console.log('RedisCluster', redisCluster.attrPrimaryEndPointAddress);
470
+ console.log('BotLambdaRole', botLambdaRoleParameter.stringValue);
471
+ console.log('WAF', waf.attrArn);
472
+ console.log('WAF Association', wafAssociation.node.id);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Static app infrastructure, which deploys app content to an S3 bucket.
478
+ *
479
+ * The app redirects from HTTP to HTTPS, using a CloudFront distribution,
480
+ * Route53 alias record, and ACM certificate.
481
+ */
482
+ class FrontEnd extends constructs.Construct {
483
+ constructor(parent, config, region) {
484
+ super(parent, 'FrontEnd');
485
+ let appBucket;
486
+ if (region === config.region) {
487
+ // S3 bucket
488
+ appBucket = new awsCdkLib.aws_s3.Bucket(this, 'AppBucket', {
489
+ bucketName: config.appDomainName,
490
+ publicReadAccess: false,
491
+ blockPublicAccess: awsCdkLib.aws_s3.BlockPublicAccess.BLOCK_ALL,
492
+ removalPolicy: awsCdkLib.RemovalPolicy.DESTROY,
493
+ encryption: awsCdkLib.aws_s3.BucketEncryption.S3_MANAGED,
494
+ enforceSSL: true,
495
+ versioned: false,
496
+ });
497
+ }
498
+ else {
499
+ // Otherwise, reference the bucket by name and region
500
+ appBucket = awsCdkLib.aws_s3.Bucket.fromBucketAttributes(this, 'AppBucket', {
501
+ bucketName: config.appDomainName,
502
+ region: config.region,
503
+ });
504
+ }
505
+ if (region === 'us-east-1') {
506
+ // HTTP response headers policy
507
+ const responseHeadersPolicy = new awsCdkLib.aws_cloudfront.ResponseHeadersPolicy(this, 'ResponseHeadersPolicy', {
508
+ securityHeadersBehavior: {
509
+ contentSecurityPolicy: {
510
+ contentSecurityPolicy: [
511
+ `default-src 'none'`,
512
+ `base-uri 'self'`,
513
+ `child-src 'self'`,
514
+ `connect-src 'self' ${config.apiDomainName} *.google.com`,
515
+ `font-src 'self' fonts.gstatic.com`,
516
+ `form-action 'self' *.gstatic.com *.google.com`,
517
+ `frame-ancestors 'none'`,
518
+ `frame-src 'self' *.medplum.com *.gstatic.com *.google.com`,
519
+ `img-src 'self' data: ${config.storageDomainName} *.gstatic.com *.google.com *.googleapis.com`,
520
+ `manifest-src 'self'`,
521
+ `media-src 'self' ${config.storageDomainName}`,
522
+ `script-src 'self' *.medplum.com *.gstatic.com *.google.com`,
523
+ `style-src 'self' 'unsafe-inline' *.medplum.com *.gstatic.com *.google.com`,
524
+ `worker-src 'self' blob: *.gstatic.com *.google.com`,
525
+ `upgrade-insecure-requests`,
526
+ ].join('; '),
527
+ override: true,
528
+ },
529
+ contentTypeOptions: { override: true },
530
+ frameOptions: { frameOption: awsCdkLib.aws_cloudfront.HeadersFrameOption.DENY, override: true },
531
+ strictTransportSecurity: {
532
+ accessControlMaxAge: awsCdkLib.Duration.seconds(63072000),
533
+ includeSubdomains: true,
534
+ override: true,
535
+ },
536
+ xssProtection: {
537
+ protection: true,
538
+ modeBlock: true,
539
+ override: true,
540
+ },
541
+ },
542
+ });
543
+ // WAF
544
+ const waf = new awsCdkLib.aws_wafv2.CfnWebACL(this, 'FrontEndWAF', {
545
+ defaultAction: { allow: {} },
546
+ scope: 'CLOUDFRONT',
547
+ name: `${config.stackName}-FrontEndWAF`,
548
+ rules: awsManagedRules,
549
+ visibilityConfig: {
550
+ cloudWatchMetricsEnabled: true,
551
+ metricName: `${config.stackName}-FrontEndWAF-Metric`,
552
+ sampledRequestsEnabled: false,
553
+ },
554
+ });
555
+ // API Origin Cache Policy
556
+ const apiOriginCachePolicy = new awsCdkLib.aws_cloudfront.CachePolicy(this, 'ApiOriginCachePolicy', {
557
+ cachePolicyName: `${config.stackName}-ApiOriginCachePolicy`,
558
+ cookieBehavior: awsCdkLib.aws_cloudfront.CacheCookieBehavior.all(),
559
+ headerBehavior: awsCdkLib.aws_cloudfront.CacheHeaderBehavior.allowList('Authorization', 'Content-Encoding', 'Content-Type', 'If-None-Match', 'Origin', 'Referer', 'User-Agent', 'X-Medplum'),
560
+ queryStringBehavior: awsCdkLib.aws_cloudfront.CacheQueryStringBehavior.all(),
561
+ });
562
+ // Origin access identity
563
+ const originAccessIdentity = new awsCdkLib.aws_cloudfront.OriginAccessIdentity(this, 'OriginAccessIdentity', {});
564
+ appBucket.grantRead(originAccessIdentity);
565
+ // CloudFront distribution
566
+ const distribution = new awsCdkLib.aws_cloudfront.Distribution(this, 'AppDistribution', {
567
+ defaultRootObject: 'index.html',
568
+ defaultBehavior: {
569
+ origin: new awsCdkLib.aws_cloudfront_origins.S3Origin(appBucket, { originAccessIdentity }),
570
+ responseHeadersPolicy,
571
+ viewerProtocolPolicy: awsCdkLib.aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
572
+ },
573
+ additionalBehaviors: {
574
+ '/api/*': {
575
+ origin: new awsCdkLib.aws_cloudfront_origins.HttpOrigin(config.apiDomainName),
576
+ allowedMethods: awsCdkLib.aws_cloudfront.AllowedMethods.ALLOW_ALL,
577
+ cachePolicy: apiOriginCachePolicy,
578
+ viewerProtocolPolicy: awsCdkLib.aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
579
+ },
580
+ },
581
+ certificate: awsCdkLib.aws_certificatemanager.Certificate.fromCertificateArn(this, 'AppCertificate', config.appSslCertArn),
582
+ domainNames: [config.appDomainName],
583
+ errorResponses: [
584
+ {
585
+ httpStatus: 403,
586
+ responseHttpStatus: 200,
587
+ responsePagePath: '/index.html',
588
+ },
589
+ {
590
+ httpStatus: 404,
591
+ responseHttpStatus: 200,
592
+ responsePagePath: '/index.html',
593
+ },
594
+ ],
595
+ webAclId: waf.attrArn,
596
+ });
597
+ const zone = awsCdkLib.aws_route53.HostedZone.fromLookup(this, 'Zone', {
598
+ domainName: config.domainName.split('.').slice(-2).join('.'),
599
+ });
600
+ // Route53 alias record for the CloudFront distribution
601
+ const record = new awsCdkLib.aws_route53.ARecord(this, 'AppAliasRecord', {
602
+ recordName: config.appDomainName,
603
+ target: awsCdkLib.aws_route53.RecordTarget.fromAlias(new awsCdkLib.aws_route53_targets.CloudFrontTarget(distribution)),
604
+ zone,
605
+ });
606
+ // Debug
607
+ console.log('ARecord', record.domainName);
608
+ }
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Binary storage bucket and CloudFront distribution.
614
+ */
615
+ class Storage extends constructs.Construct {
616
+ constructor(parent, config, region) {
617
+ super(parent, 'Storage');
618
+ let storageBucket;
619
+ if (region === config.region) {
620
+ // S3 bucket
621
+ storageBucket = new awsCdkLib.aws_s3.Bucket(this, 'StorageBucket', {
622
+ bucketName: config.storageBucketName,
623
+ publicReadAccess: false,
624
+ blockPublicAccess: awsCdkLib.aws_s3.BlockPublicAccess.BLOCK_ALL,
625
+ encryption: awsCdkLib.aws_s3.BucketEncryption.S3_MANAGED,
626
+ enforceSSL: true,
627
+ versioned: false,
628
+ });
629
+ if (config.clamscanEnabled) {
630
+ // ClamAV serverless scan
631
+ const sc = new cdkServerlessClamscan.ServerlessClamscan(this, 'ServerlessClamscan', {
632
+ defsBucketAccessLogsConfig: {
633
+ logsBucket: awsCdkLib.aws_s3.Bucket.fromBucketName(this, 'LoggingBucket', config.clamscanLoggingBucket),
634
+ logsPrefix: config.clamscanLoggingPrefix,
635
+ },
636
+ });
637
+ sc.addSourceBucket(storageBucket);
638
+ }
639
+ }
640
+ else {
641
+ // Otherwise, reference the bucket by name
642
+ storageBucket = awsCdkLib.aws_s3.Bucket.fromBucketAttributes(this, 'StorageBucket', {
643
+ bucketName: config.storageBucketName,
644
+ region: config.region,
645
+ });
646
+ }
647
+ if (region === 'us-east-1') {
648
+ // Public key in PEM format
649
+ const publicKey = new awsCdkLib.aws_cloudfront.PublicKey(this, 'StoragePublicKey', {
650
+ encodedKey: config.storagePublicKey,
651
+ });
652
+ // Authorized key group for presigned URLs
653
+ const keyGroup = new awsCdkLib.aws_cloudfront.KeyGroup(this, 'StorageKeyGroup', {
654
+ items: [publicKey],
655
+ });
656
+ // HTTP response headers policy
657
+ const responseHeadersPolicy = new awsCdkLib.aws_cloudfront.ResponseHeadersPolicy(this, 'ResponseHeadersPolicy', {
658
+ securityHeadersBehavior: {
659
+ contentSecurityPolicy: {
660
+ contentSecurityPolicy: "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors *.medplum.com;",
661
+ override: true,
662
+ },
663
+ contentTypeOptions: { override: true },
664
+ frameOptions: { frameOption: awsCdkLib.aws_cloudfront.HeadersFrameOption.DENY, override: true },
665
+ referrerPolicy: { referrerPolicy: awsCdkLib.aws_cloudfront.HeadersReferrerPolicy.NO_REFERRER, override: true },
666
+ strictTransportSecurity: {
667
+ accessControlMaxAge: awsCdkLib.Duration.seconds(63072000),
668
+ includeSubdomains: true,
669
+ override: true,
670
+ },
671
+ xssProtection: {
672
+ protection: true,
673
+ modeBlock: true,
674
+ override: true,
675
+ },
676
+ },
677
+ });
678
+ // WAF
679
+ const waf = new awsCdkLib.aws_wafv2.CfnWebACL(this, 'StorageWAF', {
680
+ defaultAction: { allow: {} },
681
+ scope: 'CLOUDFRONT',
682
+ name: `${config.stackName}-StorageWAF`,
683
+ rules: awsManagedRules,
684
+ visibilityConfig: {
685
+ cloudWatchMetricsEnabled: true,
686
+ metricName: `${config.stackName}-StorageWAF-Metric`,
687
+ sampledRequestsEnabled: false,
688
+ },
689
+ });
690
+ // Origin access identity
691
+ const originAccessIdentity = new awsCdkLib.aws_cloudfront.OriginAccessIdentity(this, 'OriginAccessIdentity', {});
692
+ storageBucket.grantRead(originAccessIdentity);
693
+ // CloudFront distribution
694
+ const distribution = new awsCdkLib.aws_cloudfront.Distribution(this, 'StorageDistribution', {
695
+ defaultBehavior: {
696
+ origin: new awsCdkLib.aws_cloudfront_origins.S3Origin(storageBucket, { originAccessIdentity }),
697
+ responseHeadersPolicy,
698
+ viewerProtocolPolicy: awsCdkLib.aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
699
+ trustedKeyGroups: [keyGroup],
700
+ },
701
+ certificate: awsCdkLib.aws_certificatemanager.Certificate.fromCertificateArn(this, 'StorageCertificate', config.storageSslCertArn),
702
+ domainNames: [config.storageDomainName],
703
+ webAclId: waf.attrArn,
704
+ });
705
+ const zone = awsCdkLib.aws_route53.HostedZone.fromLookup(this, 'Zone', {
706
+ domainName: config.domainName.split('.').slice(-2).join('.'),
707
+ });
708
+ // Route53 alias record for the CloudFront distribution
709
+ const record = new awsCdkLib.aws_route53.ARecord(this, 'StorageAliasRecord', {
710
+ recordName: config.storageDomainName,
711
+ target: awsCdkLib.aws_route53.RecordTarget.fromAlias(new awsCdkLib.aws_route53_targets.CloudFrontTarget(distribution)),
712
+ zone,
713
+ });
714
+ // Debug
715
+ console.log('ARecord', record.domainName);
716
+ }
717
+ }
718
+ }
719
+
720
+ class MedplumStack {
721
+ constructor(scope, config) {
722
+ this.primaryStack = new awsCdkLib.Stack(scope, config.stackName, {
723
+ env: {
724
+ region: config.region,
725
+ account: config.accountNumber,
726
+ },
727
+ });
728
+ this.backEnd = new BackEnd(this.primaryStack, config);
729
+ this.frontEnd = new FrontEnd(this.primaryStack, config, config.region);
730
+ this.storage = new Storage(this.primaryStack, config, config.region);
731
+ if (config.region !== 'us-east-1') {
732
+ // Some resources must be created in us-east-1
733
+ // For example, CloudFront distributions and ACM certificates
734
+ // If the primary region is not us-east-1, create these resources in us-east-1
735
+ const usEast1Stack = new awsCdkLib.Stack(scope, config.stackName + '-us-east-1', {
736
+ env: {
737
+ region: 'us-east-1',
738
+ account: config.accountNumber,
739
+ },
740
+ });
741
+ this.frontEnd = new FrontEnd(usEast1Stack, config, 'us-east-1');
742
+ this.storage = new Storage(usEast1Stack, config, 'us-east-1');
743
+ }
744
+ }
745
+ }
746
+ function main(context) {
747
+ const app = new awsCdkLib.App({ context });
748
+ const configFileName = app.node.tryGetContext('config');
749
+ if (!configFileName) {
750
+ console.log('Missing "config" context variable');
751
+ console.log('Usage: cdk deploy -c config=my-config.json');
752
+ return;
753
+ }
754
+ const config = JSON.parse(fs.readFileSync(path.resolve(configFileName), 'utf-8'));
755
+ const stack = new MedplumStack(app, config);
756
+ console.log('Stack', stack.primaryStack.stackId);
757
+ console.log('BackEnd', stack.backEnd.node.id);
758
+ console.log('FrontEnd', stack.frontEnd.node.id);
759
+ console.log('Storage', stack.storage.node.id);
760
+ app.synth();
761
+ }
762
+ if (require.main === module) {
763
+ main();
764
+ }
765
+
766
+ exports.main = main;
767
+ //# sourceMappingURL=index.cjs.map