From b88edb6641b0626f9cdd77ac414a236029d9e694 Mon Sep 17 00:00:00 2001 From: Eric Rabinowitz Date: Sat, 15 Oct 2022 22:04:46 -0400 Subject: [PATCH 1/3] feat: configure custom IAM roles with set permissions --- package/googlePackage.js | 6 +- package/lib/createIamRoles.js | 152 ++++++++++++++++++++++++++++++++++ provider/googleProvider.js | 45 +++++++++- 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 package/lib/createIamRoles.js diff --git a/package/googlePackage.js b/package/googlePackage.js index 7fbb0a7..566d34b 100644 --- a/package/googlePackage.js +++ b/package/googlePackage.js @@ -10,6 +10,7 @@ const prepareDeployment = require('./lib/prepareDeployment'); const saveCreateTemplateFile = require('./lib/writeFilesToDisk'); const mergeServiceResources = require('./lib/mergeServiceResources'); const generateArtifactDirectoryName = require('./lib/generateArtifactDirectoryName'); +const createIamRoles = require('./lib/createIamRoles'); const compileFunctions = require('./lib/compileFunctions'); const saveUpdateTemplateFile = require('./lib/writeFilesToDisk'); @@ -54,6 +55,7 @@ class GooglePackage { prepareDeployment, saveCreateTemplateFile, generateArtifactDirectoryName, + createIamRoles, compileFunctions, mergeServiceResources, saveUpdateTemplateFile @@ -72,7 +74,9 @@ class GooglePackage { .then(this.saveCreateTemplateFile), 'before:package:compileFunctions': () => - BbPromise.bind(this).then(this.generateArtifactDirectoryName), + BbPromise.bind(this) + .then(this.generateArtifactDirectoryName) + .then(this.createIamRoles), 'package:compileFunctions': () => BbPromise.bind(this).then(this.compileFunctions), diff --git a/package/lib/createIamRoles.js b/package/lib/createIamRoles.js new file mode 100644 index 0000000..b1573ce --- /dev/null +++ b/package/lib/createIamRoles.js @@ -0,0 +1,152 @@ +'use strict'; + +const _ = require('lodash'); + +module.exports = { + createIamRoles() { + const provider = this.serverless.service.provider; + const iamConfig = provider.iam; + if (!iamConfig || !iamConfig.permissions) return; + + if (provider.serviceAccountEmail) { + throw new Error('Cannot set both iam permissions and serviceAccountEmail on provider') + } + + const projectId = provider.project; + const serviceName = `${this.serverless.service.service}-${this.options.stage}`; + const serviceAccountName = `sls-${serviceName}`; + const serviceAccountEmail = `${serviceAccountName}@${projectId}.iam.gserviceaccount.com`; + + provider.serviceAccountEmail = serviceAccountEmail; + + const deploymentResources = + this.serverless.service.provider.compiledConfigurationTemplate.resources; + + // Create Cloud Function identity service account to assign custom IAM roles to + deploymentResources.push({ + type: 'iam.v1.serviceAccount', + name: serviceAccountName, + properties: { + accountId: serviceAccountName, + displayName: serviceAccountName, + description: `Generated service account for Serverless project ${serviceName}`, + }, + }); + + // Collect all permissions that don't apply to a specific resource + const [permissions, resourceSpecificRoles] = _.partition( + iamConfig.permissions, + (item) => typeof item === 'string' + ); + + // Create and assign custom role for permissions without a resource + if (permissions.length > 0) { + const iamObject = { permissions, projectId }; + const role = getCustomRoleTemplate(projectId, serviceName, iamObject); + deploymentResources.push(role); + deploymentResources.push( + getIamMemberTemplate(projectId, serviceAccountEmail, role, iamObject) + ); + } + + // Create and assign custom role(s) for each specific resource resource + resourceSpecificRoles.forEach((iamObject) => { + const role = getCustomRoleTemplate(projectId, serviceName, iamObject); + deploymentResources.push(role); + deploymentResources.push( + getIamMemberTemplate(projectId, serviceAccountEmail, role, iamObject) + ); + }); + }, +}; + +const getCustomRoleTemplate = (project, serviceName, config) => { + const namePrefix = serviceName.slice(0, 48).replaceAll('-', '_'); + const nameSuffix = getResourceRoleSuffix(config) + .replace(/[^a-zA-Z_]/g, '') + .slice(0, 64 - namePrefix.length); + const name = `${namePrefix}_${nameSuffix}`; + + return { + type: 'gcp-types/iam-v1:projects.roles', + name, + properties: { + parent: `projects/${project}`, + roleId: name, + role: { + title: name, + description: 'Generated IAM role for Serverless project ${serviceName}', + stage: 'GA', + includedPermissions: config.permissions, + }, + }, + }; +}; + +const getIamMemberTemplate = (project, serviceAccountEmail, role, config) => { + const { type, resource } = getIamMembershipResourceType(config); + + return { + type, + name: `${role.name}_members`, + properties: { + ...resource, + role: `projects/${project}/roles/${role.properties.roleId}`, + member: `serviceAccount:${serviceAccountEmail}`, + }, + }; +}; + +const getResourceRoleSuffix = (config) => { + if (config.bucket) return `gcs_${config.bucket}`; + if (config.organizationId) return `org_${config.organizationId}`; + if (config.folderId) return `fol_${config.folderId}`; + if (config.projectId) return `pro_${config.projectId}`; + if (config.cloudFunction) return `gcf_${config.cloudFunction}`; + return ''; +}; + +const getIamMembershipResourceType = (config) => { + if (config.bucket) { + return { + type: 'gcp-types/storage-v1:virtual.buckets.iamMemberBinding', + resource: { + bucket: config.bucket, + }, + }; + } + if (config.organizationId) { + return { + type: 'gcp-types/cloudresourcemanager-v1:virtual.organizations.iamMemberBinding', + resource: { + resource: config.organizationId, + }, + }; + } + if (config.folderId) { + return { + type: 'gcp-types/cloudresourcemanager-v2:virtual.folders.iamMemberBinding', + resource: { + resource: config.folderId, + }, + }; + } + if (config.projectId) { + return { + type: 'gcp-types/cloudresourcemanager-v1:virtual.projects.iamMemberBinding', + resource: { + resource: config.projectId, + }, + }; + } + if (config.cloudFunction) { + return { + type: 'gcp-types/cloudfunctions-v1:virtual.projects.locations.functions.iamMemberBinding', + resource: { + resource: config.cloudFunction, + }, + }; + } + + throw new Error('IAM resource type not supported'); +}; diff --git a/provider/googleProvider.js b/provider/googleProvider.js index 00bbd85..b0ce62c 100644 --- a/provider/googleProvider.js +++ b/provider/googleProvider.js @@ -130,8 +130,50 @@ class GoogleProvider { }, additionalProperties: false, }, + iamCustomRoles: { + type: 'object', + properties: { + permissions: { + type: 'array', + items: { + anyOf: [ + { + $ref: '#/definitions/iamPermissionsOnResource', + }, + { + type: 'string', + }, + ], + }, + }, + }, + additionalProperties: false, + }, + iamPermissionsOnResource: { + type: 'object', + properties: { + bucket: { type: 'string' }, + organizationId: { type: 'string' }, + folderId: { type: 'string' }, + projectId: { type: 'string' }, + cloudFunction: { type: 'string' }, + permissions: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + additionalProperties: false, + oneOf: [ + { required: ['bucket', 'permissions'] }, + { required: ['organizationId', 'permissions'] }, + { required: ['folderId', 'permissions'] }, + { required: ['projectId', 'permissions'] }, + { required: ['cloudFunction', 'permissions'] }, + ] + }, }, - provider: { properties: { credentials: { type: 'string' }, @@ -146,6 +188,7 @@ class GoogleProvider { vpc: { type: 'string' }, // Can be overridden by function configuration vpcEgress: { $ref: '#/definitions/cloudFunctionVpcEgress' }, // Can be overridden by function configuration labels: { $ref: '#/definitions/resourceManagerLabels' }, // Can be overridden by function configuration + iam: { $ref: '#/definitions/iamCustomRoles' }, }, }, function: { From 0e67fe22cb67dd24a67d3423d82697892eaaab7f Mon Sep 17 00:00:00 2001 From: Eric Rabinowitz Date: Sun, 16 Oct 2022 10:41:59 -0400 Subject: [PATCH 2/3] chore: extract constant --- package/lib/createIamRoles.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package/lib/createIamRoles.js b/package/lib/createIamRoles.js index b1573ce..909903d 100644 --- a/package/lib/createIamRoles.js +++ b/package/lib/createIamRoles.js @@ -49,7 +49,7 @@ module.exports = { ); } - // Create and assign custom role(s) for each specific resource resource + // Create and assign custom role(s) for each specific resource resourceSpecificRoles.forEach((iamObject) => { const role = getCustomRoleTemplate(projectId, serviceName, iamObject); deploymentResources.push(role); @@ -60,12 +60,13 @@ module.exports = { }, }; +const ROLE_NAME_MAX_LENGTH = 64; const getCustomRoleTemplate = (project, serviceName, config) => { const namePrefix = serviceName.slice(0, 48).replaceAll('-', '_'); const nameSuffix = getResourceRoleSuffix(config) .replace(/[^a-zA-Z_]/g, '') - .slice(0, 64 - namePrefix.length); - const name = `${namePrefix}_${nameSuffix}`; + .slice(0, ROLE_NAME_MAX_LENGTH - namePrefix.length); + const name = `${namePrefix}${nameSuffix}`; return { type: 'gcp-types/iam-v1:projects.roles', From 12b7557406133f5c58b8aa88c0bc86092355fc37 Mon Sep 17 00:00:00 2001 From: Eric Rabinowitz Date: Sun, 16 Oct 2022 13:00:36 -0400 Subject: [PATCH 3/3] fix: use template string --- package/lib/createIamRoles.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/lib/createIamRoles.js b/package/lib/createIamRoles.js index 909903d..ccf9a20 100644 --- a/package/lib/createIamRoles.js +++ b/package/lib/createIamRoles.js @@ -76,7 +76,7 @@ const getCustomRoleTemplate = (project, serviceName, config) => { roleId: name, role: { title: name, - description: 'Generated IAM role for Serverless project ${serviceName}', + description: `Generated IAM role for Serverless project ${serviceName}`, stage: 'GA', includedPermissions: config.permissions, },