UNPKG

serverless-iam-roles-per-function

Version:

A Serverless plugin to define IAM Role statements as part of the function definition block

582 lines (519 loc) 24.2 kB
import {assert} from 'chai'; import Plugin from '../lib/index'; import _ from 'lodash'; import os from 'os'; import fs from 'fs'; import path from 'path'; const Serverless = require('serverless/lib/Serverless'); const funcWithIamTemplate = require('../../src/test/funcs-with-iam.json'); 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 packageFile = path.join(dir, funcWithIamTemplate.package.artifact); fs.writeFileSync(packageFile, 'test123'); console.log('### serverless version: %s ###', (new Serverless()).version); }); beforeEach(async () => { serverless = new Serverless(); serverless.cli = new serverless.classes.CLI(); // Since serverless 2.24.0 processInput function doesn't exist if (serverless.cli.processInput) { 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 compileHooks: any[] = serverless.pluginManager.getHooks('package:setupProviderConfiguration'); compileHooks = compileHooks.concat( serverless.pluginManager.getHooks('package:compileFunctions'), serverless.pluginManager.getHooks('package:compileEvents')); for (const ent of compileHooks) { try { await ent.hook(); } catch (error) { console.log('failed running compileFunction hook: [%s] with error: ', ent, error); assert.fail(); } } }); /** * @param {string} name * @param {*} roleNameObj * @returns void */ 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', () => { let plugin: Plugin; beforeEach(async () => { plugin = new Plugin(serverless); }); describe('#constructor()', () => { it('should initialize the plugin', () => { assert.instanceOf(plugin, Plugin); }); it('should NOT initialize the plugin for non AWS providers', () => { assert.throws(() => new Plugin({ service: { provider: { name: 'not-aws' } } })); }); it('defaultInherit should be false', () => { assert.isFalse(plugin.defaultInherit); }); }); const statements = [{ Effect: 'Allow', Action: [ 'xray:PutTelemetryRecords', 'xray:PutTraceSegments', ], Resource: '*', }]; describe('#validateStatements', () => { it('should validate valid statement', () => { assert.doesNotThrow(() => {plugin.validateStatements(statements);}); }); it('should throw an error for invalid statement', () => { const badStatement = [{ // missing effect Action: [ 'xray:PutTelemetryRecords', 'xray:PutTraceSegments', ], Resource: '*', }]; assert.throws(() => {plugin.validateStatements(badStatement);}); }); it('should throw an error for non array type of statement', () => { const badStatement = { // missing effect Action: [ 'xray:PutTelemetryRecords', 'xray:PutTraceSegments', ], Resource: '*', }; assert.throws(() => {plugin.validateStatements(badStatement);}); }); }); describe('#getRoleNameLength', () => { it('Should calculate the accurate role name length us-east-1', () => { serverless.service.provider.region = 'us-east-1'; const functionName = 'a'.repeat(10); const nameParts = [ 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 functionName, // 'a'.repeat(10), length 10 : 34 'lambdaRole', // lambdaRole, length 10 : 44 ]; const roleNameLength = plugin.getRoleNameLength(nameParts); const expected = 44; // 12 + 3 + 9 + 10 + 10 == 44 assert.equal(roleNameLength, expected + nameParts.length - 1); }); it('Should calculate the accurate role name length ap-northeast-1', () => { serverless.service.provider.region = 'ap-northeast-1'; const functionName = 'a'.repeat(10); const nameParts = [ serverless.service.service, // test-service , length of 12 serverless.service.provider.stage, // dev, length of 3 { Ref: 'AWS::Region' }, // ap-northeast-1, length 14 functionName, // 'a'.repeat(10), length 10 'lambdaRole', // lambdaRole, length 10 ]; const roleNameLength = plugin.getRoleNameLength(nameParts); const expected = 49; // 12 + 3 + 14 + 10 + 10 == 49 assert.equal(roleNameLength, expected + nameParts.length - 1); }); it('Should calculate the actual length for a non AWS::Region ref to maintain backward compatibility', () => { serverless.service.provider.region = 'ap-northeast-1'; const functionName = 'a'.repeat(10); const nameParts = [ serverless.service.service, // test-service , length of 12 { Ref: 'bananas'}, // bananas, length of 7 { Ref: 'AWS::Region' }, // ap-northeast-1, length 14 functionName, // 'a'.repeat(10), length 10 'lambdaRole', // lambdaRole, length 10 ]; const roleNameLength = plugin.getRoleNameLength(nameParts); const expected = 53; // 12 + 7 + 14 + 10 + 10 == 53 assert.equal(roleNameLength, expected + nameParts.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 nameParts = roleName['Fn::Join'][1]; assert.equal(nameParts[nameParts.length - 1], 'lambdaRole'); }); it('should throw an error on long name', () => { const longName = 'long-long-long-long-long-long-long-long-long-long-long-long-long-name'; assert.throws(() => {plugin.getFunctionRoleName(longName);}); try { plugin.getFunctionRoleName(longName); } catch (error) { // some validation that the error we throw is what we expect const msg: string = error.message; assert.isString(msg); assert.isTrue(msg.startsWith('serverless-iam-roles-per-function: ERROR:')); assert.isTrue(msg.includes(longName)); assert.isTrue(msg.endsWith('iamRoleStatementsName.')); } }); it('should throw with invalid Fn:Join statement', () => { assert.throws(() => { const longName = 'test-name'; const invalidRoleName = { 'Fn::Join': [], }; const slsMock = { service: { provider: { name: 'aws', }, }, providers: { aws: { naming: { getRoleName: () => invalidRoleName } }, }, }; (new Plugin(slsMock)).getFunctionRoleName(longName); }); }); it('should return a name without "lambdaRole"', () => { 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 name += 'a'.repeat(64 - len + 1); roleName = plugin.getFunctionRoleName(name); assertFunctionRoleName(name, roleName); const nameParts = roleName['Fn::Join'][1]; assert.notEqual(nameParts[nameParts.length - 1], 'lambdaRole'); }); }); describe('#createRolesPerFunction', () => { it('should create role per function', () => { plugin.createRolesPerFunction(); const compiledResources = serverless.service.provider.compiledCloudFormationTemplate.Resources; const helloRole = compiledResources.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 = compiledResources.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 = compiledResources.HelloInheritIamRoleLambdaExecution; assertFunctionRoleName('helloInherit', helloInheritRole.Properties.RoleName); let policyStatements: any[] = helloInheritRole.Properties.Policies[0].PolicyDocument.Statement; assert.isObject( policyStatements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords'), 'global statements imported upon inherit', ); assert.isObject( policyStatements.find((s) => s.Action[0] === 'dynamodb:GetItem'), 'per function statements imported upon inherit', ); const streamHandlerRole = compiledResources.StreamHandlerIamRoleLambdaExecution; assertFunctionRoleName('streamHandler', streamHandlerRole.Properties.RoleName); policyStatements = streamHandlerRole.Properties.Policies[0].PolicyDocument.Statement; assert.isObject( policyStatements.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(policyStatements.find((s) => s.Action[0] === 'sns:Publish'), 'sns dlq statements included'); const streamMapping = compiledResources.StreamHandlerEventSourceMappingDynamodbTest; assert.equal(streamMapping.DependsOn, 'StreamHandlerIamRoleLambdaExecution'); // verify sqsHandler should have SQS permissions const sqsHandlerRole = compiledResources.SqsHandlerIamRoleLambdaExecution; assertFunctionRoleName('sqsHandler', sqsHandlerRole.Properties.RoleName); policyStatements = sqsHandlerRole.Properties.Policies[0].PolicyDocument.Statement; JSON.stringify(policyStatements); assert.isObject( policyStatements.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(policyStatements.find((s) => s.Action[0] === 'sns:Publish'), 'sns dlq statements included'); const sqsMapping = compiledResources.SqsHandlerEventSourceMappingSQSMyQueue; assert.equal(sqsMapping.DependsOn, 'SqsHandlerIamRoleLambdaExecution'); // verify helloNoPerFunction should have global role const helloNoPerFunctionResource = compiledResources.HelloNoPerFunctionLambdaFunction; // role is the default role generated by the framework assert.isFalse( 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 = compiledResources.HelloEmptyIamStatementsIamRoleLambdaExecution; assertFunctionRoleName('helloEmptyIamStatements', helloEmptyIamStatementsRole.Properties.RoleName); // assert.equal( // helloEmptyIamStatementsRole.Properties.ManagedPolicyArns[0], // 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole', // ); const helloEmptyFunctionResource = compiledResources.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', () => { const compiledResources = serverless.service.provider.compiledCloudFormationTemplate.Resources; serverless.service.functions = {}; serverless.service.resources = {}; plugin.createRolesPerFunction(); for (const key in compiledResources) { if (key !== 'IamRoleLambdaExecution' && Object.prototype.hasOwnProperty.call(compiledResources, key)) { const resource = compiledResources[key]; if (resource.Type === 'AWS::IAM::Role') { assert.fail(resource, undefined, 'There shouldn\'t be extra roles beyond IamRoleLambdaExecution'); } } } }); it('should throw when external role is defined', () => { _.set(serverless.service, 'functions.hello.role', 'arn:${AWS::Partition}:iam::0123456789:role/Test'); assert.throws(() => { plugin.createRolesPerFunction(); }); }); }); describe('#throwErorr', () => { it('should throw formatted error', () => { try { plugin.throwError('msg :%s', 'testing'); assert.fail('expected error to be thrown'); } catch (error) { const msg: string = error.message; assert.isString(msg); assert.isTrue(msg.startsWith('serverless-iam-roles-per-function: ERROR:')); assert.isTrue(msg.includes('testing')); } }); }); }); describe('defaultInherit set', () => { let plugin: Plugin; beforeEach(() => { // set defaultInherit _.set(serverless.service, 'custom.serverless-iam-roles-per-function.defaultInherit', true); // change helloInherit to false for testing _.set(serverless.service, 'functions.helloInherit.iamRoleStatementsInherit', false); plugin = new Plugin(serverless); }); describe('#constructor()', () => { it('defaultInherit should be true', () => { assert.isTrue(plugin.defaultInherit); }); }); describe('#createRolesPerFunction', () => { it('should create role per function', () => { const compiledResources = serverless.service.provider.compiledCloudFormationTemplate.Resources; plugin.createRolesPerFunction(); const helloRole = compiledResources.HelloIamRoleLambdaExecution; assert.isNotEmpty(helloRole); assertFunctionRoleName('hello', helloRole.Properties.RoleName); // check depends and role is set properlly const helloFunctionResource = compiledResources.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', ); 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 = compiledResources.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, 'global statements not imported as iamRoleStatementsInherit is false'); }); it('should add permission policy arn when there is iamPermissionsBoundary defined', () => { const compiledResources = serverless.service.provider.compiledCloudFormationTemplate.Resources; plugin.createRolesPerFunction(); const helloPermissionsBoundaryIamRole = compiledResources.HelloPermissionsBoundaryIamRoleLambdaExecution; const policyName = helloPermissionsBoundaryIamRole.Properties.PermissionsBoundary['Fn::Sub']; assert.equal(policyName, 'arn:aws:iam::xxxxx:policy/your_permissions_boundary_policy'); }) it('should add permission policy arn when there is iamGlobalPermissionsBoundary defined', () => { const compiledResources = serverless.service.provider.compiledCloudFormationTemplate.Resources; serverless.service.custom['serverless-iam-roles-per-function'] = { iamGlobalPermissionsBoundary: { 'Fn::Sub': 'arn:aws:iam::xxxxx:policy/permissions_boundary', }, }; plugin.createRolesPerFunction(); const defaultIamRoleLambdaExecution = compiledResources.IamRoleLambdaExecution; const policyName = defaultIamRoleLambdaExecution.Properties.PermissionsBoundary['Fn::Sub']; assert.equal(policyName, 'arn:aws:iam::xxxxx:policy/permissions_boundary'); }) }); }); describe('support new provider.iam property', () => { const getLambdaTestStatements = (): any[] => { const plugin = new Plugin(serverless); const compiledResources = serverless.service.provider.compiledCloudFormationTemplate.Resources; plugin.createRolesPerFunction(); const helloInherit = compiledResources.HelloInheritIamRoleLambdaExecution; assert.isNotEmpty(helloInherit); return helloInherit.Properties.Policies[0].PolicyDocument.Statement; } it('no global iam and iamRoleStatements properties', () => { _.set(serverless.service, 'provider.iam', undefined); _.set(serverless.service, 'provider.iamRoleStatements', undefined); const statements = getLambdaTestStatements(); assert.isTrue(statements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords') === undefined, 'provider.iamRoleStatements values shouldn\'t exists'); assert.isObject( statements.find((s) => s.Action[0] === 'dynamodb:GetItem'), 'per function statements imported upon inherit', ); }); describe('new iam property takes precedence over old iamRoleStatements property', () => { it('empty iam object', () => { _.set(serverless.service, 'provider.iam', {}); const statements = getLambdaTestStatements(); assert.isTrue(statements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords') === undefined, 'provider.iamRoleStatements values shouldn\'t exists'); assert.isObject( statements.find((s) => s.Action[0] === 'dynamodb:GetItem'), 'per function statements imported upon inherit', ); }); it('no role property', () => { _.set(serverless.service, 'provider.iam', { deploymentRole: 'arn:aws:iam::123456789012:role/deploy-role', }); const statements = getLambdaTestStatements(); assert.isTrue(statements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords') === undefined, 'provider.iamRoleStatements values shouldn\'t exists'); assert.isObject( statements.find((s) => s.Action[0] === 'dynamodb:GetItem'), 'per function statements imported upon inherit', ); }); it('role property set to role ARN', () => { _.set(serverless.service, 'provider.iam', { role: 'arn:aws:iam::0123456789:role//my/default/path/roleInMyAccount', }); const statements = getLambdaTestStatements(); assert.isTrue(statements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords') === undefined, 'provider.iamRoleStatements values shouldn\'t exists'); assert.isObject( statements.find((s) => s.Action[0] === 'dynamodb:GetItem'), 'per function statements imported upon inherit', ); }); it('role is set without statements', () => { _.set(serverless.service, 'provider.iam', { role: { managedPolicies: ['arn:aws:iam::123456789012:user/*'], }, }); const statements = getLambdaTestStatements(); assert.isTrue(statements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords') === undefined, 'provider.iamRoleStatements values shouldn\'t exists'); assert.isObject( statements.find((s) => s.Action[0] === 'dynamodb:GetItem'), 'per function statements imported upon inherit', ); }); it('empty statements', () => { _.set(serverless.service, 'provider.iam', { role: { statements: [], }, }); const statements = getLambdaTestStatements(); assert.isTrue(statements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords') === undefined, 'provider.iamRoleStatements values shouldn\'t exists'); assert.isObject( statements.find((s) => s.Action[0] === 'dynamodb:GetItem'), 'per function statements imported upon inherit', ); }); }); it('global iam role statements exists in lambda role statements', () => { _.set(serverless.service, 'provider.iam', { role: { statements: [{ Effect: 'Allow', Action: [ 'ec2:CreateNetworkInterface', ], Resource: '*', }], }, }); const statements = getLambdaTestStatements(); assert.isObject( statements.find((s) => s.Action[0] === 'ec2:CreateNetworkInterface'), 'global iam role statements exists', ); assert.isTrue(statements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords') === undefined, 'old provider.iamRoleStatements shouldn\'t exists'); assert.isObject( statements.find((s) => s.Action[0] === 'dynamodb:GetItem'), 'per function statements imported upon inherit', ); }); }); });