From 41057fd8f86b2881c60f46a7087b73e219571531 Mon Sep 17 00:00:00 2001 From: Benny Thomas Date: Wed, 11 Mar 2026 10:04:51 +0100 Subject: [PATCH] fix(iam): apply provider.iam.role.path to state machine execution roles Step function IAM roles were not inheriting the path set via provider.iam.role.path, causing deployment failures when IAM path restrictions are enforced. Applies the path to both the state machine execution role and the scheduled events role. Closes #653 Co-Authored-By: Claude Sonnet 4.6 --- lib/deploy/events/apiGateway/iamRole.js | 5 ++++ lib/deploy/events/apiGateway/iamRole.test.js | 21 ++++++++++++++ .../compileCloudWatchEventEvents.js | 9 +++++- .../compileCloudWatchEventEvents.test.js | 29 +++++++++++++++++++ .../events/schedule/compileScheduledEvents.js | 7 +++++ .../schedule/compileScheduledEvents.test.js | 25 ++++++++++++++++ lib/deploy/stepFunctions/compileIamRole.js | 7 +++++ .../stepFunctions/compileIamRole.test.js | 27 +++++++++++++++++ .../stepFunctions/compileNotifications.js | 13 +++++---- .../compileNotifications.test.js | 15 ++++++++++ 10 files changed, 152 insertions(+), 6 deletions(-) diff --git a/lib/deploy/events/apiGateway/iamRole.js b/lib/deploy/events/apiGateway/iamRole.js index 163a86f7..f3b4f8b8 100644 --- a/lib/deploy/events/apiGateway/iamRole.js +++ b/lib/deploy/events/apiGateway/iamRole.js @@ -60,6 +60,11 @@ module.exports = { }; + const rolePath = _.get(this.serverless.service, 'provider.iam.role.path'); + if (rolePath) { + iamRoleApiGatewayToStepFunctions.Properties.Path = rolePath; + } + const getApiToStepFunctionsIamRoleLogicalId = this.getApiToStepFunctionsIamRoleLogicalId(); const newIamRoleStateMachineExecutionObject = { [getApiToStepFunctionsIamRoleLogicalId]: iamRoleApiGatewayToStepFunctions, diff --git a/lib/deploy/events/apiGateway/iamRole.test.js b/lib/deploy/events/apiGateway/iamRole.test.js index 0eb31cbe..30b2e835 100644 --- a/lib/deploy/events/apiGateway/iamRole.test.js +++ b/lib/deploy/events/apiGateway/iamRole.test.js @@ -176,4 +176,25 @@ describe('#compileHttpIamRole()', () => { .to.deep.equal(['states:StartExecution']); }); }); + + it('should apply provider.iam.role.path to API Gateway IAM role', () => { + serverlessStepFunctions.pluginhttpValidated = { + events: [ + { + stateMachineName: 'first', + http: { + path: 'foo/bar1', + method: 'post', + }, + }, + ], + }; + serverless.service.provider.iam = { role: { path: '/teamA/' } }; + + return serverlessStepFunctions.compileHttpIamRole().then(() => { + const properties = serverlessStepFunctions.serverless.service.provider + .compiledCloudFormationTemplate.Resources.ApigatewayToStepFunctionsRole.Properties; + expect(properties.Path).to.equal('/teamA/'); + }); + }); }); diff --git a/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.js b/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.js index 342cfb95..a879536e 100644 --- a/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.js +++ b/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.js @@ -128,7 +128,7 @@ module.exports = { } `; - const iamRoleTemplate = ` + let iamRoleTemplate = ` { "Type": "AWS::IAM::Role", "Properties": { @@ -174,6 +174,13 @@ module.exports = { const objectsToMerge = [newCloudWatchEventRuleObject]; if (!IamRole) { + const rolePath = _.get(this.serverless.service, 'provider.iam.role.path'); + if (rolePath) { + const jsonIamRole = JSON.parse(iamRoleTemplate); + jsonIamRole.Properties.Path = rolePath; + iamRoleTemplate = JSON.stringify(jsonIamRole); + } + const newPermissionObject = { [cloudWatchIamRoleLogicalId]: JSON.parse(iamRoleTemplate), }; diff --git a/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.test.js b/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.test.js index 85df8b9c..43bde738 100644 --- a/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.test.js +++ b/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.test.js @@ -580,6 +580,35 @@ describe('awsCompileCloudWatchEventEvents', () => { .Properties.Targets[0].RoleArn).to.equal('{"Fn::GetAtt": ["StepFunctionsRole", "Arn"]}'); }); + it('should apply provider.iam.role.path to CloudWatch event IAM role', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + }, + }, + ], + }, + }, + }; + serverless.service.provider.iam = { role: { path: '/teamA/' } }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstEventToStepFunctionsRole + .Properties.Path).to.equal('/teamA/'); + }); + it('should not create corresponding resources when events are not given', () => { serverlessStepFunctions.serverless.service.stepFunctions = { stateMachines: { diff --git a/lib/deploy/events/schedule/compileScheduledEvents.js b/lib/deploy/events/schedule/compileScheduledEvents.js index 2adf70c3..f4ae4757 100644 --- a/lib/deploy/events/schedule/compileScheduledEvents.js +++ b/lib/deploy/events/schedule/compileScheduledEvents.js @@ -266,6 +266,13 @@ module.exports = { iamRoleTemplate = JSON.stringify(jsonIamRole); } + const rolePath = _.get(service, 'provider.iam.role.path'); + if (rolePath) { + const jsonIamRole = JSON.parse(iamRoleTemplate); + jsonIamRole.Properties.Path = rolePath; + iamRoleTemplate = JSON.stringify(jsonIamRole); + } + const newScheduleObject = { [scheduleLogicalId]: JSON.parse(scheduleTemplate), }; diff --git a/lib/deploy/events/schedule/compileScheduledEvents.test.js b/lib/deploy/events/schedule/compileScheduledEvents.test.js index bf3e7d80..4ca7531b 100644 --- a/lib/deploy/events/schedule/compileScheduledEvents.test.js +++ b/lib/deploy/events/schedule/compileScheduledEvents.test.js @@ -448,6 +448,31 @@ describe('#httpValidate()', () => { .Properties.PermissionsBoundary).to.equal('arn:aws:iam::myAccount:policy/permission_boundary'); }); + it('should handle provider.iam.role.path', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + inputPath: '$.stageVariables', + }, + }, + ], + }, + }, + }; + serverless.service.provider.iam = { role: { path: '/teamA/' } }; + serverlessStepFunctions.compileScheduledEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstScheduleToStepFunctionsRole + .Properties.Path).to.equal('/teamA/'); + }); + it('should have type of AWS::Scheduler::Schedule if method is scheduler', () => { serverlessStepFunctions.serverless.service.stepFunctions = { stateMachines: { diff --git a/lib/deploy/stepFunctions/compileIamRole.js b/lib/deploy/stepFunctions/compileIamRole.js index bb50fcad..7612adfa 100644 --- a/lib/deploy/stepFunctions/compileIamRole.js +++ b/lib/deploy/stepFunctions/compileIamRole.js @@ -943,6 +943,13 @@ module.exports = { iamRoleJson = JSON.stringify(jsonIamRole); } + const rolePath = _.get(service, 'provider.iam.role.path'); + if (rolePath) { + const jsonIamRole = JSON.parse(iamRoleJson); + jsonIamRole.Properties.Path = rolePath; + iamRoleJson = JSON.stringify(jsonIamRole); + } + const stateMachineLogicalId = this.getStateMachineLogicalId( stateMachineId, stateMachineObj, diff --git a/lib/deploy/stepFunctions/compileIamRole.test.js b/lib/deploy/stepFunctions/compileIamRole.test.js index 5d81a143..f13f359f 100644 --- a/lib/deploy/stepFunctions/compileIamRole.test.js +++ b/lib/deploy/stepFunctions/compileIamRole.test.js @@ -4425,6 +4425,33 @@ describe('#compileIamRole', () => { expect(boundary).to.equal('arn:aws:iam::myAccount:policy/permission_boundary'); }); + it('should handle provider.iam.role.path', () => { + serverless.service.stepFunctions = { + stateMachines: { + myStateMachine1: { + id: 'StateMachine1', + definition: { + StartAt: 'A', + States: { + A: { + Type: 'Task', + Resource: + 'arn:aws:lambda:#{AWS::Region}:#{AWS::AccountId}:function:hello', + End: true, + }, + }, + }, + }, + }, + }; + serverless.service.provider.iam = { role: { path: '/teamA/' } }; + serverlessStepFunctions.compileIamRole(); + const rolePath = serverlessStepFunctions.serverless.service.provider + .compiledCloudFormationTemplate.Resources.StateMachine1Role.Properties + .Path; + expect(rolePath).to.equal('/teamA/'); + }); + it('should handle permissions listObjectsV2', () => { const myBucket = 'myBucket'; serverless.service.stepFunctions = { diff --git a/lib/deploy/stepFunctions/compileNotifications.js b/lib/deploy/stepFunctions/compileNotifications.js index f63da322..d8e2a518 100644 --- a/lib/deploy/stepFunctions/compileNotifications.js +++ b/lib/deploy/stepFunctions/compileNotifications.js @@ -201,7 +201,7 @@ function compilePermissionForTarget(status, targetObj) { }; } -function bootstrapIamRole() { +function bootstrapIamRole(rolePath) { const iamRole = { Type: 'AWS::IAM::Role', Properties: { @@ -215,6 +215,7 @@ function bootstrapIamRole() { }, }, Policies: [], + ...(rolePath && { Path: rolePath }), }, }; const addPolicy = (name, action, resource) => { @@ -234,8 +235,8 @@ function bootstrapIamRole() { return { iamRole, addPolicy }; } -function* compilePermissionResources(stateMachineLogicalId, iamRoleLogicalId, targets) { - const { iamRole, addPolicy } = bootstrapIamRole(); +function* compilePermissionResources(stateMachineLogicalId, iamRoleLogicalId, targets, rolePath) { + const { iamRole, addPolicy } = bootstrapIamRole(rolePath); for (let index = 0; index < targets.length; index++) { const { status, target } = targets[index]; @@ -263,12 +264,12 @@ function* compilePermissionResources(stateMachineLogicalId, iamRoleLogicalId, ta } } -function* compileResources(stateMachineLogicalId, stateMachineName, notificationsObj) { +function* compileResources(stateMachineLogicalId, stateMachineName, notificationsObj, rolePath) { const iamRoleLogicalId = `${stateMachineLogicalId}NotificationsIamRole`; const allTargets = _.flatMap(executionStatuses, status => _.get(notificationsObj, status, []).map(target => ({ status, target }))); const permissions = compilePermissionResources( - stateMachineLogicalId, iamRoleLogicalId, allTargets, + stateMachineLogicalId, iamRoleLogicalId, allTargets, rolePath, ); const permissionResources = Array.from(permissions); for (const { logicalId, resource } of permissionResources) { @@ -354,10 +355,12 @@ module.exports = { return []; } + const rolePath = _.get(this.serverless.service, 'provider.iam.role.path'); const resourcesIterator = compileResources( stateMachineLogicalId, stateMachineName, notificationsObj, + rolePath, ); return Array.from(resourcesIterator); diff --git a/lib/deploy/stepFunctions/compileNotifications.test.js b/lib/deploy/stepFunctions/compileNotifications.test.js index 8dfec843..1be8de1d 100644 --- a/lib/deploy/stepFunctions/compileNotifications.test.js +++ b/lib/deploy/stepFunctions/compileNotifications.test.js @@ -522,4 +522,19 @@ describe('#compileNotifications', () => { expect(logMessage.startsWith('State machine [Beta1] : notifications are not supported on Express Workflows.')) .to.equal(true); }); + + it('should apply provider.iam.role.path to notifications IAM role', () => { + serverless.service.stepFunctions = { + stateMachines: { + beta1: genStateMachineWithTargets('Beta1', [{ stepFunctions: 'STATE_MACHINE_ARN' }]), + }, + }; + serverless.service.provider.iam = { role: { path: '/teamA/' } }; + + serverlessStepFunctions.compileNotifications(); + const resources = serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources; + + expect(resources.Beta1NotificationsIamRole.Properties.Path).to.equal('/teamA/'); + }); });