Skip to content

Commit 1a46808

Browse files
authored
feat(redshift): IAM roles can be attached to a cluster, post creation (#23791)
Created an `addIamRole` method that will allow attaching an IAM role to a cluster, post its creation. closes #22632 ---- ### All Submissions: * [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Construct Runtime Dependencies: * [ ] This PR adds new construct runtime dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-construct-runtime-dependencies) ### New Features * [x] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [x] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 74512fa commit 1a46808

21 files changed

+2254
-31
lines changed

packages/@aws-cdk/aws-redshift/README.md

+36
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,39 @@ const redshiftCluster = new Cluster(stack, 'Redshift', {
454454

455455
redshiftCluster.addDefaultIamRole(defaultRole);
456456
```
457+
458+
## IAM roles
459+
460+
Attaching IAM roles to a Redshift Cluster grants permissions to the Redshift service to perform actions on your behalf.
461+
462+
```ts
463+
declare const vpc: ec2.Vpc
464+
465+
const role = new iam.Role(this, 'Role', {
466+
assumedBy: new iam.ServicePrincipal('redshift.amazonaws.com'),
467+
});
468+
const cluster = new Cluster(this, 'Redshift', {
469+
masterUser: {
470+
masterUsername: 'admin',
471+
},
472+
vpc,
473+
roles: [role],
474+
});
475+
```
476+
477+
Additional IAM roles can be attached to a cluster using the `addIamRole` method.
478+
479+
```ts
480+
declare const vpc: ec2.Vpc
481+
482+
const role = new iam.Role(this, 'Role', {
483+
assumedBy: new iam.ServicePrincipal('redshift.amazonaws.com'),
484+
});
485+
const cluster = new Cluster(this, 'Redshift', {
486+
masterUser: {
487+
masterUsername: 'admin',
488+
},
489+
vpc,
490+
});
491+
cluster.addIamRole(role);
492+
```

packages/@aws-cdk/aws-redshift/lib/cluster.ts

+30-6
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import * as iam from '@aws-cdk/aws-iam';
33
import * as kms from '@aws-cdk/aws-kms';
44
import * as s3 from '@aws-cdk/aws-s3';
55
import * as secretsmanager from '@aws-cdk/aws-secretsmanager';
6-
import { Duration, IResource, RemovalPolicy, Resource, SecretValue, Token } from '@aws-cdk/core';
7-
import { AwsCustomResource, PhysicalResourceId, AwsCustomResourcePolicy } from '@aws-cdk/custom-resources';
6+
import { Duration, IResource, Lazy, RemovalPolicy, Resource, SecretValue, Token } from '@aws-cdk/core';
7+
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from '@aws-cdk/custom-resources';
88
import { Construct } from 'constructs';
99
import { DatabaseSecret } from './database-secret';
1010
import { Endpoint } from './endpoint';
@@ -299,7 +299,7 @@ export interface ClusterProps {
299299

300300
/**
301301
* A list of AWS Identity and Access Management (IAM) role that can be used by the cluster to access other AWS services.
302-
* Specify a maximum of 10 roles.
302+
* The maximum number of roles to attach to a cluster is subject to a quota.
303303
*
304304
* @default - No role is attached to the cluster.
305305
*/
@@ -470,6 +470,13 @@ export class Cluster extends ClusterBase {
470470
*/
471471
protected parameterGroup?: IClusterParameterGroup;
472472

473+
/**
474+
* The ARNs of the roles that will be attached to the cluster.
475+
*
476+
* **NOTE** Please do not access this directly, use the `addIamRole` method instead.
477+
*/
478+
private readonly roles: iam.IRole[];
479+
473480
constructor(scope: Construct, id: string, props: ClusterProps) {
474481
super(scope, id);
475482

@@ -478,6 +485,7 @@ export class Cluster extends ClusterBase {
478485
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
479486
};
480487
this.parameterGroup = props.parameterGroup;
488+
this.roles = props?.roles ? [...props.roles] : [];
481489

482490
const removalPolicy = props.removalPolicy ?? RemovalPolicy.RETAIN;
483491

@@ -557,7 +565,7 @@ export class Cluster extends ClusterBase {
557565
nodeType: props.nodeType || NodeType.DC2_LARGE,
558566
numberOfNodes: nodeCount,
559567
loggingProperties,
560-
iamRoles: props?.roles?.map(role => role.roleArn),
568+
iamRoles: Lazy.list({ produce: () => this.roles.map(role => role.roleArn) }, { omitEmpty: true }),
561569
dbName: props.defaultDatabaseName || 'default_db',
562570
publiclyAccessible: props.publiclyAccessible || false,
563571
// Encryption
@@ -688,12 +696,12 @@ export class Cluster extends ClusterBase {
688696
*/
689697
public addDefaultIamRole(defaultIamRole: iam.IRole): void {
690698
// Get list of IAM roles attached to cluster
691-
const clusterRoleList = this.cluster.iamRoles ?? [];
699+
const clusterRoleList = this.roles ?? [];
692700

693701
// Check to see if default role is included in list of cluster IAM roles
694702
var roleAlreadyOnCluster = false;
695703
for (var i = 0; i < clusterRoleList.length; i++) {
696-
if (clusterRoleList[i] == defaultIamRole.roleArn) {
704+
if (clusterRoleList[i] === defaultIamRole) {
697705
roleAlreadyOnCluster = true;
698706
break;
699707
}
@@ -729,8 +737,24 @@ export class Cluster extends ClusterBase {
729737
policy: AwsCustomResourcePolicy.fromSdkCalls({
730738
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
731739
}),
740+
installLatestAwsSdk: false,
732741
});
733742

734743
defaultIamRole.grantPassRole(defaultRoleCustomResource.grantPrincipal);
735744
}
745+
746+
/**
747+
* Adds a role to the cluster
748+
*
749+
* @param role the role to add
750+
*/
751+
public addIamRole(role: iam.IRole): void {
752+
const clusterRoleList = this.roles;
753+
754+
if (clusterRoleList.includes(role)) {
755+
throw new Error(`Role '${role.roleArn}' is already attached to the cluster`);
756+
}
757+
758+
clusterRoleList.push(role);
759+
}
736760
}

packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/privileges.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/* eslint-disable-next-line import/no-unresolved */
22
import * as AWSLambda from 'aws-lambda';
3-
import { TablePrivilege, UserTablePrivilegesHandlerProps } from '../handler-props';
43
import { executeStatement } from './redshift-data';
54
import { ClusterProps } from './types';
65
import { makePhysicalId } from './util';
6+
import { TablePrivilege, UserTablePrivilegesHandlerProps } from '../handler-props';
77

88
export async function handler(props: UserTablePrivilegesHandlerProps & ClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) {
99
const username = props.username;

packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/table.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/* eslint-disable-next-line import/no-unresolved */
22
import * as AWSLambda from 'aws-lambda';
3-
import { Column } from '../../table';
43
import { executeStatement } from './redshift-data';
54
import { ClusterProps, TableAndClusterProps, TableSortStyle } from './types';
65
import { areColumnsEqual, getDistKeyColumn, getSortKeyColumns } from './util';
6+
import { Column } from '../../table';
77

88
export async function handler(props: TableAndClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) {
99
const tableNamePrefix = props.tableName.prefix;

packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/user.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import * as AWSLambda from 'aws-lambda';
33
/* eslint-disable-next-line import/no-extraneous-dependencies */
44
import * as SecretsManager from 'aws-sdk/clients/secretsmanager';
5-
import { UserHandlerProps } from '../handler-props';
65
import { executeStatement } from './redshift-data';
76
import { ClusterProps } from './types';
87
import { makePhysicalId } from './util';
8+
import { UserHandlerProps } from '../handler-props';
99

1010
const secretsManager = new SecretsManager();
1111

packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/util.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Column } from '../../table';
21
import { ClusterProps } from './types';
2+
import { Column } from '../../table';
33

44
export function makePhysicalId(resourceName: string, clusterProps: ClusterProps, requestId: string): string {
55
return `${clusterProps.clusterName}:${clusterProps.databaseName}:${resourceName}:${requestId}`;

packages/@aws-cdk/aws-redshift/lib/private/database-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import * as secretsmanager from '@aws-cdk/aws-secretsmanager';
55
import * as cdk from '@aws-cdk/core';
66
import * as customresources from '@aws-cdk/custom-resources';
77
import { Construct } from 'constructs';
8+
import { DatabaseQueryHandlerProps } from './handler-props';
89
import { Cluster } from '../cluster';
910
import { DatabaseOptions } from '../database-options';
10-
import { DatabaseQueryHandlerProps } from './handler-props';
1111

1212
export interface DatabaseQueryProps<HandlerProps> extends DatabaseOptions {
1313
readonly handler: string;

packages/@aws-cdk/aws-redshift/lib/private/privileges.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as cdk from '@aws-cdk/core';
22
import { Construct } from 'constructs';
3-
import { DatabaseOptions } from '../database-options';
4-
import { ITable, TableAction } from '../table';
5-
import { IUser } from '../user';
63
import { DatabaseQuery } from './database-query';
74
import { HandlerName } from './database-query-provider/handler-name';
85
import { TablePrivilege as SerializedTablePrivilege, UserTablePrivilegesHandlerProps } from './handler-props';
6+
import { DatabaseOptions } from '../database-options';
7+
import { ITable, TableAction } from '../table';
8+
import { IUser } from '../user';
99

1010
/**
1111
* The Redshift table and action that make up a privilege that can be granted to a Redshift user.

packages/@aws-cdk/aws-redshift/test/cluster.test.ts

+97
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,103 @@ describe('default IAM role', () => {
648648
});
649649
});
650650

651+
describe('IAM role', () => {
652+
test('roles can be directly attached to cluster during declaration', () => {
653+
// GIVEN
654+
const role = new iam.Role(stack, 'Role', {
655+
assumedBy: new iam.ServicePrincipal('redshift.amazonaws.com'),
656+
});
657+
new Cluster(stack, 'Redshift', {
658+
masterUser: {
659+
masterUsername: 'admin',
660+
},
661+
vpc,
662+
roles: [role],
663+
});
664+
665+
// THEN
666+
Template.fromStack(stack).hasResource('AWS::Redshift::Cluster', {
667+
Properties: {
668+
IamRoles: Match.arrayEquals([
669+
{ 'Fn::GetAtt': [Match.stringLikeRegexp('Role*'), 'Arn'] },
670+
]),
671+
},
672+
});
673+
});
674+
675+
test('roles can be attached to cluster after declaration', () => {
676+
// GIVEN
677+
const role = new iam.Role(stack, 'Role', {
678+
assumedBy: new iam.ServicePrincipal('redshift.amazonaws.com'),
679+
});
680+
const cluster = new Cluster(stack, 'Redshift', {
681+
masterUser: {
682+
masterUsername: 'admin',
683+
},
684+
vpc,
685+
});
686+
687+
// WHEN
688+
cluster.addIamRole(role);
689+
690+
// THEN
691+
Template.fromStack(stack).hasResource('AWS::Redshift::Cluster', {
692+
Properties: {
693+
IamRoles: Match.arrayEquals([
694+
{ 'Fn::GetAtt': [Match.stringLikeRegexp('Role*'), 'Arn'] },
695+
]),
696+
},
697+
});
698+
});
699+
700+
test('roles can be attached to cluster in another stack', () => {
701+
// GIVEN
702+
const cluster = new Cluster(stack, 'Redshift', {
703+
masterUser: {
704+
masterUsername: 'admin',
705+
},
706+
vpc,
707+
});
708+
709+
const newTestStack = new cdk.Stack(stack, 'NewTestStack', { env: { account: stack.account, region: stack.region } });
710+
const role = new iam.Role(newTestStack, 'Role', {
711+
assumedBy: new iam.ServicePrincipal('redshift.amazonaws.com'),
712+
});
713+
714+
// WHEN
715+
cluster.addIamRole(role);
716+
717+
// THEN
718+
Template.fromStack(stack).hasResource('AWS::Redshift::Cluster', {
719+
Properties: {
720+
IamRoles: Match.arrayEquals([
721+
{ 'Fn::ImportValue': Match.stringLikeRegexp('NewTestStack:ExportsOutputFnGetAttRole*') },
722+
]),
723+
},
724+
});
725+
});
726+
727+
test('throws when adding role that is already in cluster', () => {
728+
// GIVEN
729+
const role = new iam.Role(stack, 'Role', {
730+
assumedBy: new iam.ServicePrincipal('redshift.amazonaws.com'),
731+
});
732+
const cluster = new Cluster(stack, 'Redshift', {
733+
masterUser: {
734+
masterUsername: 'admin',
735+
},
736+
vpc,
737+
roles: [role],
738+
});
739+
740+
expect(() =>
741+
// WHEN
742+
cluster.addIamRole(role),
743+
// THEN
744+
).toThrow(`Role '${role.roleArn}' is already attached to the cluster`);
745+
});
746+
});
747+
651748
function testStack() {
652749
const newTestStack = new cdk.Stack(undefined, undefined, { env: { account: '12345', region: 'us-test-1' } });
653750
newTestStack.node.setContext('availability-zones:12345:us-test-1', ['us-test-1a', 'us-test-1b']);

packages/@aws-cdk/aws-redshift/test/integ.cluster-defaultiamrole.js.snapshot/manifest.json

+1-13
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"validateOnSynth": false,
1818
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}",
1919
"cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}",
20-
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/fa991089a30fe26fab8225c5ca4145a65cd9c6f1c7ebebae477f130972053ca5.json",
20+
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/2a0f381c4c3f8c34bfd6f8afdccd71f2d437e9a354e3383b625429e833e7a53f.json",
2121
"requiresBootstrapStackVersion": 6,
2222
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version",
2323
"additionalDependencies": [
@@ -207,12 +207,6 @@
207207
"data": "Cluster192CD0375"
208208
}
209209
],
210-
"/redshift-defaultiamrole-integ/Cluster1/default-role": [
211-
{
212-
"type": "aws:cdk:warning",
213-
"data": "installLatestAwsSdk was not specified, and defaults to true. You probably do not want this. Set the global context flag '@aws-cdk/customresources:installLatestAwsSdkDefault' to false to switch this behavior off project-wide, or set the property explicitly to true if you know you need to call APIs that are not in Lambda's built-in SDK version."
214-
}
215-
],
216210
"/redshift-defaultiamrole-integ/Cluster1/default-role/Resource/Default": [
217211
{
218212
"type": "aws:cdk:logicalId",
@@ -273,12 +267,6 @@
273267
"data": "Cluster2720FF351"
274268
}
275269
],
276-
"/redshift-defaultiamrole-integ/Cluster2/default-role": [
277-
{
278-
"type": "aws:cdk:warning",
279-
"data": "installLatestAwsSdk was not specified, and defaults to true. You probably do not want this. Set the global context flag '@aws-cdk/customresources:installLatestAwsSdkDefault' to false to switch this behavior off project-wide, or set the property explicitly to true if you know you need to call APIs that are not in Lambda's built-in SDK version."
280-
}
281-
],
282270
"/redshift-defaultiamrole-integ/Cluster2/default-role/Resource/Default": [
283271
{
284272
"type": "aws:cdk:logicalId",

packages/@aws-cdk/aws-redshift/test/integ.cluster-defaultiamrole.js.snapshot/redshift-defaultiamrole-integ.assets.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
1414
}
1515
}
1616
},
17-
"fa991089a30fe26fab8225c5ca4145a65cd9c6f1c7ebebae477f130972053ca5": {
17+
"2a0f381c4c3f8c34bfd6f8afdccd71f2d437e9a354e3383b625429e833e7a53f": {
1818
"source": {
1919
"path": "redshift-defaultiamrole-integ.template.json",
2020
"packaging": "file"
2121
},
2222
"destinations": {
2323
"current_account-current_region": {
2424
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
25-
"objectKey": "fa991089a30fe26fab8225c5ca4145a65cd9c6f1c7ebebae477f130972053ca5.json",
25+
"objectKey": "2a0f381c4c3f8c34bfd6f8afdccd71f2d437e9a354e3383b625429e833e7a53f.json",
2626
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
2727
}
2828
}

packages/@aws-cdk/aws-redshift/test/integ.cluster-defaultiamrole.js.snapshot/redshift-defaultiamrole-integ.template.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@
615615
]
616616
]
617617
},
618-
"InstallLatestAwsSdk": "false"
618+
"InstallLatestAwsSdk": false
619619
},
620620
"DependsOn": [
621621
"Cluster1defaultroleCustomResourcePolicy6ECBAB35"
@@ -932,7 +932,7 @@
932932
]
933933
]
934934
},
935-
"InstallLatestAwsSdk": "false"
935+
"InstallLatestAwsSdk": false
936936
},
937937
"DependsOn": [
938938
"Cluster2defaultroleCustomResourcePolicy042F9AF5"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"version": "29.0.0",
3+
"files": {
4+
"21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": {
5+
"source": {
6+
"path": "IamRoleIntegDefaultTestDeployAssertBEF20992.template.json",
7+
"packaging": "file"
8+
},
9+
"destinations": {
10+
"current_account-current_region": {
11+
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
12+
"objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json",
13+
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
14+
}
15+
}
16+
}
17+
},
18+
"dockerImages": {}
19+
}

0 commit comments

Comments
 (0)