From ef0fa2d9cefce40da8ae859a1a5b223ec27d36b7 Mon Sep 17 00:00:00 2001 From: Erick Daniszewski Date: Tue, 23 Jun 2020 23:39:08 -0400 Subject: [PATCH 1/3] feat: add support for allowUnauthenticated and custom IAM definitions --- package/lib/compileFunctions.js | 46 +++ package/lib/compileFunctions.test.js | 455 +++++++++++++++++++++++++++ 2 files changed, 501 insertions(+) diff --git a/package/lib/compileFunctions.js b/package/lib/compileFunctions.js index d24afbf..1ff639b 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 (_.get(funcObject, 'allowUnauthenticated') === true) { + 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 (!_.size(funcTemplate.accessControl.gcpIamPolicy.bindings)) { + 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 (!binding.members || binding.members.length === 0) { + 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); + }); + }); }); }); From d3bd7ce93a53f368d00e3d27978d8de1b1712b94 Mon Sep 17 00:00:00 2001 From: Erick Daniszewski Date: Wed, 24 Jun 2020 08:43:10 -0400 Subject: [PATCH 2/3] fmt: formatting updates --- package/lib/compileFunctions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package/lib/compileFunctions.js b/package/lib/compileFunctions.js index 1ff639b..2ac7590 100644 --- a/package/lib/compileFunctions.js +++ b/package/lib/compileFunctions.js @@ -90,7 +90,7 @@ module.exports = { funcTemplate.properties.httpsTrigger = {}; funcTemplate.properties.httpsTrigger.url = url; - if (_.get(funcObject, 'allowUnauthenticated') === true) { + if (funcObject.allowUnauthenticated === true) { funcTemplate.accessControl.gcpIamPolicy.bindings = _.unionBy( [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }], funcTemplate.accessControl.gcpIamPolicy.bindings, @@ -109,7 +109,7 @@ module.exports = { funcTemplate.properties.eventTrigger.resource = resource; } - if (!_.size(funcTemplate.accessControl.gcpIamPolicy.bindings)) { + if (!funcTemplate.accessControl.gcpIamPolicy.bindings.length) { delete funcTemplate.accessControl; } @@ -186,7 +186,7 @@ const validateIamProperty = (funcObject, functionName) => { ].join(''); throw new Error(errorMessage); } - if (!binding.members || binding.members.length === 0) { + 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', From d679b3775cd3a2ccb5fcf59d9b7fecb48f5fc0b0 Mon Sep 17 00:00:00 2001 From: Erick Daniszewski Date: Wed, 24 Jun 2020 09:45:14 -0400 Subject: [PATCH 3/3] fmt: update formatting --- package/lib/compileFunctions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/lib/compileFunctions.js b/package/lib/compileFunctions.js index 2ac7590..b318d47 100644 --- a/package/lib/compileFunctions.js +++ b/package/lib/compileFunctions.js @@ -90,7 +90,7 @@ module.exports = { funcTemplate.properties.httpsTrigger = {}; funcTemplate.properties.httpsTrigger.url = url; - if (funcObject.allowUnauthenticated === true) { + if (funcObject.allowUnauthenticated) { funcTemplate.accessControl.gcpIamPolicy.bindings = _.unionBy( [{ role: 'roles/cloudfunctions.invoker', members: ['allUsers'] }], funcTemplate.accessControl.gcpIamPolicy.bindings,