@medplum/cdk 2.1.17 → 2.1.19

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/config.ts DELETED
@@ -1,202 +0,0 @@
1
- import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
2
- import {
3
- ExternalSecret,
4
- ExternalSecretPrimitive,
5
- ExternalSecretPrimitiveType,
6
- MedplumInfraConfig,
7
- MedplumSourceInfraConfig,
8
- OperationOutcomeError,
9
- badRequest,
10
- validationError,
11
- } from '@medplum/core';
12
-
13
- const VALID_PRIMITIVE_TYPES = ['string', 'boolean', 'number'];
14
- const ssmClients = {} as Record<string, SSMClient>;
15
-
16
- export class InfraConfigNormalizer {
17
- private config: MedplumSourceInfraConfig;
18
- private clients: { ssm: SSMClient };
19
- constructor(config: MedplumSourceInfraConfig) {
20
- const { region } = config;
21
- if (!region) {
22
- throw new OperationOutcomeError(validationError("'region' must be defined as a string literal in config."));
23
- }
24
- if (!ssmClients[region]) {
25
- ssmClients[region] = new SSMClient({ region });
26
- }
27
- this.config = config;
28
- this.clients = { ssm: ssmClients[region] };
29
- }
30
-
31
- async fetchParameterStoreSecret(key: string): Promise<string> {
32
- const response = await this.clients.ssm.send(
33
- new GetParameterCommand({
34
- Name: key,
35
- WithDecryption: true,
36
- })
37
- );
38
- const param = response.Parameter;
39
- if (!param) {
40
- throw new OperationOutcomeError(
41
- badRequest(
42
- `Key '${key}' not found. Make sure your key is correct and that it is defined in your Parameter Store.`
43
- )
44
- );
45
- }
46
- const paramValue = param.Value;
47
- if (!paramValue) {
48
- throw new OperationOutcomeError(
49
- badRequest(
50
- `Key '${key}' found but has no value. Make sure your key is correct and that it is defined in your Parameter Store.`
51
- )
52
- );
53
- }
54
- return paramValue;
55
- }
56
-
57
- async fetchExternalSecret(externalSecret: ExternalSecret): Promise<ExternalSecretPrimitive> {
58
- assertValidExternalSecret(externalSecret);
59
- const { system, key, type } = externalSecret;
60
- let rawValue: ExternalSecretPrimitive;
61
- switch (system) {
62
- case 'aws_ssm_parameter_store': {
63
- rawValue = await this.fetchParameterStoreSecret(key);
64
- break;
65
- }
66
- default:
67
- throw new OperationOutcomeError(
68
- validationError(`Unknown system '${system}' for ExternalSecret. Unable to fetch the secret for key '${key}'.`)
69
- );
70
- }
71
- return normalizeFetchedValue(key, rawValue, type);
72
- }
73
-
74
- async normalizeInfraConfigArray(currentVal: any[]): Promise<ExternalSecretPrimitive[] | Record<string, any>[]> {
75
- // ------ case 3a: primitives or `ExternalSecret`
76
- const firstEle = currentVal[0];
77
- let newArray: ExternalSecretPrimitive[] | Record<string, any>[];
78
- if ((typeof firstEle !== 'object' && firstEle !== null) || isExternalSecretLike(firstEle)) {
79
- newArray = new Array(currentVal.length) as ExternalSecretPrimitive[];
80
- for (let i = 0; i < currentVal.length; i++) {
81
- const currIdxVal = currentVal[i] as unknown as ExternalSecretPrimitive | ExternalSecret;
82
- if (typeof currIdxVal !== 'object') {
83
- newArray[i] = currIdxVal;
84
- continue;
85
- }
86
- const fetchedVal = await this.fetchExternalSecret(currIdxVal);
87
- newArray[i] = fetchedVal;
88
- }
89
- }
90
- // ------ case 3b: other objects (recurse)
91
- else {
92
- newArray = new Array(currentVal.length) as Record<string, any>[];
93
- for (let i = 0; i < currentVal.length; i++) {
94
- newArray[i] = await this.normalizeObjectInInfraConfig(currentVal[i]);
95
- }
96
- }
97
- return newArray;
98
- }
99
-
100
- async normalizeValueForKey(obj: Record<string, any>, key: string): Promise<void> {
101
- const currentVal = obj[key];
102
- // cases:
103
- // --- case 1: primitive
104
- if (typeof currentVal !== 'object') {
105
- obj[key] = currentVal;
106
- }
107
- // --- case 2: object conforming to `ExternalSecret` schema
108
- else if (isExternalSecretLike(currentVal)) {
109
- obj[key] = await this.fetchExternalSecret(currentVal);
110
- }
111
- // --- case 3: an array of:
112
- else if (Array.isArray(currentVal) && currentVal.length) {
113
- obj[key] = await this.normalizeInfraConfigArray(currentVal);
114
- }
115
- // --- case 4: other object (recurse)
116
- else if (typeof currentVal === 'object') {
117
- obj[key] = await this.normalizeObjectInInfraConfig(currentVal);
118
- }
119
- }
120
-
121
- async normalizeObjectInInfraConfig(obj: Record<string, any>): Promise<Record<string, any>> {
122
- const normalizedObj = { ...obj };
123
- // walk config object
124
- for (const key of Object.keys(normalizedObj)) {
125
- await this.normalizeValueForKey(normalizedObj, key);
126
- }
127
- return normalizedObj;
128
- }
129
-
130
- async normalizeConfig(): Promise<MedplumInfraConfig> {
131
- return this.normalizeObjectInInfraConfig(this.config) as Promise<MedplumInfraConfig>;
132
- }
133
- }
134
-
135
- export function normalizeFetchedValue(
136
- key: string,
137
- rawValue: ExternalSecretPrimitive,
138
- expectedType: ExternalSecretPrimitiveType
139
- ): ExternalSecretPrimitive {
140
- const typeOfVal = typeof rawValue;
141
- // Return raw type if type is string and value is of type string, or if type isn't string and typeof val isn't string
142
- if (!VALID_PRIMITIVE_TYPES.includes(typeOfVal)) {
143
- throw new OperationOutcomeError(
144
- validationError(
145
- `Invalid value found for type; expected either ${VALID_PRIMITIVE_TYPES.join(', or')} but got ${typeOfVal}`
146
- )
147
- );
148
- }
149
- if (typeOfVal === expectedType) {
150
- return rawValue;
151
- } else if (typeOfVal === 'string' && expectedType === 'boolean') {
152
- const normalized = (rawValue as string).toLowerCase() as 'true' | 'false';
153
- if (normalized !== 'true' && normalized !== 'false') {
154
- throw new OperationOutcomeError(
155
- validationError(`Invalid value found for key '${key}'; expected boolean value but got '${rawValue}'`)
156
- );
157
- }
158
- return normalized === 'true';
159
- } else if (typeOfVal === 'string' && expectedType === 'number') {
160
- const parsed = parseInt(rawValue as string, 10);
161
- if (Number.isNaN(parsed)) {
162
- throw new OperationOutcomeError(
163
- validationError(`Invalid value found for key '${key}'; expected integer value but got '${rawValue}'`)
164
- );
165
- }
166
- return parsed;
167
- } else {
168
- throw new OperationOutcomeError(
169
- validationError(`Invalid value found for type; expected ${expectedType} value but got value of type ${typeOfVal}`)
170
- );
171
- }
172
- }
173
-
174
- export function isExternalSecretLike(obj: Record<string, any>): obj is ExternalSecret {
175
- return (
176
- typeof obj === 'object' &&
177
- typeof obj.system === 'string' &&
178
- typeof obj.key === 'string' &&
179
- typeof obj.type === 'string'
180
- );
181
- }
182
-
183
- export function isExternalSecret(obj: Record<string, any>): obj is ExternalSecret {
184
- return (
185
- typeof obj === 'object' &&
186
- typeof obj.system === 'string' &&
187
- typeof obj.key === 'string' &&
188
- VALID_PRIMITIVE_TYPES.includes(obj.type)
189
- );
190
- }
191
-
192
- export function assertValidExternalSecret(obj: Record<string, any>): asserts obj is ExternalSecret {
193
- if (!isExternalSecret(obj)) {
194
- throw new OperationOutcomeError(
195
- validationError('obj is not a valid `ExternalSecret`, must contain a valid `system`, `key`, and `type` prop.')
196
- );
197
- }
198
- }
199
-
200
- export async function normalizeInfraConfig(config: MedplumSourceInfraConfig): Promise<MedplumInfraConfig> {
201
- return new InfraConfigNormalizer(config).normalizeConfig();
202
- }
package/src/frontend.ts DELETED
@@ -1,188 +0,0 @@
1
- import { MedplumInfraConfig } from '@medplum/core';
2
- import {
3
- aws_certificatemanager as acm,
4
- aws_cloudfront as cloudfront,
5
- Duration,
6
- aws_iam as iam,
7
- aws_cloudfront_origins as origins,
8
- RemovalPolicy,
9
- aws_route53 as route53,
10
- aws_s3 as s3,
11
- aws_route53_targets as targets,
12
- aws_wafv2 as wafv2,
13
- } from 'aws-cdk-lib';
14
- import { Construct } from 'constructs';
15
- import { grantBucketAccessToOriginAccessIdentity } from './oai';
16
- import { awsManagedRules } from './waf';
17
-
18
- /**
19
- * Static app infrastructure, which deploys app content to an S3 bucket.
20
- *
21
- * The app redirects from HTTP to HTTPS, using a CloudFront distribution,
22
- * Route53 alias record, and ACM certificate.
23
- */
24
- export class FrontEnd extends Construct {
25
- appBucket: s3.IBucket;
26
- responseHeadersPolicy?: cloudfront.IResponseHeadersPolicy;
27
- waf?: wafv2.CfnWebACL;
28
- apiOriginCachePolicy?: cloudfront.ICachePolicy;
29
- originAccessIdentity?: cloudfront.OriginAccessIdentity;
30
- originAccessPolicyStatement?: iam.PolicyStatement;
31
- distribution?: cloudfront.IDistribution;
32
- dnsRecord?: route53.IRecordSet;
33
-
34
- constructor(parent: Construct, config: MedplumInfraConfig, region: string) {
35
- super(parent, 'FrontEnd');
36
-
37
- if (region === config.region) {
38
- // S3 bucket
39
- this.appBucket = new s3.Bucket(this, 'AppBucket', {
40
- bucketName: config.appDomainName,
41
- publicReadAccess: false,
42
- blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
43
- removalPolicy: RemovalPolicy.DESTROY,
44
- encryption: s3.BucketEncryption.S3_MANAGED,
45
- enforceSSL: true,
46
- versioned: true,
47
- });
48
- } else {
49
- // Otherwise, reference the bucket by name and region
50
- this.appBucket = s3.Bucket.fromBucketAttributes(this, 'AppBucket', {
51
- bucketName: config.appDomainName,
52
- region: config.region,
53
- });
54
- }
55
-
56
- if (region === 'us-east-1') {
57
- // HTTP response headers policy
58
- this.responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'ResponseHeadersPolicy', {
59
- securityHeadersBehavior: {
60
- contentSecurityPolicy: {
61
- contentSecurityPolicy: [
62
- `default-src 'none'`,
63
- `base-uri 'self'`,
64
- `child-src 'self'`,
65
- `connect-src 'self' ${config.apiDomainName} *.google.com`,
66
- `font-src 'self' fonts.gstatic.com`,
67
- `form-action 'self' *.gstatic.com *.google.com`,
68
- `frame-ancestors 'none'`,
69
- `frame-src 'self' *.medplum.com *.gstatic.com *.google.com`,
70
- `img-src 'self' data: ${config.storageDomainName} *.gstatic.com *.google.com *.googleapis.com`,
71
- `manifest-src 'self'`,
72
- `media-src 'self' ${config.storageDomainName}`,
73
- `script-src 'self' *.medplum.com *.gstatic.com *.google.com`,
74
- `style-src 'self' 'unsafe-inline' *.medplum.com *.gstatic.com *.google.com`,
75
- `worker-src 'self' blob: *.gstatic.com *.google.com`,
76
- `upgrade-insecure-requests`,
77
- ].join('; '),
78
- override: true,
79
- },
80
- contentTypeOptions: { override: true },
81
- frameOptions: { frameOption: cloudfront.HeadersFrameOption.DENY, override: true },
82
- strictTransportSecurity: {
83
- accessControlMaxAge: Duration.seconds(63072000),
84
- includeSubdomains: true,
85
- override: true,
86
- },
87
- xssProtection: {
88
- protection: true,
89
- modeBlock: true,
90
- override: true,
91
- },
92
- },
93
- });
94
-
95
- // WAF
96
- this.waf = new wafv2.CfnWebACL(this, 'FrontEndWAF', {
97
- defaultAction: { allow: {} },
98
- scope: 'CLOUDFRONT',
99
- name: `${config.stackName}-FrontEndWAF`,
100
- rules: awsManagedRules,
101
- visibilityConfig: {
102
- cloudWatchMetricsEnabled: true,
103
- metricName: `${config.stackName}-FrontEndWAF-Metric`,
104
- sampledRequestsEnabled: false,
105
- },
106
- });
107
-
108
- // API Origin Cache Policy
109
- this.apiOriginCachePolicy = new cloudfront.CachePolicy(this, 'ApiOriginCachePolicy', {
110
- cachePolicyName: `${config.stackName}-ApiOriginCachePolicy`,
111
- cookieBehavior: cloudfront.CacheCookieBehavior.all(),
112
- headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
113
- 'Authorization',
114
- 'Content-Encoding',
115
- 'Content-Type',
116
- 'If-None-Match',
117
- 'Origin',
118
- 'Referer',
119
- 'User-Agent',
120
- 'X-Medplum'
121
- ),
122
- queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
123
- });
124
-
125
- // Origin access identity
126
- this.originAccessIdentity = new cloudfront.OriginAccessIdentity(this, 'OriginAccessIdentity', {});
127
- this.originAccessPolicyStatement = grantBucketAccessToOriginAccessIdentity(
128
- this.appBucket,
129
- this.originAccessIdentity
130
- );
131
-
132
- // CloudFront distribution
133
- this.distribution = new cloudfront.Distribution(this, 'AppDistribution', {
134
- defaultRootObject: 'index.html',
135
- defaultBehavior: {
136
- origin: new origins.S3Origin(this.appBucket, {
137
- originAccessIdentity: this.originAccessIdentity,
138
- }),
139
- responseHeadersPolicy: this.responseHeadersPolicy,
140
- viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
141
- },
142
- additionalBehaviors: config.appApiProxy
143
- ? {
144
- '/api/*': {
145
- origin: new origins.HttpOrigin(config.apiDomainName),
146
- allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
147
- cachePolicy: this.apiOriginCachePolicy,
148
- viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
149
- },
150
- }
151
- : undefined,
152
- certificate: acm.Certificate.fromCertificateArn(this, 'AppCertificate', config.appSslCertArn),
153
- domainNames: [config.appDomainName],
154
- errorResponses: [
155
- {
156
- httpStatus: 403,
157
- responseHttpStatus: 200,
158
- responsePagePath: '/index.html',
159
- },
160
- {
161
- httpStatus: 404,
162
- responseHttpStatus: 200,
163
- responsePagePath: '/index.html',
164
- },
165
- ],
166
- webAclId: this.waf.attrArn,
167
- logBucket: config.appLoggingBucket
168
- ? s3.Bucket.fromBucketName(this, 'LoggingBucket', config.appLoggingBucket)
169
- : undefined,
170
- logFilePrefix: config.appLoggingPrefix,
171
- });
172
-
173
- // DNS
174
- if (!config.skipDns) {
175
- const zone = route53.HostedZone.fromLookup(this, 'Zone', {
176
- domainName: config.domainName.split('.').slice(-2).join('.'),
177
- });
178
-
179
- // Route53 alias record for the CloudFront distribution
180
- this.dnsRecord = new route53.ARecord(this, 'AppAliasRecord', {
181
- recordName: config.appDomainName,
182
- target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(this.distribution)),
183
- zone,
184
- });
185
- }
186
- }
187
- }
188
- }