@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.
package/src/backend.ts ADDED
@@ -0,0 +1,416 @@
1
+ import {
2
+ aws_ec2 as ec2,
3
+ aws_ecs as ecs,
4
+ aws_elasticache as elasticache,
5
+ aws_elasticloadbalancingv2 as elbv2,
6
+ aws_iam as iam,
7
+ aws_logs as logs,
8
+ aws_rds as rds,
9
+ aws_route53 as route53,
10
+ aws_route53_targets as targets,
11
+ aws_s3 as s3,
12
+ aws_secretsmanager as secretsmanager,
13
+ aws_ssm as ssm,
14
+ aws_wafv2 as wafv2,
15
+ Duration,
16
+ RemovalPolicy,
17
+ } from 'aws-cdk-lib';
18
+ import { Repository } from 'aws-cdk-lib/aws-ecr';
19
+ import { Construct } from 'constructs';
20
+ import { MedplumInfraConfig } from './config';
21
+ import { awsManagedRules } from './waf';
22
+
23
+ /**
24
+ * Based on: https://github.com/aws-samples/http-api-aws-fargate-cdk/blob/master/cdk/singleAccount/lib/fargate-vpclink-stack.ts
25
+ *
26
+ * RDS config: https://docs.aws.amazon.com/cdk/api/latest/docs/aws-rds-readme.html
27
+ */
28
+ export class BackEnd extends Construct {
29
+ constructor(scope: Construct, config: MedplumInfraConfig) {
30
+ super(scope, 'BackEnd');
31
+
32
+ const name = config.name;
33
+
34
+ // VPC
35
+ let vpc: ec2.IVpc;
36
+
37
+ if (config.vpcId) {
38
+ // Lookup VPC by ARN
39
+ vpc = ec2.Vpc.fromLookup(this, 'VPC', { vpcId: config.vpcId });
40
+ } else {
41
+ // VPC Flow Logs
42
+ const vpcFlowLogs = new logs.LogGroup(this, 'VpcFlowLogs', {
43
+ logGroupName: '/medplum/flowlogs/' + name,
44
+ removalPolicy: RemovalPolicy.DESTROY,
45
+ });
46
+
47
+ // Create VPC
48
+ vpc = new ec2.Vpc(this, 'VPC', {
49
+ maxAzs: config.maxAzs,
50
+ flowLogs: {
51
+ cloudwatch: {
52
+ destination: ec2.FlowLogDestination.toCloudWatchLogs(vpcFlowLogs),
53
+ trafficType: ec2.FlowLogTrafficType.ALL,
54
+ },
55
+ },
56
+ });
57
+ }
58
+
59
+ // Bot Lambda Role
60
+ const botLambdaRole = new iam.Role(this, 'BotLambdaRole', {
61
+ assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
62
+ });
63
+
64
+ // RDS
65
+ let rdsCluster = undefined;
66
+ let rdsSecretsArn = config.rdsSecretsArn;
67
+ if (!rdsSecretsArn) {
68
+ rdsCluster = new rds.DatabaseCluster(this, 'DatabaseCluster', {
69
+ engine: rds.DatabaseClusterEngine.auroraPostgres({
70
+ version: rds.AuroraPostgresEngineVersion.VER_12_9,
71
+ }),
72
+ credentials: rds.Credentials.fromGeneratedSecret('clusteradmin'),
73
+ defaultDatabaseName: 'medplum',
74
+ storageEncrypted: true,
75
+ instances: config.rdsInstances,
76
+ instanceProps: {
77
+ vpc: vpc,
78
+ vpcSubnets: {
79
+ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
80
+ },
81
+ instanceType: config.rdsInstanceType ? new ec2.InstanceType(config.rdsInstanceType) : undefined,
82
+ enablePerformanceInsights: true,
83
+ },
84
+ backup: {
85
+ retention: Duration.days(7),
86
+ },
87
+ cloudwatchLogsExports: ['postgresql'],
88
+ instanceUpdateBehaviour: rds.InstanceUpdateBehaviour.ROLLING,
89
+ });
90
+
91
+ rdsSecretsArn = (rdsCluster.secret as secretsmanager.ISecret).secretArn;
92
+ }
93
+
94
+ // Redis
95
+ // Important: For HIPAA compliance, you must specify TransitEncryptionEnabled as true, an AuthToken, and a CacheSubnetGroup.
96
+ const redisSubnetGroup = new elasticache.CfnSubnetGroup(this, 'RedisSubnetGroup', {
97
+ description: 'Redis Subnet Group',
98
+ subnetIds: vpc.privateSubnets.map((subnet) => subnet.subnetId),
99
+ });
100
+
101
+ const redisSecurityGroup = new ec2.SecurityGroup(this, 'RedisSecurityGroup', {
102
+ vpc,
103
+ description: 'Redis Security Group',
104
+ allowAllOutbound: false,
105
+ });
106
+
107
+ const redisPassword = new secretsmanager.Secret(this, 'RedisPassword', {
108
+ generateSecretString: {
109
+ secretStringTemplate: '{}',
110
+ generateStringKey: 'password',
111
+ excludeCharacters: '@%*()_+=`~{}|[]\\:";\'?,./',
112
+ },
113
+ });
114
+
115
+ const redisCluster = new elasticache.CfnReplicationGroup(this, 'RedisCluster', {
116
+ engine: 'Redis',
117
+ engineVersion: '6.x',
118
+ cacheNodeType: 'cache.t2.medium',
119
+ replicationGroupDescription: 'RedisReplicationGroup',
120
+ authToken: redisPassword.secretValueFromJson('password').toString(),
121
+ transitEncryptionEnabled: true,
122
+ atRestEncryptionEnabled: true,
123
+ multiAzEnabled: true,
124
+ cacheSubnetGroupName: redisSubnetGroup.ref,
125
+ numNodeGroups: 1,
126
+ replicasPerNodeGroup: 1,
127
+ securityGroupIds: [redisSecurityGroup.securityGroupId],
128
+ });
129
+ redisCluster.node.addDependency(redisPassword);
130
+
131
+ const redisSecrets = new secretsmanager.Secret(this, 'RedisSecrets', {
132
+ generateSecretString: {
133
+ secretStringTemplate: JSON.stringify({
134
+ host: redisCluster.attrPrimaryEndPointAddress,
135
+ port: redisCluster.attrPrimaryEndPointPort,
136
+ password: redisPassword.secretValueFromJson('password').toString(),
137
+ tls: {},
138
+ }),
139
+ generateStringKey: 'unused',
140
+ },
141
+ });
142
+ redisSecrets.node.addDependency(redisPassword);
143
+ redisSecrets.node.addDependency(redisCluster);
144
+
145
+ // ECS Cluster
146
+ const cluster = new ecs.Cluster(this, 'Cluster', {
147
+ vpc: vpc,
148
+ });
149
+
150
+ // Task Policies
151
+ const taskRolePolicies = new iam.PolicyDocument({
152
+ statements: [
153
+ // CloudWatch Logs: Create streams and put events
154
+ new iam.PolicyStatement({
155
+ effect: iam.Effect.ALLOW,
156
+ actions: ['logs:CreateLogStream', 'logs:PutLogEvents'],
157
+ resources: ['arn:aws:logs:*'],
158
+ }),
159
+
160
+ // Secrets Manager: Read only access to secrets
161
+ // https://docs.aws.amazon.com/mediaconnect/latest/ug/iam-policy-examples-asm-secrets.html
162
+ new iam.PolicyStatement({
163
+ effect: iam.Effect.ALLOW,
164
+ actions: [
165
+ 'secretsmanager:GetResourcePolicy',
166
+ 'secretsmanager:GetSecretValue',
167
+ 'secretsmanager:DescribeSecret',
168
+ 'secretsmanager:ListSecrets',
169
+ 'secretsmanager:ListSecretVersionIds',
170
+ ],
171
+ resources: ['arn:aws:secretsmanager:*'],
172
+ }),
173
+
174
+ // Parameter Store: Read only access
175
+ // https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-access.html
176
+ new iam.PolicyStatement({
177
+ effect: iam.Effect.ALLOW,
178
+ actions: ['ssm:GetParametersByPath', 'ssm:GetParameters', 'ssm:GetParameter', 'ssm:DescribeParameters'],
179
+ resources: ['arn:aws:ssm:*'],
180
+ }),
181
+
182
+ // SES: Send emails
183
+ // https://docs.aws.amazon.com/ses/latest/dg/sending-authorization-policy-examples.html
184
+ new iam.PolicyStatement({
185
+ effect: iam.Effect.ALLOW,
186
+ actions: ['ses:SendEmail', 'ses:SendRawEmail'],
187
+ resources: ['arn:aws:ses:*'],
188
+ }),
189
+
190
+ // S3: Read and write access to buckets
191
+ // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazons3.html
192
+ new iam.PolicyStatement({
193
+ effect: iam.Effect.ALLOW,
194
+ actions: ['s3:ListBucket', 's3:GetObject', 's3:PutObject', 's3:DeleteObject'],
195
+ resources: ['arn:aws:s3:::*'],
196
+ }),
197
+
198
+ // IAM: Pass role to innvoke lambda functions
199
+ // https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html
200
+ new iam.PolicyStatement({
201
+ effect: iam.Effect.ALLOW,
202
+ actions: ['iam:ListRoles', 'iam:GetRole', 'iam:PassRole'],
203
+ resources: [botLambdaRole.roleArn],
204
+ }),
205
+
206
+ // Lambda: Create, read, update, delete, and invoke functions
207
+ // https://docs.aws.amazon.com/lambda/latest/dg/access-control-identity-based.html
208
+ new iam.PolicyStatement({
209
+ effect: iam.Effect.ALLOW,
210
+ actions: [
211
+ 'lambda:CreateFunction',
212
+ 'lambda:GetFunction',
213
+ 'lambda:GetFunctionConfiguration',
214
+ 'lambda:UpdateFunctionCode',
215
+ 'lambda:UpdateFunctionConfiguration',
216
+ 'lambda:ListLayerVersions',
217
+ 'lambda:GetLayerVersion',
218
+ 'lambda:InvokeFunction',
219
+ ],
220
+ resources: ['arn:aws:lambda:*'],
221
+ }),
222
+ ],
223
+ });
224
+
225
+ // Task Role
226
+ const taskRole = new iam.Role(this, 'TaskExecutionRole', {
227
+ assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
228
+ description: 'Medplum Server Task Execution Role',
229
+ inlinePolicies: {
230
+ TaskExecutionPolicies: taskRolePolicies,
231
+ },
232
+ });
233
+
234
+ // Task Definitions
235
+ const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition', {
236
+ memoryLimitMiB: config.serverMemory,
237
+ cpu: config.serverCpu,
238
+ taskRole: taskRole,
239
+ });
240
+
241
+ // Log Groups
242
+ const logGroup = new logs.LogGroup(this, 'LogGroup', {
243
+ logGroupName: '/ecs/medplum/' + name,
244
+ removalPolicy: RemovalPolicy.DESTROY,
245
+ });
246
+
247
+ const logDriver = new ecs.AwsLogDriver({
248
+ logGroup: logGroup,
249
+ streamPrefix: 'Medplum',
250
+ });
251
+
252
+ // Task Containers
253
+ let serverImage: ecs.ContainerImage | undefined = undefined;
254
+ // Pull out the image name and tag from the image URI if it's an ECR image
255
+ const ecrImageUriRegex = new RegExp(
256
+ `^${config.accountNumber}\\.dkr\\.ecr\\.${config.region}\\.amazonaws\\.com/(.*)[:@](.*)$`
257
+ );
258
+ const nameTagMatches = config.serverImage.match(ecrImageUriRegex);
259
+ const serverImageName = nameTagMatches?.[1];
260
+ const serverImageTag = nameTagMatches?.[2];
261
+ if (serverImageName && serverImageTag) {
262
+ // Creating an ecr repository image will automatically grant fine-grained permissions to ecs to access the image
263
+ const ecrRepo = Repository.fromRepositoryArn(
264
+ this,
265
+ 'ServerImageRepo',
266
+ `arn:aws:ecr:${config.region}:${config.accountNumber}:repository/${serverImageName}`
267
+ );
268
+ serverImage = ecs.ContainerImage.fromEcrRepository(ecrRepo, serverImageTag);
269
+ } else {
270
+ // Otherwise, use the standard container image
271
+ serverImage = ecs.ContainerImage.fromRegistry(config.serverImage);
272
+ }
273
+ const serviceContainer = taskDefinition.addContainer('MedplumTaskDefinition', {
274
+ image: serverImage,
275
+ command: [config.region === 'us-east-1' ? `aws:/medplum/${name}/` : `aws:${config.region}:/medplum/${name}/`],
276
+ logging: logDriver,
277
+ });
278
+
279
+ serviceContainer.addPortMappings({
280
+ containerPort: config.apiPort,
281
+ hostPort: config.apiPort,
282
+ });
283
+
284
+ // Security Groups
285
+ const fargateSecurityGroup = new ec2.SecurityGroup(this, 'ServiceSecurityGroup', {
286
+ allowAllOutbound: true,
287
+ securityGroupName: 'MedplumSecurityGroup',
288
+ vpc: vpc,
289
+ });
290
+
291
+ // Fargate Services
292
+ const fargateService = new ecs.FargateService(this, 'FargateService', {
293
+ cluster: cluster,
294
+ taskDefinition: taskDefinition,
295
+ assignPublicIp: false,
296
+ vpcSubnets: {
297
+ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
298
+ },
299
+ desiredCount: config.desiredServerCount,
300
+ securityGroups: [fargateSecurityGroup],
301
+ });
302
+
303
+ // Load Balancer Target Group
304
+ const targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
305
+ vpc: vpc,
306
+ port: config.apiPort,
307
+ protocol: elbv2.ApplicationProtocol.HTTP,
308
+ healthCheck: {
309
+ path: '/healthcheck',
310
+ interval: Duration.seconds(30),
311
+ timeout: Duration.seconds(3),
312
+ healthyThresholdCount: 2,
313
+ unhealthyThresholdCount: 5,
314
+ },
315
+ targets: [fargateService],
316
+ });
317
+
318
+ // Load Balancer
319
+ const loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
320
+ vpc: vpc,
321
+ internetFacing: true,
322
+ http2Enabled: true,
323
+ });
324
+
325
+ if (config.loadBalancerLoggingEnabled) {
326
+ // Load Balancer logging
327
+ loadBalancer.logAccessLogs(
328
+ s3.Bucket.fromBucketName(this, 'LoggingBucket', config.loadBalancerLoggingBucket),
329
+ config.loadBalancerLoggingPrefix
330
+ );
331
+ }
332
+
333
+ // HTTPS Listener
334
+ // Forward to the target group
335
+ loadBalancer.addListener('HttpsListener', {
336
+ port: 443,
337
+ certificates: [
338
+ {
339
+ certificateArn: config.apiSslCertArn,
340
+ },
341
+ ],
342
+ sslPolicy: elbv2.SslPolicy.FORWARD_SECRECY_TLS12_RES_GCM,
343
+ defaultAction: elbv2.ListenerAction.forward([targetGroup]),
344
+ });
345
+
346
+ // WAF
347
+ const waf = new wafv2.CfnWebACL(this, 'BackEndWAF', {
348
+ defaultAction: { allow: {} },
349
+ scope: 'REGIONAL',
350
+ name: `${config.stackName}-BackEndWAF`,
351
+ rules: awsManagedRules,
352
+ visibilityConfig: {
353
+ cloudWatchMetricsEnabled: true,
354
+ metricName: `${config.stackName}-BackEndWAF-Metric`,
355
+ sampledRequestsEnabled: false,
356
+ },
357
+ });
358
+
359
+ // Create an association between the load balancer and the WAF
360
+ const wafAssociation = new wafv2.CfnWebACLAssociation(this, 'LoadBalancerAssociation', {
361
+ resourceArn: loadBalancer.loadBalancerArn,
362
+ webAclArn: waf.attrArn,
363
+ });
364
+
365
+ // Grant RDS access to the fargate group
366
+ if (rdsCluster) {
367
+ rdsCluster.connections.allowDefaultPortFrom(fargateSecurityGroup);
368
+ }
369
+
370
+ // Grant Redis access to the fargate group
371
+ redisSecurityGroup.addIngressRule(fargateSecurityGroup, ec2.Port.tcp(6379));
372
+
373
+ // Route 53
374
+ const zone = route53.HostedZone.fromLookup(this, 'Zone', {
375
+ domainName: config.domainName.split('.').slice(-2).join('.'),
376
+ });
377
+
378
+ // Route53 alias record for the load balancer
379
+ const record = new route53.ARecord(this, 'LoadBalancerAliasRecord', {
380
+ recordName: config.apiDomainName,
381
+ target: route53.RecordTarget.fromAlias(new targets.LoadBalancerTarget(loadBalancer)),
382
+ zone: zone,
383
+ });
384
+
385
+ // SSM Parameters
386
+ const databaseSecrets = new ssm.StringParameter(this, 'DatabaseSecretsParameter', {
387
+ tier: ssm.ParameterTier.STANDARD,
388
+ parameterName: `/medplum/${name}/DatabaseSecrets`,
389
+ description: 'Database secrets ARN',
390
+ stringValue: rdsSecretsArn,
391
+ });
392
+
393
+ const redisSecretsParameter = new ssm.StringParameter(this, 'RedisSecretsParameter', {
394
+ tier: ssm.ParameterTier.STANDARD,
395
+ parameterName: `/medplum/${name}/RedisSecrets`,
396
+ description: 'Redis secrets ARN',
397
+ stringValue: redisSecrets.secretArn,
398
+ });
399
+
400
+ const botLambdaRoleParameter = new ssm.StringParameter(this, 'BotLambdaRoleParameter', {
401
+ tier: ssm.ParameterTier.STANDARD,
402
+ parameterName: `/medplum/${name}/botLambdaRoleArn`,
403
+ description: 'Bot lambda execution role ARN',
404
+ stringValue: botLambdaRole.roleArn,
405
+ });
406
+
407
+ // Debug
408
+ console.log('ARecord', record.domainName);
409
+ console.log('DatabaseSecretsParameter', databaseSecrets.parameterArn);
410
+ console.log('RedisSecretsParameter', redisSecretsParameter.parameterArn);
411
+ console.log('RedisCluster', redisCluster.attrPrimaryEndPointAddress);
412
+ console.log('BotLambdaRole', botLambdaRoleParameter.stringValue);
413
+ console.log('WAF', waf.attrArn);
414
+ console.log('WAF Association', wafAssociation.node.id);
415
+ }
416
+ }
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ export interface MedplumInfraConfig {
2
+ name: string;
3
+ stackName: string;
4
+ accountNumber: string;
5
+ region: string;
6
+ domainName: string;
7
+ vpcId: string;
8
+ apiPort: number;
9
+ apiDomainName: string;
10
+ apiSslCertArn: string;
11
+ appDomainName: string;
12
+ appSslCertArn: string;
13
+ storageBucketName: string;
14
+ storageDomainName: string;
15
+ storageSslCertArn: string;
16
+ storagePublicKey: string;
17
+ maxAzs: number;
18
+ rdsInstances: number;
19
+ rdsInstanceType: string;
20
+ rdsSecretsArn?: string;
21
+ desiredServerCount: number;
22
+ serverImage: string;
23
+ serverMemory: number;
24
+ serverCpu: number;
25
+ loadBalancerLoggingEnabled: boolean;
26
+ loadBalancerLoggingBucket: string;
27
+ loadBalancerLoggingPrefix: string;
28
+ clamscanEnabled: boolean;
29
+ clamscanLoggingBucket: string;
30
+ clamscanLoggingPrefix: string;
31
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ aws_certificatemanager as acm,
3
+ aws_cloudfront as cloudfront,
4
+ aws_cloudfront_origins as origins,
5
+ aws_route53 as route53,
6
+ aws_route53_targets as targets,
7
+ aws_s3 as s3,
8
+ aws_wafv2 as wafv2,
9
+ Duration,
10
+ RemovalPolicy,
11
+ } from 'aws-cdk-lib';
12
+ import { Construct } from 'constructs';
13
+ import { MedplumInfraConfig } from './config';
14
+ import { awsManagedRules } from './waf';
15
+
16
+ /**
17
+ * Static app infrastructure, which deploys app content to an S3 bucket.
18
+ *
19
+ * The app redirects from HTTP to HTTPS, using a CloudFront distribution,
20
+ * Route53 alias record, and ACM certificate.
21
+ */
22
+ export class FrontEnd extends Construct {
23
+ constructor(parent: Construct, config: MedplumInfraConfig, region: string) {
24
+ super(parent, 'FrontEnd');
25
+
26
+ let appBucket: s3.IBucket;
27
+
28
+ if (region === config.region) {
29
+ // S3 bucket
30
+ appBucket = new s3.Bucket(this, 'AppBucket', {
31
+ bucketName: config.appDomainName,
32
+ publicReadAccess: false,
33
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
34
+ removalPolicy: RemovalPolicy.DESTROY,
35
+ encryption: s3.BucketEncryption.S3_MANAGED,
36
+ enforceSSL: true,
37
+ versioned: false,
38
+ });
39
+ } else {
40
+ // Otherwise, reference the bucket by name and region
41
+ appBucket = s3.Bucket.fromBucketAttributes(this, 'AppBucket', {
42
+ bucketName: config.appDomainName,
43
+ region: config.region,
44
+ });
45
+ }
46
+
47
+ if (region === 'us-east-1') {
48
+ // HTTP response headers policy
49
+ const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'ResponseHeadersPolicy', {
50
+ securityHeadersBehavior: {
51
+ contentSecurityPolicy: {
52
+ contentSecurityPolicy: [
53
+ `default-src 'none'`,
54
+ `base-uri 'self'`,
55
+ `child-src 'self'`,
56
+ `connect-src 'self' ${config.apiDomainName} *.google.com`,
57
+ `font-src 'self' fonts.gstatic.com`,
58
+ `form-action 'self' *.gstatic.com *.google.com`,
59
+ `frame-ancestors 'none'`,
60
+ `frame-src 'self' *.medplum.com *.gstatic.com *.google.com`,
61
+ `img-src 'self' data: ${config.storageDomainName} *.gstatic.com *.google.com *.googleapis.com`,
62
+ `manifest-src 'self'`,
63
+ `media-src 'self' ${config.storageDomainName}`,
64
+ `script-src 'self' *.medplum.com *.gstatic.com *.google.com`,
65
+ `style-src 'self' 'unsafe-inline' *.medplum.com *.gstatic.com *.google.com`,
66
+ `worker-src 'self' blob: *.gstatic.com *.google.com`,
67
+ `upgrade-insecure-requests`,
68
+ ].join('; '),
69
+ override: true,
70
+ },
71
+ contentTypeOptions: { override: true },
72
+ frameOptions: { frameOption: cloudfront.HeadersFrameOption.DENY, override: true },
73
+ strictTransportSecurity: {
74
+ accessControlMaxAge: Duration.seconds(63072000),
75
+ includeSubdomains: true,
76
+ override: true,
77
+ },
78
+ xssProtection: {
79
+ protection: true,
80
+ modeBlock: true,
81
+ override: true,
82
+ },
83
+ },
84
+ });
85
+
86
+ // WAF
87
+ const waf = new wafv2.CfnWebACL(this, 'FrontEndWAF', {
88
+ defaultAction: { allow: {} },
89
+ scope: 'CLOUDFRONT',
90
+ name: `${config.stackName}-FrontEndWAF`,
91
+ rules: awsManagedRules,
92
+ visibilityConfig: {
93
+ cloudWatchMetricsEnabled: true,
94
+ metricName: `${config.stackName}-FrontEndWAF-Metric`,
95
+ sampledRequestsEnabled: false,
96
+ },
97
+ });
98
+
99
+ // API Origin Cache Policy
100
+ const apiOriginCachePolicy = new cloudfront.CachePolicy(this, 'ApiOriginCachePolicy', {
101
+ cachePolicyName: `${config.stackName}-ApiOriginCachePolicy`,
102
+ cookieBehavior: cloudfront.CacheCookieBehavior.all(),
103
+ headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
104
+ 'Authorization',
105
+ 'Content-Encoding',
106
+ 'Content-Type',
107
+ 'If-None-Match',
108
+ 'Origin',
109
+ 'Referer',
110
+ 'User-Agent',
111
+ 'X-Medplum'
112
+ ),
113
+ queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
114
+ });
115
+
116
+ // Origin access identity
117
+ const originAccessIdentity = new cloudfront.OriginAccessIdentity(this, 'OriginAccessIdentity', {});
118
+ appBucket.grantRead(originAccessIdentity);
119
+
120
+ // CloudFront distribution
121
+ const distribution = new cloudfront.Distribution(this, 'AppDistribution', {
122
+ defaultRootObject: 'index.html',
123
+ defaultBehavior: {
124
+ origin: new origins.S3Origin(appBucket, { originAccessIdentity }),
125
+ responseHeadersPolicy,
126
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
127
+ },
128
+ additionalBehaviors: {
129
+ '/api/*': {
130
+ origin: new origins.HttpOrigin(config.apiDomainName),
131
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
132
+ cachePolicy: apiOriginCachePolicy,
133
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
134
+ },
135
+ },
136
+ certificate: acm.Certificate.fromCertificateArn(this, 'AppCertificate', config.appSslCertArn),
137
+ domainNames: [config.appDomainName],
138
+ errorResponses: [
139
+ {
140
+ httpStatus: 403,
141
+ responseHttpStatus: 200,
142
+ responsePagePath: '/index.html',
143
+ },
144
+ {
145
+ httpStatus: 404,
146
+ responseHttpStatus: 200,
147
+ responsePagePath: '/index.html',
148
+ },
149
+ ],
150
+ webAclId: waf.attrArn,
151
+ });
152
+
153
+ const zone = route53.HostedZone.fromLookup(this, 'Zone', {
154
+ domainName: config.domainName.split('.').slice(-2).join('.'),
155
+ });
156
+
157
+ // Route53 alias record for the CloudFront distribution
158
+ const record = new route53.ARecord(this, 'AppAliasRecord', {
159
+ recordName: config.appDomainName,
160
+ target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
161
+ zone,
162
+ });
163
+
164
+ // Debug
165
+ console.log('ARecord', record.domainName);
166
+ }
167
+ }
168
+ }