diff --git a/package/lib/compileFunctions.js b/package/lib/compileFunctions.js index d24afbf..b318d47 100644 --- a/package/lib/compileFunctions.js +++ b/package/lib/compileFunctions.js @@ -24,6 +24,7 @@ module.exports = { validateHandlerProperty(funcObject, functionName); validateEventsProperty(funcObject, functionName); validateVpcConnectorProperty(funcObject, functionName); + validateIamProperty(funcObject, functionName); const funcTemplate = getFunctionTemplate( funcObject, @@ -51,6 +52,11 @@ module.exports = { _.get(this, 'serverless.service.provider.environment'), funcObject.environment // eslint-disable-line comma-dangle ); + funcTemplate.accessControl.gcpIamPolicy.bindings = _.unionBy( + _.get(funcObject, 'iam.bindings'), + _.get(this, 'serverless.service.provider.iam.bindings'), + 'role' + ); if (!funcTemplate.properties.serviceAccountEmail) { delete funcTemplate.properties.serviceAccountEmail; @@ -83,6 +89,14 @@ module.exports = { funcTemplate.properties.httpsTrigger = {}; funcTemplate.properties.httpsTrigger.url = url; + + if (funcObject.allowUnauthenticated) { + funcTemplate.accessControl.gcpIamPolicy.bindings = _.unionBy( + [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }], + funcTemplate.accessControl.gcpIamPolicy.bindings, + 'role' + ); + } } if (eventType === 'event') { const type = funcObject.events[0].event.eventType; @@ -95,6 +109,10 @@ module.exports = { funcTemplate.properties.eventTrigger.resource = resource; } + if (!funcTemplate.accessControl.gcpIamPolicy.bindings.length) { + delete funcTemplate.accessControl; + } + this.serverless.service.provider.compiledConfigurationTemplate.resources.push(funcTemplate); }); @@ -157,6 +175,29 @@ const validateVpcConnectorProperty = (funcObject, functionName) => { } }; +const validateIamProperty = (funcObject, functionName) => { + if (_.get(funcObject, 'iam.bindings') && funcObject.iam.bindings.length > 0) { + funcObject.iam.bindings.forEach((binding) => { + if (!binding.role) { + const errorMessage = [ + `The function "${functionName}" has no role specified for an IAM binding.`, + ' Each binding requires a role. For details on supported roles, see the documentation', + ' at: https://cloud.google.com/iam/docs/understanding-roles', + ].join(''); + throw new Error(errorMessage); + } + if (!Array.isArray(binding.members) || !binding.members.length) { + const errorMessage = [ + `The function "${functionName}" has no members specified for an IAM binding.`, + ' Each binding requires at least one member to be assigned. See the IAM documentation', + ' for details on configuring members: https://cloud.google.com/iam/docs/overview', + ].join(''); + throw new Error(errorMessage); + } + }); + } +}; + const getFunctionTemplate = (funcObject, projectName, region, sourceArchiveUrl) => { //eslint-disable-line return { @@ -171,5 +212,10 @@ const getFunctionTemplate = (funcObject, projectName, region, sourceArchiveUrl) function: funcObject.name, sourceArchiveUrl, }, + accessControl: { + gcpIamPolicy: { + bindings: [], + }, + }, }; }; diff --git a/package/lib/compileFunctions.test.js b/package/lib/compileFunctions.test.js index cfdc6f4..a4c764d 100644 --- a/package/lib/compileFunctions.test.js +++ b/package/lib/compileFunctions.test.js @@ -766,5 +766,460 @@ describe('CompileFunctions', () => { ).toEqual(compiledResources); }); }); + + it('should throw an error if an IAM policy binding has no role', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + iam: { + bindings: [ + { + members: ['foobar'], + }, + ], + }, + }, + }; + + expect(() => googlePackage.compileFunctions()).toThrow(Error); + }); + + it('should throw an error if an IAM policy binding has no members defined', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + iam: { + bindings: [ + { + role: 'foobar', + }, + ], + }, + }, + }; + + expect(() => googlePackage.compileFunctions()).toThrow(Error); + }); + + it('should throw an error if an IAM policy binding has 0 members', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + iam: { + bindings: [ + { + role: 'foobar', + members: [], + }, + ], + }, + }, + }; + + expect(() => googlePackage.compileFunctions()).toThrow(Error); + }); + + it('should add the cloudfunctions.invoker role for allUsers when allowUnauthenticated is set for "http" event', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + allowUnauthenticated: true, + }, + }; + + const compiledResources = [ + { + type: 'gcp-types/cloudfunctions-v1:projects.locations.functions', + name: 'my-service-dev-func1', + properties: { + parent: 'projects/myProject/locations/us-central1', + runtime: 'nodejs8', + function: 'my-service-dev-func1', + entryPoint: 'func1', + availableMemoryMb: 256, + timeout: '60s', + sourceArchiveUrl: 'gs://sls-my-service-dev-12345678/some-path/artifact.zip', + httpsTrigger: { + url: 'foo', + }, + labels: {}, + }, + accessControl: { + gcpIamPolicy: { + bindings: [ + { + role: 'roles/cloudfunctions.invoker', + members: ['allUsers'], + }, + ], + }, + }, + }, + ]; + + return googlePackage.compileFunctions().then(() => { + expect(consoleLogStub.calledOnce).toEqual(true); + expect( + googlePackage.serverless.service.provider.compiledConfigurationTemplate.resources + ).toEqual(compiledResources); + }); + }); + + it('should not add the cloudfunctions.invoker role for allUsers when allowUnauthenticated is set for "event" event', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [ + { + event: { + eventType: 'foo', + resource: 'some-resource', + }, + }, + ], + allowUnauthenticated: true, + }, + }; + + const compiledResources = [ + { + type: 'gcp-types/cloudfunctions-v1:projects.locations.functions', + name: 'my-service-dev-func1', + properties: { + parent: 'projects/myProject/locations/us-central1', + runtime: 'nodejs8', + function: 'my-service-dev-func1', + entryPoint: 'func1', + availableMemoryMb: 256, + timeout: '60s', + sourceArchiveUrl: 'gs://sls-my-service-dev-12345678/some-path/artifact.zip', + eventTrigger: { + eventType: 'foo', + resource: 'some-resource', + }, + labels: {}, + }, + }, + ]; + + return googlePackage.compileFunctions().then(() => { + expect(consoleLogStub.calledOnce).toEqual(true); + expect( + googlePackage.serverless.service.provider.compiledConfigurationTemplate.resources + ).toEqual(compiledResources); + }); + }); + + it('should add the custom IAM policy bindings based on function configuration', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + iam: { + bindings: [ + { + role: 'roles/foobar', + members: ['some-user'], + }, + ], + }, + }, + }; + + const compiledResources = [ + { + type: 'gcp-types/cloudfunctions-v1:projects.locations.functions', + name: 'my-service-dev-func1', + properties: { + parent: 'projects/myProject/locations/us-central1', + runtime: 'nodejs8', + function: 'my-service-dev-func1', + entryPoint: 'func1', + availableMemoryMb: 256, + timeout: '60s', + sourceArchiveUrl: 'gs://sls-my-service-dev-12345678/some-path/artifact.zip', + httpsTrigger: { + url: 'foo', + }, + labels: {}, + }, + accessControl: { + gcpIamPolicy: { + bindings: [ + { + role: 'roles/foobar', + members: ['some-user'], + }, + ], + }, + }, + }, + ]; + + return googlePackage.compileFunctions().then(() => { + expect(consoleLogStub.calledOnce).toEqual(true); + expect( + googlePackage.serverless.service.provider.compiledConfigurationTemplate.resources + ).toEqual(compiledResources); + }); + }); + + it('should add the custom IAM policy bindings based on the provider configuration', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + }, + }; + googlePackage.serverless.service.provider.iam = { + bindings: [ + { + role: 'roles/foobar', + members: ['some-user'], + }, + ], + }; + + const compiledResources = [ + { + type: 'gcp-types/cloudfunctions-v1:projects.locations.functions', + name: 'my-service-dev-func1', + properties: { + parent: 'projects/myProject/locations/us-central1', + runtime: 'nodejs8', + function: 'my-service-dev-func1', + entryPoint: 'func1', + availableMemoryMb: 256, + timeout: '60s', + sourceArchiveUrl: 'gs://sls-my-service-dev-12345678/some-path/artifact.zip', + httpsTrigger: { + url: 'foo', + }, + labels: {}, + }, + accessControl: { + gcpIamPolicy: { + bindings: [ + { + role: 'roles/foobar', + members: ['some-user'], + }, + ], + }, + }, + }, + ]; + + return googlePackage.compileFunctions().then(() => { + expect(consoleLogStub.calledOnce).toEqual(true); + expect( + googlePackage.serverless.service.provider.compiledConfigurationTemplate.resources + ).toEqual(compiledResources); + }); + }); + + it('should add the custom IAM policy bindings based on the merged provider and function configuration', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + iam: { + bindings: [ + { + role: 'role2', + members: ['user1'], + }, + { + role: 'role3', + members: ['user4', 'user2'], + }, + ], + }, + }, + }; + googlePackage.serverless.service.provider.iam = { + bindings: [ + { + role: 'role1', + members: ['user1'], + }, + { + role: 'role2', + members: ['user1', 'user2', 'user3'], + }, + ], + }; + + const compiledResources = [ + { + type: 'gcp-types/cloudfunctions-v1:projects.locations.functions', + name: 'my-service-dev-func1', + properties: { + parent: 'projects/myProject/locations/us-central1', + runtime: 'nodejs8', + function: 'my-service-dev-func1', + entryPoint: 'func1', + availableMemoryMb: 256, + timeout: '60s', + sourceArchiveUrl: 'gs://sls-my-service-dev-12345678/some-path/artifact.zip', + httpsTrigger: { + url: 'foo', + }, + labels: {}, + }, + accessControl: { + gcpIamPolicy: { + bindings: [ + { + role: 'role2', + members: ['user1'], + }, + { + role: 'role3', + members: ['user4', 'user2'], + }, + { + role: 'role1', + members: ['user1'], + }, + ], + }, + }, + }, + ]; + + return googlePackage.compileFunctions().then(() => { + expect(consoleLogStub.calledOnce).toEqual(true); + expect( + googlePackage.serverless.service.provider.compiledConfigurationTemplate.resources + ).toEqual(compiledResources); + }); + }); + + it('should merge the allowUnauthenticated binding with custom bindings', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + allowUnauthenticated: true, + iam: { + bindings: [ + { + role: 'role1', + members: ['user1'], + }, + ], + }, + }, + }; + + const compiledResources = [ + { + type: 'gcp-types/cloudfunctions-v1:projects.locations.functions', + name: 'my-service-dev-func1', + properties: { + parent: 'projects/myProject/locations/us-central1', + runtime: 'nodejs8', + function: 'my-service-dev-func1', + entryPoint: 'func1', + availableMemoryMb: 256, + timeout: '60s', + sourceArchiveUrl: 'gs://sls-my-service-dev-12345678/some-path/artifact.zip', + httpsTrigger: { + url: 'foo', + }, + labels: {}, + }, + accessControl: { + gcpIamPolicy: { + bindings: [ + { + role: 'roles/cloudfunctions.invoker', + members: ['allUsers'], + }, + { + role: 'role1', + members: ['user1'], + }, + ], + }, + }, + }, + ]; + + return googlePackage.compileFunctions().then(() => { + expect(consoleLogStub.calledOnce).toEqual(true); + expect( + googlePackage.serverless.service.provider.compiledConfigurationTemplate.resources + ).toEqual(compiledResources); + }); + }); + + it('should merge the allowUnauthenticated binding with custom bindings with same role', () => { + googlePackage.serverless.service.functions = { + func1: { + handler: 'func1', + events: [{ http: 'foo' }], + allowUnauthenticated: true, + iam: { + bindings: [ + { + role: 'role1', + members: ['user1'], + }, + { + role: 'roles/cloudfunctions.invoker', + members: ['user1', 'user2'], + }, + ], + }, + }, + }; + + const compiledResources = [ + { + type: 'gcp-types/cloudfunctions-v1:projects.locations.functions', + name: 'my-service-dev-func1', + properties: { + parent: 'projects/myProject/locations/us-central1', + runtime: 'nodejs8', + function: 'my-service-dev-func1', + entryPoint: 'func1', + availableMemoryMb: 256, + timeout: '60s', + sourceArchiveUrl: 'gs://sls-my-service-dev-12345678/some-path/artifact.zip', + httpsTrigger: { + url: 'foo', + }, + labels: {}, + }, + accessControl: { + gcpIamPolicy: { + bindings: [ + { + role: 'roles/cloudfunctions.invoker', + members: ['allUsers'], + }, + { + role: 'role1', + members: ['user1'], + }, + ], + }, + }, + }, + ]; + + return googlePackage.compileFunctions().then(() => { + expect(consoleLogStub.calledOnce).toEqual(true); + expect( + googlePackage.serverless.service.provider.compiledConfigurationTemplate.resources + ).toEqual(compiledResources); + }); + }); }); });