From eb634b708d3471daad7c0897de06b0acbda65c0f Mon Sep 17 00:00:00 2001 From: Kyle Laker Date: Tue, 11 Jan 2022 07:49:24 -0500 Subject: [PATCH] feat(iam): generate AccessKeys (#18180) This adds an L2 resource for creating IAM access keys. Instructions for creating access keys are added to the README near the information on creating users. Tests are added (including an integration test) and locations elsewhere in the CDK where `CfnAccessKey` was used have been updated to leverage the new L2 construct (which required changes in the `secretsmanager` and `apigatewayv2-authorizers` packages). Excludes were added for two `awslint` rules. Access Keys don't support specifying physical names, so having such a property is impossible. Additionally, since the primary value of an `AWS::IAM::AccessKey` is to gain access to the `SecretAccessKey` value, a `fromXXX` static method doesn't seem to make a lot of sense (because ideally you'd just pull that from a Secret anyway if it was required in the app). I looked into integrating with `secretsmanager.Secret` as part of this PR; however, at this time it's currently experimental to support strings via tokens and the experimental resource's documentation isn't available so it seemed suboptimal to do that integration. Resolves: #8432 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../test/http/integ.iam.expected.json | 6 +- .../test/http/integ.iam.ts | 8 +- packages/@aws-cdk/aws-iam/README.md | 21 +++++ packages/@aws-cdk/aws-iam/lib/access-key.ts | 93 +++++++++++++++++++ packages/@aws-cdk/aws-iam/lib/index.ts | 1 + packages/@aws-cdk/aws-iam/package.json | 2 + .../@aws-cdk/aws-iam/test/access-key.test.ts | 79 ++++++++++++++++ .../test/integ.access-key.expected.json | 22 +++++ .../@aws-cdk/aws-iam/test/integ.access-key.ts | 12 +++ .../@aws-cdk/aws-secretsmanager/lib/secret.ts | 6 +- .../test/integ.secret.lit.expected.json | 4 +- .../test/integ.secret.lit.ts | 4 +- .../aws-secretsmanager/test/secret.test.ts | 16 ++-- 13 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 packages/@aws-cdk/aws-iam/lib/access-key.ts create mode 100644 packages/@aws-cdk/aws-iam/test/access-key.test.ts create mode 100644 packages/@aws-cdk/aws-iam/test/integ.access-key.expected.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.access-key.ts diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.expected.json b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.expected.json index ab33666949d9b..be1c18a7e49f7 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.expected.json +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.expected.json @@ -67,7 +67,7 @@ ] } }, - "UserAccess": { + "UserAccessEC42ADF7": { "Type": "AWS::IAM::AccessKey", "Properties": { "UserName": { @@ -184,13 +184,13 @@ }, "TESTACCESSKEYID": { "Value": { - "Ref": "UserAccess" + "Ref": "UserAccessEC42ADF7" } }, "TESTSECRETACCESSKEY": { "Value": { "Fn::GetAtt": [ - "UserAccess", + "UserAccessEC42ADF7", "SecretAccessKey" ] } diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.ts index a010e6c0b990e..6ae3c42bc8421 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.ts @@ -17,8 +17,8 @@ class ExampleComIntegration extends apigatewayv2.HttpRouteIntegration { const app = new cdk.App(); const stack = new cdk.Stack(app, 'IntegApiGatewayV2Iam'); const user = new iam.User(stack, 'User'); -const userAccessKey = new iam.CfnAccessKey(stack, 'UserAccess', { - userName: user.userName, +const userAccessKey = new iam.AccessKey(stack, 'UserAccess', { + user, }); const httpApi = new apigatewayv2.HttpApi(stack, 'HttpApi', { @@ -44,11 +44,11 @@ new cdk.CfnOutput(stack, 'API', { }); new cdk.CfnOutput(stack, 'TESTACCESSKEYID', { - value: userAccessKey.ref, + value: userAccessKey.accessKeyId, }); new cdk.CfnOutput(stack, 'TESTSECRETACCESSKEY', { - value: userAccessKey.attrSecretAccessKey, + value: userAccessKey.secretAccessKey.toString(), }); new cdk.CfnOutput(stack, 'TESTREGION', { diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index 438b54693b5b5..e03de10050294 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -457,6 +457,27 @@ const user = iam.User.fromUserAttributes(this, 'MyImportedUserByAttributes', { }); ``` +### Access Keys + +The ability for a user to make API calls via the CLI or an SDK is enabled by the user having an +access key pair. To create an access key: + +```ts +const user = new iam.User(this, 'MyUser'); +const accessKey = new iam.AccessKey(this, 'MyAccessKey', { user: user }); +``` + +You can force CloudFormation to rotate the access key by providing a monotonically increasing `serial` +property. Simply provide a higher serial value than any number used previously: + +```ts +const user = new iam.User(this, 'MyUser'); +const accessKey = new iam.AccessKey(this, 'MyAccessKey', { user: user, serial: 1 }); +``` + +An access key may only be associated with a single user and cannot be "moved" between users. Changing +the user associated with an access key replaces the access key (and its ID and secret value). + ## Groups An IAM user group is a collection of IAM users. User groups let you specify permissions for multiple users. diff --git a/packages/@aws-cdk/aws-iam/lib/access-key.ts b/packages/@aws-cdk/aws-iam/lib/access-key.ts new file mode 100644 index 0000000000000..259e46c67a572 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/lib/access-key.ts @@ -0,0 +1,93 @@ +import { IResource, Resource, SecretValue } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnAccessKey } from './iam.generated'; +import { IUser } from './user'; + +/** + * Valid statuses for an IAM Access Key. + */ +export enum AccessKeyStatus { + /** + * An active access key. An active key can be used to make API calls. + */ + ACTIVE = 'Active', + + /** + * An inactive access key. An inactive key cannot be used to make API calls. + */ + INACTIVE = 'Inactive' +} + +/** + * Represents an IAM Access Key. + * + * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html + */ +export interface IAccessKey extends IResource { + /** + * The Access Key ID. + * + * @attribute + */ + readonly accessKeyId: string; + + /** + * The Secret Access Key. + * + * @attribute + */ + readonly secretAccessKey: SecretValue; +} + +/** + * Properties for defining an IAM access key. + */ +export interface AccessKeyProps { + /** + * A CloudFormation-specific value that signifies the access key should be + * replaced/rotated. This value can only be incremented. Incrementing this + * value will cause CloudFormation to replace the Access Key resource. + * + * @default - No serial value + */ + readonly serial?: number; + + /** + * The status of the access key. An Active access key is allowed to be used + * to make API calls; An Inactive key cannot. + * + * @default - The access key is active + */ + readonly status?: AccessKeyStatus; + + /** + * The IAM user this key will belong to. + * + * Changing this value will result in the access key being deleted and a new + * access key (with a different ID and secret value) being assigned to the new + * user. + */ + readonly user: IUser; +} + +/** + * Define a new IAM Access Key. + */ +export class AccessKey extends Resource implements IAccessKey { + public readonly accessKeyId: string; + public readonly secretAccessKey: SecretValue; + + constructor(scope: Construct, id: string, props: AccessKeyProps) { + super(scope, id); + const accessKey = new CfnAccessKey(this, 'Resource', { + userName: props.user.userName, + serial: props.serial, + status: props.status, + }); + + this.accessKeyId = accessKey.ref; + + // Not actually 'plainText', but until we have a more apt constructor + this.secretAccessKey = SecretValue.plainText(accessKey.attrSecretAccessKey); + } +} diff --git a/packages/@aws-cdk/aws-iam/lib/index.ts b/packages/@aws-cdk/aws-iam/lib/index.ts index 06c2a9bb6cdcd..7b13245b49f39 100644 --- a/packages/@aws-cdk/aws-iam/lib/index.ts +++ b/packages/@aws-cdk/aws-iam/lib/index.ts @@ -13,6 +13,7 @@ export * from './unknown-principal'; export * from './oidc-provider'; export * from './permissions-boundary'; export * from './saml-provider'; +export * from './access-key'; // AWS::IAM CloudFormation Resources: export * from './iam.generated'; diff --git a/packages/@aws-cdk/aws-iam/package.json b/packages/@aws-cdk/aws-iam/package.json index 3977a8576fae2..fec5003622341 100644 --- a/packages/@aws-cdk/aws-iam/package.json +++ b/packages/@aws-cdk/aws-iam/package.json @@ -108,9 +108,11 @@ "awslint": { "exclude": [ "from-signature:@aws-cdk/aws-iam.Role.fromRoleArn", + "from-method:@aws-cdk/aws-iam.AccessKey", "construct-interface-extends-iconstruct:@aws-cdk/aws-iam.IManagedPolicy", "props-physical-name:@aws-cdk/aws-iam.OpenIdConnectProviderProps", "props-physical-name:@aws-cdk/aws-iam.SamlProviderProps", + "props-physical-name:@aws-cdk/aws-iam.AccessKeyProps", "resource-interface-extends-resource:@aws-cdk/aws-iam.IManagedPolicy", "docs-public-apis:@aws-cdk/aws-iam.IUser" ] diff --git a/packages/@aws-cdk/aws-iam/test/access-key.test.ts b/packages/@aws-cdk/aws-iam/test/access-key.test.ts new file mode 100644 index 0000000000000..fe54ffef2b159 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/access-key.test.ts @@ -0,0 +1,79 @@ +import '@aws-cdk/assert-internal/jest'; +import { App, Stack } from '@aws-cdk/core'; +import { AccessKey, AccessKeyStatus, User } from '../lib'; + +describe('IAM Access keys', () => { + test('user name is identifed via reference', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const user = new User(stack, 'MyUser'); + + // WHEN + new AccessKey(stack, 'MyAccessKey', { user }); + + // THEN + expect(stack).toMatchTemplate({ + Resources: { + MyUserDC45028B: { + Type: 'AWS::IAM::User', + }, + MyAccessKeyF0FFBE2E: { + Type: 'AWS::IAM::AccessKey', + Properties: { + UserName: { Ref: 'MyUserDC45028B' }, + }, + }, + }, + }); + }); + + test('active status is specified with correct capitalization', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const user = new User(stack, 'MyUser'); + + // WHEN + new AccessKey(stack, 'MyAccessKey', { user, status: AccessKeyStatus.ACTIVE }); + + // THEN + expect(stack).toHaveResourceLike('AWS::IAM::AccessKey', { Status: 'Active' }); + }); + + test('inactive status is specified with correct capitalization', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const user = new User(stack, 'MyUser'); + + // WHEN + new AccessKey(stack, 'MyAccessKey', { + user, + status: AccessKeyStatus.INACTIVE, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::IAM::AccessKey', { + Status: 'Inactive', + }); + }); + + test('access key secret ', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const user = new User(stack, 'MyUser'); + + // WHEN + const accessKey = new AccessKey(stack, 'MyAccessKey', { + user, + }); + + // THEN + expect(stack.resolve(accessKey.secretAccessKey)).toStrictEqual({ + 'Fn::GetAtt': ['MyAccessKeyF0FFBE2E', 'SecretAccessKey'], + }); + }); + +}); diff --git a/packages/@aws-cdk/aws-iam/test/integ.access-key.expected.json b/packages/@aws-cdk/aws-iam/test/integ.access-key.expected.json new file mode 100644 index 0000000000000..d0d33a2ebefb3 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.access-key.expected.json @@ -0,0 +1,22 @@ +{ + "Resources": { + "TestUser6A619381": { + "Type": "AWS::IAM::User" + }, + "TestAccessKey4BFC5CF5": { + "Type": "AWS::IAM::AccessKey", + "Properties": { + "UserName": { + "Ref": "TestUser6A619381" + } + } + } + }, + "Outputs": { + "AccessKeyId": { + "Value": { + "Ref": "TestAccessKey4BFC5CF5" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.access-key.ts b/packages/@aws-cdk/aws-iam/test/integ.access-key.ts new file mode 100644 index 0000000000000..65d229ed2b500 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.access-key.ts @@ -0,0 +1,12 @@ +import { App, CfnOutput, Stack } from '@aws-cdk/core'; +import { AccessKey, User } from '../lib'; + +const app = new App(); +const stack = new Stack(app, 'integ-iam-access-key-1'); + +const user = new User(stack, 'TestUser'); +const accessKey = new AccessKey(stack, 'TestAccessKey', { user }); + +new CfnOutput(stack, 'AccessKeyId', { value: accessKey.accessKeyId }); + +app.synth(); diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 5485a812f9120..cc51f4b009d26 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -205,8 +205,8 @@ export class SecretStringValueBeta1 { * ```ts * // Creates a new IAM user, access and secret keys, and stores the secret access key in a Secret. * const user = new iam.User(this, 'User'); - * const accessKey = new iam.CfnAccessKey(this, 'AccessKey', { userName: user.userName }); - * const secretValue = secretsmanager.SecretStringValueBeta1.fromToken(accessKey.attrSecretAccessKey); + * const accessKey = new iam.AccessKey(this, 'AccessKey', { user }); + * const secretValue = secretsmanager.SecretStringValueBeta1.fromToken(accessKey.secretAccessKey.toString()); * new secretsmanager.Secret(this, 'Secret', { * secretStringBeta1: secretValue, * }); @@ -216,7 +216,7 @@ export class SecretStringValueBeta1 { * const secretValue = secretsmanager.SecretStringValueBeta1.fromToken(JSON.stringify({ * username: user.userName, * database: 'foo', - * password: accessKey.attrSecretAccessKey + * password: accessKey.secretAccessKey.toString(), * })); * * Note that the value being a Token does *not* guarantee safety. For example, a Lazy-evaluated string diff --git a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json index 1ae60ce0e2437..e72f363ac687f 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json @@ -127,7 +127,7 @@ } } }, - "AccessKey": { + "AccessKeyE6B25659": { "Type": "AWS::IAM::AccessKey", "Properties": { "UserName": { @@ -140,7 +140,7 @@ "Properties": { "SecretString": { "Fn::GetAtt": [ - "AccessKey", + "AccessKeyE6B25659", "SecretAccessKey" ] } diff --git a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts index f86acbabc12e2..7d63f61dbb34a 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts @@ -31,9 +31,9 @@ class SecretsManagerStack extends cdk.Stack { }); // Secret with predefined value - const accessKey = new iam.CfnAccessKey(this, 'AccessKey', { userName: user.userName }); + const accessKey = new iam.AccessKey(this, 'AccessKey', { user }); new secretsmanager.Secret(this, 'PredefinedSecret', { - secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.attrSecretAccessKey), + secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.secretAccessKey.toString()), }); /// !hide } diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts b/packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts index 365374e84bdd4..66ec71c497474 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts @@ -180,11 +180,11 @@ test('templated secret string', () => { describe('secretStringBeta1', () => { let user: iam.User; - let accessKey: iam.CfnAccessKey; + let accessKey: iam.AccessKey; beforeEach(() => { user = new iam.User(stack, 'User'); - accessKey = new iam.CfnAccessKey(stack, 'MyKey', { userName: user.userName }); + accessKey = new iam.AccessKey(stack, 'MyKey', { user }); }); test('fromUnsafePlaintext allows specifying a plaintext string', () => { @@ -206,18 +206,18 @@ describe('secretStringBeta1', () => { test('toToken allows referencing a construct attribute', () => { new secretsmanager.Secret(stack, 'Secret', { - secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.attrSecretAccessKey), + secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.secretAccessKey.toString()), }); expect(stack).toHaveResource('AWS::SecretsManager::Secret', { GenerateSecretString: ABSENT, - SecretString: { 'Fn::GetAtt': ['MyKey', 'SecretAccessKey'] }, + SecretString: { 'Fn::GetAtt': ['MyKey6AB29FA6', 'SecretAccessKey'] }, }); }); test('toToken allows referencing a construct attribute in nested JSON', () => { const secretString = secretsmanager.SecretStringValueBeta1.fromToken(JSON.stringify({ - key: accessKey.attrSecretAccessKey, + key: accessKey.secretAccessKey.toString(), username: 'myUser', })); new secretsmanager.Secret(stack, 'Secret', { @@ -233,7 +233,7 @@ describe('secretStringBeta1', () => { '{"key":"', { 'Fn::GetAtt': [ - 'MyKey', + 'MyKey6AB29FA6', 'SecretAccessKey', ], }, @@ -248,7 +248,7 @@ describe('secretStringBeta1', () => { // NOTE - This is actually not desired behavior, but the simple `!Token.isUnresolved` // check is the simplest and most consistent to implement. Covering this edge case of // a resolved Token representing a Ref/Fn::GetAtt is out of scope for this initial pass. - const secretKey = stack.resolve(accessKey.attrSecretAccessKey); + const secretKey = stack.resolve(accessKey.secretAccessKey); expect(() => new secretsmanager.Secret(stack, 'Secret', { secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(secretKey), })).toThrow(/appears to be plaintext/); @@ -260,7 +260,7 @@ describe('secretStringBeta1', () => { generateStringKey: 'username', secretStringTemplate: JSON.stringify({ username: 'username' }), }, - secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.attrSecretAccessKey), + secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.secretAccessKey.toString()), })).toThrow(/Cannot specify both `generateSecretString` and `secretStringBeta1`./); });