@medplum/cdk 2.0.24 → 2.0.26

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