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..ccf9a20 --- /dev/null +++ b/package/lib/createIamRoles.js @@ -0,0 +1,153 @@ +'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 + resourceSpecificRoles.forEach((iamObject) => { + const role = getCustomRoleTemplate(projectId, serviceName, iamObject); + deploymentResources.push(role); + deploymentResources.push( + getIamMemberTemplate(projectId, serviceAccountEmail, role, iamObject) + ); + }); + }, +}; + +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, ROLE_NAME_MAX_LENGTH - 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: {