diff --git a/src/cloudformation/iam/PolicyDocument.ts b/src/cloudformation/iam/PolicyDocument.ts index 30e21ad..61e5d0e 100644 --- a/src/cloudformation/iam/PolicyDocument.ts +++ b/src/cloudformation/iam/PolicyDocument.ts @@ -26,28 +26,33 @@ export function iamPolicy(policyDocument: PolicyDocument): PolicyDocument { retu export class Policy { private constructor(readonly document: PolicyDocument = {statement: [], version: "2012-10-17"}) { } - grant(action: Action | Action[], effect: PolicyStatement['effect'], onResource: PolicyStatement['resource']): this { + grant( + action: Action | Action[], + effect: PolicyStatement['effect'], + onResource: PolicyStatement['resource'], + statement?: Omit + ): this { this.document.statement = [ ...(this.document.statement ?? []), - { action, effect, resource: onResource } + { action, effect, resource: onResource, ...statement } ] return this; } - allow(action: Action | Action[], onResource: PolicyStatement['resource']): this { - return this.grant(action, 'Allow', onResource) + allow(action: Action | Action[], onResource: PolicyStatement['resource'], statement?: Omit): this { + return this.grant(action, 'Allow', onResource, statement) } - deny(action: Action | Action[], onResource: PolicyStatement['resource']): this { - return this.grant(action, 'Deny', onResource) + deny(action: Action | Action[], onResource: PolicyStatement['resource'], statement?: Omit): this { + return this.grant(action, 'Deny', onResource, statement) } - static allow(action: Action | Action[], onResource: PolicyStatement['resource']): Policy { - return new Policy().allow(action, onResource); + static allow(action: Action | Action[], onResource: PolicyStatement['resource'], statement?: Omit): Policy { + return new Policy().allow(action, onResource, statement); } - static deny(action: Action | Action[], onResource: PolicyStatement['resource']): Policy { - return new Policy().deny(action, onResource); + static deny(action: Action | Action[], onResource: PolicyStatement['resource'], statement?: Omit): Policy { + return new Policy().deny(action, onResource, statement); } } diff --git a/src/cloudformation/modules/api.ts b/src/cloudformation/modules/api.ts index 1571608..0161d12 100644 --- a/src/cloudformation/modules/api.ts +++ b/src/cloudformation/modules/api.ts @@ -2,19 +2,21 @@ import {Authorizer} from "../../aws/apigateway/Authorizer"; import {BasePathMapping} from "../../aws/apigateway/BasePathMapping"; import {Deployment} from "../../aws/apigateway/Deployment"; +import { DomainName } from '../../aws/apigateway/DomainName'; import {Method} from "../../aws/apigateway/Method"; import {Resource} from "../../aws/apigateway/Resource"; import {RestApi} from "../../aws/apigateway/RestApi"; import {Permission} from "../../aws/lambda/Permission"; +import { RecordSet } from '../../aws/route53/RecordSet'; import { AWSResourcesFor } from '../aws'; import {join, joinWith, Value} from "../Value"; import {Tag} from '../../aws/Tag'; -export type ApiExpects = AWSResourcesFor<'apigateway' | 'lambda'> +export type ApiExpects = AWSResourcesFor<'apigateway' | 'lambda' | 'route53'> export class Path { constructor( - private readonly aws: AWSResourcesFor<'apigateway' | 'lambda'>, + private readonly aws: ApiExpects, public api: Api, public resources: Resource[], public parent?: Path, @@ -212,7 +214,9 @@ export class Api{ public lambdaArn?: Value, public lambdaPermissions: Permission[] = [], public basePathMapping?: BasePathMapping, - private paths: Path[] = [] + private paths: Path[] = [], + public customDomain?: DomainName, + public aRecord?: RecordSet, ) {} pathMethodsFrom(path: Path): Array<{path: string, method: string}> { @@ -244,6 +248,24 @@ export class Api{ return this; } + mapToARecord(certificateArnInUsEast1: Value, hostedZoneId: Value, apiDomain: Value): this { + this.customDomain = this.aws.apigateway.domainName({ + domainName: apiDomain, + certificateArn: certificateArnInUsEast1, + endpointConfiguration: { types: ['EDGE'] } + }); + this.aRecord = this.aws.route53.recordSet({ + name: apiDomain, + type: 'A', + hostedZoneId: hostedZoneId, + aliasTarget: { + dNSName: this.customDomain.attributes.DistributionDomainName(), + hostedZoneId: this.customDomain.attributes.DistributionHostedZoneId() + } + }) + return this.mapToDomain(this.customDomain); + } + mapToDomain(domainName: Value): this { this.basePathMapping = this.aws.apigateway.basePathMapping({ restApiId: this.restApi, diff --git a/src/cloudformation/modules/dynamo.ts b/src/cloudformation/modules/dynamo.ts index 6da2265..9392966 100644 --- a/src/cloudformation/modules/dynamo.ts +++ b/src/cloudformation/modules/dynamo.ts @@ -52,7 +52,6 @@ export function dynamoTable< } } const table = aws.dynamodb.table({ - ...(name ? {_logicalName: normalize(name) }: {}), ...props, ...(name ? {tableName: name }: {}), keySchema: [{keyType: 'HASH', attributeName: definition.partitionKey as string}, ...(definition.sortKey ? [{keyType: 'RANGE', attributeName: definition.sortKey as unknown as string}]: [])], diff --git a/src/cloudformation/modules/website.ts b/src/cloudformation/modules/website.ts index db4d119..484faa8 100644 --- a/src/cloudformation/modules/website.ts +++ b/src/cloudformation/modules/website.ts @@ -1,28 +1,106 @@ +import { DistributionProperties, Distribution } from '../../aws/cloudfront/Distribution'; +import { RecordSet } from '../../aws/route53/RecordSet'; import {Bucket} from "../../aws/s3/Bucket"; import {BucketPolicy} from "../../aws/s3/BucketPolicy"; import { AWSResourcesFor } from "../aws"; +import { Policy } from '../iam/PolicyDocument'; import {join, Value} from "../Value"; +export type WebsiteExpects = AWSResourcesFor<'s3' | 'cloudfront' | 'route53'> export class Website { - - bucket: Bucket; - bucketPolicy: BucketPolicy; - - private constructor(bucket: Bucket, bucketPolicy: BucketPolicy) { - this.bucket = bucket; - this.bucketPolicy = bucketPolicy; + + private constructor( + private aws: WebsiteExpects, + public bucket: Bucket, + public bucketPolicy: BucketPolicy, + public distribution?: Distribution, + public aRecord?: RecordSet, + ) { + } + + withARecord(domain: Value, hostedZoneId: Value) { + if(!this.distribution) throw new Error('You must call withDistribution to create A record'); + this.aRecord = this.aws.route53.recordSet({ + name: domain, + type: 'A', + hostedZoneId, + aliasTarget: { + dNSName: this.distribution!.attributes.DomainName(), + hostedZoneId: this.aws.cloudFrontHostedZoneId + } + }); + } + + withDistribution( + originId: Value, + certificateArn: Value, + aliases: Value[]>, + rootObject: Value = 'index.html', + httpVersion: DistributionProperties['distributionConfig']['httpVersion'] = 'http2', + edgeLambdaArn?: Value + ): this { + this.distribution = this.aws.cloudfront.distribution({ + distributionConfig: { + aliases, + customErrorResponses: [{ errorCode: 404, responseCode: 200, responsePagePath: '/' }], + defaultCacheBehavior: { + cachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', + compress: false, + ...(edgeLambdaArn ? { lambdaFunctionAssociations: [{ + eventType: 'viewer-request', + lambdaFunctionARN: edgeLambdaArn, + }] } : {}), + responseHeadersPolicyId: '67f7725c-6f97-4210-82d7-5512b31e9d03', + targetOriginId: originId, + viewerProtocolPolicy: 'redirect-to-https' + }, + defaultRootObject: rootObject, + enabled: true, + httpVersion: httpVersion, + iPV6Enabled: true, + origins: [ + { + id: originId, + customOriginConfig: { + originProtocolPolicy: 'http-only', + originSSLProtocols: [ 'TLSv1.2' ], + }, + domainName: this.aws.functions.select(2, this.aws.functions.split('/', this.bucket.attributes.WebsiteURL())), + } + ], + viewerCertificate: { + acmCertificateArn: certificateArn, + minimumProtocolVersion: 'TLSv1.2_2021', + sslSupportMethod: 'sni-only' + } + } + }); + return this; } - static create(aws: AWSResourcesFor<'s3'>, indexDocument: Value = 'index.html', errorDocument: Value = indexDocument): Website { + static create( + aws: WebsiteExpects, + bucketName: Value, + indexDocument: Value = 'index.html', + errorDocument: Value = indexDocument + ): Website { const bucket = aws.s3.bucket({ - accessControl: 'PublicRead', - websiteConfiguration: { indexDocument, errorDocument } + bucketName: bucketName, + websiteConfiguration: { indexDocument, errorDocument }, + publicAccessBlockConfiguration: { + blockPublicAcls: false, + blockPublicPolicy: false, + ignorePublicAcls: false, + restrictPublicBuckets: false + } }); + const policy = aws.s3.bucketPolicy({ bucket: bucket, - policyDocument: { statement: [{action: 's3:GetObject', effect: 'Allow', resource: [join('arn:aws:s3:::', bucket, '/*')], principal: '*'}] } + policyDocument: Policy.allow('s3:GetObject', [join(bucket.attributes.Arn(), '/*')], { principal: { AWS: '*' } }).document }); - return new Website(bucket, policy); + + return new Website(aws, bucket, policy); } } diff --git a/src/cloudformation/template/template-creator.ts b/src/cloudformation/template/template-creator.ts index 04535e6..c132467 100644 --- a/src/cloudformation/template/template-creator.ts +++ b/src/cloudformation/template/template-creator.ts @@ -20,6 +20,7 @@ export type BuiltIns = { stackId: { Ref: 'AWS::StackId' } stackName: { Ref: 'AWS::StackName' } urlSuffix: { Ref: 'AWS::URLSuffix' } + cloudFrontHostedZoneId: string; condition(name: Condition): ConditionalValue; functions: { import(name: Value): T; @@ -47,6 +48,7 @@ export class TemplateCreator { return { logicalName, customResource, + cloudFrontHostedZoneId: 'Z2FDTNDATAQYW2', accountId: ref('AWS::AccountId'), notificationArns: ref('AWS::NotificationARNs'), noValue: ref('AWS::NoValue'), @@ -202,4 +204,4 @@ export class TemplateCreator { } return {template: output, outputs: outputs || undefined} as any; } -} \ No newline at end of file +}