diff --git a/packages/aws-cdk-lib/aws-ecs/lib/cluster.ts b/packages/aws-cdk-lib/aws-ecs/lib/cluster.ts index 26f476983ee07..7077e7339d854 100644 --- a/packages/aws-cdk-lib/aws-ecs/lib/cluster.ts +++ b/packages/aws-cdk-lib/aws-ecs/lib/cluster.ts @@ -7,11 +7,13 @@ import * as autoscaling from '../../aws-autoscaling'; import * as cloudwatch from '../../aws-cloudwatch'; import * as ec2 from '../../aws-ec2'; import * as iam from '../../aws-iam'; +import { PolicyStatement, PolicyStatementProps, ServicePrincipal } from '../../aws-iam'; import * as kms from '../../aws-kms'; +import { IKey } from '../../aws-kms'; import * as logs from '../../aws-logs'; import * as s3 from '../../aws-s3'; import * as cloudmap from '../../aws-servicediscovery'; -import { Duration, IResource, Resource, Stack, Aspects, ArnFormat, IAspect, Token, Names } from '../../core'; +import { Aws, Duration, IResource, Resource, Stack, Aspects, ArnFormat, IAspect, Token, Names } from '../../core'; const CLUSTER_SYMBOL = Symbol.for('@aws-cdk/aws-ecs/lib/cluster.Cluster'); @@ -76,6 +78,13 @@ export interface ClusterProps { * @default - no configuration will be provided. */ readonly executeCommandConfiguration?: ExecuteCommandConfiguration; + + /** + * Encryption configuration for ECS Managed storage + * + * @default - no encryption will be applied. + */ + readonly managedStorageConfiguration?: ManagedStorageConfiguration; } /** @@ -98,10 +107,10 @@ export enum MachineImageType { export class Cluster extends Resource implements ICluster { /** - * Return whether the given object is a Cluster + * Return whether the given object is a Cluster */ - public static isCluster(x: any) : x is Cluster { - return x !== null && typeof(x) === 'object' && CLUSTER_SYMBOL in x; + public static isCluster(x: any): x is Cluster { + return x !== null && typeof (x) === 'object' && CLUSTER_SYMBOL in x; } /** @@ -130,12 +139,15 @@ export class Cluster extends Resource implements ICluster { class Import extends Resource implements ICluster { public readonly clusterArn = clusterArn; public readonly clusterName = clusterName!; + get hasEc2Capacity(): boolean { throw new Error(`hasEc2Capacity ${errorSuffix}`); } + get connections(): ec2.Connections { throw new Error(`connections ${errorSuffix}`); } + get vpc(): ec2.IVpc { throw new Error(`vpc ${errorSuffix}`); } @@ -196,6 +208,8 @@ export class Cluster extends Resource implements ICluster { */ private _executeCommandConfiguration?: ExecuteCommandConfiguration; + private _managedStorageConfiguration?: ManagedStorageConfiguration; + /** * CfnCluster instance */ @@ -213,10 +227,13 @@ export class Cluster extends Resource implements ICluster { * clusterSettings needs to be undefined if containerInsights is not explicitly set in order to allow any * containerInsights settings on the account to apply. See: * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ecs-cluster-clustersettings.html#cfn-ecs-cluster-clustersettings-value - */ + */ let clusterSettings = undefined; if (props.containerInsights !== undefined) { - clusterSettings = [{ name: 'containerInsights', value: props.containerInsights ? ContainerInsights.ENABLED : ContainerInsights.DISABLED }]; + clusterSettings = [{ + name: 'containerInsights', + value: props.containerInsights ? ContainerInsights.ENABLED : ContainerInsights.DISABLED, + }]; } this._capacityProviderNames = props.capacityProviders ?? []; @@ -232,10 +249,15 @@ export class Cluster extends Resource implements ICluster { this._executeCommandConfiguration = props.executeCommandConfiguration; } + if (props.managedStorageConfiguration) { + this._managedStorageConfiguration = props.managedStorageConfiguration; + } + this._cfnCluster = new CfnCluster(this, 'Resource', { clusterName: this.physicalName, clusterSettings, - configuration: this._executeCommandConfiguration && this.renderExecuteCommandConfiguration(), + configuration: (this._executeCommandConfiguration && this.renderExecuteCommandConfiguration()) + || (this._managedStorageConfiguration && this.renderManagedStorageConfigurationConfiguration()), }); this.clusterArn = this.getResourceArnAttribute(this._cfnCluster.attrArn, { @@ -255,6 +277,11 @@ export class Cluster extends Resource implements ICluster { ? this.addCapacity('DefaultAutoScalingGroup', props.capacity) : undefined; + if (this._managedStorageConfiguration?.fargateEphemeralStorageKmsKey) { + const key = this._managedStorageConfiguration.fargateEphemeralStorageKmsKey; + this.updateKeyPolicyForEphemeralStorageConfiguration(key, props.clusterName); + } + // Only create cluster capacity provider associations if there are any EC2 // capacity providers. Ordinarily we'd just add the construct to the tree // since it's harmless, but we'd prefer not to add unexpected new @@ -263,6 +290,37 @@ export class Cluster extends Resource implements ICluster { Aspects.of(this).add(new MaybeCreateCapacityProviderAssociations(this, id)); } + private updateKeyPolicyForEphemeralStorageConfiguration(key: IKey, clusterName?: string) { + const clusterConditions: PolicyStatementProps['conditions'] = { + StringEquals: { + 'kms:EncryptionContext:aws:ecs:clusterAccount': [Aws.ACCOUNT_ID], + }, + }; + if (clusterName) { + clusterConditions.StringEquals['kms:EncryptionContext:aws:ecs:clusterName'] = [clusterName]; + } + + key.addToResourcePolicy(new PolicyStatement({ + sid: 'Allow generate data key access for Fargate tasks.', + principals: [new ServicePrincipal('fargate.amazonaws.com')], + resources: ['*'], + actions: ['kms:GenerateDataKeyWithoutPlaintext'], + conditions: clusterConditions, + })); + key.addToResourcePolicy(new PolicyStatement({ + sid: 'Allow grant creation permission for Fargate tasks.', + principals: [new ServicePrincipal('fargate.amazonaws.com')], + resources: ['*'], + actions: ['kms:CreateGrant'], + conditions: { + ...clusterConditions, + 'ForAllValues:StringEquals': { + 'kms:GrantOperations': ['Decrypt'], + }, + }, + })); + } + /** * Enable the Fargate capacity providers for this cluster. */ @@ -319,6 +377,13 @@ export class Cluster extends Resource implements ICluster { }, }; } + private renderManagedStorageConfigurationConfiguration(): CfnCluster.ClusterConfigurationProperty { + return { + managedStorageConfiguration: { + fargateEphemeralStorageKmsKeyId: this._managedStorageConfiguration?.fargateEphemeralStorageKmsKey?.keyId, + }, + }; + } private renderExecuteCommandLogConfiguration(): CfnCluster.ExecuteCommandLogConfigurationProperty { const logConfiguration = this._executeCommandConfiguration?.logConfiguration; @@ -452,7 +517,7 @@ export class Cluster extends Resource implements ICluster { ...options, machineImageType: provider.machineImageType, // Don't enable the instance-draining lifecycle hook if managed termination protection or managed draining is enabled - taskDrainTime: (provider.enableManagedTerminationProtection || provider.enableManagedDraining)? Duration.seconds(0) : options.taskDrainTime, + taskDrainTime: (provider.enableManagedTerminationProtection || provider.enableManagedDraining) ? Duration.seconds(0) : options.taskDrainTime, canContainersAccessInstanceRole: options.canContainersAccessInstanceRole ?? provider.canContainersAccessInstanceRole, }); @@ -602,12 +667,12 @@ export class Cluster extends Resource implements ICluster { } /** - * Grants an ECS Task Protection API permission to the specified grantee. - * This method provides a streamlined way to assign the 'ecs:UpdateTaskProtection' - * permission, enabling the grantee to manage task protection in the ECS cluster. - * - * @param grantee The entity (e.g., IAM role or user) to grant the permissions to. - */ + * Grants an ECS Task Protection API permission to the specified grantee. + * This method provides a streamlined way to assign the 'ecs:UpdateTaskProtection' + * permission, enabling the grantee to manage task protection in the ECS cluster. + * + * @param grantee The entity (e.g., IAM role or user) to grant the permissions to. + */ public grantTaskProtection(grantee: iam.IGrantable): iam.Grant { return iam.Grant.addToPrincipal({ grantee, @@ -1068,7 +1133,7 @@ export interface CapacityProviderStrategy { /** * The weight value designates the relative percentage of the total number of tasks launched that should use the * specified -capacity provider. The weight value is taken into consideration after the base value, if defined, is satisfied. + capacity provider. The weight value is taken into consideration after the base value, if defined, is satisfied. * * @default - 0 */ @@ -1254,6 +1319,22 @@ export interface AsgCapacityProviderProps extends AddAutoScalingGroupCapacityOpt readonly instanceWarmupPeriod?: number; } +/** + * Kms Keys for encryption ECS managed storage + */ +export interface ManagedStorageConfiguration { + + /** + * KMS Key used to encrypt ECS Fargate ephemeral Storage. + * The configured KMS Key's policy will be modified to allow ECS to use the Key to encrypt the ephemeral Storage for this cluster. + * + * For more information, see [Customer managed keys for AWS Fargate ephemeral storage](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-storage-encryption.html) in the ECS Developer Guide. + * + * @default No encryption will be applied + */ + readonly fargateEphemeralStorageKmsKey?: IKey; +} + /** * An Auto Scaling Group Capacity Provider. This allows an ECS cluster to target * a specific EC2 Auto Scaling Group for the placement of tasks. Optionally (and diff --git a/packages/aws-cdk-lib/aws-ecs/test/cluster.test.ts b/packages/aws-cdk-lib/aws-ecs/test/cluster.test.ts index ee4850409a510..cf7831598bc49 100644 --- a/packages/aws-cdk-lib/aws-ecs/test/cluster.test.ts +++ b/packages/aws-cdk-lib/aws-ecs/test/cluster.test.ts @@ -1763,6 +1763,110 @@ describe('cluster', () => { }); + test('enable fargate ephemeral storage encryption on cluster with random name', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'test'); + const key = new kms.Key(stack, 'key', { policy: new iam.PolicyDocument() }); + new ecs.Cluster(stack, 'EcsCluster', { managedStorageConfiguration: { fargateEphemeralStorageKmsKey: key } }); + + // THEN + const output = Template.fromStack(stack); + output.hasResourceProperties('AWS::ECS::Cluster', { + Configuration: { + ManagedStorageConfiguration: { + FargateEphemeralStorageKmsKeyId: { + Ref: 'keyFEDD6EC0', + }, + }, + }, + }); + output.hasResourceProperties('AWS::KMS::Key', { + KeyPolicy: { + Statement: [ + { + Resource: '*', + Effect: 'Allow', + Action: 'kms:GenerateDataKeyWithoutPlaintext', + Principal: { Service: 'fargate.amazonaws.com' }, + Condition: { + StringEquals: { + 'kms:EncryptionContext:aws:ecs:clusterAccount': [{ Ref: 'AWS::AccountId' }], + }, + }, + }, + { + Resource: '*', + Effect: 'Allow', + Action: 'kms:CreateGrant', + Principal: { Service: 'fargate.amazonaws.com' }, + Condition: { + 'StringEquals': { + 'kms:EncryptionContext:aws:ecs:clusterAccount': [{ Ref: 'AWS::AccountId' }], + }, + 'ForAllValues:StringEquals': { + 'kms:GrantOperations': ['Decrypt'], + }, + }, + }, + ], + }, + }); + }); + + test('enable fargate ephemeral storage encryption on cluster with defined name', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'test'); + const key = new kms.Key(stack, 'key', { policy: new iam.PolicyDocument() }); + new ecs.Cluster(stack, 'EcsCluster', { clusterName: 'cluster-name', managedStorageConfiguration: { fargateEphemeralStorageKmsKey: key } }); + + // THEN + const output = Template.fromStack(stack); + output.hasResourceProperties('AWS::ECS::Cluster', { + Configuration: { + ManagedStorageConfiguration: { + FargateEphemeralStorageKmsKeyId: { + Ref: 'keyFEDD6EC0', + }, + }, + }, + }); + output.hasResourceProperties('AWS::KMS::Key', { + KeyPolicy: { + Statement: [ + { + Resource: '*', + Effect: 'Allow', + Action: 'kms:GenerateDataKeyWithoutPlaintext', + Principal: { Service: 'fargate.amazonaws.com' }, + Condition: { + StringEquals: { + 'kms:EncryptionContext:aws:ecs:clusterAccount': [{ Ref: 'AWS::AccountId' }], + 'kms:EncryptionContext:aws:ecs:clusterName': ['cluster-name'], + }, + }, + }, + { + Resource: '*', + Effect: 'Allow', + Action: 'kms:CreateGrant', + Principal: { Service: 'fargate.amazonaws.com' }, + Condition: { + 'StringEquals': { + 'kms:EncryptionContext:aws:ecs:clusterAccount': [{ Ref: 'AWS::AccountId' }], + 'kms:EncryptionContext:aws:ecs:clusterName': ['cluster-name'], + }, + 'ForAllValues:StringEquals': { + 'kms:GrantOperations': ['Decrypt'], + }, + }, + }, + ], + }, + }); + }); + test('BottleRocketImage() returns correct AMI', () => { // GIVEN const app = new cdk.App();