diff --git a/packages/aws-cdk-lib/aws-ecs/README.md b/packages/aws-cdk-lib/aws-ecs/README.md index c8b78b06d728e..2df6c107b1d53 100644 --- a/packages/aws-cdk-lib/aws-ecs/README.md +++ b/packages/aws-cdk-lib/aws-ecs/README.md @@ -1938,3 +1938,40 @@ taskDefinition.addContainer('TheContainer', { }], }); ``` + +## Service Connect TLS + +Service Connect TLS is a feature that allows you to secure the communication between services using TLS. + +You can specify the `tls` option in the `services` array of the `serviceConnectConfiguration` property. + +The `tls` property is an object with the following properties: + +- `role`: The IAM role that's associated with the Service Connect TLS. +- `awsPcaAuthorityArn`: The ARN of the certificate root authority that secures your service. +- `kmsKey`: The KMS key used for encryption and decryption. + +```ts +declare const cluster: ecs.Cluster; +declare const taskDefinition: ecs.TaskDefinition; +declare const kmsKey: kms.IKey; +declare const role: iam.IRole; + +const service = new ecs.FargateService(this, 'FargateService', { + cluster, + taskDefinition, + serviceConnectConfiguration: { + services: [ + { + tls: { + role, + kmsKey, + awsPcaAuthorityArn: 'arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/123456789012', + }, + portMappingName: 'api', + }, + ], + namespace: 'sample namespace', + }, +}); +``` diff --git a/packages/aws-cdk-lib/aws-ecs/lib/base/base-service.ts b/packages/aws-cdk-lib/aws-ecs/lib/base/base-service.ts index bf7e14a910770..9e3dae401539f 100644 --- a/packages/aws-cdk-lib/aws-ecs/lib/base/base-service.ts +++ b/packages/aws-cdk-lib/aws-ecs/lib/base/base-service.ts @@ -7,6 +7,7 @@ import * as ec2 from '../../../aws-ec2'; import * as elb from '../../../aws-elasticloadbalancing'; import * as elbv2 from '../../../aws-elasticloadbalancingv2'; import * as iam from '../../../aws-iam'; +import * as kms from '../../../aws-kms'; import * as cloudmap from '../../../aws-servicediscovery'; import { Annotations, @@ -254,6 +255,39 @@ export interface ServiceConnectService { * @default - Duration.seconds(15) */ readonly perRequestTimeout?: Duration; + + /** + * A reference to an object that represents a Transport Layer Security (TLS) configuration. + * + * @default - none + */ + readonly tls?: ServiceConnectTlsConfiguration; +} + +/** + * TLS configuration for Service Connect service + */ +export interface ServiceConnectTlsConfiguration { + /** + * The ARN of the certificate root authority that secures your service. + * + * @default - none + */ + readonly awsPcaAuthorityArn?: string; + + /** + * The KMS key used for encryption and decryption. + * + * @default - none + */ + readonly kmsKey?: kms.IKey; + + /** + * The IAM role that's associated with the Service Connect TLS. + * + * @default - none + */ + readonly role?: iam.IRole; } /** @@ -920,12 +954,21 @@ export abstract class BaseService extends Resource dnsName: svc.dnsName, }; + const tls: CfnService.ServiceConnectTlsConfigurationProperty | undefined = svc.tls ? { + issuerCertificateAuthority: { + awsPcaAuthorityArn: svc.tls.awsPcaAuthorityArn, + }, + kmsKey: svc.tls.kmsKey?.keyArn, + roleArn: svc.tls.role?.roleArn, + } : undefined; + return { portName: svc.portMappingName, discoveryName: svc.discoveryName, ingressPortOverride: svc.ingressPortOverride, clientAliases: [alias], timeout: this.renderTimeout(svc.idleTimeout, svc.perRequestTimeout), + tls, } as CfnService.ServiceConnectServiceProperty; }); @@ -996,6 +1039,12 @@ export abstract class BaseService extends Resource !this.isValidPort(serviceConnectService.port)) { throw new Error(`Client Alias port ${serviceConnectService.port} is not valid.`); } + + // tls.awsPcaAuthorityArn should be an ARN + const awsPcaAuthorityArn = serviceConnectService.tls?.awsPcaAuthorityArn; + if (awsPcaAuthorityArn && !Token.isUnresolved(awsPcaAuthorityArn) && !awsPcaAuthorityArn.startsWith('arn:')) { + throw new Error(`awsPcaAuthorityArn must start with "arn:" and have at least 6 components; received ${awsPcaAuthorityArn}`); + } }); } diff --git a/packages/aws-cdk-lib/aws-ecs/test/base-service.test.ts b/packages/aws-cdk-lib/aws-ecs/test/base-service.test.ts index f5beabbf8f0fb..ab2c65a7df87b 100644 --- a/packages/aws-cdk-lib/aws-ecs/test/base-service.test.ts +++ b/packages/aws-cdk-lib/aws-ecs/test/base-service.test.ts @@ -1,6 +1,7 @@ import { Template, Match } from '../../assertions'; import * as ec2 from '../../aws-ec2'; import * as iam from '../../aws-iam'; +import * as kms from '../../aws-kms'; import * as cdk from '../../core'; import { App, Stack } from '../../core'; import * as cxapi from '../../cx-api'; @@ -79,6 +80,100 @@ describe('When import an ECS Service', () => { ], }); }); + + test('should add tls configuration to service connect service', () => { + // GIVEN + const vpc = new ec2.Vpc(stack, 'Vpc'); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'TaskDef'); + const kmsKey = new kms.Key(stack, 'KmsKey'); + const role = new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('ecs.amazonaws.com'), + }); + taskDefinition.addContainer('Web', { + image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), + portMappings: [ + { + name: 'api', + containerPort: 80, + }, + ], + }); + const service = new ecs.FargateService(stack, 'Service', { + cluster, + taskDefinition, + }); + + // WHEN + service.enableServiceConnect({ + services: [ + { + tls: { + awsPcaAuthorityArn: + 'arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/123456789012', + kmsKey, + role, + }, + portMappingName: 'api', + }, + ], + namespace: 'test namespace', + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ECS::Service', { + ServiceConnectConfiguration: { + Services: [ + { + Tls: { + IssuerCertificateAuthority: { + AwsPcaAuthorityArn: + 'arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/123456789012', + }, + KmsKey: stack.resolve(kmsKey.keyArn), + RoleArn: stack.resolve(role.roleArn), + }, + }, + ], + }, + }); + }); + + test('throws an error when awsPcaAuthorityArn is not an ARN', () => { + // GIVEN + const vpc = new ec2.Vpc(stack, 'Vpc'); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'TaskDef'); + taskDefinition.addContainer('Web', { + image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), + portMappings: [ + { + name: 'api', + containerPort: 80, + }, + ], + }); + + // WHEN + const createFargateService = () => new ecs.FargateService(stack, 'Service', { + cluster, + taskDefinition, + serviceConnectConfiguration: { + services: [ + { + tls: { + awsPcaAuthorityArn: 'invalid-arn', + }, + portMappingName: 'api', + }, + ], + namespace: 'test namespace', + }, + }); + + // THEN + expect(() => createFargateService()).toThrow(/awsPcaAuthorityArn must start with "arn:" and have at least 6 components; received invalid-arn/); + }); }); describe('For alarm-based rollbacks', () => {