diff --git a/packages/@aws-cdk/aws-iam/lib/role.ts b/packages/@aws-cdk/aws-iam/lib/role.ts index 54643646311fc..7fb0ceb982765 100644 --- a/packages/@aws-cdk/aws-iam/lib/role.ts +++ b/packages/@aws-cdk/aws-iam/lib/role.ts @@ -1,4 +1,4 @@ -import { Construct, Duration, Lazy, Resource, Stack } from '@aws-cdk/core'; +import { Construct, Duration, Lazy, Resource, Stack, Token } from '@aws-cdk/core'; import { Grant } from './grant'; import { CfnRole } from './iam.generated'; import { IIdentity } from './identity-base'; @@ -123,6 +123,20 @@ export interface RoleProps { readonly maxSessionDuration?: Duration; } +/** + * Options allowing customizing the behavior of {@link Role.fromRoleArn}. + */ +export interface FromRoleArnOptions { + /** + * Whether the imported role can be modified by attaching policy resources to it. + * + * @default true + * + * @experimental + */ + readonly mutable?: boolean; +} + /** * IAM Role * @@ -130,22 +144,57 @@ export interface RoleProps { * the specified AWS service principal defined in `serviceAssumeRole`. */ export class Role extends Resource implements IRole { - /** - * Imports an external role by ARN + * Imports an external role by ARN. + * * @param scope construct scope * @param id construct id * @param roleArn the ARN of the role to import + * @param options allow customizing the behavior of the returned role */ - public static fromRoleArn(scope: Construct, id: string, roleArn: string): IRole { + public static fromRoleArn(scope: Construct, id: string, roleArn: string, options: FromRoleArnOptions = {}): IRole { + const scopeStack = Stack.of(scope); + const parsedArn = scopeStack.parseArn(roleArn); + const roleName = parsedArn.resourceName!; - class Import extends Resource implements IRole { + abstract class Import extends Resource implements IRole { public readonly grantPrincipal: IPrincipal = this; public readonly assumeRoleAction: string = 'sts:AssumeRole'; public readonly policyFragment = new ArnPrincipal(roleArn).policyFragment; public readonly roleArn = roleArn; - public readonly roleName = Stack.of(scope).parseArn(roleArn).resourceName!; + public readonly roleName = roleName; + + public abstract addToPolicy(statement: PolicyStatement): boolean; + + public abstract attachInlinePolicy(policy: Policy): void; + public addManagedPolicy(_policy: IManagedPolicy): void { + // FIXME: Add warning that we're ignoring this + } + + /** + * Grant permissions to the given principal to pass this role. + */ + public grantPassRole(identity: IPrincipal): Grant { + return this.grant(identity, 'iam:PassRole'); + } + + /** + * Grant the actions defined in actions to the identity Principal on this resource. + */ + public grant(grantee: IPrincipal, ...actions: string[]): Grant { + return Grant.addToPrincipal({ + grantee, + actions, + resourceArns: [this.roleArn], + scope: this, + }); + } + } + + const roleAccount = parsedArn.account; + + class MutableImport extends Import { private readonly attachedPolicies = new AttachedPolicies(); private defaultPolicy?: Policy; @@ -159,36 +208,36 @@ export class Role extends Resource implements IRole { } public attachInlinePolicy(policy: Policy): void { - this.attachedPolicies.attach(policy); - policy.attachToRole(this); - } + const policyAccount = Stack.of(policy).account; - public addManagedPolicy(_policy: IManagedPolicy): void { - // FIXME: Add warning that we're ignoring this + if (accountsAreEqualOrOneIsUnresolved(policyAccount, roleAccount)) { + this.attachedPolicies.attach(policy); + policy.attachToRole(this); + } } + } - /** - * Grant the actions defined in actions to the identity Principal on this resource. - */ - public grant(grantee: IPrincipal, ...actions: string[]): Grant { - return Grant.addToPrincipal({ - grantee, - actions, - resourceArns: [this.roleArn], - scope: this - }); + class ImmutableImport extends Import { + public addToPolicy(_statement: PolicyStatement): boolean { + return false; } - /** - * Grant permissions to the given principal to pass this role. - */ - public grantPassRole(identity: IPrincipal): Grant { - return this.grant(identity, 'iam:PassRole'); + public attachInlinePolicy(_policy: Policy): void { + // do nothing } } - return new Import(scope, id); + const scopeAccount = scopeStack.account; + + return options.mutable !== false && accountsAreEqualOrOneIsUnresolved(scopeAccount, roleAccount) + ? new MutableImport(scope, id) + : new ImmutableImport(scope, id); + function accountsAreEqualOrOneIsUnresolved(account1: string | undefined, + account2: string | undefined): boolean { + return Token.isUnresolved(account1) || Token.isUnresolved(account2) || + account1 === account2; + } } public readonly grantPrincipal: IPrincipal = this; diff --git a/packages/@aws-cdk/aws-iam/package.json b/packages/@aws-cdk/aws-iam/package.json index 3ee9f08370880..384e63cd20021 100644 --- a/packages/@aws-cdk/aws-iam/package.json +++ b/packages/@aws-cdk/aws-iam/package.json @@ -96,6 +96,7 @@ }, "awslint": { "exclude": [ + "from-signature:@aws-cdk/aws-iam.Role.fromRoleArn", "construct-interface-extends-iconstruct:@aws-cdk/aws-iam.IManagedPolicy", "resource-interface-extends-resource:@aws-cdk/aws-iam.IManagedPolicy" ] diff --git a/packages/@aws-cdk/aws-iam/test/role.from-role-arn.test.ts b/packages/@aws-cdk/aws-iam/test/role.from-role-arn.test.ts new file mode 100644 index 0000000000000..4f69d8fde13f6 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/role.from-role-arn.test.ts @@ -0,0 +1,533 @@ +import '@aws-cdk/assert/jest'; +import { App, CfnElement, Lazy, Stack } from "@aws-cdk/core"; +import { AnyPrincipal, ArnPrincipal, IRole, Policy, PolicyStatement, Role } from "../lib"; + +// tslint:disable:object-literal-key-quotes + +const roleAccount = '123456789012'; +const notRoleAccount = '012345678901'; + +describe('IAM Role.fromRoleArn', () => { + let app: App; + + beforeEach(() => { + app = new App(); + }); + + let roleStack: Stack; + let importedRole: IRole; + + describe('imported with a static ARN', () => { + const roleName = 'MyRole'; + + describe('into an env-agnostic stack', () => { + beforeEach(() => { + roleStack = new Stack(app, 'RoleStack'); + importedRole = Role.fromRoleArn(roleStack, 'ImportedRole', + `arn:aws:iam::${roleAccount}:role/${roleName}`); + }); + + test('correctly parses the imported role ARN', () => { + expect(importedRole.roleArn).toBe(`arn:aws:iam::${roleAccount}:role/${roleName}`); + }); + + test('correctly parses the imported role name', () => { + expect(importedRole.roleName).toBe(roleName); + }); + + describe('then adding a PolicyStatement to it', () => { + let addToPolicyResult: boolean; + + beforeEach(() => { + addToPolicyResult = importedRole.addToPolicy(somePolicyStatement()); + }); + + test('returns true', () => { + expect(addToPolicyResult).toBe(true); + }); + + test("generates a default Policy resource pointing at the imported role's physical name", () => { + assertRoleHasDefaultPolicy(roleStack, roleName); + }); + }); + + describe('then attaching a Policy to it', () => { + let policyStack: Stack; + + describe('that belongs to the same stack as the imported role', () => { + beforeEach(() => { + importedRole.attachInlinePolicy(somePolicy(roleStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(roleStack, roleName, 'MyPolicy'); + }); + }); + + describe('that belongs to a different env-agnostic stack', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack'); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + + describe('that belongs to a targeted stack, with account set to', () => { + describe('the same account as in the ARN of the imported role', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: roleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + + describe('a different account than in the ARN of the imported role', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: notRoleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("does NOT attach the Policy to the imported role", () => { + assertPolicyDidNotAttachToRole(policyStack, 'MyPolicy'); + }); + }); + }); + }); + }); + + describe('into a targeted stack with account set to', () => { + describe('the same account as in the ARN the role was imported with', () => { + beforeEach(() => { + roleStack = new Stack(app, 'RoleStack', { env: { account: roleAccount } }); + importedRole = Role.fromRoleArn(roleStack, 'ImportedRole', + `arn:aws:iam::${roleAccount}:role/${roleName}`); + }); + + describe('then adding a PolicyStatement to it', () => { + let addToPolicyResult: boolean; + + beforeEach(() => { + addToPolicyResult = importedRole.addToPolicy(somePolicyStatement()); + }); + + test('returns true', () => { + expect(addToPolicyResult).toBe(true); + }); + + test("generates a default Policy resource pointing at the imported role's physical name", () => { + assertRoleHasDefaultPolicy(roleStack, roleName); + }); + }); + + describe('then attaching a Policy to it', () => { + describe('that belongs to the same stack as the imported role', () => { + beforeEach(() => { + importedRole.attachInlinePolicy(somePolicy(roleStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(roleStack, roleName, 'MyPolicy'); + }); + }); + + describe('that belongs to an env-agnostic stack', () => { + let policyStack: Stack; + + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack'); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + + describe('that belongs to a targeted stack, with account set to', () => { + let policyStack: Stack; + + describe('the same account as in the imported role ARN and in the stack the imported role belongs to', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: roleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + + describe('a different account than in the imported role ARN and in the stack the imported role belongs to', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: notRoleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("does NOT attach the Policy to the imported role", () => { + assertPolicyDidNotAttachToRole(policyStack, 'MyPolicy'); + }); + }); + }); + }); + }); + + describe('a different account than in the ARN the role was imported with', () => { + beforeEach(() => { + roleStack = new Stack(app, 'RoleStack', { env: { account: notRoleAccount } }); + importedRole = Role.fromRoleArn(roleStack, 'ImportedRole', + `arn:aws:iam::${roleAccount}:role/${roleName}`); + }); + + describe('then adding a PolicyStatement to it', () => { + let addToPolicyResult: boolean; + + beforeEach(() => { + addToPolicyResult = importedRole.addToPolicy(somePolicyStatement()); + }); + + test('returns false', () => { + expect(addToPolicyResult).toBe(false); + }); + + test("does NOT generate a default Policy resource pointing at the imported role's physical name", () => { + expect(roleStack).not.toHaveResourceLike('AWS::IAM::Policy'); + }); + }); + + describe('then attaching a Policy to it', () => { + describe('that belongs to the same stack as the imported role', () => { + beforeEach(() => { + importedRole.attachInlinePolicy(somePolicy(roleStack, 'MyPolicy')); + }); + + test("does NOT attach the Policy to the imported role", () => { + assertPolicyDidNotAttachToRole(roleStack, 'MyPolicy'); + }); + }); + + describe('that belongs to an env-agnostic stack', () => { + let policyStack: Stack; + + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack'); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("does NOT attach the Policy to the imported role", () => { + assertPolicyDidNotAttachToRole(policyStack, 'MyPolicy'); + }); + }); + + describe('that belongs to a different targeted stack, with account set to', () => { + let policyStack: Stack; + + describe('the same account as in the ARN of the imported role', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: roleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("does NOT attach the Policy to the imported role", () => { + assertPolicyDidNotAttachToRole(policyStack, 'MyPolicy'); + }); + }); + + describe('the same account as in the stack the imported role belongs to', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: notRoleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("does NOT attach the Policy to the imported role", () => { + assertPolicyDidNotAttachToRole(policyStack, 'MyPolicy'); + }); + }); + + describe('a third account, different from both the role and scope stack accounts', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: 'some-random-account' } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("does NOT attach the Policy to the imported role", () => { + assertPolicyDidNotAttachToRole(policyStack, 'MyPolicy'); + }); + }); + }); + }); + }); + }); + + describe('and with mutable=false', () => { + beforeEach(() => { + roleStack = new Stack(app, 'RoleStack'); + importedRole = Role.fromRoleArn(roleStack, 'ImportedRole', + `arn:aws:iam::${roleAccount}:role/${roleName}`, { mutable: false }); + }); + + describe('then adding a PolicyStatement to it', () => { + let addToPolicyResult: boolean; + + beforeEach(() => { + addToPolicyResult = importedRole.addToPolicy(somePolicyStatement()); + }); + + test('returns false', () => { + expect(addToPolicyResult).toBe(false); + }); + + test("does NOT generate a default Policy resource pointing at the imported role's physical name", () => { + expect(roleStack).not.toHaveResourceLike('AWS::IAM::Policy'); + }); + }); + + describe('then attaching a Policy to it', () => { + let policyStack: Stack; + + describe('that belongs to a stack with account equal to the account in the imported role ARN', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account : roleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("does NOT attach the Policy to the imported role", () => { + assertPolicyDidNotAttachToRole(policyStack, 'MyPolicy'); + }); + }); + }); + }); + }); + + describe('imported with a dynamic ARN', () => { + const dynamicValue = Lazy.stringValue({ produce: () => 'role-arn' }); + const roleName: any = { + "Fn::Select": [1, + { + "Fn::Split": ["/", + { + "Fn::Select": [5, + { "Fn::Split": [":", "role-arn"] }, + ] + }, + ], + }, + ], + }; + + describe('into an env-agnostic stack', () => { + beforeEach(() => { + roleStack = new Stack(app, 'RoleStack'); + importedRole = Role.fromRoleArn(roleStack, 'ImportedRole', dynamicValue); + }); + + test('correctly parses the imported role ARN', () => { + expect(importedRole.roleArn).toBe(dynamicValue); + }); + + test('correctly parses the imported role name', () => { + new Role(roleStack, 'AnyRole', { + roleName: 'AnyRole', + assumedBy: new ArnPrincipal(importedRole.roleName), + }); + + expect(roleStack).toHaveResourceLike('AWS::IAM::Role', { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": roleName, + }, + }, + ], + }, + }); + }); + + describe('then adding a PolicyStatement to it', () => { + let addToPolicyResult: boolean; + + beforeEach(() => { + addToPolicyResult = importedRole.addToPolicy(somePolicyStatement()); + }); + + test('returns true', () => { + expect(addToPolicyResult).toBe(true); + }); + + test("generates a default Policy resource pointing at the imported role's physical name", () => { + assertRoleHasDefaultPolicy(roleStack, roleName); + }); + }); + + describe('then attaching a Policy to it', () => { + let policyStack: Stack; + + describe('that belongs to the same stack as the imported role', () => { + beforeEach(() => { + importedRole.attachInlinePolicy(somePolicy(roleStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(roleStack, roleName, 'MyPolicy'); + }); + }); + + describe('that belongs to a different env-agnostic stack', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack'); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + + describe('that belongs to a targeted stack', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: roleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + }); + }); + + describe('into a targeted stack with account set', () => { + beforeEach(() => { + roleStack = new Stack(app, 'RoleStack', { env: { account: roleAccount } }); + importedRole = Role.fromRoleArn(roleStack, 'ImportedRole', dynamicValue); + }); + + describe('then adding a PolicyStatement to it', () => { + let addToPolicyResult: boolean; + + beforeEach(() => { + addToPolicyResult = importedRole.addToPolicy(somePolicyStatement()); + }); + + test('returns true', () => { + expect(addToPolicyResult).toBe(true); + }); + + test("generates a default Policy resource pointing at the imported role's physical name", () => { + assertRoleHasDefaultPolicy(roleStack, roleName); + }); + }); + + describe('then attaching a Policy to it', () => { + let policyStack: Stack; + + describe('that belongs to the same stack as the imported role', () => { + beforeEach(() => { + importedRole.attachInlinePolicy(somePolicy(roleStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(roleStack, roleName, 'MyPolicy'); + }); + }); + + describe('that belongs to an env-agnostic stack', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack'); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + + describe('that belongs to a different targeted stack, with account set to', () => { + describe('the same account as the stack the role was imported into', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: roleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + + describe('a different account than the stack the role was imported into', () => { + beforeEach(() => { + policyStack = new Stack(app, 'PolicyStack', { env: { account: notRoleAccount } }); + importedRole.attachInlinePolicy(somePolicy(policyStack, 'MyPolicy')); + }); + + test("correctly attaches the Policy to the imported role", () => { + assertRoleHasAttachedPolicy(policyStack, roleName, 'MyPolicy'); + }); + }); + }); + }); + }); + }); +}); + +function somePolicyStatement() { + return new PolicyStatement({ + actions: ['s3:*'], + resources: ['xyz'], + }); +} + +function somePolicy(policyStack: Stack, policyName: string) { + const someRole = new Role(policyStack, 'SomeExampleRole', { + assumedBy: new AnyPrincipal(), + }); + const roleResource = someRole.node.defaultChild as CfnElement; + roleResource.overrideLogicalId('SomeRole'); // force a particular logical ID in the Ref expression + + return new Policy(policyStack, 'MyPolicy', { + policyName, + statements: [somePolicyStatement()], + // need at least one of user/group/role, otherwise validation fails + roles: [someRole], + }); +} + +function assertRoleHasDefaultPolicy(stack: Stack, roleName: string) { + _assertStackContainsPolicyResource(stack, [roleName], undefined); +} + +function assertRoleHasAttachedPolicy(stack: Stack, roleName: string, attachedPolicyName: string) { + _assertStackContainsPolicyResource(stack, [{ Ref: 'SomeRole' }, roleName], attachedPolicyName); +} + +function assertPolicyDidNotAttachToRole(stack: Stack, policyName: string) { + _assertStackContainsPolicyResource(stack, [{ Ref: 'SomeRole' }], policyName); +} + +function _assertStackContainsPolicyResource(stack: Stack, roleNames: any[], nameOfPolicy: string | undefined) { + const expected: any = { + PolicyDocument: { + Statement: [ + { + Action: "s3:*", + Effect: "Allow", + Resource: "xyz", + }, + ], + }, + Roles: roleNames, + }; + if (nameOfPolicy) { + expected.PolicyName = nameOfPolicy; + } + + expect(stack).toHaveResourceLike('AWS::IAM::Policy', expected); +} diff --git a/packages/@aws-cdk/aws-iam/test/role.test.ts b/packages/@aws-cdk/aws-iam/test/role.test.ts index d2331b701709a..9580b97556038 100644 --- a/packages/@aws-cdk/aws-iam/test/role.test.ts +++ b/packages/@aws-cdk/aws-iam/test/role.test.ts @@ -293,46 +293,6 @@ describe('IAM role', () => { }); }); - test('fromRoleArn', () => { - // GIVEN - const stack = new Stack(); - - // WHEN - const importedRole = Role.fromRoleArn(stack, 'ImportedRole', 'arn:aws:iam::123456789012:role/S3Access'); - - // THEN - expect(importedRole.roleArn).toEqual('arn:aws:iam::123456789012:role/S3Access'); - expect(importedRole.roleName).toEqual('S3Access'); - }); - - test('add policy to imported role', () => { - // GIVEN - const stack = new Stack(); - const importedRole = Role.fromRoleArn(stack, 'ImportedRole', 'arn:aws:iam::123456789012:role/MyRole'); - - // WHEN - importedRole.addToPolicy(new PolicyStatement({ - actions: ['s3:*'], - resources: ['xyz'] - })); - - // THEN - expect(stack).toHaveResource('AWS::IAM::Policy', { - PolicyDocument: { - Statement: [ - { - Action: "s3:*", - Effect: "Allow", - Resource: "xyz" - } - ], - Version: "2012-10-17" - }, - Roles: [ "MyRole" ] - }); - - }); - test('can supply permissions boundary managed policy', () => { // GIVEN const stack = new Stack();