Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(apigatewayv2): domain endpoint type, security policy and endpoint migration #17518

Merged
merged 9 commits into from
Nov 22, 2021
4 changes: 4 additions & 0 deletions packages/@aws-cdk/aws-apigatewayv2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 96 additions & 9 deletions packages/@aws-cdk/aws-apigatewayv2/lib/common/domain-name.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -57,14 +82,40 @@ export interface DomainNameProps {
*/
readonly domainName: string;
/**
* 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.
*/
readonly mtls?: MTLSConfig
readonly mtls?: MTLSConfig;
/**
* 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
SmritiVashisth marked this conversation as resolved.
Show resolved Hide resolved
*/
readonly ownershipCertificate?: ICertificate;
}

/**
Expand Down Expand Up @@ -107,6 +158,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);
Expand All @@ -115,21 +167,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 {
Expand All @@ -139,4 +195,35 @@ export class DomainName extends Resource implements IDomainName {
truststoreVersion: mtlsConfig.version,
};
}

/**
* Adds a configuration to a domain name. Properties like certificate, endpoint type and security policy can be set using this method.
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-apigatewayv2-domainname-domainnameconfiguration.html
SmritiVashisth marked this conversation as resolved.
Show resolved Hide resolved
* @param props domain name properties to be set
*/
public addEndpoint(props: DomainNameProps) : void {
SmritiVashisth marked this conversation as resolved.
Show resolved Hide resolved
const domainNameConfig: CfnDomainName.DomainNameConfigurationProperty = {
certificateArn: props.certificate.certificateArn,
certificateName: props.certificateName,
endpointType: props.endpointType ? props.endpointType?.toString() : 'REGIONAL',
ownershipVerificationCertificateArn: props.ownershipCertificate?.certificateArn,
securityPolicy: props.securityPolicy?.toString(),
};

if (this.isDuplicateEndpointType(domainNameConfig.endpointType)) {
throw new Error('no two domain name configurations should have the same endpointType');
}

this.domainNameConfigurations.push(domainNameConfig);
}

// validates whether the new domain name configuration has a unique endpoint or not
private isDuplicateEndpointType(endpointType: string | undefined) : boolean {
for (let config of this.domainNameConfigurations) {
if (endpointType && endpointType == config.endpointType) {
return true;
}
}
return false;
SmritiVashisth marked this conversation as resolved.
Show resolved Hide resolved
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm reading this correctly, this will allow one endpoint type undefined and one endpoint type of REGIONAL. But this should not be allowed, right?

Can you fix this up and add a corresponding test case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It won't be that way. If you look at the addEndpoint() method which is the caller for this method, if there is no EndpointType defined by the customer, we set a default 'REGIONAL' endpoint. So this value will never be undefined. Some eslint code-check complained about it though, that's why I had to add undefined in the parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can check the last test I have added. When I call dn.addEndpoint(), I am not giving an EndpointType in the input, but the code still figures out that I'm trying to add a REGIONAL endpoint here and throws an error.

}
111 changes: 110 additions & 1 deletion packages/@aws-cdk/aws-apigatewayv2/test/http/domain-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -231,4 +233,111 @@ 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({
domainName,
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({
domainName,
certificate: Certificate.fromCertificateArn(stack, 'cert2', certArn2),
});
};

// THEN
expect(t).toThrow(/no two domain name configurations should have the same endpointType/);
});
});