diff --git a/packages/aws-cdk-lib/aws-iam/lib/role.ts b/packages/aws-cdk-lib/aws-iam/lib/role.ts index 5ed1302543d47..b8937d7b951aa 100644 --- a/packages/aws-cdk-lib/aws-iam/lib/role.ts +++ b/packages/aws-cdk-lib/aws-iam/lib/role.ts @@ -413,6 +413,10 @@ export class Role extends Resource implements IRole { physicalName: props.roleName, }); + if (props.roleName && !Token.isUnresolved(props.roleName) && !/^[\w+=,.@-]{1,64}$/.test(props.roleName)) { + throw new Error('Invalid roleName. The name must be a string of characters consisting of upper and lowercase alphanumeric characters with no spaces. You can also include any of the following characters: _+=,.@-. Length must be between 1 and 64 characters.'); + } + const externalIds = props.externalIds || []; if (props.externalId) { externalIds.push(props.externalId); diff --git a/packages/aws-cdk-lib/aws-iam/test/role.test.ts b/packages/aws-cdk-lib/aws-iam/test/role.test.ts index 5d239254d5bbc..88ee4f2d6cbbc 100644 --- a/packages/aws-cdk-lib/aws-iam/test/role.test.ts +++ b/packages/aws-cdk-lib/aws-iam/test/role.test.ts @@ -1,7 +1,7 @@ import { testDeprecated } from '@aws-cdk/cdk-build-tools'; import { Construct } from 'constructs'; import { Template, Match, Annotations } from '../../assertions'; -import { Duration, Stack, App, CfnResource, RemovalPolicy, Lazy, Stage, DefaultStackSynthesizer, CliCredentialsStackSynthesizer, PERMISSIONS_BOUNDARY_CONTEXT_KEY, PermissionsBoundary } from '../../core'; +import { Duration, Stack, App, CfnResource, RemovalPolicy, Lazy, Stage, DefaultStackSynthesizer, CliCredentialsStackSynthesizer, PERMISSIONS_BOUNDARY_CONTEXT_KEY, PermissionsBoundary, Token } from '../../core'; import { AnyPrincipal, ArnPrincipal, CompositePrincipal, FederatedPrincipal, ManagedPolicy, PolicyStatement, Role, ServicePrincipal, User, Policy, PolicyDocument, Effect } from '../lib'; describe('isRole() returns', () => { @@ -1325,3 +1325,84 @@ test('cross-env role ARNs include path', () => { }, }); }); + +test('doesn\'t throw with roleName of 64 chars', () => { + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const valdName = 'a'.repeat(64); + + expect(() => { + new Role(stack, 'Test', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + roleName: valdName, + }); + }).not.toThrow('Invalid roleName'); +}); + +test('throws with roleName over 64 chars', () => { + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const longName = 'a'.repeat(65); + + expect(() => { + new Role(stack, 'Test', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + roleName: longName, + }); + }).toThrow('Invalid roleName'); +}); + +describe('roleName validation', () => { + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const invalidChars = '!#$%^&*()'; + + it('rejects names with spaces', () => { + expect(() => { + new Role(stack, 'test spaces', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + roleName: 'invalid name', + }); + }).toThrow('Invalid roleName'); + }); + + invalidChars.split('').forEach(char => { + it(`rejects name with ${char}`, () => { + expect(() => { + new Role(stack, `test ${char}`, { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + roleName: `invalid${char}`, + }); + }).toThrow('Invalid roleName'); + }); + }); + +}); + +test('roleName validation with Tokens', () =>{ + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const token = Lazy.string({ produce: () => 'token' }); + + // Mock isUnresolved to return false + jest.spyOn(Token, 'isUnresolved').mockReturnValue(false); + + expect(() => { + new Role(stack, 'Valid', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + roleName: token, + }); + }).toThrow('Invalid roleName'); + + // Mock isUnresolved to return true + jest.spyOn(Token, 'isUnresolved').mockReturnValue(true); + + expect(() => { + new Role(stack, 'Invalid', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + roleName: token, + }); + }).not.toThrow('Invalid roleName'); + + jest.clearAllMocks(); +}); \ No newline at end of file