diff --git a/.travis.yml b/.travis.yml index 4c8ab41..2b0908a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ env: - SERVERLESS_VERSION=latest COV_PUB=true - SERVERLESS_VERSION=latest~1 - SERVERLESS_VERSION=latest~2 + - SERVERLESS_VERSION=^1.51.0 before_install: - npm i -g npm@6 diff --git a/package-lock.json b/package-lock.json index ad992ef..c937cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8006,9 +8006,9 @@ "dev": true }, "typescript": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.2.tgz", - "integrity": "sha512-lmQ4L+J6mnu3xweP8+rOrUwzmN+MRAj7TgtJtDaXE5PMyX2kCrklhg3rvOsOIfNeAWMQWO2F1GPc1kMD2vLAfw==", + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.5.tgz", + "integrity": "sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index c493051..f35ab88 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "coverage": "nyc report --reporter=text-lcov | coveralls", "compile": "tsc", "watch": "tsc -w", - "prepublishOnly": "npm run clean && npm run compile", + "prepare": "npm run clean && npm run compile", "release": "standard-version" }, "author": "Functional One, Ltd.", @@ -55,7 +55,7 @@ "standard-version": "^7.0.0", "ts-node": "^8.3.0", "tslint": "^5.19.0", - "typescript": "^3.6.2" + "typescript": "^3.7.5" }, "files": [ "dist/index.*", diff --git a/src/lib/index.ts b/src/lib/index.ts index 5478aad..4caad57 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -9,6 +9,8 @@ interface Statement { Resource: string | any[]; } +type ArbitraryCFN = string | ArbitraryCFN[] | { [key: string]: ArbitraryCFN} ; + class ServerlessIamPerFunctionPlugin { provider: string; @@ -195,59 +197,36 @@ class ServerlessIamPerFunctionPlugin { return res; } - /** - * Will check if function has a definition of iamRoleStatements. If so will create a new Role for the function based on these statements. - * @param functionName - * @param functionToRoleMap - populate the map with a mapping from function resource name to role resource name - */ - createRoleForFunction(functionName: string, functionToRoleMap: Map) { - const functionObject = this.serverless.service.getFunction(functionName); - if(functionObject.iamRoleStatements === undefined) { - return; - } - if(functionObject.role) { - this.throwError("Defing function with both 'role' and 'iamRoleStatements' is not supported. Function name: " + functionName); - } - this.validateStatements(functionObject.iamRoleStatements); - //we use the configured role as a template - const globalRoleName = this.serverless.providers.aws.naming.getRoleLogicalId(); - const globalIamRole = this.serverless.service.provider.compiledCloudFormationTemplate.Resources[globalRoleName]; - const functionIamRole = _.cloneDeep(globalIamRole); - //remove the statements - const policyStatements: Statement[] = []; - functionIamRole.Properties.Policies[0].PolicyDocument.Statement = policyStatements; - //set log statements - policyStatements[0] = { - Effect: "Allow", - Action: ["logs:CreateLogStream", "logs:PutLogEvents"], - Resource: [ - { - 'Fn::Sub': 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}' + + collectInlinePolicy(functionObject: any) { + const policyStatements: Statement[] = [ + { //set log statements + Effect: "Allow", + Action: [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + Resource: [ + { + 'Fn::Sub': 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}' + `:log-group:${this.serverless.providers.aws.naming.getLogGroupName(functionObject.name)}:*:*`, - }, - ], - }; - // remove managed policies - functionIamRole.Properties.ManagedPolicyArns = []; - //set vpc if needed - if (!_.isEmpty(functionObject.vpc) || !_.isEmpty(this.serverless.service.provider.vpc)) { - functionIamRole.Properties.ManagedPolicyArns = [{ - 'Fn::Join': ['', - [ - 'arn:', - { Ref: 'AWS::Partition' }, - ':iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole', - ], + }, ], - }]; - } - for (const s of this.getStreamStatements(functionObject)) { //set stream statements (if needed) + }, + ]; + + //set stream statements (if needed) + const streamStatements = this.getStreamStatements(functionObject); + for (const s of streamStatements) { policyStatements.push(s); } - const sqsStatement = this.getSqsStatement(functionObject); //set sqs statement (if needed) + + //set sqs statement (if needed) + const sqsStatement = this.getSqsStatement(functionObject); if (sqsStatement) { policyStatements.push(sqsStatement); } + // set sns publish for DLQ if needed // currently only sns is supported: https://serverless.com/framework/docs/providers/aws/events/sns#dlq-with-sqs if (!_.isEmpty(functionObject.onError)) { // @@ -259,18 +238,89 @@ class ServerlessIamPerFunctionPlugin { Resource: functionObject.onError, }); } + + //add global statements if((functionObject.iamRoleStatementsInherit || (this.defaultInherit && functionObject.iamRoleStatementsInherit !== false)) - && !_.isEmpty(this.serverless.service.provider.iamRoleStatements)) { //add global statements + && !_.isEmpty(this.serverless.service.provider.iamRoleStatements)) { for (const s of this.serverless.service.provider.iamRoleStatements) { policyStatements.push(s); } } + //add iamRoleStatements if(_.isArray(functionObject.iamRoleStatements)) { for (const s of functionObject.iamRoleStatements) { policyStatements.push(s); } } + return policyStatements; + } + + collectManagedPolicies(functionObject: any) { + const managedPolicies: ArbitraryCFN[] = []; + //add global statements + if((functionObject.iamManagedPoliciesInherit || (this.defaultInherit && functionObject.iamManagedPoliciesInherit !== false)) + && !_.isEmpty(this.serverless.service.provider.iamManagedPolicies)) { + for (const s of this.serverless.service.provider.iamManagedPolicies) { + managedPolicies.push(s); + } + } + + //add iamRoleStatements + if(_.isArray(functionObject.iamManagedPolicies)) { + for (const s of functionObject.iamManagedPolicies) { + managedPolicies.push(s); + } + } + + //set vpc if needed + if (!_.isEmpty(functionObject.vpc) || !_.isEmpty(this.serverless.service.provider.vpc)) { + if (!_.includes(managedPolicies, 'arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole')) { + managedPolicies.push( + { + 'Fn::Join': ['', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole', + ], + ], + }, + ); + } + } + return _.uniq(managedPolicies); + } + + /** + * Will check if function has a definition of iamRoleStatements. If so will create a new Role for the function based on these statements. + * @param functionName + * @param functionToRoleMap - populate the map with a mapping from function resource name to role resource name + */ + createRoleForFunction(functionName: string, functionToRoleMap: Map) { + const functionObject = this.serverless.service.getFunction(functionName); + + if(functionObject.iamRoleStatements === undefined) { + return; + } + + if(functionObject.role) { + this.throwError("Defining function with both 'role' and 'iamRoleStatements' is not supported. Function name: " + functionName); + } + + this.validateStatements(functionObject.iamRoleStatements); + + //we use the configured role as a template + const globalRoleName = this.serverless.providers.aws.naming.getRoleLogicalId(); + const globalIamRole = this.serverless.service.provider.compiledCloudFormationTemplate.Resources[globalRoleName]; + const functionIamRole = _.cloneDeep(globalIamRole); + + // rebuild managed policies + functionIamRole.Properties.ManagedPolicyArns = this.collectManagedPolicies(functionObject); + + // rebuild the inline policy + functionIamRole.Properties.Policies[0].PolicyDocument.Statement = this.collectInlinePolicy(functionObject); + functionIamRole.Properties.RoleName = functionObject.iamRoleStatementsName || this.getFunctionRoleName(functionName); const roleResourceName = this.serverless.providers.aws.naming.getNormalizedFunctionName(functionName) + globalRoleName; this.serverless.service.provider.compiledCloudFormationTemplate.Resources[roleResourceName] = functionIamRole; diff --git a/src/test/funcs-with-iam-and-managed-policies.json b/src/test/funcs-with-iam-and-managed-policies.json new file mode 100644 index 0000000..912d471 --- /dev/null +++ b/src/test/funcs-with-iam-and-managed-policies.json @@ -0,0 +1,169 @@ +{ + "service": "test-service", + "provider": { + "stage": "dev", + "region": "us-east-1", + "name": "aws", + "runtime": "python2.7", + "iamManagedPolicies": [ + "arn:aws:iam::aws:policy/AmazonRDSFullAccess" + ], + "iamRoleStatements": [ + { + "Effect": "Allow", + "Action": [ + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ], + "Resource": "*" + } + ] + }, + "functions": { + "hello": { + "handler": "handler.hello", + "iamRoleStatements": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem" + ], + "Resource": "arn:aws:dynamodb:us-east-1:*:table/test" + } + ], + "events": [], + "name": "test-python-dev-hello", + "package": {}, + "vpc": {} + }, + "helloInherit": { + "handler": "handler.hello", + "iamRoleStatements": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem" + ], + "Resource": "arn:aws:dynamodb:us-east-1:*:table/test" + } + ], + "iamManagedPoliciesInherit": true, + "events": [], + "name": "test-python-dev-hello", + "package": {}, + "vpc": {} + }, + "streamHandler": { + "handler": "handler.stream", + "iamManagedPolicies": [ + "arn:aws:iam::aws:policy/AmazonKinesisFullAccess" + ], + "iamRoleStatements": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem" + ], + "Resource": "arn:aws:dynamodb:us-east-1:*:table/test" + } + ], + "events": [ + {"stream": "arn:aws:dynamodb:us-east-1:1234567890:table/test/stream/2017-10-09T19:39:15.151"} + ], + "name": "test-python-dev-stream-handler", + "onError": "arn:aws:sns:us-east-1:1234567890123:lambda-dlq", + "package": {}, + "vpc": {} + }, + "sqsHandler": { + "handler": "handler.sqs", + "iamManagedPoliciesInherit": true, + "iamManagedPolicies": [ + "arn:aws:iam::aws:policy/AmazonKinesisFullAccess" + ], + "iamRoleStatements": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem" + ], + "Resource": "arn:aws:dynamodb:us-east-1:*:table/test" + } + ], + "events": [ + {"sqs": "arn:aws:sqs:us-east-1:1234567890:MyQueue"}, + {"sqs": {"arn": "arn:aws:sqs:us-east-1:1234567890:MyOtherQueue"}} + ], + "name": "test-python-dev-sqs-handler", + "onError": "arn:aws:sns:us-east-1:1234567890123:lambda-dlq", + "package": {}, + "vpc": {} + }, + "helloNoPerFunction": { + "handler": "handler.hello", + "events": [], + "name": "test-python-dev-hello", + "package": {}, + "vpc": {} + }, + "helloEmptyIamPolicies": { + "handler": "handler.hello", + "iamManagedPolicies": [], + "iamRoleStatements": [], + "events": [], + "name": "test-python-dev-hello", + "package": {}, + "vpc": { + "securityGroupIds": ["sg-xxxxxx"], + "subnetIds": ["subnet-xxxx", "subnet-yyyy"] + } + }, + "helloNoDuplicateVpcIamPolicies": { + "handler": "handler.hello", + "iamManagedPolicies": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" + ], + "iamRoleStatements": [], + "events": [], + "name": "test-python-dev-hello", + "package": {}, + "vpc": { + "securityGroupIds": ["sg-xxxxxx"], + "subnetIds": ["subnet-xxxx", "subnet-yyyy"] + } + }, + "helloDuplicateIamPolicies": { + "handler": "handler.hello", + "iamManagedPoliciesInherit": true, + "iamManagedPolicies": [ + "arn:aws:iam::aws:policy/AmazonRDSFullAccess", + "arn:aws:iam::aws:policy/AmazonKinesisFullAccess" + ], + "iamRoleStatements": [], + "events": [], + "name": "test-python-dev-hello", + "package": {} + } + }, + "resources": { + "Resources": { + "HelloLambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "TracingConfig": { + "Mode": "Active" + } + } + } + } + }, + "package": { + "artifact": "test-service.zip", + "exclude": [ + "node_modules/**", + "package-lock.json" + ], + "artifactDirectoryName": "serverless/test-service/dev/1517233344526-2018-01-29T13:42:24.526Z" + }, + "artifact": "test-service.zip" +} diff --git a/src/test/funcs-with-iam.json b/src/test/funcs-with-iam.json index ccbd4ff..229024c 100644 --- a/src/test/funcs-with-iam.json +++ b/src/test/funcs-with-iam.json @@ -1,5 +1,5 @@ { - "service": "test-service", + "service": "test-service", "provider": { "stage": "dev", "region": "us-east-1", @@ -15,7 +15,7 @@ "Resource": "*" } ] - }, + }, "functions": { "hello": { "handler": "handler.hello", @@ -90,7 +90,7 @@ "vpc": {} }, "helloNoPerFunction": { - "handler": "handler.hello", + "handler": "handler.hello", "events": [], "name": "test-python-dev-hello", "package": {}, @@ -98,14 +98,14 @@ }, "helloEmptyIamStatements": { "handler": "handler.hello", - "iamRoleStatements": [], + "iamRoleStatements": [], "events": [], "name": "test-python-dev-hello", "package": {}, "vpc": { "securityGroupIds": ["sg-xxxxxx"], "subnetIds": ["subnet-xxxx", "subnet-yyyy"] - } + } } }, "resources": { diff --git a/src/test/index.test.ts b/src/test/index.test.ts index 7c369a4..2239181 100644 --- a/src/test/index.test.ts +++ b/src/test/index.test.ts @@ -1,92 +1,99 @@ // tslint:disable:no-var-requires -import {assert} from 'chai'; +import { assert } from 'chai'; import Plugin from '../lib/index'; const Serverless = require('serverless/lib/Serverless'); const sls_config = require('serverless/lib/utils/config'); const funcWithIamTemplate = require('../../src/test/funcs-with-iam.json'); +const funcWithIamTemplateAndManagedPolicies = require('../../src/test/funcs-with-iam-and-managed-policies'); const writeFileAtomic = require('write-file-atomic'); import _ from 'lodash'; import os from 'os'; import fs from 'fs'; import path from 'path'; -describe('plugin tests', function(this: any) { +const loadServerlessConfig = (serverlessConfigAsJson: any, tempdir: string) => () => { + const dir = path.join(tempdir, '.serverless'); + try { + fs.mkdirSync(dir); + } catch (error) { + if (error.code !== 'EEXIST') { + console.log('failed to create dir: %s, error: ', dir, error); + throw error; + } + } + const rc = sls_config.CONFIG_FILE_PATH; + writeFileAtomic.sync(rc, JSON.stringify({ + userId: null, + frameworkId: "test", + trackingDisabled: true, + enterpriseDisabled: true, + meta: { + created_at: 1567187050, + updated_at: null, + }, + }, null, 2)); + const packageFile = path.join(dir, serverlessConfigAsJson.package.artifact); + fs.writeFileSync(packageFile, "test123"); + console.log('### serverless version: %s ###', (new Serverless()).version); +}; + +const getServerlessInstance = async (serverlessConfigAsJson: any, tempdir: string) => { + const serverless = new Serverless(); + serverless.cli = new serverless.classes.CLI(); + serverless.processedInput = serverless.cli.processInput(); + Object.assign(serverless.service, _.cloneDeep(serverlessConfigAsJson)); + serverless.service.provider.compiledCloudFormationTemplate = { + Resources: {}, + Outputs: {}, + }; + serverless.config.servicePath = tempdir; + serverless.pluginManager.loadAllPlugins(); + let compile_hooks: any[] = serverless.pluginManager.getHooks('package:setupProviderConfiguration'); + compile_hooks = compile_hooks.concat( + serverless.pluginManager.getHooks('package:compileFunctions'), + serverless.pluginManager.getHooks('package:compileEvents')); + for (const ent of compile_hooks) { + try { + await ent.hook(); + } catch (error) { + console.log("failed running compileFunction hook: [%s] with error: ", ent, error); + assert.fail(); + } + } + return serverless; +}; +describe('plugin tests', function(this: any) { this.timeout(15000); let serverless: any; const tempdir = os.tmpdir(); - before(() => { - const dir = path.join(tempdir, '.serverless'); - try { - fs.mkdirSync(dir); - } catch (error) { - if(error.code !== 'EEXIST') { - console.log('failed to create dir: %s, error: ', dir, error); - throw error; - } - } - const rc = sls_config.CONFIG_FILE_PATH; - writeFileAtomic.sync(rc, JSON.stringify({ - userId: null, - frameworkId: "test", - trackingDisabled: true, - enterpriseDisabled: true, - meta: { - created_at: 1567187050, - updated_at: null, - }, - }, null, 2)); - const packageFile = path.join(dir, funcWithIamTemplate.package.artifact); - fs.writeFileSync(packageFile, "test123"); - console.log('### serverless version: %s ###', (new Serverless()).version); - }); + before(loadServerlessConfig(funcWithIamTemplate, tempdir)); beforeEach(async () => { - serverless = new Serverless(); - serverless.cli = new serverless.classes.CLI(); - serverless.processedInput = serverless.cli.processInput(); - Object.assign(serverless.service, _.cloneDeep(funcWithIamTemplate)); - serverless.service.provider.compiledCloudFormationTemplate = { - Resources: {}, - Outputs: {}, - }; - serverless.config.servicePath = tempdir; - serverless.pluginManager.loadAllPlugins(); - let compile_hooks: any[] = serverless.pluginManager.getHooks('package:setupProviderConfiguration'); - compile_hooks = compile_hooks.concat( - serverless.pluginManager.getHooks('package:compileFunctions'), - serverless.pluginManager.getHooks('package:compileEvents')); - for (const ent of compile_hooks) { - try { - await ent.hook(); - } catch (error) { - console.log("failed running compileFunction hook: [%s] with error: ", ent, error); - assert.fail(); - } - } + serverless = await getServerlessInstance(funcWithIamTemplate, tempdir); }); function assertFunctionRoleName(name: string, roleNameObj: any) { assert.isArray(roleNameObj['Fn::Join']); assert.isTrue(roleNameObj['Fn::Join'][1].toString().indexOf(name) >= 0, 'role name contains function name'); } - - describe('defaultInherit not set', () => { + + describe('defaultInherit not set', () => { let plugin: Plugin; - - beforeEach(async () => { + + beforeEach(async () => { plugin = new Plugin(serverless); }); describe('#constructor()', () => { it('should initialize the plugin', () => { assert.instanceOf(plugin, Plugin); - }); + }); - it('defaultInherit shuuld be false', () => { + it('defaultInherit should be false', () => { assert.isFalse(plugin.defaultInherit); }); }); @@ -96,24 +103,24 @@ describe('plugin tests', function(this: any) { Action: [ 'xray:PutTelemetryRecords', 'xray:PutTraceSegments', - ], + ], Resource: "*", }]; describe('#validateStatements', () => { - it('should validate valid statement', () => { - assert.doesNotThrow(() => {plugin.validateStatements(statements);}); + it('should validate valid statement', () => { + assert.doesNotThrow(() => { plugin.validateStatements(statements); }); }); it('should throw an error for invalid statement', () => { - const bad_statement = [{ //missing effect + const bad_statement = [{ //missing effect Action: [ 'xray:PutTelemetryRecords', 'xray:PutTraceSegments', - ], + ], Resource: "*", - }]; - assert.throws(() => {plugin.validateStatements(bad_statement);}); + }]; + assert.throws(() => { plugin.validateStatements(bad_statement); }); }); it('should throw error if no awsPackage plugin', () => { @@ -127,70 +134,70 @@ describe('plugin tests', function(this: any) { }); describe('#getRoleNameLength', () => { - it('Should calculate the acurate role name length us-east-1', () => { + it('Should calculate the accurate role name length us-east-1', () => { serverless.service.provider.region = 'us-east-1'; - let function_name = 'a'.repeat(10); - let name_parts = [ + const function_name = 'a'.repeat(10); + const name_parts = [ serverless.service.service, // test-service , length of 12 serverless.service.provider.stage, // dev, length of 3 : 15 { Ref: 'AWS::Region' }, // us-east-1, length 9 : 24 function_name, // 'a'.repeat(10), length 10 : 34 - 'lambdaRole' // lambdaRole, length 10 : 44 + 'lambdaRole', // lambdaRole, length 10 : 44 ]; - let role_name_length = plugin.getRoleNameLength(name_parts) - let expected = 44 // 12 + 3 + 9 + 10 + 10 == 44 + const role_name_length = plugin.getRoleNameLength(name_parts); + const expected = 44; // 12 + 3 + 9 + 10 + 10 == 44 assert.equal(role_name_length, expected + name_parts.length - 1); }); it('Should calculate the acurate role name length ap-northeast-1', () => { serverless.service.provider.region = 'ap-northeast-1'; - let function_name = 'a'.repeat(10); - let name_parts = [ + const function_name = 'a'.repeat(10); + const name_parts = [ serverless.service.service, // test-service , length of 12 serverless.service.provider.stage, // dev, length of 3 { Ref: 'AWS::Region' }, // ap-northeast-1, length 14 function_name, // 'a'.repeat(10), length 10 - 'lambdaRole' // lambdaRole, length 10 + 'lambdaRole', // lambdaRole, length 10 ]; - let role_name_length = plugin.getRoleNameLength(name_parts) - let expected = 49 // 12 + 3 + 14 + 10 + 10 == 49 + const role_name_length = plugin.getRoleNameLength(name_parts); + const expected = 49; // 12 + 3 + 14 + 10 + 10 == 49 assert.equal(role_name_length, expected + name_parts.length - 1); }); it('Should calculate the actual length for a non AWS::Region ref to maintain backward compatability', () => { serverless.service.provider.region = 'ap-northeast-1'; - let function_name = 'a'.repeat(10); - let name_parts = [ + const function_name = 'a'.repeat(10); + const name_parts = [ serverless.service.service, // test-service , length of 12 - { Ref: 'bananas'}, // bananas, length of 7 + { Ref: 'bananas' }, // bananas, length of 7 { Ref: 'AWS::Region' }, // ap-northeast-1, length 14 function_name, // 'a'.repeat(10), length 10 - 'lambdaRole' // lambdaRole, length 10 + 'lambdaRole', // lambdaRole, length 10 ]; - let role_name_length = plugin.getRoleNameLength(name_parts) - let expected = 53 // 12 + 7 + 14 + 10 + 10 == 53 + const role_name_length = plugin.getRoleNameLength(name_parts); + const expected = 53; // 12 + 7 + 14 + 10 + 10 == 53 assert.equal(role_name_length, expected + name_parts.length - 1); }); }); - + describe('#getFunctionRoleName', () => { it('should return a name with the function name', () => { const name = 'test-name'; const roleName = plugin.getFunctionRoleName(name); assertFunctionRoleName(name, roleName); const name_parts = roleName['Fn::Join'][1]; - assert.equal(name_parts[name_parts.length - 1], 'lambdaRole'); + assert.equal(name_parts[name_parts.length - 1], 'lambdaRole'); }); it('should throw an error on long name', () => { const long_name = 'long-long-long-long-long-long-long-long-long-long-long-long-long-name'; - assert.throws(() => {plugin.getFunctionRoleName(long_name);}); + assert.throws(() => { plugin.getFunctionRoleName(long_name); }); try { plugin.getFunctionRoleName(long_name); } catch (error) { //some validation that the error we throw is what we expect const msg: string = error.message; - assert.isString(msg); + assert.isString(msg); assert.isTrue(msg.startsWith('serverless-iam-roles-per-function: ERROR:')); assert.isTrue(msg.includes(long_name)); assert.isTrue(msg.endsWith('iamRoleStatementsName.')); @@ -198,7 +205,7 @@ describe('plugin tests', function(this: any) { }); it('should return a name without "lambdaRole"', () => { - let name = 'test-name'; + let name = 'test-name'; let roleName = plugin.getFunctionRoleName(name); const len = plugin.getRoleNameLength(roleName['Fn::Join'][1]); //create a name which causes role name to be longer than 64 chars by 1. Will cause then lambdaRole to be removed @@ -206,75 +213,93 @@ describe('plugin tests', function(this: any) { roleName = plugin.getFunctionRoleName(name); assertFunctionRoleName(name, roleName); const name_parts = roleName['Fn::Join'][1]; - assert.notEqual(name_parts[name_parts.length - 1], 'lambdaRole'); + assert.notEqual(name_parts[name_parts.length - 1], 'lambdaRole'); }); }); describe('#createRolesPerFunction', () => { - it('should create role per function', () => { - plugin.createRolesPerFunction(); - const helloRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloIamRoleLambdaExecution; - assert.isNotEmpty(helloRole); - assertFunctionRoleName('hello', helloRole.Properties.RoleName); - assert.isEmpty(helloRole.Properties.ManagedPolicyArns, 'function resource role has no managed policy'); - //check depends and role is set properlly - const helloFunctionResource = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloLambdaFunction; - assert.isTrue(helloFunctionResource.DependsOn.indexOf('HelloIamRoleLambdaExecution') >= 0, 'function resource depends on role'); - assert.equal(helloFunctionResource.Properties.Role["Fn::GetAtt"][0], 'HelloIamRoleLambdaExecution', "function resource role is set properly"); - const helloInheritRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloInheritIamRoleLambdaExecution; - assertFunctionRoleName('helloInherit', helloInheritRole.Properties.RoleName); - let policy_statements: any[] = helloInheritRole.Properties.Policies[0].PolicyDocument.Statement; - assert.isObject(policy_statements.find((s) => s.Action[0] === "xray:PutTelemetryRecords"), 'global statements imported upon inherit'); - assert.isObject(policy_statements.find((s) => s.Action[0] === "dynamodb:GetItem"), 'per function statements imported upon inherit'); - const streamHandlerRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.StreamHandlerIamRoleLambdaExecution; - assertFunctionRoleName('streamHandler', streamHandlerRole.Properties.RoleName); - policy_statements = streamHandlerRole.Properties.Policies[0].PolicyDocument.Statement; - assert.isObject( - policy_statements.find((s) => - _.isEqual(s.Action, [ - "dynamodb:GetRecords", - "dynamodb:GetShardIterator", - "dynamodb:DescribeStream", - "dynamodb:ListStreams"]) && - _.isEqual(s.Resource, [ - "arn:aws:dynamodb:us-east-1:1234567890:table/test/stream/2017-10-09T19:39:15.151"])), - 'stream statements included' - ); - assert.isObject(policy_statements.find((s) => s.Action[0] === "sns:Publish"), 'sns dlq statements included'); - const streamMapping = serverless.service.provider.compiledCloudFormationTemplate.Resources.StreamHandlerEventSourceMappingDynamodbTest; - assert.equal(streamMapping.DependsOn, "StreamHandlerIamRoleLambdaExecution"); - //verify sqsHandler should have SQS permissions - const sqsHandlerRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.SqsHandlerIamRoleLambdaExecution; - assertFunctionRoleName('sqsHandler', sqsHandlerRole.Properties.RoleName); - policy_statements = sqsHandlerRole.Properties.Policies[0].PolicyDocument.Statement; - JSON.stringify(policy_statements); - assert.isObject( - policy_statements.find((s) => - _.isEqual(s.Action, [ - "sqs:ReceiveMessage", - "sqs:DeleteMessage", - "sqs:GetQueueAttributes"]) && - _.isEqual(s.Resource, [ - "arn:aws:sqs:us-east-1:1234567890:MyQueue", - "arn:aws:sqs:us-east-1:1234567890:MyOtherQueue"])), - 'sqs statements included' - ); - assert.isObject(policy_statements.find((s) => s.Action[0] === "sns:Publish"), 'sns dlq statements included'); - const sqsMapping = serverless.service.provider.compiledCloudFormationTemplate.Resources.SqsHandlerEventSourceMappingSQSMyQueue; - assert.equal(sqsMapping.DependsOn, "SqsHandlerIamRoleLambdaExecution"); - //verify helloNoPerFunction should have global role - const helloNoPerFunctionResource = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloNoPerFunctionLambdaFunction; - assert.isTrue(helloNoPerFunctionResource.DependsOn.indexOf('IamRoleLambdaExecution') >= 0, 'function resource depends on global role'); - assert.equal(helloNoPerFunctionResource.Properties.Role["Fn::GetAtt"][0], 'IamRoleLambdaExecution', "function resource role is set to global role"); - //verify helloEmptyIamStatements - const helloEmptyIamStatementsRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloEmptyIamStatementsIamRoleLambdaExecution; - assertFunctionRoleName('helloEmptyIamStatements', helloEmptyIamStatementsRole.Properties.RoleName); - // tslint:disable-next-line:max-line-length - // assert.equal(helloEmptyIamStatementsRole.Properties.ManagedPolicyArns[0], 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole'); - const helloEmptyFunctionResource = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloEmptyIamStatementsLambdaFunction; - assert.isTrue(helloEmptyFunctionResource.DependsOn.indexOf('HelloEmptyIamStatementsIamRoleLambdaExecution') >= 0, 'function resource depends on role'); - assert.equal(helloEmptyFunctionResource.Properties.Role["Fn::GetAtt"][0], 'HelloEmptyIamStatementsIamRoleLambdaExecution', - "function resource role is set properly"); + describe('should create role per function', () => { + + beforeEach(() => plugin.createRolesPerFunction()); + + it('create simple role', () => { + const helloRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloIamRoleLambdaExecution; + assert.isNotEmpty(helloRole); + assertFunctionRoleName('hello', helloRole.Properties.RoleName); + assert.isEmpty(helloRole.Properties.ManagedPolicyArns, 'function resource role has no managed policy'); + + //check depends and role is set properly + const helloFunctionResource = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloLambdaFunction; + assert.isTrue(helloFunctionResource.DependsOn.indexOf('HelloIamRoleLambdaExecution') >= 0, 'function resource depends on role'); + assert.equal(helloFunctionResource.Properties.Role["Fn::GetAtt"][0], 'HelloIamRoleLambdaExecution', "function resource role is set properly"); + }); + + it('create role with iamRoleStatementsInherit', () => { + const helloInheritRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloInheritIamRoleLambdaExecution; + assertFunctionRoleName('helloInherit', helloInheritRole.Properties.RoleName); + const policy_statements: any[] = helloInheritRole.Properties.Policies[0].PolicyDocument.Statement; + assert.isObject(policy_statements.find((s) => s.Action[0] === "xray:PutTelemetryRecords"), 'global statements imported upon inherit'); + assert.isObject(policy_statements.find((s) => s.Action[0] === "dynamodb:GetItem"), 'per function statements imported upon inherit'); + }); + + it('create role for permission inferred from event [dynamodbstream]', () => { + const streamHandlerRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.StreamHandlerIamRoleLambdaExecution; + assertFunctionRoleName('streamHandler', streamHandlerRole.Properties.RoleName); + const policy_statements: any[] = streamHandlerRole.Properties.Policies[0].PolicyDocument.Statement; + assert.isObject( + policy_statements.find((s) => + _.isEqual(s.Action, [ + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:DescribeStream", + "dynamodb:ListStreams"]) && + _.isEqual(s.Resource, [ + "arn:aws:dynamodb:us-east-1:1234567890:table/test/stream/2017-10-09T19:39:15.151"])), + 'stream statements included', + ); + assert.isObject(policy_statements.find((s) => s.Action[0] === "sns:Publish"), 'sns dlq statements included'); + const streamMapping = serverless.service.provider.compiledCloudFormationTemplate.Resources.StreamHandlerEventSourceMappingDynamodbTest; + assert.equal(streamMapping.DependsOn, "StreamHandlerIamRoleLambdaExecution"); + + }); + + it('create role for permission inferred from event [sqs]', () => { + const sqsHandlerRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.SqsHandlerIamRoleLambdaExecution; + assertFunctionRoleName('sqsHandler', sqsHandlerRole.Properties.RoleName); + const policy_statements: any[] = sqsHandlerRole.Properties.Policies[0].PolicyDocument.Statement; + assert.isObject( + policy_statements.find((s) => + _.isEqual(s.Action, [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes"]) && + _.isEqual(s.Resource, [ + "arn:aws:sqs:us-east-1:1234567890:MyQueue", + "arn:aws:sqs:us-east-1:1234567890:MyOtherQueue"])), + 'sqs statements included', + ); + assert.isObject(policy_statements.find((s) => s.Action[0] === "sns:Publish"), 'sns dlq statements included'); + + const sqsMapping = serverless.service.provider.compiledCloudFormationTemplate.Resources.SqsHandlerEventSourceMappingSQSMyQueue; + assert.equal(sqsMapping.DependsOn, "SqsHandlerIamRoleLambdaExecution"); + }); + + it('ensure empty IAM statements are supported', () => { + const helloEmptyIamStatementsRole = + serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloEmptyIamStatementsIamRoleLambdaExecution; + assertFunctionRoleName('helloEmptyIamStatements', helloEmptyIamStatementsRole.Properties.RoleName); + + // tslint:disable-next-line:max-line-length + // assert.equal(helloEmptyIamStatementsRole.Properties.ManagedPolicyArns[0], 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole'); + const helloEmptyFunctionResource = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloEmptyIamStatementsLambdaFunction; + assert.isTrue(helloEmptyFunctionResource.DependsOn.indexOf( + 'HelloEmptyIamStatementsIamRoleLambdaExecution') >= 0, + 'function resource depends on role', + ); + assert.equal(helloEmptyFunctionResource.Properties.Role["Fn::GetAtt"][0], 'HelloEmptyIamStatementsIamRoleLambdaExecution', + "function resource role is set properly", + ); + }); }); it('should do nothing when no functions defined', () => { @@ -284,7 +309,7 @@ describe('plugin tests', function(this: any) { for (const key in serverless.service.provider.compiledCloudFormationTemplate.Resources) { if (key !== 'IamRoleLambdaExecution' && serverless.service.provider.compiledCloudFormationTemplate.Resources.hasOwnProperty(key)) { const resource = serverless.service.provider.compiledCloudFormationTemplate.Resources[key]; - if(resource.Type === "AWS::IAM::Role") { + if (resource.Type === "AWS::IAM::Role") { assert.fail(resource, undefined, "There shouldn't be extra roles beyond IamRoleLambdaExecution"); } } @@ -300,8 +325,8 @@ describe('plugin tests', function(this: any) { }); - describe('#throwErorr', () => { - it('should throw formated error', () => { + describe('#throwError', () => { + it('should throw formatted error', () => { try { plugin.throwError('msg :%s', 'testing'); assert.fail('expected error to be thrown'); @@ -316,10 +341,10 @@ describe('plugin tests', function(this: any) { }); - describe('defaultInherit set', () => { + describe('defaultInherit set', () => { let plugin: Plugin; - beforeEach(() => { + beforeEach(() => { //set defaultInherit _.set(serverless.service, "custom.serverless-iam-roles-per-function.defaultInherit", true); //change helloInherit to false for testing @@ -327,33 +352,160 @@ describe('plugin tests', function(this: any) { plugin = new Plugin(serverless); }); - describe('#constructor()', () => { - it('defaultInherit shuuld be true', () => { + describe('#constructor()', () => { + it('defaultInherit should be true', () => { assert.isTrue(plugin.defaultInherit); }); }); describe('#createRolesPerFunction', () => { - it('should create role per function', () => { + it('should create role per function with correct inheritance', () => { plugin.createRolesPerFunction(); + const helloRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloIamRoleLambdaExecution; assert.isNotEmpty(helloRole); assertFunctionRoleName('hello', helloRole.Properties.RoleName); - //check depends and role is set properlly + + //check depends and role is set properly const helloFunctionResource = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloLambdaFunction; assert.isTrue(helloFunctionResource.DependsOn.indexOf('HelloIamRoleLambdaExecution') >= 0, 'function resource depends on role'); - assert.equal(helloFunctionResource.Properties.Role["Fn::GetAtt"][0], 'HelloIamRoleLambdaExecution', "function resource role is set properly"); + assert.equal(helloFunctionResource.Properties.Role["Fn::GetAtt"][0], 'HelloIamRoleLambdaExecution', "function resource role is set properly"); let statements: any[] = helloRole.Properties.Policies[0].PolicyDocument.Statement; assert.isObject(statements.find((s) => s.Action[0] === "xray:PutTelemetryRecords"), 'global statements imported as defaultInherit is set'); assert.isObject(statements.find((s) => s.Action[0] === "dynamodb:GetItem"), 'per function statements imported upon inherit'); + const helloInheritRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloInheritIamRoleLambdaExecution; assertFunctionRoleName('helloInherit', helloInheritRole.Properties.RoleName); statements = helloInheritRole.Properties.Policies[0].PolicyDocument.Statement; assert.isObject(statements.find((s) => s.Action[0] === "dynamodb:GetItem"), 'per function statements imported'); - assert.isTrue(statements.find((s) => s.Action[0] === "xray:PutTelemetryRecords") === undefined, + assert.isTrue(statements.find((s) => s.Action[0] === "xray:PutTelemetryRecords") === undefined, 'global statements not imported as iamRoleStatementsInherit is false'); }); - }); + }); }); + describe('managedPolicy handling', () => { + let plugin: Plugin; + before(loadServerlessConfig(funcWithIamTemplateAndManagedPolicies, tempdir)); + + beforeEach(async () => { + serverless = await getServerlessInstance(funcWithIamTemplateAndManagedPolicies, tempdir); + plugin = new Plugin(serverless); + }); + + describe('#createRolesPerFunction', () => { + describe('should create role per function', () => { + + beforeEach(() => plugin.createRolesPerFunction()); + + it('create simple role', () => { + const helloRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloIamRoleLambdaExecution; + assert.isNotEmpty(helloRole); + assertFunctionRoleName('hello', helloRole.Properties.RoleName); + assert.isEmpty(helloRole.Properties.ManagedPolicyArns, 'function resource role has no managed policy'); + + //check depends and role is set properly + const helloFunctionResource = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloLambdaFunction; + assert.isTrue(helloFunctionResource.DependsOn.indexOf('HelloIamRoleLambdaExecution') >= 0, 'function resource depends on role'); + assert.equal(helloFunctionResource.Properties.Role["Fn::GetAtt"][0], 'HelloIamRoleLambdaExecution', "function resource role is set properly"); + }); + + it('create role with iamRoleStatementsInherit', () => { + const helloInheritRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloInheritIamRoleLambdaExecution; + assertFunctionRoleName('helloInherit', helloInheritRole.Properties.RoleName); + assert.deepEqual(helloInheritRole.Properties.ManagedPolicyArns, ['arn:aws:iam::aws:policy/AmazonRDSFullAccess'], 'managed policy was not inherited'); + }); + + it('create role for permission inferred from event [no inherit]', () => { + const streamHandlerRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.StreamHandlerIamRoleLambdaExecution; + assertFunctionRoleName('streamHandler', streamHandlerRole.Properties.RoleName); + assert.deepEqual( + streamHandlerRole.Properties.ManagedPolicyArns, + ['arn:aws:iam::aws:policy/AmazonKinesisFullAccess'], + 'iamManagedPolicies not applies', + ); + + const policy_statements: any[] = streamHandlerRole.Properties.Policies[0].PolicyDocument.Statement; + assert.isObject( + policy_statements.find((s) => + _.isEqual(s.Action, [ + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:DescribeStream", + "dynamodb:ListStreams"]) && + _.isEqual(s.Resource, [ + "arn:aws:dynamodb:us-east-1:1234567890:table/test/stream/2017-10-09T19:39:15.151"])), + 'stream statements included', + ); + assert.isObject(policy_statements.find((s) => s.Action[0] === "sns:Publish"), 'sns dlq statements included'); + const streamMapping = serverless.service.provider.compiledCloudFormationTemplate.Resources.StreamHandlerEventSourceMappingDynamodbTest; + assert.equal(streamMapping.DependsOn, "StreamHandlerIamRoleLambdaExecution"); + }); + + it('create role for permission inferred from event [inherit]', () => { + const sqsHandlerRole = serverless.service.provider.compiledCloudFormationTemplate.Resources.SqsHandlerIamRoleLambdaExecution; + assertFunctionRoleName('sqsHandler', sqsHandlerRole.Properties.RoleName); + assert.deepEqual( + sqsHandlerRole.Properties.ManagedPolicyArns, + ['arn:aws:iam::aws:policy/AmazonRDSFullAccess', 'arn:aws:iam::aws:policy/AmazonKinesisFullAccess'], + 'iamManagedPolicies not applies from function and/or inherited globally', + ); + const policy_statements: any[] = sqsHandlerRole.Properties.Policies[0].PolicyDocument.Statement; + + assert.isObject( + policy_statements.find((s) => + _.isEqual(s.Action, [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes"]) && + _.isEqual(s.Resource, [ + "arn:aws:sqs:us-east-1:1234567890:MyQueue", + "arn:aws:sqs:us-east-1:1234567890:MyOtherQueue"])), + 'sqs statements included', + ); + assert.isObject(policy_statements.find((s) => s.Action[0] === "sns:Publish"), 'sns dlq statements included'); + + const sqsMapping = serverless.service.provider.compiledCloudFormationTemplate.Resources.SqsHandlerEventSourceMappingSQSMyQueue; + assert.equal(sqsMapping.DependsOn, "SqsHandlerIamRoleLambdaExecution"); + }); + + it('ensure empty IAM managed are supported', () => { + const helloEmptyIamPolicyRole = + serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloEmptyIamPoliciesIamRoleLambdaExecution; + assertFunctionRoleName('helloEmptyIamPolicies', helloEmptyIamPolicyRole.Properties.RoleName); + + const helloEmptyFunctionResource = serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloEmptyIamPoliciesLambdaFunction; + assert.isTrue(helloEmptyFunctionResource.DependsOn.indexOf( + 'HelloEmptyIamPoliciesIamRoleLambdaExecution') >= 0, + 'function resource depends on role', + ); + assert.equal(helloEmptyFunctionResource.Properties.Role["Fn::GetAtt"][0], 'HelloEmptyIamPoliciesIamRoleLambdaExecution', + "function resource role is set properly", + ); + }); + + it('ensure no duplicated IAM managed policies', () => { + const helloDuplicateIamPolicyRole = + serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloDuplicateIamPoliciesIamRoleLambdaExecution; + assertFunctionRoleName('helloDuplicateIamPolicies', helloDuplicateIamPolicyRole.Properties.RoleName); + assert.deepEqual( + helloDuplicateIamPolicyRole.Properties.ManagedPolicyArns, + ['arn:aws:iam::aws:policy/AmazonRDSFullAccess', 'arn:aws:iam::aws:policy/AmazonKinesisFullAccess'], + 'managed policies were not merged correctly', + ); + }); + + it('ensure no duplicated vpc IAM managed policies', () => { + const helloNoDuplicateVpcIamPolicyRole = + serverless.service.provider.compiledCloudFormationTemplate.Resources.HelloNoDuplicateVpcIamPoliciesIamRoleLambdaExecution; + assertFunctionRoleName('helloNoDuplicateVpcIamPolicies', helloNoDuplicateVpcIamPolicyRole.Properties.RoleName); + assert.deepEqual( + helloNoDuplicateVpcIamPolicyRole.Properties.ManagedPolicyArns, + ['arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole'], + 'some issue with no AWSLambdaVPCAccessExecutionRole duplicate mechanism', + ); + }); + }); + }); + }); });