Skip to content

Commit

Permalink
feat(iam): generate AccessKeys (#18180)
Browse files Browse the repository at this point in the history
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*
  • Loading branch information
laurelmay authored Jan 11, 2022
1 parent c9909c2 commit beb5706
Show file tree
Hide file tree
Showing 13 changed files with 252 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
]
}
},
"UserAccess": {
"UserAccessEC42ADF7": {
"Type": "AWS::IAM::AccessKey",
"Properties": {
"UserName": {
Expand Down Expand Up @@ -184,13 +184,13 @@
},
"TESTACCESSKEYID": {
"Value": {
"Ref": "UserAccess"
"Ref": "UserAccessEC42ADF7"
}
},
"TESTSECRETACCESSKEY": {
"Value": {
"Fn::GetAtt": [
"UserAccess",
"UserAccessEC42ADF7",
"SecretAccessKey"
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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', {
Expand Down
21 changes: 21 additions & 0 deletions packages/@aws-cdk/aws-iam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
93 changes: 93 additions & 0 deletions packages/@aws-cdk/aws-iam/lib/access-key.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-iam/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-iam/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
79 changes: 79 additions & 0 deletions packages/@aws-cdk/aws-iam/test/access-key.test.ts
Original file line number Diff line number Diff line change
@@ -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'],
});
});

});
22 changes: 22 additions & 0 deletions packages/@aws-cdk/aws-iam/test/integ.access-key.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"Resources": {
"TestUser6A619381": {
"Type": "AWS::IAM::User"
},
"TestAccessKey4BFC5CF5": {
"Type": "AWS::IAM::AccessKey",
"Properties": {
"UserName": {
"Ref": "TestUser6A619381"
}
}
}
},
"Outputs": {
"AccessKeyId": {
"Value": {
"Ref": "TestAccessKey4BFC5CF5"
}
}
}
}
12 changes: 12 additions & 0 deletions packages/@aws-cdk/aws-iam/test/integ.access-key.ts
Original file line number Diff line number Diff line change
@@ -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();
6 changes: 3 additions & 3 deletions packages/@aws-cdk/aws-secretsmanager/lib/secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
* });
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
}
}
},
"AccessKey": {
"AccessKeyE6B25659": {
"Type": "AWS::IAM::AccessKey",
"Properties": {
"UserName": {
Expand All @@ -140,7 +140,7 @@
"Properties": {
"SecretString": {
"Fn::GetAtt": [
"AccessKey",
"AccessKeyE6B25659",
"SecretAccessKey"
]
}
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading

0 comments on commit beb5706

Please sign in to comment.