@medplum/cdk 2.0.24 → 2.0.25
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/dist/cjs/index.cjs +1 -853
- package/dist/cjs/index.cjs.map +7 -1
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/index.mjs.map +7 -0
- package/dist/esm/package.json +1 -0
- package/dist/types/backend.d.ts +19 -0
- package/dist/types/frontend.d.ts +11 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/oai.d.ts +16 -0
- package/dist/types/storage.d.ts +8 -0
- package/dist/types/waf.d.ts +2 -0
- package/esbuild.mjs +47 -0
- package/package.json +2 -2
- package/src/backend.ts +1 -0
- package/tsconfig.build.json +9 -0
- package/rollup.config.mjs +0 -44
package/dist/cjs/index.cjs
CHANGED
|
@@ -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 n=new RegExp(`^${o.accountNumber}\\.dkr\\.ecr\\.${o.region}\\.amazonaws\\.com/(.*)[:@](.*)$`),r=e.match(n),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
|