diff --git a/src/lib/index.ts b/src/lib/index.ts index 6f025ae..e4f183e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -14,6 +14,7 @@ class ServerlessIamPerFunctionPlugin { hooks: {[i: string]: () => void}; serverless: any; awsPackagePlugin: any; + defaultCreateCustomerManagedPolicy: boolean; defaultInherit: boolean; readonly PROVIDER_AWS = 'aws'; @@ -39,6 +40,7 @@ class ServerlessIamPerFunctionPlugin { [PLUGIN_NAME]: { type: 'object', properties: { + defaultCreateCustomerManagedPolicy: { type: 'boolean' }, defaultInherit: { type: 'boolean' }, iamGlobalPermissionsBoundary: { $ref: '#/definitions/awsArn' }, }, @@ -53,6 +55,7 @@ class ServerlessIamPerFunctionPlugin { if (this.serverless.configSchemaHandler.defineFunctionProperties) { this.serverless.configSchemaHandler.defineFunctionProperties(this.PROVIDER_AWS, { properties: { + iamRoleCreateCustomerManagedPolicy: { type: 'boolean' }, iamRoleStatementsInherit: { type: 'boolean' }, iamRoleStatementsName: { type: 'string' }, iamPermissionsBoundary: { $ref: '#/definitions/awsArn' }, @@ -65,6 +68,9 @@ class ServerlessIamPerFunctionPlugin { this.hooks = { 'before:package:finalize': this.createRolesPerFunction.bind(this), }; + + const policyKey = `custom.${PLUGIN_NAME}.defaultCreateCustomerManagedPolicy`; + this.defaultCreateCustomerManagedPolicy = _.get(this.serverless.service, policyKey, false); this.defaultInherit = _.get(this.serverless.service, `custom.${PLUGIN_NAME}.defaultInherit`, false); } @@ -177,6 +183,15 @@ class ServerlessIamPerFunctionPlugin { const functionResource = this.serverless.service.provider .compiledCloudFormationTemplate.Resources[functionResourceName]; + if (typeof functionResource.Properties.Role === 'string') { + functionResource.Properties.Role = { + 'Fn::GetAtt': [ + globalRoleName, + 'Arn', + ], + }; + } + if (_.isEmpty(functionResource) || _.isEmpty(functionResource.Properties) || _.isEmpty(functionResource.Properties.Role) @@ -271,6 +286,39 @@ class ServerlessIamPerFunctionPlugin { return res; } + /** + * Create a Customer Managed Policy with the policy statement of the lambda function to attach to the role. + * @param {*} functionName + * @param {*} roleName + * @param {*} template + * @param {*} policyStatements + * @returns void + */ + createCustomerManagedPolicy(functionName: string, roleName: string, template: any, policyStatements: Statement[]) { + const stackName = this.serverless.providers.aws.naming.getStackName(); + const managedPolicyName = `${stackName}-${functionName}-${this.serverless.service.provider.region}-policy`; + + const managedPolicy = { + 'Type': 'AWS::IAM::ManagedPolicy', + 'Properties': { + 'ManagedPolicyName': managedPolicyName, + 'PolicyDocument': { + 'Version': '2012-10-17', + 'Statement': policyStatements, + }, + 'Roles': [ + { + 'Ref': roleName, + }, + ], + }, + }; + + const normalizedIdentifier = this.serverless.providers.aws.naming.getNormalizedFunctionName(functionName) + + template['Resources'][normalizedIdentifier + 'CustomerManagedPolicy'] = managedPolicy; + } + /** * Will check if function has a definition of iamRoleStatements. * If so will create a new Role for the function based on these statements. @@ -293,10 +341,33 @@ class ServerlessIamPerFunctionPlugin { // 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); + const functionIamRole = _.cloneDeep(globalIamRole) || { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + Service: [ + 'lambda.amazonaws.com', + ], + }, + Action: [ + 'sts:AssumeRole', + ], + }, + ], + }, + Policies: [], + }, + }; // remove the statements const policyStatements: Statement[] = []; - functionIamRole.Properties.Policies[0].PolicyDocument.Statement = policyStatements; + const defaultRolePolicy = functionIamRole.Properties.Policies[0]; + // clean policies + functionIamRole.Properties.Policies = []; // set log statements policyStatements[0] = { Effect: 'Allow', @@ -378,11 +449,27 @@ class ServerlessIamPerFunctionPlugin { 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; const functionResourceName = this.updateFunctionResourceRole(functionName, roleResourceName, globalRoleName); functionToRoleMap.set(functionResourceName, roleResourceName); + + const isCustomerManagedPolicy = functionObject.iamRoleCreateCustomerManagedPolicy + || (this.defaultCreateCustomerManagedPolicy && functionObject.iamRoleCreateCustomerManagedPolicy !== false); + + if (isCustomerManagedPolicy) { + this.createCustomerManagedPolicy( + functionName, + roleResourceName, + this.serverless.service.provider.compiledCloudFormationTemplate, + policyStatements, + ); + } else if (defaultRolePolicy) { + defaultRolePolicy.PolicyDocument.Statement = policyStatements; + functionIamRole.Properties.Policies.push(defaultRolePolicy); + } } /** diff --git a/src/test/funcs-with-iam.json b/src/test/funcs-with-iam.json index 24f2517..cdcdeef 100644 --- a/src/test/funcs-with-iam.json +++ b/src/test/funcs-with-iam.json @@ -116,6 +116,23 @@ "events": [], "name": "test-permissions-boundary-hello", "package": {} + }, + "helloCustomerManagedPolicy": { + "handler": "handler.hello", + "iamRoleCreateCustomerManagedPolicy": true, + "iamRoleStatements": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem" + ], + "Resource": "arn:aws:dynamodb:us-east-1:*:table/test" + } + ], + "events": [], + "name": "test-python-dev-hello-customer-managed-policy", + "package": {}, + "vpc": {} } }, "resources": { diff --git a/src/test/index.test.ts b/src/test/index.test.ts index b8660c9..b971808 100644 --- a/src/test/index.test.ts +++ b/src/test/index.test.ts @@ -377,6 +377,8 @@ describe('plugin tests', function(this: any) { beforeEach(() => { // set defaultInherit _.set(serverless.service, 'custom.serverless-iam-roles-per-function.defaultInherit', true); + // set defaultCreateCustomerManagedPolicy + _.set(serverless.service, 'custom.serverless-iam-roles-per-function.defaultCreateCustomerManagedPolicy', true); // change helloInherit to false for testing _.set(serverless.service, 'functions.helloInherit.iamRoleStatementsInherit', false); plugin = new Plugin(serverless); @@ -406,7 +408,9 @@ describe('plugin tests', function(this: any) { 'HelloIamRoleLambdaExecution', 'function resource role is set properly', ); - let statements: any[] = helloRole.Properties.Policies[0].PolicyDocument.Statement; + + const helloCustomerManagedPolicy = compiledResources.HelloCustomerManagedPolicy; + let statements: any[] = helloCustomerManagedPolicy.Properties.PolicyDocument.Statement; assert.isObject( statements.find((s) => s.Action[0] === 'xray:PutTelemetryRecords'), 'global statements imported as defaultInherit is set', @@ -417,7 +421,9 @@ describe('plugin tests', function(this: any) { ); const helloInheritRole = compiledResources.HelloInheritIamRoleLambdaExecution; assertFunctionRoleName('helloInherit', helloInheritRole.Properties.RoleName); - statements = helloInheritRole.Properties.Policies[0].PolicyDocument.Statement; + + const helloInheritCustomerManagedPolicy = compiledResources.HelloInheritCustomerManagedPolicy; + statements = helloInheritCustomerManagedPolicy.Properties.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'); @@ -443,6 +449,40 @@ describe('plugin tests', function(this: any) { const policyName = defaultIamRoleLambdaExecution.Properties.PermissionsBoundary['Fn::Sub']; assert.equal(policyName, 'arn:aws:iam::xxxxx:policy/permissions_boundary'); }) + + it('should add policy document to a Customer Managed Policy', () => { + const compiledResources = serverless.service.provider.compiledCloudFormationTemplate.Resources; + + plugin.createRolesPerFunction(); + + const helloCustomerManagedPolicyCustomerManagedPolicy = + compiledResources.HelloCustomerManagedPolicyCustomerManagedPolicy; + + const managedPolicyName = helloCustomerManagedPolicyCustomerManagedPolicy.Properties.ManagedPolicyName; + assert.equal(managedPolicyName, 'test-service-dev-helloCustomerManagedPolicy-us-east-1-policy'); + + const policyDocument = helloCustomerManagedPolicyCustomerManagedPolicy.Properties.PolicyDocument; + assert.equal(policyDocument.Version, '2012-10-17'); + assert.lengthOf(policyDocument.Statement, 3); + assert.equal(policyDocument.Statement[1].Action[0], 'xray:PutTelemetryRecords'); + assert.equal(policyDocument.Statement[2].Action[0], 'dynamodb:GetItem'); + + const roles = helloCustomerManagedPolicyCustomerManagedPolicy.Properties.Roles; + assert.equal(roles[0].Ref, 'HelloCustomerManagedPolicyIamRoleLambdaExecution'); + + const helloInheritCustomerManagedPolicy = compiledResources.HelloInheritCustomerManagedPolicy; + + const inheritPolicyName = helloInheritCustomerManagedPolicy.Properties.ManagedPolicyName; + assert.equal(inheritPolicyName, 'test-service-dev-helloInherit-us-east-1-policy'); + + const inheritPolicyDocument = helloInheritCustomerManagedPolicy.Properties.PolicyDocument; + assert.equal(inheritPolicyDocument.Version, '2012-10-17'); + assert.lengthOf(inheritPolicyDocument.Statement, 2); + assert.equal(inheritPolicyDocument.Statement[1].Action[0], 'dynamodb:GetItem'); + + const inheritRoles = helloInheritCustomerManagedPolicy.Properties.Roles; + assert.equal(inheritRoles[0].Ref, 'HelloInheritIamRoleLambdaExecution'); + }) }); });