Repository: serverless/serverless (JavaScript)
Date: 2019-10-22 15:16:33+00:00
Task type: Bug Fix

Issue:
Support custom role setting for custom resources


# This is a Bug Report

## Description

- What went wrong?

We use our own role which is specified at the provider level. This role is used for every lambda. Since we added the Eventbridge event to a Lambda it fails to try to create a custom role (which it should not do since we use our predefined one).

I looked at the generated Cloudformation JSON and noticed that it is trying to create a Lambda using the handler (eventBridge/handler.handler) from custom resources. Additionally, it is creating a new role for this lambda called "IamRoleCustomResourcesLambdaExecution". For every other Lambda, it is using our predefined Role so I think this is wrong.

- What did you expect should have happened?

Custom-resources lambda should use the default role we specified. 

- What was the config you used?

```

provider:
  role: our ARN
​
functions:
​
  # Log from event bus
  LogEvent:
    handler: lambda.handler
    events:
      - eventBridge:
          eventBus: custom-saas-events
          pattern:
            source:
              - saas.external

```

- What stacktrace or error message from your provider did you see?

Similar or dependent issues:

## Additional Data

- **_Serverless Framework Version you're using_**: Framework Core: 1.50.0
- **_Operating System_**: windows
- **_Stack Trace_**: 
- **_Provider Error messages_**:



Issue discussion:
@gordianberger thanks for that report.

> Since we added the Eventbridge event to a Lambda it fails to try to create a custom role (which it should not do since we use our predefined one).

Why exactly it fails? Relying on custom resources is expected to work seamlessly with custom role setting

> Custom-resources lambda should use the default role we specified.

Custom resource lambdas have very specific permission requirements, and those are in most cases very different from ones needed by service lambdas. I think it wouldn't be nice if we would automatically assume that same role should be used for those and regular lambdas.

Still I think a worthwhile improvement would be to support a `customResourceRole` setting, through which you may state that existing, externally maintained, role should be used. Having that you may assign same ARN here as to `role` setting.

What do you think?
@medikoo Thanks for the quick response.

Agreed. I understand a little bit more about custom resources now, so I also think that it is a better practice to have two roles.

Also, a small setup still can share the role so I like this approach. 






Hello, I'm interested in solving this issue, can anyone point me in the right direction?
@olafur-palsson here's a place where we create a dedicated role internally: https://github.com/serverless/serverless/blob/91ae8bcc17d3ab7874507c8192375b4a28627592/lib/plugins/aws/customResources/index.js#L86

Still, as I think of it, allowing to provide custom role for that, is a bit controversial, as what polices are required may vary across versions (even minor updates), while role as provided by user will have to unconditionally support all of them for successful stack deployment

Do you have a valid use case for that?
See https://github.com/serverless/serverless/issues/6492#issuecomment-533476635

>This is a real issue in enterprise enviroments where role creation might be restricted. E.g. by requiring a naming convention, permission boundary or just completely for developers.

Allowing a custom role here would let people work around that issue. Even it that requires a bit more effort.
```
resources:
    IamRoleCustomResourcesLambdaExecution:
      RoleName: name
      PermissionsBoundary: name
```
So naming convetions and permissions boundaries can be overridden in the generated template, which works as a workaround for us.
> So naming convetions and permissions boundaries can be overridden in the generated template, which works as a workaround for us.

Yes, it can be sorted that way
> ```
> resources:
>     IamRoleCustomResourcesLambdaExecution:
>       RoleName: name
>       PermissionsBoundary: name
> ```
> 
> So naming convetions and permissions boundaries can be overridden in the generated template, which works as a workaround for us.

@hanikesn Could you please point me to a full example? Thanks

Solution patch:
diff --git a/lib/plugins/aws/customResources/index.js b/lib/plugins/aws/customResources/index.js
index b3b9b4f99cc..3b84d4663ce 100644
--- a/lib/plugins/aws/customResources/index.js
+++ b/lib/plugins/aws/customResources/index.js
@@ -86,78 +86,83 @@ function addCustomResourceToService(awsProvider, resourceName, iamRoleStatements
       const s3FileName = outputFilePath.split(path.sep).pop();
       const S3Key = `${s3Folder}/${s3FileName}`;
 
-      let customResourceRole = Resources[customResourcesRoleLogicalId];
-      if (!customResourceRole) {
-        customResourceRole = {
-          Type: 'AWS::IAM::Role',
-          Properties: {
-            AssumeRolePolicyDocument: {
-              Version: '2012-10-17',
-              Statement: [
+      const cfnRoleArn = serverless.service.provider.cfnRole;
+
+      if (!cfnRoleArn) {
+        let customResourceRole = Resources[customResourcesRoleLogicalId];
+        if (!customResourceRole) {
+          customResourceRole = {
+            Type: 'AWS::IAM::Role',
+            Properties: {
+              AssumeRolePolicyDocument: {
+                Version: '2012-10-17',
+                Statement: [
+                  {
+                    Effect: 'Allow',
+                    Principal: {
+                      Service: ['lambda.amazonaws.com'],
+                    },
+                    Action: ['sts:AssumeRole'],
+                  },
+                ],
+              },
+              Policies: [
                 {
-                  Effect: 'Allow',
-                  Principal: {
-                    Service: ['lambda.amazonaws.com'],
+                  PolicyName: {
+                    'Fn::Join': [
+                      '-',
+                      [
+                        awsProvider.getStage(),
+                        awsProvider.serverless.service.service,
+                        'custom-resources-lambda',
+                      ],
+                    ],
+                  },
+                  PolicyDocument: {
+                    Version: '2012-10-17',
+                    Statement: [],
                   },
-                  Action: ['sts:AssumeRole'],
                 },
               ],
             },
-            Policies: [
+          };
+          Resources[customResourcesRoleLogicalId] = customResourceRole;
+
+          if (shouldWriteLogs) {
+            const logGroupsPrefix = awsProvider.naming.getLogGroupName(funcPrefix);
+            customResourceRole.Properties.Policies[0].PolicyDocument.Statement.push(
               {
-                PolicyName: {
-                  'Fn::Join': [
-                    '-',
-                    [
-                      awsProvider.getStage(),
-                      awsProvider.serverless.service.service,
-                      'custom-resources-lambda',
-                    ],
-                  ],
-                },
-                PolicyDocument: {
-                  Version: '2012-10-17',
-                  Statement: [],
-                },
+                Effect: 'Allow',
+                Action: ['logs:CreateLogStream'],
+                Resource: [
+                  {
+                    'Fn::Sub':
+                      'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}' +
+                      `:log-group:${logGroupsPrefix}*:*`,
+                  },
+                ],
               },
-            ],
-          },
-        };
-
-        if (shouldWriteLogs) {
-          const logGroupsPrefix = awsProvider.naming.getLogGroupName(funcPrefix);
-          customResourceRole.Properties.Policies[0].PolicyDocument.Statement.push(
-            {
-              Effect: 'Allow',
-              Action: ['logs:CreateLogStream'],
-              Resource: [
-                {
-                  'Fn::Sub':
-                    'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}' +
-                    `:log-group:${logGroupsPrefix}*:*`,
-                },
-              ],
-            },
-            {
-              Effect: 'Allow',
-              Action: ['logs:PutLogEvents'],
-              Resource: [
-                {
-                  'Fn::Sub':
-                    'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}' +
-                    `:log-group:${logGroupsPrefix}*:*:*`,
-                },
-              ],
-            }
-          );
+              {
+                Effect: 'Allow',
+                Action: ['logs:PutLogEvents'],
+                Resource: [
+                  {
+                    'Fn::Sub':
+                      'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}' +
+                      `:log-group:${logGroupsPrefix}*:*:*`,
+                  },
+                ],
+              }
+            );
+          }
         }
+        const { Statement } = customResourceRole.Properties.Policies[0].PolicyDocument;
+        iamRoleStatements.forEach(newStmt => {
+          if (!Statement.find(existingStmt => existingStmt.Resource === newStmt.Resource)) {
+            Statement.push(newStmt);
+          }
+        });
       }
-      const { Statement } = customResourceRole.Properties.Policies[0].PolicyDocument;
-      iamRoleStatements.forEach(newStmt => {
-        if (!Statement.find(existingStmt => existingStmt.Resource === newStmt.Resource)) {
-          Statement.push(newStmt);
-        }
-      });
 
       const customResourceFunction = {
         Type: 'AWS::Lambda::Function',
@@ -169,19 +174,21 @@ function addCustomResourceToService(awsProvider, resourceName, iamRoleStatements
           FunctionName: absoluteFunctionName,
           Handler,
           MemorySize: 1024,
-          Role: {
-            'Fn::GetAtt': [customResourcesRoleLogicalId, 'Arn'],
-          },
           Runtime: 'nodejs10.x',
           Timeout: 180,
         },
-        DependsOn: [customResourcesRoleLogicalId],
+        DependsOn: [],
       };
+      Resources[customResourceFunctionLogicalId] = customResourceFunction;
 
-      Object.assign(Resources, {
-        [customResourceFunctionLogicalId]: customResourceFunction,
-        [customResourcesRoleLogicalId]: customResourceRole,
-      });
+      if (cfnRoleArn) {
+        customResourceFunction.Properties.Role = cfnRoleArn;
+      } else {
+        customResourceFunction.Properties.Role = {
+          'Fn::GetAtt': [customResourcesRoleLogicalId, 'Arn'],
+        };
+        customResourceFunction.DependsOn.push(customResourcesRoleLogicalId);
+      }
 
       if (shouldWriteLogs) {
         const customResourceLogGroupLogicalId = awsProvider.naming.getLogGroupLogicalId(


Test patch:
diff --git a/lib/plugins/aws/customResources/index.test.js b/lib/plugins/aws/customResources/index.test.js
index ceb5a328dfd..639f25cd881 100644
--- a/lib/plugins/aws/customResources/index.test.js
+++ b/lib/plugins/aws/customResources/index.test.js
@@ -221,6 +221,119 @@ describe('#addCustomResourceToService()', () => {
     });
   });
 
+  it('Should not setup new IAM role, when cfnRole is provided', () => {
+    const cfnRoleArn = (serverless.service.provider.cfnRole =
+      'arn:aws:iam::999999999999:role/some-role');
+    return expect(
+      BbPromise.all([
+        // add the custom S3 resource
+        addCustomResourceToService(provider, 's3', [
+          ...iamRoleStatements,
+          {
+            Effect: 'Allow',
+            Resource: 'arn:aws:s3:::some-bucket',
+            Action: ['s3:PutBucketNotification', 's3:GetBucketNotification'],
+          },
+        ]),
+        // add the custom Cognito User Pool resource
+        addCustomResourceToService(provider, 'cognitoUserPool', [
+          ...iamRoleStatements,
+          {
+            Effect: 'Allow',
+            Resource: '*',
+            Action: [
+              'cognito-idp:ListUserPools',
+              'cognito-idp:DescribeUserPool',
+              'cognito-idp:UpdateUserPool',
+            ],
+          },
+        ]),
+        // add the custom Event Bridge resource
+        addCustomResourceToService(provider, 'eventBridge', [
+          ...iamRoleStatements,
+          {
+            Effect: 'Allow',
+            Resource: 'arn:aws:events:*:*:rule/some-rule',
+            Action: [
+              'events:PutRule',
+              'events:RemoveTargets',
+              'events:PutTargets',
+              'events:DeleteRule',
+            ],
+          },
+          {
+            Action: ['events:CreateEventBus', 'events:DeleteEventBus'],
+            Effect: 'Allow',
+            Resource: 'arn:aws:events:*:*:event-bus/some-event-bus',
+          },
+        ]),
+      ])
+    ).to.be.fulfilled.then(() => {
+      const { Resources } = serverless.service.provider.compiledCloudFormationTemplate;
+      const customResourcesZipFilePath = path.join(
+        tmpDirPath,
+        '.serverless',
+        'custom-resources.zip'
+      );
+
+      expect(execAsyncStub).to.have.callCount(3);
+      expect(fs.existsSync(customResourcesZipFilePath)).to.equal(true);
+      // S3 Lambda Function
+      expect(Resources.CustomDashresourceDashexistingDashs3LambdaFunction).to.deep.equal({
+        Type: 'AWS::Lambda::Function',
+        Properties: {
+          Code: {
+            S3Bucket: { Ref: 'ServerlessDeploymentBucket' },
+            S3Key: 'artifact-dir-name/custom-resources.zip',
+          },
+          FunctionName: `${serviceName}-dev-custom-resource-existing-s3`,
+          Handler: 's3/handler.handler',
+          MemorySize: 1024,
+          Role: cfnRoleArn,
+          Runtime: 'nodejs10.x',
+          Timeout: 180,
+        },
+        DependsOn: [],
+      });
+      // Cognito User Pool Lambda Function
+      expect(Resources.CustomDashresourceDashexistingDashcupLambdaFunction).to.deep.equal({
+        Type: 'AWS::Lambda::Function',
+        Properties: {
+          Code: {
+            S3Bucket: { Ref: 'ServerlessDeploymentBucket' },
+            S3Key: 'artifact-dir-name/custom-resources.zip',
+          },
+          FunctionName: `${serviceName}-dev-custom-resource-existing-cup`,
+          Handler: 'cognitoUserPool/handler.handler',
+          MemorySize: 1024,
+          Role: cfnRoleArn,
+          Runtime: 'nodejs10.x',
+          Timeout: 180,
+        },
+        DependsOn: [],
+      });
+      // Event Bridge Lambda Function
+      expect(Resources.CustomDashresourceDasheventDashbridgeLambdaFunction).to.deep.equal({
+        Type: 'AWS::Lambda::Function',
+        Properties: {
+          Code: {
+            S3Bucket: { Ref: 'ServerlessDeploymentBucket' },
+            S3Key: 'artifact-dir-name/custom-resources.zip',
+          },
+          FunctionName: `${serviceName}-dev-custom-resource-event-bridge`,
+          Handler: 'eventBridge/handler.handler',
+          MemorySize: 1024,
+          Role: cfnRoleArn,
+          Runtime: 'nodejs10.x',
+          Timeout: 180,
+        },
+        DependsOn: [],
+      });
+      // Iam Role
+      expect(Resources.IamRoleCustomResourcesLambdaExecution).to.be.undefined;
+    });
+  });
+
   it('should setup CloudWatch Logs when logs.frameworkLambda is true', () => {
     serverless.service.provider.logs = { frameworkLambda: true };
     return BbPromise.all([