From 33f11288744440e897f2d8841aff19e8e0039060 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Tue, 26 May 2020 19:07:02 +0000 Subject: [PATCH 1/6] feat(ec2): Adds an L2 construct for AWS::EC2::Volume --- packages/@aws-cdk/aws-ec2/README.md | 82 ++ packages/@aws-cdk/aws-ec2/lib/volume.ts | 356 ++++++- packages/@aws-cdk/aws-ec2/package.json | 2 + packages/@aws-cdk/aws-ec2/test/test.volume.ts | 965 ++++++++++++++++++ 4 files changed, 1402 insertions(+), 3 deletions(-) create mode 100644 packages/@aws-cdk/aws-ec2/test/test.volume.ts diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index 318ffb55a1274..c4a77e1201282 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -608,6 +608,88 @@ new ec2.Instance(this, 'Instance', { ``` +## Volumes + +Whereas Block Devices are created, and automatically associated, with a specific instance when that instance is created. Volumes allow you to create a block device that can be attached to, or detached from, any instance at any time. Some types of Volumes can also be attached to multiple instances at the same time to allow you to have shared storage between those instances. A notable restriction is that a Volume can only be attached to instances in the same availability zone as the Volume itself. + +The following demonstrates how to create a 500 GiB encrypted Volume in the `us-west-2a` availability zone, and give a role the ability to attach that Volume to a specific instance: + +```ts +const instance = new ec2.Instance(this, 'Instance', { + // ... +}); +const role = new iam.Role(stack, 'SomeRole', { + assumedBy: new iam.AccountRootPrincipal(), +}); +const volume = new ec2.Volume(this, 'Volume', { + availabilityZone: 'us-west-2a', + size: cdk.Size.gibibytes(500), + encrypted: true, +}); + +volume.grantAttachVolume(role, [instance]); +``` + +### Instances Attaching Volumes to Themselves + +If you need to grant an instance the ability to attach/detach an EBS volume to/from itself, then using `grantAttachVolume` and `grantDetachVolume` as outlined above +will lead to an unresolvable circular reference between the instance role and the instance. To securely provide this grant we recommend conditioning the grant +on a unique resource tag as follows: + +```ts +const instance = new ec2.Instance(this, 'Instance', { + // ... +}); +const volume = new ec2.Volume(this, 'Volume', { + availabilityZone: 'us-west-2a', + size: cdk.Size.gibibytes(500), + encrypted: true, +}); + +// Restrict the grant to only attach to instances with a specific Tag value. +// Note: The volume must have the tag as well. +const tagValue: string = 'uniqueTagValue_1234'; +cdk.Tag.add(instance, 'VolumeGrant', tagValue); +cdk.Tag.add(volume, 'VolumeGrant', tagValue); +const attachGrant: iam.Grant = volume.grantAttachVolume(instance); +attachGrant.principalStatement!.addCondition( + 'ForAnyValue:StringEquals', { 'ec2:ResourceTag/VolumeGrant': tagValue } +); +``` + +### Attaching Volumes + +The Amazon EC2 documentation for +[Linux Instances](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AmazonEBS.html) and +[Windows Instances](https://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/ebs-volumes.html) contains information on how +to attach and detach your Volumes to/from instances, and how to format them for use. + +The following is a sample skeleton of a script that can be used to attach a Volume to the Linux instance that it is running on: + +```bash +# The volume ID of the EBS Volume +EBS_VOL_ID=vol-1234 +# The AWS Region that the instance and volume are within +AWS_REGION=us-west-2 +# The device to mount the volume to; change this as you wish. +TARGET_DEV=/dev/xvdz + +# Use the EC2 metadata service v2 to retrieve the instance-id of this instance. +TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30" 2> /dev/null) +INSTANCE_ID=$(curl -H "X-aws-ec2-metadata-token: $TOKEN" -v http://169.254.169.254/latest/meta-data/instance-id 2> /dev/null) + +# Attach the volume +aws --region ${AWS_REGION} ec2 attach-volume --volume-id ${EBS_VOL_ID} --instance-id ${INSTANCE_ID} --device ${TARGET_DEV} + +# Wait until the volume has attached +while ! test -e ${TARGET_DEV} +do + sleep 1 +done + +# The volume is attached, next you will need to mount it and possibly format it for use. +``` + ## VPC Flow Logs VPC Flow Logs is a feature that enables you to capture information about the IP traffic going to and from network interfaces in your VPC. Flow log data can be published to Amazon CloudWatch Logs and Amazon S3. After you've created a flow log, you can retrieve and view its data in the chosen destination. (https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html). diff --git a/packages/@aws-cdk/aws-ec2/lib/volume.ts b/packages/@aws-cdk/aws-ec2/lib/volume.ts index 5108ea2effff1..fd1e72a71755b 100644 --- a/packages/@aws-cdk/aws-ec2/lib/volume.ts +++ b/packages/@aws-cdk/aws-ec2/lib/volume.ts @@ -1,5 +1,8 @@ -import { Construct } from '@aws-cdk/core'; -import { CfnInstance } from './ec2.generated'; +import { Anyone, Grant, IGrantable } from '@aws-cdk/aws-iam'; +import { IKey, ViaServicePrincipal } from '@aws-cdk/aws-kms'; +import { Construct, IResource, Resource, Size, SizeRoundingBehavior, Stack, Token } from '@aws-cdk/core'; +import { CfnInstance, CfnVolume } from './ec2.generated'; +import { IInstance } from './instance'; /** * Block device @@ -210,4 +213,351 @@ export enum EbsDeviceVolumeType { * Cold HDD */ SC1 = 'sc1', -} \ No newline at end of file + + /** + * General purpose SSD volume that balances price and performance for a wide variety of workloads. + */ + GENERAL_PURPOSE_SSD = GP2, + + /** + * Highest-performance SSD volume for mission-critical low-latency or high-throughput workloads. + */ + PROVISIONED_IOPS_SSD = IO1, + + /** + * Low-cost HDD volume designed for frequently accessed, throughput-intensive workloads. + */ + THROUGHPUT_OPTIMIZED_HDD = ST1, + + /** + * Lowest cost HDD volume designed for less frequently accessed workloads. + */ + COLD_HDD = SC1, + + /** + * Magnetic volumes are backed by magnetic drives and are suited for workloads where data is accessed infrequently, and scenarios where low-cost + * storage for small volume sizes is important. + */ + MAGNETIC = STANDARD, +} + +/** + * An EBS Volume in AWS EC2. + */ +export interface IVolume extends IResource { + /** + * The EBS Volume's ID + * + * @attribute + */ + readonly volumeId: string; + + /** + * The availability zone that the EBS Volume is contained within (ex: us-west-2a) + */ + readonly availabilityZone: string; + + /** + * The customer-managed encryption key that is used to encrypt the Volume. + * + * @attribute + */ + readonly encryptionKey?: IKey; + + /** + * Grants permission to attach this Volume to an instance. + * CAUTION: Granting an instance permission to attach to itself will lead to an unresolvable circular + * reference between the instance role and the instance. See the README for an example of how to + * resolve this circular reference. + * @param grantee the principal being granted permission. + * @param instances the instances to which permission is being granted to attach this + * volume to. If not specified, then permission is granted to attach + * to all instances in this account. + */ + grantAttachVolume(grantee: IGrantable, instances?: IInstance[]): Grant; + + /** + * Grants permission to detach this Volume from an instance + * CAUTION: Granting an instance permission to detach from itself will lead to an unresolvable circular + * reference between the instance role and the instance. See the README for an example of how to + * resolve this circular reference. + * @param grantee the principal being granted permission. + * @param instances the instances to which permission is being granted to detach this + * volume from. If not specified, then permission is granted to attach + * to all instances in this account. + */ + grantDetachVolume(grantee: IGrantable, instances?: IInstance[]): Grant; +} + +/** + * Properties of an EBS Volume + */ +export interface VolumeProps { + /** + * The value of the physicalName property of this resource. + * + * @default The physical name will be allocated by CloudFormation at deployment time + */ + readonly volumeName?: string; + + /** + * The Availability Zone in which to create the volume. + */ + readonly availabilityZone: string; + + /** + * The size of the volume, in GiBs. You must specify either a snapshot ID or a volume size. + * See {@link https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-volume-types.html#ebs-volume-characteristics|Volume Characteristics} + * for details on the allowable size for each type of volume. + * + * @default If you're creating the volume from a snapshot and don't specify a volume size, the default is the snapshot size. + */ + readonly size?: Size; + + /** + * The snapshot from which to create the volume. You must specify either a snapshot ID or a volume size. + * + * @default The EBS volume is not created from a snapshot. + */ + readonly snapshotId?: string; + + /** + * Indicates whether Amazon EBS Multi-Attach is enabled. + * See {@link https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-volumes-multi.html#considerations|Considerations and limitations} + * for the constraints of multi-attach. + * + * @default false + */ + readonly enableMultiAttach?: boolean; + + /** + * Specifies whether the volume should be encrypted. The effect of setting the encryption state to true depends on the volume origin + * (new or from a snapshot), starting encryption state, ownership, and whether encryption by default is enabled. For more information, + * see {@link https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default|Encryption by Default} + * in the Amazon Elastic Compute Cloud User Guide. + * + * Encrypted Amazon EBS volumes must be attached to instances that support Amazon EBS encryption. For more information, see + * {@link https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#EBSEncryption_supported_instances|Supported Instance Types.} + * + * @default false + */ + readonly encrypted?: boolean; + + /** + * The customer-managed encryption key that is used to encrypt the Volume. The encrypted property must + * be true if this is provided. + * + * @default The default KMS key for the account, region, and EC2 service is used. + */ + readonly encryptionKey?: IKey; + + /** + * Indicates whether the volume is auto-enabled for I/O operations. By default, Amazon EBS disables I/O to the volume from attached EC2 + * instances when it determines that a volume's data is potentially inconsistent. If the consistency of the volume is not a concern, and + * you prefer that the volume be made available immediately if it's impaired, you can configure the volume to automatically enable I/O. + * + * @default false + */ + readonly autoEnableIo?: boolean; + + /** + * The type of the volume; what type of storage to use to form the EBS Volume. + * + * @default {@link EbsDeviceVolumeType.GENERAL_PURPOSE_SSD} + */ + readonly volumeType?: EbsDeviceVolumeType; + + /** + * The number of I/O operations per second (IOPS) to provision for the volume, with a maximum ratio of 50 IOPS/GiB. + * See {@link https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-volume-types.html#EBSVolumeTypes_piops|Provisioned IOPS SSD (io1) volumes} + * for more information. + * + * This parameter is valid only for PROVISIONED_IOPS_SSD volumes. + * + * @default None -- Required for {@link EbsDeviceVolumeType.PROVISIONED_IOPS_SSD} + */ + readonly iops?: number; +} + +/** + * Attributes required to import an existing EBS Volume into the Stack. + */ +export interface VolumeAttributes { + /** + * The EBS Volume's ID + */ + readonly volumeId: string; + + /** + * The availability zone that the EBS Volume is contained within (ex: us-west-2a) + */ + readonly availabilityZone: string; + + /** + * The customer-managed encryption key that is used to encrypt the Volume. + * + * @default None -- The EBS Volume is not using a customer-managed KMS key for encryption. + */ + readonly encryptionKey?: IKey; +} + +/** + * Common behavior of Volumes. Users should not use this class directly, and instead use ``Volume``. + */ +abstract class VolumeBase extends Resource implements IVolume { + public abstract readonly volumeId: string; + public abstract readonly availabilityZone: string; + public abstract readonly encryptionKey?: IKey; + + public grantAttachVolume(grantee: IGrantable, instances?: IInstance[]): Grant { + const result = Grant.addToPrincipal({ + grantee, + actions: [ 'ec2:AttachVolume' ], + resourceArns : this.collectGrantResourceArns(instances), + }); + // Note: Access to the encryption key is not required. The service having access to it is sufficient. + return result; + } + + public grantDetachVolume(grantee: IGrantable, instances?: IInstance[]): Grant { + const result = Grant.addToPrincipal({ + grantee, + actions: [ 'ec2:DetachVolume' ], + resourceArns : this.collectGrantResourceArns(instances), + }); + // Note: Access to the encryption key is not required. The service having access to it is sufficient. + return result; + } + + private collectGrantResourceArns(instances?: IInstance[]): string[] { + const stack = Stack.of(this); + const resourceArns: string[] = [ + `arn:aws:ec2:${stack.region}:${stack.account}:volume/${this.volumeId}`, + ]; + if (instances) { + instances.forEach(instance => resourceArns.push(`arn:aws:ec2:${stack.region}:${stack.account}:instance/${instance?.instanceId}`)); + } else { + resourceArns.push(`arn:aws:ec2:${stack.region}:${stack.account}:instance/*`); + } + return resourceArns; + } +} + +/** + * Creates a new EBS Volume in AWS EC2. + */ +export class Volume extends VolumeBase { + /** + * Import an existing EBS Volume into the Stack. + * + * @param scope the scope of the import. + * @param id the ID of the imported Volume in the construct tree. + * @param attrs the attributes of the imported Volume + */ + public static fromVolumeAttributes(scope: Construct, id: string, attrs: VolumeAttributes): IVolume { + class Import extends VolumeBase { + public readonly volumeId = attrs.volumeId; + public readonly availabilityZone = attrs.availabilityZone; + public readonly encryptionKey = attrs.encryptionKey; + } + // Check that the provided volumeId looks like it could be valid. + if (!Token.isUnresolved(attrs.volumeId) && !/^vol-[0-9a-fA-F]+$/.test(attrs.volumeId)) { + throw new Error('`volumeId` does not match expected pattern. Expected `vol-` (ex: `vol-05abe246af`) or a Token'); + } + return new Import(scope, id); + } + + public readonly volumeId: string; + public readonly availabilityZone: string; + public readonly encryptionKey?: IKey; + + constructor(scope: Construct, id: string, props: VolumeProps) { + super(scope, id, { + physicalName: props.volumeName, + }); + + this.validateProps(props); + + const resource = new CfnVolume(this, 'Resource', { + availabilityZone: props.availabilityZone, + autoEnableIo: props.autoEnableIo, + encrypted: props.encrypted, + kmsKeyId: props.encryptionKey?.keyArn, + iops: props.iops, + multiAttachEnabled: props.enableMultiAttach ?? false, + size: props.size?.toGibibytes({rounding: SizeRoundingBehavior.FAIL}), + snapshotId: props.snapshotId, + volumeType: props.volumeType ?? EbsDeviceVolumeType.GENERAL_PURPOSE_SSD, + }); + + this.volumeId = resource.ref; + this.availabilityZone = props.availabilityZone; + this.encryptionKey = props.encryptionKey; + + if (this.encryptionKey) { + const principal = + new ViaServicePrincipal(`ec2.${Stack.of(this).region}.amazonaws.com`, new Anyone()).withConditions({ + StringEquals: { + 'kms:CallerAccount': Stack.of(this).account, + }, + }); + this.encryptionKey.grantEncryptDecrypt(principal).principalStatement?.addActions( + 'kms:CreateGrant', + 'kms:DescribeKey', + ); + } + } + + protected validateProps(props: VolumeProps) { + if (!Token.isUnresolved(props.availabilityZone) && !/^[a-z]{2}-[a-z]+-[1-9]+[a-z]$/.test(props.availabilityZone)) { + throw new Error('`availabilityZone` is a region followed by a letter (ex: `us-east-1a`), or a token'); + } + + if (!(props.size || props.snapshotId)) { + throw new Error('Must provide at least one of `size` or `snapshotId`'); + } + + if (props.snapshotId && !Token.isUnresolved(props.snapshotId) && !/^snap-[0-9a-fA-F]+$/.test(props.snapshotId)) { + throw new Error('`snapshotId` does match expected pattern. Expected `snap-` (ex: `snap-05abe246af`) or Token'); + } + + if (props.encryptionKey && !props.encrypted) { + throw new Error('`encrypted` must be true when providing an `encryptionKey`.'); + } + + if (props.iops) { + if (props.volumeType !== EbsDeviceVolumeType.PROVISIONED_IOPS_SSD) { + throw new Error('`iops` may only be specified if the `volumeType` is `PROVISIONED_IOPS_SSD`/`IO1`'); + } + + if (props.iops < 100 || props.iops > 64000) { + throw new Error('`iops` must be in the range 100 to 64,000, inclusive.'); + } + + if (props.size && (props.iops > 50 * props.size.toGibibytes({rounding: SizeRoundingBehavior.FAIL}))) { + throw new Error('`iops` has a maximum ratio of 50 IOPS/GiB.'); + } + } + + if (props.enableMultiAttach && props.volumeType !== EbsDeviceVolumeType.PROVISIONED_IOPS_SSD) { + throw new Error('multi-attach is supported exclusively on `PROVISIONED_IOPS_SSD` volumes.'); + } + + if (props.size) { + const size = props.size.toGibibytes({rounding: SizeRoundingBehavior.FAIL}); + // Enforce maximum volume size: + // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-volume-types.html#ebs-volume-characteristics + const sizeRanges: { [key: string]: { Min: number, Max: number } } = {}; + sizeRanges[EbsDeviceVolumeType.GENERAL_PURPOSE_SSD] = { Min: 1, Max: 16000 }; + sizeRanges[EbsDeviceVolumeType.PROVISIONED_IOPS_SSD] = { Min: 4, Max: 16000 }; + sizeRanges[EbsDeviceVolumeType.THROUGHPUT_OPTIMIZED_HDD] = { Min: 500, Max: 16000 }; + sizeRanges[EbsDeviceVolumeType.COLD_HDD] = { Min: 500, Max: 16000 }; + sizeRanges[EbsDeviceVolumeType.MAGNETIC] = { Min: 1, Max: 1000 }; + const volumeType = props.volumeType ?? EbsDeviceVolumeType.GENERAL_PURPOSE_SSD; + const { Min, Max } = sizeRanges[volumeType]; + if (size < Min || size > Max) { + throw new Error(`\`${volumeType}\` volumes must be between ${Min} GiB and ${Max} GiB in size.`); + } + } + } +} diff --git a/packages/@aws-cdk/aws-ec2/package.json b/packages/@aws-cdk/aws-ec2/package.json index b49f8b3d00007..eadb31460c21d 100644 --- a/packages/@aws-cdk/aws-ec2/package.json +++ b/packages/@aws-cdk/aws-ec2/package.json @@ -74,6 +74,7 @@ "dependencies": { "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-ssm": "0.0.0", @@ -87,6 +88,7 @@ "peerDependencies": { "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-ssm": "0.0.0", diff --git a/packages/@aws-cdk/aws-ec2/test/test.volume.ts b/packages/@aws-cdk/aws-ec2/test/test.volume.ts new file mode 100644 index 0000000000000..96405ecc41b1a --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/test.volume.ts @@ -0,0 +1,965 @@ +import { + expect as cdkExpect, + haveResource, + haveResourceLike, + ResourcePart, +} from '@aws-cdk/assert'; +import { + AccountRootPrincipal, + Role, +} from '@aws-cdk/aws-iam'; +import * as kms from '@aws-cdk/aws-kms'; +import * as cdk from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import { + AmazonLinuxGeneration, + EbsDeviceVolumeType, + Instance, + InstanceType, + MachineImage, + Volume, + Vpc, +} from '../lib'; + +export = { + 'basic volume'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::EC2::Volume', { + AvailabilityZone: 'us-east-1a', + MultiAttachEnabled: false, + Size: 8, + VolumeType: 'gp2', + }, ResourcePart.Properties)); + + test.done(); + }, + + 'fromVolumeAttributes'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const encryptionKey = new kms.Key(stack, 'Key'); + const volumeId = 'vol-000000'; + const availabilityZone = 'us-east-1a'; + + // WHEN + const volume = Volume.fromVolumeAttributes(stack, 'Volume', { + volumeId, + availabilityZone, + encryptionKey, + }); + + // THEN + test.strictEqual(volume.volumeId, volumeId); + test.strictEqual(volume.availabilityZone, availabilityZone); + test.strictEqual(volume.encryptionKey, encryptionKey); + test.done(); + }, + + 'tagged volume'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + cdk.Tag.add(volume, 'TagKey', 'TagValue'); + + // THEN + cdkExpect(stack).to(haveResource('AWS::EC2::Volume', { + AvailabilityZone: 'us-east-1a', + MultiAttachEnabled: false, + Size: 8, + VolumeType: 'gp2', + Tags: [{ + Key: 'TagKey', + Value: 'TagValue', + }], + }, ResourcePart.Properties)); + + test.done(); + }, + + 'autoenableIO'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + autoEnableIo: true, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + AutoEnableIO: true, + }, ResourcePart.Properties)); + + test.done(); + }, + + 'encryption'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + encrypted: true, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + Encrypted: true, + }, ResourcePart.Properties)); + + test.done(); + }, + + 'encryption with kms'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const encryptionKey = new kms.Key(stack, 'Key'); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + encrypted: true, + encryptionKey, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + Encrypted: true, + KmsKeyId: { + 'Fn::GetAtt': [ + 'Key961B73FD', + 'Arn', + ], + }, + }, ResourcePart.Properties)); + cdkExpect(stack).to(haveResourceLike('AWS::KMS::Key', { + KeyPolicy: { + Statement: [ + {}, + { + Effect: 'Allow', + Principal: '*', + Resource: '*', + Action: [ + 'kms:Decrypt', + 'kms:Encrypt', + 'kms:ReEncrypt*', + 'kms:GenerateDataKey*', + 'kms:CreateGrant', + 'kms:DescribeKey', + ], + Condition: { + StringEquals: { + 'kms:ViaService': { + 'Fn::Join': [ + '', + [ + 'ec2.', + { + Ref: 'AWS::Region', + }, + '.amazonaws.com', + ], + ], + }, + 'kms:CallerAccount': { + Ref: 'AWS::AccountId', + }, + }, + }, + }, + ], + }, + })); + + test.done(); + }, + + 'iops'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + iops: 500, + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + Iops: 500, + VolumeType: 'io1', + }, ResourcePart.Properties)); + + test.done(); + }, + + 'multi-attach'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + iops: 500, + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + enableMultiAttach: true, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + MultiAttachEnabled: true, + }, ResourcePart.Properties)); + + test.done(); + }, + + 'snapshotId'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + snapshotId: 'snap-00000000', + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + SnapshotId: 'snap-00000000', + }, ResourcePart.Properties)); + + test.done(); + }, + + 'volume: standard'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + volumeType: EbsDeviceVolumeType.MAGNETIC, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + VolumeType: 'standard', + }, ResourcePart.Properties)); + + test.done(); + }, + + 'volume: io1'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + VolumeType: 'io1', + }, ResourcePart.Properties)); + + test.done(); + }, + + 'volume: gp2'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + volumeType: EbsDeviceVolumeType.GENERAL_PURPOSE_SSD, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + VolumeType: 'gp2', + }, ResourcePart.Properties)); + + test.done(); + }, + + 'volume: st1'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + volumeType: EbsDeviceVolumeType.THROUGHPUT_OPTIMIZED_HDD, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + VolumeType: 'st1', + }, ResourcePart.Properties)); + + test.done(); + }, + + 'volume: sc1'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + volumeType: EbsDeviceVolumeType.COLD_HDD, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + VolumeType: 'sc1', + }, ResourcePart.Properties)); + + test.done(); + }, + + 'grantAttachVolume to any instance'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const role = new Role(stack, 'Role', { assumedBy: new AccountRootPrincipal() }); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + volume.grantAttachVolume(role); + + // THEN + cdkExpect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'ec2:AttachVolume', + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': [ + '', + [ + 'arn:aws:ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':volume/', + { + Ref: 'VolumeA92988D3', + }, + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/*', + ], + ], + }, + ], + }], + }, + })); + test.done(); + }, + + 'grantAttachVolume to specific instances'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const role = new Role(stack, 'Role', { assumedBy: new AccountRootPrincipal() }); + const vpc = new Vpc(stack, 'Vpc'); + const instance1 = new Instance(stack, 'Instance1', { + vpc, + instanceType: new InstanceType('t3.small'), + machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + availabilityZone: 'us-east-1a', + }); + const instance2 = new Instance(stack, 'Instance2', { + vpc, + instanceType: new InstanceType('t3.small'), + machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + availabilityZone: 'us-east-1a', + }); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + volume.grantAttachVolume(role, [instance1, instance2]); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'ec2:AttachVolume', + Effect: 'Allow', + Resource: [ + {}, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/', + { + Ref: 'Instance14BC3991D', + }, + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/', + { + Ref: 'Instance255F35265', + }, + ], + ], + }, + ], + }], + }, + })); + + test.done(); + }, + + 'grantDetachVolume to any instance'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const role = new Role(stack, 'Role', { assumedBy: new AccountRootPrincipal() }); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + volume.grantDetachVolume(role); + + // THEN + cdkExpect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'ec2:DetachVolume', + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': [ + '', + [ + 'arn:aws:ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':volume/', + { + Ref: 'VolumeA92988D3', + }, + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/*', + ], + ], + }, + ], + }], + }, + })); + test.done(); + }, + + 'grantDetachVolume from specific instance'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const role = new Role(stack, 'Role', { assumedBy: new AccountRootPrincipal() }); + const vpc = new Vpc(stack, 'Vpc'); + const instance1 = new Instance(stack, 'Instance1', { + vpc, + instanceType: new InstanceType('t3.small'), + machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + availabilityZone: 'us-east-1a', + }); + const instance2 = new Instance(stack, 'Instance2', { + vpc, + instanceType: new InstanceType('t3.small'), + machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + availabilityZone: 'us-east-1a', + }); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + volume.grantDetachVolume(role, [instance1, instance2]); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'ec2:DetachVolume', + Effect: 'Allow', + Resource: [ + {}, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/', + { + Ref: 'Instance14BC3991D', + }, + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/', + { + Ref: 'Instance255F35265', + }, + ], + ], + }, + ], + }], + }, + })); + + test.done(); + }, + + 'validation fromVolumeAttributes'(test: Test) { + // GIVEN + let idx: number = 0; + const stack = new cdk.Stack(); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // THEN + test.doesNotThrow(() => { + Volume.fromVolumeAttributes(stack, `Volume${idx++}`, { + volumeId: volume.volumeId, + availabilityZone: volume.availabilityZone, + }); + }); + test.doesNotThrow(() => { + Volume.fromVolumeAttributes(stack, `Volume${idx++}`, { + volumeId: 'vol-0123456789abcdefABCDEF', + availabilityZone: 'us-east-1a', + }); + }); + test.throws(() => { + Volume.fromVolumeAttributes(stack, `Volume${idx++}`, { + volumeId: ' vol-0123456789abcdefABCDEF', // leading invalid character(s) + availabilityZone: 'us-east-1a', + }); + }, Error, '`volumeId` does not match expected pattern. Expected `vol-` (ex: `vol-05abe246af`) or a Token'); + test.throws(() => { + Volume.fromVolumeAttributes(stack, `Volume${idx++}`, { + volumeId: 'vol-0123456789abcdefABCDEF ', // trailing invalid character(s) + availabilityZone: 'us-east-1a', + }); + }, Error, '`volumeId` does not match expected pattern. Expected `vol-` (ex: `vol-05abe246af`) or a Token'); + test.done(); + }, + + 'validation required props'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const key = new kms.Key(stack, 'Key'); + let idx: number = 0; + + // THEN + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + }); + }, Error, 'Must provide at least one of `size` or `snapshotId`'); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + }); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + snapshotId: 'snap-000000000', + }); + }); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + snapshotId: 'snap-000000000', + }); + }); + + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + encryptionKey: key, + }); + }, Error, '`encrypted` must be true when providing an `encryptionKey`.'); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + encrypted: false, + encryptionKey: key, + }); + }, Error, '`encrypted` must be true when providing an `encryptionKey`.'); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + encrypted: true, + encryptionKey: key, + }); + }); + + test.done(); + }, + + 'validation availabilityZone'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const volume = new Volume(stack, 'ForToken', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + let idx: number = 0; + + // THEN + test.doesNotThrow(() => { + // Should not throw if we provide a token for the AZ + new Volume(stack, `Volume${idx++}`, { + availabilityZone: volume.volumeId, + size: cdk.Size.gibibytes(8), + }); + }); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1', + }); + }, Error, '`availabilityZone` is a region followed by a letter (ex: `us-east-1a`), or a token'); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'Virginia', + }); + }, Error, '`availabilityZone` is a region followed by a letter (ex: `us-east-1a`), or a token'); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: ' us-east-1a', // leading character(s) + }); + }, Error, '`availabilityZone` is a region followed by a letter (ex: `us-east-1a`), or a token'); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a ', // trailing character(s) + }); + }, Error, '`availabilityZone` is a region followed by a letter (ex: `us-east-1a`), or a token'); + + test.done(); + }, + + 'validation snapshotId'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const volume = new Volume(stack, 'ForToken', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + let idx: number = 0; + + // THEN + test.doesNotThrow(() => { + // Should not throw if we provide a Token for the snapshotId + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + snapshotId: volume.volumeId, + }); + }); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + snapshotId: 'snap-0123456789abcdefABCDEF', + }); + }); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + snapshotId: ' snap-1234', // leading extra character(s) + }); + }, Error, '`snapshotId` does match expected pattern. Expected `snap-` (ex: `snap-05abe246af`) or Token'); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + snapshotId: 'snap-1234 ', // trailing extra character(s) + }); + }, Error, '`snapshotId` does match expected pattern. Expected `snap-` (ex: `snap-05abe246af`) or Token'); + + test.done(); + }, + + 'validation iops'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + let idx: number = 0; + + // THEN + // Test: Type of volume + for (const volumeType of [ + EbsDeviceVolumeType.GENERAL_PURPOSE_SSD, + EbsDeviceVolumeType.THROUGHPUT_OPTIMIZED_HDD, + EbsDeviceVolumeType.COLD_HDD, + EbsDeviceVolumeType.MAGNETIC, + ]) { + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + iops: 100, + volumeType, + }); + }, Error, '`iops` may only be specified if the `volumeType` is `PROVISIONED_IOPS_SSD`/`IO1`'); + } + + // Test: iops in range + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(10), + iops: 99, + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + }); + }, Error, '`iops` must be in the range 100 to 64,000, inclusive.'); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(10), + iops: 100, + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + }); + }); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(1300), + iops: 64000, + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + }); + }); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(1300), + iops: 64001, + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + }); + }, Error, '`iops` must be in the range 100 to 64,000, inclusive.'); + + // Test: iops ratio + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(10), + iops: 500, + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + }); + }); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(10), + iops: 501, + volumeType: EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, + }); + }, Error, '`iops` has a maximum ratio of 50 IOPS/GiB.'); + + test.done(); + }, + + 'validation multi-attach'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + let idx: number = 0; + + // THEN + for (const volumeType of [ + EbsDeviceVolumeType.GENERAL_PURPOSE_SSD, + EbsDeviceVolumeType.THROUGHPUT_OPTIMIZED_HDD, + EbsDeviceVolumeType.COLD_HDD, + EbsDeviceVolumeType.MAGNETIC, + ]) { + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + iops: 100, + enableMultiAttach: true, + volumeType, + }); + }, Error, 'multi-attach is supported exclusively on `PROVISIONED_IOPS_SSD` volumes.'); + } + + test.done(); + }, + + 'validation size in range'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + let idx: number = 0; + + // THEN + for (const testData of [ + [EbsDeviceVolumeType.GENERAL_PURPOSE_SSD, 1, 16000], + [EbsDeviceVolumeType.PROVISIONED_IOPS_SSD, 4, 16000], + [EbsDeviceVolumeType.THROUGHPUT_OPTIMIZED_HDD, 500, 16000], + [EbsDeviceVolumeType.COLD_HDD, 500, 16000], + [EbsDeviceVolumeType.MAGNETIC, 1, 1000], + ]) { + const volumeType = testData[0] as EbsDeviceVolumeType; + const min = testData[1] as number; + const max = testData[2] as number; + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(min - 1), + volumeType, + }); + }, Error, `\`${volumeType}\` volumes must be between ${min} GiB and ${max} GiB in size.`); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(min), + volumeType, + }); + }); + test.doesNotThrow(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(max), + volumeType, + }); + }); + test.throws(() => { + new Volume(stack, `Volume${idx++}`, { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(max + 1), + volumeType, + }); + }, Error, `\`${volumeType}\` volumes must be between ${min} GiB and ${max} GiB in size.`); + } + + test.done(); + }, + +}; \ No newline at end of file From 546de2c915cc22281046a6c03be632fb75720619 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Thu, 18 Jun 2020 05:03:36 +0000 Subject: [PATCH 2/6] Reduces IAM permissions granted for KMS key. This changes the permissions appended to the KMS key policy such they are as minimal as possible. Permissions are split between a grant when creating the volume -- a grant that allows the Volume to be created -- and a grant when allowing attachment of the Volume. This properly restricts use of the CMK-encrypted volume to only those users that have access to both the EBS volume and, separately, the CMK used for encryption. --- packages/@aws-cdk/aws-ec2/lib/volume.ts | 39 ++++++--- packages/@aws-cdk/aws-ec2/test/test.volume.ts | 87 +++++++++++++++++-- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/volume.ts b/packages/@aws-cdk/aws-ec2/lib/volume.ts index fd1e72a71755b..bb76c1bd30bb4 100644 --- a/packages/@aws-cdk/aws-ec2/lib/volume.ts +++ b/packages/@aws-cdk/aws-ec2/lib/volume.ts @@ -1,4 +1,4 @@ -import { Anyone, Grant, IGrantable } from '@aws-cdk/aws-iam'; +import { AccountRootPrincipal, Grant, IGrantable } from '@aws-cdk/aws-iam'; import { IKey, ViaServicePrincipal } from '@aws-cdk/aws-kms'; import { Construct, IResource, Resource, Size, SizeRoundingBehavior, Stack, Token } from '@aws-cdk/core'; import { CfnInstance, CfnVolume } from './ec2.generated'; @@ -415,7 +415,22 @@ abstract class VolumeBase extends Resource implements IVolume { actions: [ 'ec2:AttachVolume' ], resourceArns : this.collectGrantResourceArns(instances), }); - // Note: Access to the encryption key is not required. The service having access to it is sufficient. + if (this.encryptionKey) { + // When attaching a volume, the EC2 Service will need to grant to itself permission + // to be able to decrypt the encryption key. We restrict the CreateGrant for principle + // of least privilege, in accordance with best practices. + // See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#ebs-encryption-permissions + const kmsGrant: Grant = this.encryptionKey.grant(grantee, 'kms:CreateGrant'); + kmsGrant.principalStatement!.addConditions( + { + Bool: { 'kms:GrantIsForAWSResource': true }, + StringEquals: { + 'kms:ViaService': `ec2.${Stack.of(this).region}.amazonaws.com`, + 'kms:GrantConstraintType': 'EncryptionContextSubset', + }, + }, + ); + } return result; } @@ -425,7 +440,7 @@ abstract class VolumeBase extends Resource implements IVolume { actions: [ 'ec2:DetachVolume' ], resourceArns : this.collectGrantResourceArns(instances), }); - // Note: Access to the encryption key is not required. The service having access to it is sufficient. + // Note: No encryption key permissions are required to detach an encrypted volume. return result; } @@ -495,15 +510,19 @@ export class Volume extends VolumeBase { this.encryptionKey = props.encryptionKey; if (this.encryptionKey) { + // Per: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#ebs-encryption-requirements const principal = - new ViaServicePrincipal(`ec2.${Stack.of(this).region}.amazonaws.com`, new Anyone()).withConditions({ - StringEquals: { - 'kms:CallerAccount': Stack.of(this).account, - }, - }); - this.encryptionKey.grantEncryptDecrypt(principal).principalStatement?.addActions( - 'kms:CreateGrant', + new ViaServicePrincipal(`ec2.${Stack.of(this).region}.amazonaws.com`, new AccountRootPrincipal()).withConditions({ + StringEquals: { + 'kms:CallerAccount': Stack.of(this).account, + }, + }); + this.encryptionKey.grant(principal, + // Describe & Generate are required to be able to create the CMK-encrypted Volume. 'kms:DescribeKey', + 'kms:GenerateDataKeyWithoutPlainText', + // ReEncrypt is required for when the CMK is rotated. + 'kms:ReEncrypt*', ); } } diff --git a/packages/@aws-cdk/aws-ec2/test/test.volume.ts b/packages/@aws-cdk/aws-ec2/test/test.volume.ts index 96405ecc41b1a..1e51596f9d864 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.volume.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.volume.ts @@ -157,15 +157,29 @@ export = { {}, { Effect: 'Allow', - Principal: '*', + Principal: { + AWS: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':root', + ], + ], + }, + }, Resource: '*', Action: [ - 'kms:Decrypt', - 'kms:Encrypt', - 'kms:ReEncrypt*', - 'kms:GenerateDataKey*', - 'kms:CreateGrant', 'kms:DescribeKey', + 'kms:GenerateDataKeyWithoutPlainText', + 'kms:ReEncrypt*', ], Condition: { StringEquals: { @@ -411,6 +425,67 @@ export = { test.done(); }, + 'grantAttachVolume to any instance with encryption'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const role = new Role(stack, 'Role', { assumedBy: new AccountRootPrincipal() }); + const encryptionKey = new kms.Key(stack, 'Key'); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + encrypted: true, + encryptionKey, + }); + + // WHEN + volume.grantAttachVolume(role); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::KMS::Key', { + KeyPolicy: { + Statement: [ + {}, + {}, + { + Effect: 'Allow', + Principal: { + AWS: { + 'Fn::GetAtt': [ + 'Role1ABCC5F0', + 'Arn', + ], + }, + }, + Action: 'kms:CreateGrant', + Condition: { + Bool: { + 'kms:GrantIsForAWSResource': true, + }, + StringEquals: { + 'kms:ViaService': { + 'Fn::Join': [ + '', + [ + 'ec2.', + { + Ref: 'AWS::Region', + }, + '.amazonaws.com', + ], + ], + }, + 'kms:GrantConstraintType': 'EncryptionContextSubset', + }, + }, + Resource: '*', + }, + ], + }, + })); + + test.done(); + }, + 'grantAttachVolume to specific instances'(test: Test) { // GIVEN const stack = new cdk.Stack(); From b7bfcbb93365ec0c6a2753a93925b4b275addd12 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Thu, 18 Jun 2020 19:56:35 +0000 Subject: [PATCH 3/6] Adds tests for Key.fromKeyArn() & snapshot handling --- packages/@aws-cdk/aws-ec2/lib/volume.ts | 28 ++++- packages/@aws-cdk/aws-ec2/test/test.volume.ts | 109 +++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/volume.ts b/packages/@aws-cdk/aws-ec2/lib/volume.ts index bb76c1bd30bb4..8ff9bad25baf6 100644 --- a/packages/@aws-cdk/aws-ec2/lib/volume.ts +++ b/packages/@aws-cdk/aws-ec2/lib/volume.ts @@ -347,6 +347,26 @@ export interface VolumeProps { * The customer-managed encryption key that is used to encrypt the Volume. The encrypted property must * be true if this is provided. * + * Note: If using an {@link aws-kms.IKey} created from a {@link aws-kms.Key.fromKeyArn()} here, + * then the KMS key **must** have the following in its Key policy; otherwise, the Volume + * will fail to create. + * + * { + * "Effect": "Allow", + * "Principal": { "AWS": " ex: arn:aws:iam::00000000000:root" }, + * "Resource": "*", + * "Action": [ + * "kms:DescribeKey", + * "kms:GenerateDataKeyWithoutPlainText", + * ], + * "Condition": { + * "StringEquals": { + * "kms:ViaService": "ec2..amazonaws.com", (eg: ec2.us-east-1.amazonaws.com) + * "kms:CallerAccount": "0000000000" (your account ID) + * } + * } + * } + * * @default The default KMS key for the account, region, and EC2 service is used. */ readonly encryptionKey?: IKey; @@ -517,13 +537,15 @@ export class Volume extends VolumeBase { 'kms:CallerAccount': Stack.of(this).account, }, }); - this.encryptionKey.grant(principal, + const grant = this.encryptionKey.grant(principal, // Describe & Generate are required to be able to create the CMK-encrypted Volume. 'kms:DescribeKey', 'kms:GenerateDataKeyWithoutPlainText', - // ReEncrypt is required for when the CMK is rotated. - 'kms:ReEncrypt*', ); + if (props.snapshotId) { + // ReEncrypt is required for when re-encrypting from an encrypted snapshot. + grant.principalStatement?.addActions('kms:ReEncrypt*'); + } } } diff --git a/packages/@aws-cdk/aws-ec2/test/test.volume.ts b/packages/@aws-cdk/aws-ec2/test/test.volume.ts index 1e51596f9d864..526f5ccca1150 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.volume.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.volume.ts @@ -179,7 +179,6 @@ export = { Action: [ 'kms:DescribeKey', 'kms:GenerateDataKeyWithoutPlainText', - 'kms:ReEncrypt*', ], Condition: { StringEquals: { @@ -208,6 +207,39 @@ export = { test.done(); }, + 'encryption with kms from snapshot'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const encryptionKey = new kms.Key(stack, 'Key'); + + // WHEN + new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(500), + encrypted: true, + encryptionKey, + snapshotId: 'snap-1234567890', + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::KMS::Key', { + KeyPolicy: { + Statement: [ + {}, + { + Action: [ + 'kms:DescribeKey', + 'kms:GenerateDataKeyWithoutPlainText', + 'kms:ReEncrypt*', + ], + }, + ], + }, + })); + + test.done(); + }, + 'iops'(test: Test) { // GIVEN const stack = new cdk.Stack(); @@ -486,6 +518,81 @@ export = { test.done(); }, + 'grantAttachVolume to any instance with KMS.fromKeyArn() encryption'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const role = new Role(stack, 'Role', { assumedBy: new AccountRootPrincipal() }); + const kmsKey = new kms.Key(stack, 'Key'); + // kmsKey policy is not strictly necessary for the test. + // Demonstrating how to properly construct the Key. + const principal = + new kms.ViaServicePrincipal(`ec2.${stack.region}.amazonaws.com`, new AccountRootPrincipal()).withConditions({ + StringEquals: { + 'kms:CallerAccount': stack.account, + }, + }); + kmsKey.grant(principal, + // Describe & Generate are required to be able to create the CMK-encrypted Volume. + 'kms:DescribeKey', + 'kms:GenerateDataKeyWithoutPlainText', + // ReEncrypt is required for when the CMK is rotated. + 'kms:ReEncrypt*', + ); + + const encryptionKey = kms.Key.fromKeyArn(stack, 'KeyArn', kmsKey.keyArn); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + encrypted: true, + encryptionKey, + }); + + // WHEN + volume.grantAttachVolume(role); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + {}, + { + Effect: 'Allow', + Action: 'kms:CreateGrant', + Resource: { + 'Fn::GetAtt': [ + 'Key961B73FD', + 'Arn', + ], + }, + Condition: { + Bool: { + 'kms:GrantIsForAWSResource': true, + }, + StringEquals: { + 'kms:ViaService': { + 'Fn::Join': [ + '', + [ + 'ec2.', + { + Ref: 'AWS::Region', + }, + '.amazonaws.com', + ], + ], + }, + 'kms:GrantConstraintType': 'EncryptionContextSubset', + }, + }, + }, + ], + }, + })); + + test.done(); + }, + 'grantAttachVolume to specific instances'(test: Test) { // GIVEN const stack = new cdk.Stack(); From 7b7522dde6d830260793af1d47599bc463102978 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Thu, 25 Jun 2020 05:15:26 +0000 Subject: [PATCH 4/6] Adds separate methods for self-refrencing grant --- packages/@aws-cdk/aws-ec2/README.md | 63 ++-- packages/@aws-cdk/aws-ec2/lib/volume.ts | 106 ++++++- packages/@aws-cdk/aws-ec2/test/test.volume.ts | 288 +++++++++++++++++- 3 files changed, 397 insertions(+), 60 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index c4a77e1201282..9d9ce5d0d1b96 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -584,7 +584,6 @@ EBS volume for the bastion host can be encrypted like: }); ``` - ## Block Devices To add EBS block device mappings, specify the `blockDeviceMappings` property. The follow example sets the EBS-backed @@ -610,7 +609,9 @@ new ec2.Instance(this, 'Instance', { ## Volumes -Whereas Block Devices are created, and automatically associated, with a specific instance when that instance is created. Volumes allow you to create a block device that can be attached to, or detached from, any instance at any time. Some types of Volumes can also be attached to multiple instances at the same time to allow you to have shared storage between those instances. A notable restriction is that a Volume can only be attached to instances in the same availability zone as the Volume itself. +Whereas a `BlockDeviceVolume` is an EBS volume that is created and destroyed as part of the creation and destruction of a specific instance. A `Volume` is for when you want an EBS volume separate from any particular instance. A `Volume` is an EBS block device that can be attached to, or detached from, any instance at any time. Some types of `Volume`s can also be attached to multiple instances at the same time to allow you to have shared storage between those instances. + +A notable restriction is that a Volume can only be attached to instances in the same availability zone as the Volume itself. The following demonstrates how to create a 500 GiB encrypted Volume in the `us-west-2a` availability zone, and give a role the ability to attach that Volume to a specific instance: @@ -633,28 +634,18 @@ volume.grantAttachVolume(role, [instance]); ### Instances Attaching Volumes to Themselves If you need to grant an instance the ability to attach/detach an EBS volume to/from itself, then using `grantAttachVolume` and `grantDetachVolume` as outlined above -will lead to an unresolvable circular reference between the instance role and the instance. To securely provide this grant we recommend conditioning the grant -on a unique resource tag as follows: +will lead to an unresolvable circular reference between the instance role and the instance. In this case, use `grantAttachVolumeToSelf` and `grantDetachVolumeFromSelf` as follows: ```ts const instance = new ec2.Instance(this, 'Instance', { // ... }); const volume = new ec2.Volume(this, 'Volume', { - availabilityZone: 'us-west-2a', - size: cdk.Size.gibibytes(500), - encrypted: true, + // ... }); -// Restrict the grant to only attach to instances with a specific Tag value. -// Note: The volume must have the tag as well. -const tagValue: string = 'uniqueTagValue_1234'; -cdk.Tag.add(instance, 'VolumeGrant', tagValue); -cdk.Tag.add(volume, 'VolumeGrant', tagValue); -const attachGrant: iam.Grant = volume.grantAttachVolume(instance); -attachGrant.principalStatement!.addCondition( - 'ForAnyValue:StringEquals', { 'ec2:ResourceTag/VolumeGrant': tagValue } -); +const attachGrant = volume.grantAttachVolumeToSelf(instance); +const detachGrant = volume.grantDetachVolumeFromSelf(instance); ``` ### Attaching Volumes @@ -664,30 +655,24 @@ The Amazon EC2 documentation for [Windows Instances](https://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/ebs-volumes.html) contains information on how to attach and detach your Volumes to/from instances, and how to format them for use. -The following is a sample skeleton of a script that can be used to attach a Volume to the Linux instance that it is running on: - -```bash -# The volume ID of the EBS Volume -EBS_VOL_ID=vol-1234 -# The AWS Region that the instance and volume are within -AWS_REGION=us-west-2 -# The device to mount the volume to; change this as you wish. -TARGET_DEV=/dev/xvdz - -# Use the EC2 metadata service v2 to retrieve the instance-id of this instance. -TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30" 2> /dev/null) -INSTANCE_ID=$(curl -H "X-aws-ec2-metadata-token: $TOKEN" -v http://169.254.169.254/latest/meta-data/instance-id 2> /dev/null) - -# Attach the volume -aws --region ${AWS_REGION} ec2 attach-volume --volume-id ${EBS_VOL_ID} --instance-id ${INSTANCE_ID} --device ${TARGET_DEV} +The following is a sample skeleton of EC2 UserData that can be used to attach a Volume to the Linux instance that it is running on: -# Wait until the volume has attached -while ! test -e ${TARGET_DEV} -do - sleep 1 -done - -# The volume is attached, next you will need to mount it and possibly format it for use. +```ts +const volume = new ec2.Volume(this, 'Volume', { + // ... +}); +const instance = new ec2.Instance(this, 'Instance', { + // ... +}); +volume.grantAttachVolumeToSelf(instance); +const targetDevice = '/dev/xvdz'; +instance.userData.addCommands( + // Attach the volume to /dev/xvdz + `aws --region ${Stack.of(this).region} ec2 attach-volume --volume-id ${volume.volumeId} --instance-id ${instance.instanceId} --device ${targetDevice}`, + // Wait until the volume has attached + `while ! test -e ${targetDevice}; do sleep 1; done` + // The volume will now be mounted. You may have to add additional code to format the volume if it has not been prepared. +); ``` ## VPC Flow Logs diff --git a/packages/@aws-cdk/aws-ec2/lib/volume.ts b/packages/@aws-cdk/aws-ec2/lib/volume.ts index 8ff9bad25baf6..ddd5dd562bf1a 100644 --- a/packages/@aws-cdk/aws-ec2/lib/volume.ts +++ b/packages/@aws-cdk/aws-ec2/lib/volume.ts @@ -1,8 +1,10 @@ +import * as crypto from 'crypto'; + import { AccountRootPrincipal, Grant, IGrantable } from '@aws-cdk/aws-iam'; import { IKey, ViaServicePrincipal } from '@aws-cdk/aws-kms'; -import { Construct, IResource, Resource, Size, SizeRoundingBehavior, Stack, Token } from '@aws-cdk/core'; +import { Construct, IResource, Resource, Size, SizeRoundingBehavior, Stack, Tag, Token } from '@aws-cdk/core'; import { CfnInstance, CfnVolume } from './ec2.generated'; -import { IInstance } from './instance'; +import { IInstance, Instance } from './instance'; /** * Block device @@ -266,9 +268,11 @@ export interface IVolume extends IResource { /** * Grants permission to attach this Volume to an instance. - * CAUTION: Granting an instance permission to attach to itself will lead to an unresolvable circular - * reference between the instance role and the instance. See the README for an example of how to - * resolve this circular reference. + * CAUTION: Granting an instance permission to attach to itself using this method will lead to + * an unresolvable circular reference between the instance role and the instance. + * Use {@link IVolume.grantAttachVolumeToSelf} to grant an instance permission to attach this + * volume to itself. + * * @param grantee the principal being granted permission. * @param instances the instances to which permission is being granted to attach this * volume to. If not specified, then permission is granted to attach @@ -276,17 +280,51 @@ export interface IVolume extends IResource { */ grantAttachVolume(grantee: IGrantable, instances?: IInstance[]): Grant; + /** + * Grants permission to an instance to attach this Volume to itself. + * + * Note: This is implemented by adding a Tag with key `VolumeGrantAttach-` to the + * instance and this Volume, and then conditioning the Grant on the presence of that Tag on + * both the instance and Volume. If you need to grant AttachVolume of this same Volume to + * multiple instances then provide a unique `tagKeySuffix` for each attachment; failure to do + * so will result in an inability to attach this volume to some instances. + * + * @param instance The instance that will be allowed to attach this volume to itself. + * [disable-awslint:ref-via-interface] + * @param tagKeySuffix A suffix to use on the generated Tag key in place of the generated hash value. + * Defaults to a hash calculated from this volume. + */ + grantAttachVolumeToSelf(instance: Instance, tagKeySuffix?: string): Grant; + /** * Grants permission to detach this Volume from an instance - * CAUTION: Granting an instance permission to detach from itself will lead to an unresolvable circular - * reference between the instance role and the instance. See the README for an example of how to - * resolve this circular reference. + * CAUTION: Granting an instance permission to detach from itself using this method will lead to + * an unresolvable circular reference between the instance role and the instance. + * Use {@link IVolume.grantDetachVolumeFromSelf} to grant an instance permission to detach this + * volume from itself. + * * @param grantee the principal being granted permission. * @param instances the instances to which permission is being granted to detach this - * volume from. If not specified, then permission is granted to attach - * to all instances in this account. + * volume from. If not specified, then permission is granted to detach + * from all instances in this account. */ grantDetachVolume(grantee: IGrantable, instances?: IInstance[]): Grant; + + /** + * Grants permission to an instance to detach this Volume from itself. + * + * Note: This is implemented by adding a Tag with key `VolumeGrantDetach-` to the + * instance and this Volume, and then conditioning the Grant on the presence of that Tag on + * both the instance and Volume. If you need to grant DetachVolume of this same Volume to + * multiple instances then provide a unique `tagKeySuffix` for each attachment; failure to do + * so will result in an inability to detach this volume from some instances. + * + * @param instance The instance that will be allowed to detach this volume to itself. + * [disable-awslint:ref-via-interface] + * @param tagKeySuffix A suffix to use on the generated Tag key in place of the generated hash value. + * Defaults to a hash calculated from this volume. + */ + grantDetachVolumeFromSelf(instance: Instance, tagKeySuffix?: string): Grant; } /** @@ -435,6 +473,7 @@ abstract class VolumeBase extends Resource implements IVolume { actions: [ 'ec2:AttachVolume' ], resourceArns : this.collectGrantResourceArns(instances), }); + if (this.encryptionKey) { // When attaching a volume, the EC2 Service will need to grant to itself permission // to be able to decrypt the encryption key. We restrict the CreateGrant for principle @@ -451,6 +490,24 @@ abstract class VolumeBase extends Resource implements IVolume { }, ); } + + return result; + } + + public grantAttachVolumeToSelf(instance: Instance, tagKeySuffix?: string): Grant { + const tagKey = `VolumeGrantAttach-${tagKeySuffix ?? this.stringHash(this.node.uniqueId)}`; + const tagValue = this.node.uniqueId; + const grantCondition: { [key: string]: string } = {}; + grantCondition[`ec2:ResourceTag/${tagKey}`] = tagValue; + + const result = this.grantAttachVolume(instance); + result.principalStatement!.addCondition( + 'ForAnyValue:StringEquals', grantCondition, + ); + + Tag.add(this, tagKey, tagValue); + Tag.add(instance, tagKey, tagValue); + return result; } @@ -464,18 +521,41 @@ abstract class VolumeBase extends Resource implements IVolume { return result; } + public grantDetachVolumeFromSelf(instance: Instance, tagKeySuffix?: string): Grant { + const tagKey = `VolumeGrantDetach-${tagKeySuffix ?? this.stringHash(this.node.uniqueId)}`; + const tagValue = this.node.uniqueId; + const grantCondition: { [key: string]: string } = {}; + grantCondition[`ec2:ResourceTag/${tagKey}`] = tagValue; + + const result = this.grantDetachVolume(instance); + result.principalStatement!.addCondition( + 'ForAnyValue:StringEquals', grantCondition, + ); + + Tag.add(this, tagKey, tagValue); + Tag.add(instance, tagKey, tagValue); + + return result; + } + private collectGrantResourceArns(instances?: IInstance[]): string[] { const stack = Stack.of(this); const resourceArns: string[] = [ - `arn:aws:ec2:${stack.region}:${stack.account}:volume/${this.volumeId}`, + `arn:${stack.partition}:ec2:${stack.region}:${stack.account}:volume/${this.volumeId}`, ]; + const instanceArnPrefix = `arn:${stack.partition}:ec2:${stack.region}:${stack.account}:instance`; if (instances) { - instances.forEach(instance => resourceArns.push(`arn:aws:ec2:${stack.region}:${stack.account}:instance/${instance?.instanceId}`)); + instances.forEach(instance => resourceArns.push(`${instanceArnPrefix}/${instance?.instanceId}`)); } else { - resourceArns.push(`arn:aws:ec2:${stack.region}:${stack.account}:instance/*`); + resourceArns.push(`${instanceArnPrefix}/*`); } return resourceArns; } + + private stringHash(value: string): string { + const md5 = crypto.createHash('md5').update(value).digest('hex'); + return md5.slice(0, 8).toUpperCase(); + } } /** diff --git a/packages/@aws-cdk/aws-ec2/test/test.volume.ts b/packages/@aws-cdk/aws-ec2/test/test.volume.ts index 526f5ccca1150..ef18562b4fa5e 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.volume.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.volume.ts @@ -419,7 +419,11 @@ export = { 'Fn::Join': [ '', [ - 'arn:aws:ec2:', + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', { Ref: 'AWS::Region', }, @@ -438,7 +442,11 @@ export = { 'Fn::Join': [ '', [ - 'arn:aws:ec2:', + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', { Ref: 'AWS::Region', }, @@ -631,7 +639,11 @@ export = { 'Fn::Join': [ '', [ - 'arn:aws:ec2:', + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', { Ref: 'AWS::Region', }, @@ -650,7 +662,11 @@ export = { 'Fn::Join': [ '', [ - 'arn:aws:ec2:', + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', { Ref: 'AWS::Region', }, @@ -673,6 +689,126 @@ export = { test.done(); }, + 'grantAttachVolume to instance self'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new Vpc(stack, 'Vpc'); + const instance = new Instance(stack, 'Instance', { + vpc, + instanceType: new InstanceType('t3.small'), + machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + availabilityZone: 'us-east-1a', + }); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + volume.grantAttachVolumeToSelf(instance); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'ec2:AttachVolume', + Effect: 'Allow', + Resource: [ + {}, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/*', + ], + ], + }, + ], + Condition: { + 'ForAnyValue:StringEquals': { + 'ec2:ResourceTag/VolumeGrantAttach-BD7A9717': 'Volume', + }, + }, + }], + }, + })); + + test.done(); + }, + + 'grantAttachVolume to instance self with suffix'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new Vpc(stack, 'Vpc'); + const instance = new Instance(stack, 'Instance', { + vpc, + instanceType: new InstanceType('t3.small'), + machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + availabilityZone: 'us-east-1a', + }); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + volume.grantAttachVolumeToSelf(instance, 'TestSuffix'); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'ec2:AttachVolume', + Effect: 'Allow', + Resource: [ + {}, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/*', + ], + ], + }, + ], + Condition: { + 'ForAnyValue:StringEquals': { + 'ec2:ResourceTag/VolumeGrantAttach-TestSuffix': 'Volume', + }, + }, + }], + }, + })); + + test.done(); + }, + 'grantDetachVolume to any instance'(test: Test) { // GIVEN const stack = new cdk.Stack(); @@ -697,7 +833,11 @@ export = { 'Fn::Join': [ '', [ - 'arn:aws:ec2:', + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', { Ref: 'AWS::Region', }, @@ -716,7 +856,11 @@ export = { 'Fn::Join': [ '', [ - 'arn:aws:ec2:', + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', { Ref: 'AWS::Region', }, @@ -773,7 +917,11 @@ export = { 'Fn::Join': [ '', [ - 'arn:aws:ec2:', + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', { Ref: 'AWS::Region', }, @@ -792,7 +940,11 @@ export = { 'Fn::Join': [ '', [ - 'arn:aws:ec2:', + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', { Ref: 'AWS::Region', }, @@ -815,6 +967,126 @@ export = { test.done(); }, + 'grantDetachVolume from instance self'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new Vpc(stack, 'Vpc'); + const instance = new Instance(stack, 'Instance', { + vpc, + instanceType: new InstanceType('t3.small'), + machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + availabilityZone: 'us-east-1a', + }); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + volume.grantDetachVolumeFromSelf(instance); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'ec2:DetachVolume', + Effect: 'Allow', + Resource: [ + {}, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/*', + ], + ], + }, + ], + Condition: { + 'ForAnyValue:StringEquals': { + 'ec2:ResourceTag/VolumeGrantDetach-BD7A9717': 'Volume', + }, + }, + }], + }, + })); + + test.done(); + }, + + 'grantDetachVolume from instance self with suffix'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new Vpc(stack, 'Vpc'); + const instance = new Instance(stack, 'Instance', { + vpc, + instanceType: new InstanceType('t3.small'), + machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + availabilityZone: 'us-east-1a', + }); + const volume = new Volume(stack, 'Volume', { + availabilityZone: 'us-east-1a', + size: cdk.Size.gibibytes(8), + }); + + // WHEN + volume.grantDetachVolumeFromSelf(instance, 'TestSuffix'); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'ec2:DetachVolume', + Effect: 'Allow', + Resource: [ + {}, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ec2:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':instance/*', + ], + ], + }, + ], + Condition: { + 'ForAnyValue:StringEquals': { + 'ec2:ResourceTag/VolumeGrantDetach-TestSuffix': 'Volume', + }, + }, + }], + }, + })); + + test.done(); + }, + 'validation fromVolumeAttributes'(test: Test) { // GIVEN let idx: number = 0; From 47412bb875b1beb6c7f2066936a36aa18e6b2bbc Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Thu, 25 Jun 2020 17:55:22 +0000 Subject: [PATCH 5/6] Adds grantAttachVolumeByResourceTag/grantDetachVolumeByResourceTag --- packages/@aws-cdk/aws-ec2/lib/volume.ts | 68 ++++++++------- packages/@aws-cdk/aws-ec2/test/test.volume.ts | 84 +++++++++++++++++-- 2 files changed, 116 insertions(+), 36 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/volume.ts b/packages/@aws-cdk/aws-ec2/lib/volume.ts index ddd5dd562bf1a..6a6445dc87379 100644 --- a/packages/@aws-cdk/aws-ec2/lib/volume.ts +++ b/packages/@aws-cdk/aws-ec2/lib/volume.ts @@ -4,7 +4,7 @@ import { AccountRootPrincipal, Grant, IGrantable } from '@aws-cdk/aws-iam'; import { IKey, ViaServicePrincipal } from '@aws-cdk/aws-kms'; import { Construct, IResource, Resource, Size, SizeRoundingBehavior, Stack, Tag, Token } from '@aws-cdk/core'; import { CfnInstance, CfnVolume } from './ec2.generated'; -import { IInstance, Instance } from './instance'; +import { IInstance } from './instance'; /** * Block device @@ -281,20 +281,25 @@ export interface IVolume extends IResource { grantAttachVolume(grantee: IGrantable, instances?: IInstance[]): Grant; /** - * Grants permission to an instance to attach this Volume to itself. + * Grants permission to attach the Volume by a ResourceTag condition. If you are looking to + * grant an Instance, AutoScalingGroup, EC2-Fleet, SpotFleet, ECS host, etc the ability to attach + * this volume to **itself** then this is the method you want to use. * - * Note: This is implemented by adding a Tag with key `VolumeGrantAttach-` to the - * instance and this Volume, and then conditioning the Grant on the presence of that Tag on - * both the instance and Volume. If you need to grant AttachVolume of this same Volume to - * multiple instances then provide a unique `tagKeySuffix` for each attachment; failure to do - * so will result in an inability to attach this volume to some instances. + * This is implemented by adding a Tag with key `VolumeGrantAttach-` to the given + * constructs and this Volume, and then conditioning the Grant such that the grantee is only + * given the ability to AttachVolume if both the Volume and the destination Instance have that + * tag applied to them. * - * @param instance The instance that will be allowed to attach this volume to itself. - * [disable-awslint:ref-via-interface] + * If you need to call this method multiple times on different sets of constructs, then provide a + * unique `tagKeySuffix` for each call; failure to do so will result in an inability to attach this + * volume to some of the grants because it will overwrite the tag. + * + * @param grantee the principal being granted permission. + * @param constructs The list of constructs that will have the generated resource tag applied to them. * @param tagKeySuffix A suffix to use on the generated Tag key in place of the generated hash value. * Defaults to a hash calculated from this volume. */ - grantAttachVolumeToSelf(instance: Instance, tagKeySuffix?: string): Grant; + grantAttachVolumeByResourceTag(grantee: IGrantable, constructs: Construct[], tagKeySuffix?: string): Grant; /** * Grants permission to detach this Volume from an instance @@ -311,20 +316,17 @@ export interface IVolume extends IResource { grantDetachVolume(grantee: IGrantable, instances?: IInstance[]): Grant; /** - * Grants permission to an instance to detach this Volume from itself. + * Grants permission to detach the Volume by a ResourceTag condition. * - * Note: This is implemented by adding a Tag with key `VolumeGrantDetach-` to the - * instance and this Volume, and then conditioning the Grant on the presence of that Tag on - * both the instance and Volume. If you need to grant DetachVolume of this same Volume to - * multiple instances then provide a unique `tagKeySuffix` for each attachment; failure to do - * so will result in an inability to detach this volume from some instances. + * This is implemented via the same mechanism as {@link IVolume.grantAttachVolumeByResourceTag}, + * and is subject to the same conditions. * - * @param instance The instance that will be allowed to detach this volume to itself. - * [disable-awslint:ref-via-interface] - * @param tagKeySuffix A suffix to use on the generated Tag key in place of the generated hash value. + * @param grantee the principal being granted permission. + * @param constructs The list of constructs that will have the generated resource tag applied to them. + * @param tagKeySuffix A suffix to use on the generated Tag key in place of the generated hash value. * Defaults to a hash calculated from this volume. */ - grantDetachVolumeFromSelf(instance: Instance, tagKeySuffix?: string): Grant; + grantDetachVolumeByResourceTag(grantee: IGrantable, constructs: Construct[], tagKeySuffix?: string): Grant; } /** @@ -494,19 +496,21 @@ abstract class VolumeBase extends Resource implements IVolume { return result; } - public grantAttachVolumeToSelf(instance: Instance, tagKeySuffix?: string): Grant { + public grantAttachVolumeByResourceTag(grantee: IGrantable, constructs: Construct[], tagKeySuffix?: string): Grant { const tagKey = `VolumeGrantAttach-${tagKeySuffix ?? this.stringHash(this.node.uniqueId)}`; - const tagValue = this.node.uniqueId; + const tagValue = this.calculateResourceTagValue(constructs); const grantCondition: { [key: string]: string } = {}; grantCondition[`ec2:ResourceTag/${tagKey}`] = tagValue; - const result = this.grantAttachVolume(instance); + const result = this.grantAttachVolume(grantee); result.principalStatement!.addCondition( 'ForAnyValue:StringEquals', grantCondition, ); + // The ResourceTag condition requires that all resources involved in the operation have + // the given tag, so we tag this and all constructs given. Tag.add(this, tagKey, tagValue); - Tag.add(instance, tagKey, tagValue); + constructs.forEach(construct => Tag.add(construct, tagKey, tagValue)); return result; } @@ -521,19 +525,21 @@ abstract class VolumeBase extends Resource implements IVolume { return result; } - public grantDetachVolumeFromSelf(instance: Instance, tagKeySuffix?: string): Grant { + public grantDetachVolumeByResourceTag(grantee: IGrantable, constructs: Construct[], tagKeySuffix?: string): Grant { const tagKey = `VolumeGrantDetach-${tagKeySuffix ?? this.stringHash(this.node.uniqueId)}`; - const tagValue = this.node.uniqueId; + const tagValue = this.calculateResourceTagValue(constructs); const grantCondition: { [key: string]: string } = {}; grantCondition[`ec2:ResourceTag/${tagKey}`] = tagValue; - const result = this.grantDetachVolume(instance); + const result = this.grantDetachVolume(grantee); result.principalStatement!.addCondition( 'ForAnyValue:StringEquals', grantCondition, ); + // The ResourceTag condition requires that all resources involved in the operation have + // the given tag, so we tag this and all constructs given. Tag.add(this, tagKey, tagValue); - Tag.add(instance, tagKey, tagValue); + constructs.forEach(construct => Tag.add(construct, tagKey, tagValue)); return result; } @@ -556,6 +562,12 @@ abstract class VolumeBase extends Resource implements IVolume { const md5 = crypto.createHash('md5').update(value).digest('hex'); return md5.slice(0, 8).toUpperCase(); } + + private calculateResourceTagValue(constructs: Construct[]): string { + const md5 = crypto.createHash('md5'); + constructs.forEach(construct => md5.update(construct.node.uniqueId)); + return md5.digest('hex'); + } } /** diff --git a/packages/@aws-cdk/aws-ec2/test/test.volume.ts b/packages/@aws-cdk/aws-ec2/test/test.volume.ts index ef18562b4fa5e..dbef8b6ecfebe 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.volume.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.volume.ts @@ -705,7 +705,7 @@ export = { }); // WHEN - volume.grantAttachVolumeToSelf(instance); + volume.grantAttachVolumeByResourceTag(instance.grantPrincipal, [instance]); // THEN cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { @@ -739,12 +739,29 @@ export = { ], Condition: { 'ForAnyValue:StringEquals': { - 'ec2:ResourceTag/VolumeGrantAttach-BD7A9717': 'Volume', + 'ec2:ResourceTag/VolumeGrantAttach-BD7A9717': 'd9a17c1c9e8ef6866e4dbeef41c741b2', }, }, }], }, })); + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + Tags: [ + { + Key: 'VolumeGrantAttach-BD7A9717', + Value: 'd9a17c1c9e8ef6866e4dbeef41c741b2', + }, + ], + }, ResourcePart.Properties)); + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Instance', { + Tags: [ + {}, + { + Key: 'VolumeGrantAttach-BD7A9717', + Value: 'd9a17c1c9e8ef6866e4dbeef41c741b2', + }, + ], + }, ResourcePart.Properties)); test.done(); }, @@ -765,7 +782,7 @@ export = { }); // WHEN - volume.grantAttachVolumeToSelf(instance, 'TestSuffix'); + volume.grantAttachVolumeByResourceTag(instance.grantPrincipal, [instance], 'TestSuffix'); // THEN cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { @@ -799,12 +816,29 @@ export = { ], Condition: { 'ForAnyValue:StringEquals': { - 'ec2:ResourceTag/VolumeGrantAttach-TestSuffix': 'Volume', + 'ec2:ResourceTag/VolumeGrantAttach-TestSuffix': 'd9a17c1c9e8ef6866e4dbeef41c741b2', }, }, }], }, })); + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + Tags: [ + { + Key: 'VolumeGrantAttach-TestSuffix', + Value: 'd9a17c1c9e8ef6866e4dbeef41c741b2', + }, + ], + }, ResourcePart.Properties)); + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Instance', { + Tags: [ + {}, + { + Key: 'VolumeGrantAttach-TestSuffix', + Value: 'd9a17c1c9e8ef6866e4dbeef41c741b2', + }, + ], + }, ResourcePart.Properties)); test.done(); }, @@ -983,7 +1017,7 @@ export = { }); // WHEN - volume.grantDetachVolumeFromSelf(instance); + volume.grantDetachVolumeByResourceTag(instance.grantPrincipal, [instance]); // THEN cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { @@ -1017,12 +1051,29 @@ export = { ], Condition: { 'ForAnyValue:StringEquals': { - 'ec2:ResourceTag/VolumeGrantDetach-BD7A9717': 'Volume', + 'ec2:ResourceTag/VolumeGrantDetach-BD7A9717': 'd9a17c1c9e8ef6866e4dbeef41c741b2', }, }, }], }, })); + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + Tags: [ + { + Key: 'VolumeGrantDetach-BD7A9717', + Value: 'd9a17c1c9e8ef6866e4dbeef41c741b2', + }, + ], + }, ResourcePart.Properties)); + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Instance', { + Tags: [ + {}, + { + Key: 'VolumeGrantDetach-BD7A9717', + Value: 'd9a17c1c9e8ef6866e4dbeef41c741b2', + }, + ], + }, ResourcePart.Properties)); test.done(); }, @@ -1043,7 +1094,7 @@ export = { }); // WHEN - volume.grantDetachVolumeFromSelf(instance, 'TestSuffix'); + volume.grantDetachVolumeByResourceTag(instance.grantPrincipal, [instance], 'TestSuffix'); // THEN cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { @@ -1077,12 +1128,29 @@ export = { ], Condition: { 'ForAnyValue:StringEquals': { - 'ec2:ResourceTag/VolumeGrantDetach-TestSuffix': 'Volume', + 'ec2:ResourceTag/VolumeGrantDetach-TestSuffix': 'd9a17c1c9e8ef6866e4dbeef41c741b2', }, }, }], }, })); + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Volume', { + Tags: [ + { + Key: 'VolumeGrantDetach-TestSuffix', + Value: 'd9a17c1c9e8ef6866e4dbeef41c741b2', + }, + ], + }, ResourcePart.Properties)); + cdkExpect(stack).to(haveResourceLike('AWS::EC2::Instance', { + Tags: [ + {}, + { + Key: 'VolumeGrantDetach-TestSuffix', + Value: 'd9a17c1c9e8ef6866e4dbeef41c741b2', + }, + ], + }, ResourcePart.Properties)); test.done(); }, From 2da39bfea2b52304301d174f8fb2524eb7218838 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Thu, 25 Jun 2020 17:58:41 +0000 Subject: [PATCH 6/6] Missed updates to the README. --- packages/@aws-cdk/aws-ec2/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index 9d9ce5d0d1b96..d3d9e070684f8 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -634,7 +634,7 @@ volume.grantAttachVolume(role, [instance]); ### Instances Attaching Volumes to Themselves If you need to grant an instance the ability to attach/detach an EBS volume to/from itself, then using `grantAttachVolume` and `grantDetachVolume` as outlined above -will lead to an unresolvable circular reference between the instance role and the instance. In this case, use `grantAttachVolumeToSelf` and `grantDetachVolumeFromSelf` as follows: +will lead to an unresolvable circular reference between the instance role and the instance. In this case, use `grantAttachVolumeByResourceTag` and `grantDetachVolumeByResourceTag` as follows: ```ts const instance = new ec2.Instance(this, 'Instance', { @@ -644,8 +644,8 @@ const volume = new ec2.Volume(this, 'Volume', { // ... }); -const attachGrant = volume.grantAttachVolumeToSelf(instance); -const detachGrant = volume.grantDetachVolumeFromSelf(instance); +const attachGrant = volume.grantAttachVolumeByResourceTag(instance.grantPrincipal, [instance]); +const detachGrant = volume.grantDetachVolumeByResourceTag(instance.grantPrincipal, [instance]); ``` ### Attaching Volumes @@ -664,7 +664,7 @@ const volume = new ec2.Volume(this, 'Volume', { const instance = new ec2.Instance(this, 'Instance', { // ... }); -volume.grantAttachVolumeToSelf(instance); +volume.grantAttachVolumeByResourceTag(instance.grantPrincipal, [instance]); const targetDevice = '/dev/xvdz'; instance.userData.addCommands( // Attach the volume to /dev/xvdz