From 261b331e89be01dc996d153c91b4018e7ddfda29 Mon Sep 17 00:00:00 2001 From: Smriti Vashisth Date: Mon, 22 Nov 2021 02:30:29 -0800 Subject: [PATCH] feat(apigatewayv2): domain endpoint type, security policy and endpoint migration (#17518) Updating the L2 construct for `AWS::ApiGatewayV2::DomainName` resource to add support for DomainNameConfigurations. DomainNameConfigurations is a list of configurations for an API's domain name (CFN equivalent - https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-apigatewayv2-domainname-domainnameconfiguration.html) which includes endpoint type, security policy, ownership certificate. Changes include: - Code update to support the properties mentioned above - Unit test changes for existing tests to account for the updated `domainNameConfigurations` Lazy evaluation. - New unit tests for mutual tls with ownership certificate, and domain name migration that were not present before. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-apigatewayv2/README.md | 4 + .../lib/common/domain-name.ts | 114 ++++++++++++++++-- .../test/http/domain-name.test.ts | 109 ++++++++++++++++- 3 files changed, 214 insertions(+), 13 deletions(-) diff --git a/packages/@aws-cdk/aws-apigatewayv2/README.md b/packages/@aws-cdk/aws-apigatewayv2/README.md index cc6c6f48c5827..89dcb79f6409d 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2/README.md @@ -204,6 +204,10 @@ const api = new apigwv2.HttpApi(this, 'HttpProxyProdApi', { }); ``` +To migrate a domain endpoint from one type to another, you can add a new endpoint configuration via `addEndpoint()` +and then configure DNS records to route traffic to the new endpoint. After that, you can remove the previous endpoint configuration. +Learn more at [Migrating a custom domain name](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-regional-api-custom-domain-migrate.html) + To associate a specific `Stage` to a custom domain mapping - ```ts diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/domain-name.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/domain-name.ts index e550a21f915f3..70bc42e67b64e 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/domain-name.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/domain-name.ts @@ -1,9 +1,34 @@ import { ICertificate } from '@aws-cdk/aws-certificatemanager'; import { IBucket } from '@aws-cdk/aws-s3'; -import { IResource, Resource, Token } from '@aws-cdk/core'; +import { IResource, Lazy, Resource, Token } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnDomainName, CfnDomainNameProps } from '../apigatewayv2.generated'; +/** + * The minimum version of the SSL protocol that you want API Gateway to use for HTTPS connections. + */ +export enum SecurityPolicy { + /** Cipher suite TLS 1.0 */ + TLS_1_0 = 'TLS_1_0', + + /** Cipher suite TLS 1.2 */ + TLS_1_2 = 'TLS_1_2', +} + +/** + * Endpoint type for a domain name. + */ +export enum EndpointType { + /** + * For an edge-optimized custom domain name. + */ + EDGE = 'EDGE', + /** + * For a regional custom domain name. + */ + REGIONAL = 'REGIONAL', +} + /** * Represents an APIGatewayV2 DomainName * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-domainname.html @@ -51,20 +76,54 @@ export interface DomainNameAttributes { /** * properties used for creating the DomainName */ -export interface DomainNameProps { +export interface DomainNameProps extends EndpointOptions { /** * The custom domain name */ readonly domainName: string; + + /** + * The mutual TLS authentication configuration for a custom domain name. + * @default - mTLS is not configured. + */ + readonly mtls?: MTLSConfig; +} + +/** + * properties for creating a domain name endpoint + */ +export interface EndpointOptions { /** - * The ACM certificate for this domain name + * The ACM certificate for this domain name. + * Certificate can be both ACM issued or imported. */ readonly certificate: ICertificate; + /** - * The mutual TLS authentication configuration for a custom domain name. - * @default - mTLS is not configured. + * The user-friendly name of the certificate that will be used by the endpoint for this domain name. + * @default - No friendly certificate name + */ + readonly certificateName?: string; + + /** + * The type of endpoint for this DomainName. + * @default EndpointType.REGIONAL + */ + readonly endpointType?: EndpointType; + + /** + * The Transport Layer Security (TLS) version + cipher suite for this domain name. + * @default SecurityPolicy.TLS_1_2 + */ + readonly securityPolicy?: SecurityPolicy; + + /** + * A public certificate issued by ACM to validate that you own a custom domain. This parameter is required + * only when you configure mutual TLS authentication and you specify an ACM imported or private CA certificate + * for `certificate`. The ownership certificate validates that you have permissions to use the domain name. + * @default - only required when configuring mTLS */ - readonly mtls?: MTLSConfig + readonly ownershipCertificate?: ICertificate; } /** @@ -107,6 +166,7 @@ export class DomainName extends Resource implements IDomainName { public readonly name: string; public readonly regionalDomainName: string; public readonly regionalHostedZoneId: string; + private readonly domainNameConfigurations: CfnDomainName.DomainNameConfigurationProperty[] = []; constructor(scope: Construct, id: string, props: DomainNameProps) { super(scope, id); @@ -115,21 +175,25 @@ export class DomainName extends Resource implements IDomainName { throw new Error('empty string for domainName not allowed'); } + // validation for ownership certificate + if (props.ownershipCertificate && !props.mtls) { + throw new Error('ownership certificate can only be used with mtls domains'); + } + const mtlsConfig = this.configureMTLS(props.mtls); const domainNameProps: CfnDomainNameProps = { domainName: props.domainName, - domainNameConfigurations: [ - { - certificateArn: props.certificate.certificateArn, - endpointType: 'REGIONAL', - }, - ], + domainNameConfigurations: Lazy.any({ produce: () => this.domainNameConfigurations }), mutualTlsAuthentication: mtlsConfig, }; const resource = new CfnDomainName(this, 'Resource', domainNameProps); this.name = resource.ref; this.regionalDomainName = Token.asString(resource.getAtt('RegionalDomainName')); this.regionalHostedZoneId = Token.asString(resource.getAtt('RegionalHostedZoneId')); + + if (props.certificate) { + this.addEndpoint(props); + } } private configureMTLS(mtlsConfig?: MTLSConfig): CfnDomainName.MutualTlsAuthenticationProperty | undefined { @@ -139,4 +203,30 @@ export class DomainName extends Resource implements IDomainName { truststoreVersion: mtlsConfig.version, }; } + + /** + * Adds an endpoint to a domain name. + * @param options domain name endpoint properties to be set + */ + public addEndpoint(options: EndpointOptions) : void { + const domainNameConfig: CfnDomainName.DomainNameConfigurationProperty = { + certificateArn: options.certificate.certificateArn, + certificateName: options.certificateName, + endpointType: options.endpointType ? options.endpointType?.toString() : 'REGIONAL', + ownershipVerificationCertificateArn: options.ownershipCertificate?.certificateArn, + securityPolicy: options.securityPolicy?.toString(), + }; + + this.validateEndpointType(domainNameConfig.endpointType); + this.domainNameConfigurations.push(domainNameConfig); + } + + // validates that the new domain name configuration has a unique endpoint + private validateEndpointType(endpointType: string | undefined) : void { + for (let config of this.domainNameConfigurations) { + if (endpointType && endpointType == config.endpointType) { + throw new Error(`an endpoint with type ${endpointType} already exists`); + } + } + } } diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/domain-name.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/domain-name.test.ts index 9c60f7b5196e3..b1c5e0b52e35f 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/domain-name.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/domain-name.test.ts @@ -2,10 +2,12 @@ import { Template } from '@aws-cdk/assertions'; import { Certificate } from '@aws-cdk/aws-certificatemanager'; import { Bucket } from '@aws-cdk/aws-s3'; import { Stack } from '@aws-cdk/core'; -import { DomainName, HttpApi } from '../../lib'; +import { DomainName, EndpointType, HttpApi, SecurityPolicy } from '../../lib'; const domainName = 'example.com'; const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate'; +const certArn2 = 'arn:aws:acm:us-east-1:111111111111:certificate2'; +const ownershipCertArn = 'arn:aws:acm:us-east-1:111111111111:ownershipcertificate'; describe('DomainName', () => { test('create domain name correctly', () => { @@ -231,4 +233,109 @@ describe('DomainName', () => { }, }); }); + + test('domain with mutual tls configuration and ownership cert', () => { + // GIVEN + const stack = new Stack(); + const bucket = Bucket.fromBucketName(stack, 'testBucket', 'example-bucket'); + + // WHEN + new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert2', certArn2), + ownershipCertificate: Certificate.fromCertificateArn(stack, 'ownershipCert', ownershipCertArn), + endpointType: EndpointType.REGIONAL, + securityPolicy: SecurityPolicy.TLS_1_2, + mtls: { + bucket, + key: 'someca.pem', + version: 'version', + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::DomainName', { + DomainName: 'example.com', + DomainNameConfigurations: [ + { + CertificateArn: 'arn:aws:acm:us-east-1:111111111111:certificate2', + EndpointType: 'REGIONAL', + SecurityPolicy: 'TLS_1_2', + OwnershipVerificationCertificateArn: 'arn:aws:acm:us-east-1:111111111111:ownershipcertificate', + }, + ], + MutualTlsAuthentication: { + TruststoreUri: 's3://example-bucket/someca.pem', + TruststoreVersion: 'version', + }, + }); + }); + + test('throws when ownerhsip cert is used for non-mtls domain', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const t = () => { + new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert2', certArn2), + ownershipCertificate: Certificate.fromCertificateArn(stack, 'ownershipCert', ownershipCertArn), + }); + }; + + // THEN + expect(t).toThrow(/ownership certificate can only be used with mtls domains/); + }); + + test('add new configuration to a domain name for migration', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + endpointType: EndpointType.REGIONAL, + }); + dn.addEndpoint({ + certificate: Certificate.fromCertificateArn(stack, 'cert2', certArn2), + endpointType: EndpointType.EDGE, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::DomainName', { + DomainName: 'example.com', + DomainNameConfigurations: [ + { + CertificateArn: 'arn:aws:acm:us-east-1:111111111111:certificate', + EndpointType: 'REGIONAL', + }, + { + CertificateArn: 'arn:aws:acm:us-east-1:111111111111:certificate2', + EndpointType: 'EDGE', + }, + ], + }); + }); + + test('throws when endpoint types for two domain name configurations are the same', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const t = () => { + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + endpointType: EndpointType.REGIONAL, + }); + dn.addEndpoint({ + certificate: Certificate.fromCertificateArn(stack, 'cert2', certArn2), + }); + }; + + // THEN + expect(t).toThrow(/an endpoint with type REGIONAL already exists/); + }); });