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..87d9af75702f2 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, }); 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..362f49a073243 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/lib/access-key.ts @@ -0,0 +1,91 @@ +import { IResource, Resource } 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: string; +} + +/** + * 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: string; + + 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; + this.secretAccessKey = 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 c725e84b14edb..6e1d990eb5392 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..b5587a770587a 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); * 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 * })); * * 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..b45e9e2d05c98 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), }); /// !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..01ee219c9b0fd 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), }); 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, 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), })).toThrow(/Cannot specify both `generateSecretString` and `secretStringBeta1`./); });