diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index edee5fdf403c0..cb2f664d87b09 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -14,6 +14,14 @@ Managed policies can be attached using `xxx.attachManagedPolicy(arn)`: [attaching managed policies](test/example.managedpolicy.lit.ts) +### Configuring an ExternalId + +If you need to create roles that will be assumed by 3rd parties, it is generally a good idea to [require an `ExternalId` +to assume them](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html). Configuring +an `ExternalId` works like this: + +[supplying an external ID](test/example.external-id.lit.ts) + ### Features * Policy name uniqueness is enforced. If two policies by the same name are attached to the same diff --git a/packages/@aws-cdk/aws-iam/lib/role.ts b/packages/@aws-cdk/aws-iam/lib/role.ts index 7b377ef30b427..63a81907e4576 100644 --- a/packages/@aws-cdk/aws-iam/lib/role.ts +++ b/packages/@aws-cdk/aws-iam/lib/role.ts @@ -14,6 +14,16 @@ export interface RoleProps { */ assumedBy: PolicyPrincipal; + /** + * ID that the role assumer needs to provide when assuming this role + * + * If the configured and provided external IDs do not match, the + * AssumeRole operation will fail. + * + * @default No external ID required + */ + externalId?: string; + /** * A list of ARNs for managed policies associated with this role. * You can add managed policies later using `attachManagedPolicy(arn)`. @@ -120,7 +130,7 @@ export class Role extends Construct implements IRole { constructor(parent: Construct, name: string, props: RoleProps) { super(parent, name); - this.assumeRolePolicy = createAssumeRolePolicy(props.assumedBy); + this.assumeRolePolicy = createAssumeRolePolicy(props.assumedBy, props.externalId); this.managedPolicyArns = props.managedPolicyArns || [ ]; validateMaxSessionDuration(props.maxSessionDurationSec); @@ -194,11 +204,17 @@ export interface IRole extends IPrincipal, IDependable { readonly roleArn: string; } -function createAssumeRolePolicy(principal: PolicyPrincipal) { - return new PolicyDocument() - .addStatement(new PolicyStatement() +function createAssumeRolePolicy(principal: PolicyPrincipal, externalId?: string) { + const statement = new PolicyStatement(); + statement .addPrincipal(principal) - .addAction(principal.assumeRoleAction)); + .addAction(principal.assumeRoleAction); + + if (externalId !== undefined) { + statement.addCondition('StringEquals', { 'sts:ExternalId': externalId }); + } + + return new PolicyDocument().addStatement(statement); } function validateMaxSessionDuration(duration?: number) { diff --git a/packages/@aws-cdk/aws-iam/test/example.external-id.lit.ts b/packages/@aws-cdk/aws-iam/test/example.external-id.lit.ts new file mode 100644 index 0000000000000..879f16867f4eb --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/example.external-id.lit.ts @@ -0,0 +1,17 @@ +import cdk = require('@aws-cdk/cdk'); +import iam = require('../lib'); + +export class ExampleConstruct extends cdk.Construct { + constructor(parent: cdk.Construct, id: string) { + super(parent, id); + + /// !show + const role = new iam.Role(this, 'MyRole', { + assumedBy: new iam.AccountPrincipal('123456789012'), + externalId: 'SUPPLY-ME', + }); + /// !hide + + Array.isArray(role); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.role.expected.json b/packages/@aws-cdk/aws-iam/test/integ.role.expected.json index e22731b253dd9..21aa4898932ab 100644 --- a/packages/@aws-cdk/aws-iam/test/integ.role.expected.json +++ b/packages/@aws-cdk/aws-iam/test/integ.role.expected.json @@ -58,6 +58,43 @@ } ] } + }, + "TestRole25D98AB21": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "supply-me" + } + }, + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.role.ts b/packages/@aws-cdk/aws-iam/test/integ.role.ts index 8b976678d625f..f3074a389aeac 100644 --- a/packages/@aws-cdk/aws-iam/test/integ.role.ts +++ b/packages/@aws-cdk/aws-iam/test/integ.role.ts @@ -1,5 +1,5 @@ import { App, Stack } from "@aws-cdk/cdk"; -import { Policy, PolicyStatement, Role, ServicePrincipal } from "../lib"; +import { AccountRootPrincipal, Policy, PolicyStatement, Role, ServicePrincipal } from "../lib"; const app = new App(); @@ -15,4 +15,10 @@ const policy = new Policy(stack, 'HelloPolicy', { policyName: 'Default' }); policy.addStatement(new PolicyStatement().addAction('ec2:*').addResource('*')); policy.attachToRole(role); +// Role with an external ID +new Role(stack, 'TestRole2', { + assumedBy: new AccountRootPrincipal(), + externalId: 'supply-me', +}); + app.run(); diff --git a/packages/@aws-cdk/aws-iam/test/test.role.ts b/packages/@aws-cdk/aws-iam/test/test.role.ts index 625e4461d7455..9696c72b531b7 100644 --- a/packages/@aws-cdk/aws-iam/test/test.role.ts +++ b/packages/@aws-cdk/aws-iam/test/test.role.ts @@ -24,6 +24,36 @@ export = { test.done(); }, + 'can supply externalId'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + new Role(stack, 'MyRole', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + externalId: 'SomeSecret', + }); + + // THEN + expect(stack).to(haveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: "sts:AssumeRole", + Condition: { + StringEquals: { "sts:ExternalId": "SomeSecret" } + }, + Effect: "Allow", + Principal: { Service: "sns.amazonaws.com" } + } + ], + Version: "2012-10-17" + } + })); + + test.done(); + }, + 'policy is created automatically when permissions are added'(test: Test) { const stack = new Stack();