UNPKG

@kumologica/builder

Version:

Kumologica build and deploy module

666 lines (583 loc) 23.7 kB
const jsonata = require('jsonata'); const apiTrigger = require('./triggers/api'); const websocketTrigger = require('./triggers/websocket'); const sqsTrigger = require('./triggers/sqs'); const snsTrigger = require('./triggers/sns'); const s3Trigger = require('./triggers/s3'); const dynamodbTrigger = require('./triggers/dynamodb'); const kinesisTrigger = require('./triggers/kinesis'); const eventTrigger = require('./triggers/event'); const partnerEventTrigger = require('./triggers/partnerEvent'); class AWSCFTemplate { constructor() {} /* * Creates cloud formation template file for current lambda: * It is composed of: * - lambda definition * - lambda's role * - lambda's policy * - if event subscriptions confirmed then event source mappings for: * - dynamodb stream * - sns * - kinesis * - if flow nodes are provided then * - role added for each downstream aws node * * params: * functionName * zipFileName * roleName * deploymentBucketName * description * runtime * timeout * memory * reservedConcurrency * tracingConfig * role-arn * environment * tags * kmsKeyArn * vpcConfig * deadLetterConfig * fileSystemConfigs * layers */ createCfTemplate(params, flow) { let ts = new Date(); let template = { AWSTemplateFormatVersion: '2010-09-09', Resources: {}, Outputs: { LambdaArn: { Description: 'The Arn of the kumologica flow lambda.', Value: { 'Fn::GetAtt': ['Lambda', 'Arn'] } } } }; //Code: { // S3Bucket: params.deploymentBucketName, // S3Key: params.zipFileName //}, let lambda = { Type: "AWS::Lambda::Function", Properties: { FunctionName: params.functionName, Handler: "lambda.handler", Role: { "Fn::GetAtt": [ "LambdaRole", "Arn" ] }, Runtime: params.runtime || "nodejs20.x", Timeout: params.timeout || 30, MemorySize: params.memory || 128, Environment: { Variables: {} }, Tags: [ { Key : "Kumologica-ts", Value : `${ts.toISOString()}` } ] } }; template.Resources['Lambda'] = lambda; let lambdaRole = { Type: 'AWS::IAM::Role', Properties: { RoleName: params.roleName, AssumeRolePolicyDocument: { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Principal: { Service: ['lambda.amazonaws.com'] }, Action: ['sts:AssumeRole'] } ] }, Path: '/', Policies: [ { PolicyName: 'AWSLambdaBasicExecutionPolicy', PolicyDocument: { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Action: [ 'logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents' ], Resource: '*' }, { Effect: "Allow", Action: "ssm:GetParameter", Resource: { "Fn::Sub": "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/kumologica/license" } }, { Effect: "Allow", Action: "kms:Decrypt", Resource: "*" } ] } } ] } }; const xRayPolicy = { PolicyName: 'AWSLambdaXRayPolicy', PolicyDocument: { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Action: [ 'xray:PutTraceSegments', 'xray:PutTelemetryRecords' ], Resource: '*' } ] } }; const vpcPolicy = { PolicyName: 'KLVPCPolicy', PolicyDocument: { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Action: [ 'ec2:DescribeNetworkInterfaces', 'ec2:CreateNetworkInterface', 'ec2:DeleteNetworkInterface', 'ec2:DescribeInstances', 'ec2:AttachNetworkInterface' ], Resource: '*' } ] } }; if (params.logRetentionDays) { template.Resources.LambdaLogGroup = { Type: "AWS::Logs::LogGroup", Properties: { LogGroupName: "/aws/lambda/" + params.functionName, RetentionInDays: params.logRetentionDays || 365 } } } // // this flag is used whe cloudformation prepare is not used, for example // command run from the designer. Designer uses createStack command // and requires cf script to in after cf prepare state (bucket and key present). // if (params.skipPrepare && params.skipPrepare === "true") { lambda.Properties.Code = { S3Bucket: params.deploymentBucketName, S3Key: params.zipFileName } } else { lambda.Properties.Code = params.zipFileName; } if (params.description) { lambda.Properties.Description = params.description; } if (params.architectures) { lambda.Properties.Architectures = params.architectures; } if (params.tracingConfig) { lambdaRole.Properties.Policies.push(xRayPolicy); lambda.Properties.TracingConfig = params.tracingConfig; } if (params.environment) { lambda.Properties.Environment = params.environment; } if (params.tags) { for (let key in params.tags) { lambda.Properties.Tags.push({'Key': key, 'Value': params.tags[key]}); } } if (params["role-arn"]) { lambda.Properties.Role = params["role-arn"]; } if (params.reservedConcurrency) { lambda.Properties.ReservedConcurrentExecutions = params.reservedConcurrency; } if (params.kmsKeyArn) { lambda.Properties.KmsKeyArn = params.kmsKeyArn; } if (params.vpcConfig) { lambdaRole.Properties.Policies.push(vpcPolicy); lambda.Properties.VpcConfig = params.vpcConfig; } if (params.deadLetterConfig) { lambda.Properties.DeadLetterConfig = params.deadLetterConfig; } if (params.fileSystemConfigs) { lambda.Properties.FileSystemConfigs = params.fileSystemConfigs; } if (params.layers) { lambda.Properties.Layers = params.layers; } // buffer for resources - action mapping to generate policy statements at the end let resources = {}; if (params.triggers) { template.Resources = Object.assign(template.Resources, this.handleTriggers(params, resources, flow)); } if (!params["role-arn"]) { template.Resources['LambdaRole'] = lambdaRole; // adding all outbound services operations to iam role if (flow) { this.env = params.environment? params.environment.Variables: {}; let nodes = flow.filter(node => this.validNodeType(node)); //if (nodes !nodes.length) { // console.log('nothing to process') // return; //} for (var i=0; i<nodes.length; i++) { switch(nodes[i].type) { case 'Dynamo DB': if (nodes[i].indexName && nodes[i].indexName.length > 0) { let tableArn = this.mapValue(nodes[i].tableArn, nodes[i], params.strictMode); let index = this.mapValue(nodes[i].indexName, nodes[i], params.strictMode); this.addResourceAction(resources, `dynamodb:${nodes[i].operation}`, tableArn + "/index/" + index); } else { let tableArn = this.mapValue(nodes[i].tableArn, nodes[i], params.strictMode); this.addResourceAction(resources, `dynamodb:${nodes[i].operation}`, tableArn); } // index break; case 'SQS': let queueArn = this.mapQueueUrlToArn(this.mapValue(nodes[i].QueueUrl, nodes[i], params.strictMode)); this.addResourceAction(resources, `sqs:${nodes[i].operation}`, queueArn); break; case 'Lambda': let lambdaArn = this.mapValue(nodes[i].LambdaArn, nodes[i], params.strictMode); this.addResourceAction(resources, `lambda:InvokeFunction`, lambdaArn); break; case 'SNS': let topic = "*"; if (nodes[i].PublishTopic && nodes[i].operation === "PublishTarget") { topic = this.mapValue(nodes[i].PublishTopic, nodes[i], params.strictMode); } else if (nodes[i].TargetARN && ["Push", "Publish"].includes(nodes[i].operation)) { topic = this.mapValue(nodes[i].TargetARN, nodes[i], params.strictMode); } const operation = ["Push", "Publish", "PublishSMS", "PublishTarget"].includes(nodes[i].operation)? "Publish": nodes[i].operation; this.addResourceAction(resources, `sns:${operation}`, topic); break; case 'SES': this.addResourceAction(resources, `ses:${nodes[i].operation}`, '*'); break; case 'SSM': let key; if (nodes[i].operation === "GetParametersByPath") { key = this.mapValue(nodes[i].Path, nodes[i], params.strictMode); } else { key = this.mapValue(nodes[i].Key, nodes[i], params.strictMode); } if (key.startsWith('/')) { key = key.substring(1); } const ssmArn = {'Fn::Sub': 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/' + key}; this.addResourceAction(resources, `ssm:${nodes[i].operation}`, ssmArn); if (nodes[i].operation === "GetParameter" && nodes[i].Key.includes("/aws/reference/secretsmanager/")) { const smArn = {'Fn::Sub': 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:*'}; this.addResourceAction(resources, "secretsmanager:GetSecretValue", smArn); } break; case 'S3': const bucketName = this.mapValue(nodes[i].Bucket, nodes[i], params.strictMode); const bucketArn = `arn:aws:s3:::${bucketName}/*`; this.addResourceAction(resources, `s3:${nodes[i].operation}`, bucketArn); if (nodes[i].operation === "ListObjects") { this.addResourceAction(resources, "s3:ListBucket", `arn:aws:s3:::${bucketName}`); } break; case 'Cloudwatch': let source = this.mapValue(nodes[i].Source, nodes[i], params.strictMode); this.addResourceAction(resources, `event:${nodes[i].operation}`, source); break; case 'Rekognition': const resource = this.mapRekognitionResource(nodes[i].operation, nodes[i]); this.addResourceAction(resources, `rekognition:${nodes[i].operation}`, resource); if (['DetectModerationLabels', 'DetectText', 'DetectLabels', 'DetectFaces', 'IndexFaces', 'RecognizeCelebrities', 'SearchFacesByImage', 'StartStreamProcessor', 'StopStreamProcessor'].includes(nodes[i].operation)) { const bucketName = this.mapValue(nodes[i].Image, nodes[i], params.strictMode); const bucketArn = `arn:aws:s3:::${bucketName}/*`; this.addResourceAction(resources, `s3:Get*`, bucketArn); this.addResourceAction(resources, `s3:List*`, bucketArn); } break; default: throw new Error(`Unsupported node type: ${node[i].type}, unable to generate IAM Policy.`); } } let additionalPolicy; if (params.policy) { try { additionalPolicy = params.policy; if (!(additionalPolicy instanceof Array)) { additionalPolicy = [additionalPolicy]; } } catch(err) { throw new Error(`Parameter policy: ${params.policy} is not in JSON format.`); } } if (resources && Object.entries(resources).length > 0) { let policy = this.createPolicy(resources); if (additionalPolicy) { policy.PolicyDocument.Statement.push(...additionalPolicy); } lambdaRole.Properties.Policies.push(policy); } else { if (additionalPolicy) { let policy = this.createPolicy(null); policy.PolicyDocument.Statement.push(...additionalPolicy); lambdaRole.Properties.Policies.push(policy); } } } } if (template.Resources.ApiGW) { template.Outputs.RestApiId = { Description: 'The id of rest api gateway created in this script.', Value: { 'Fn::GetAtt': ['ApiGW', 'RestApiId'] } } template.Outputs.RootResourceId = { Description: 'The root resource id of rest api gateway created in this script.', Value: { 'Fn::GetAtt': ['ApiGW', 'RootResourceId'] } } } return template; } // Method iterates through triggers and sets up relevant resources: // event mappings or specific resource artefacts handleTriggers(params, resourcesActions, flow) { let resources = {}; for (let i=0; i<params.triggers.length; i++) { switch(Object.keys(params.triggers[i])[0]) { case 'event': resources = Object.assign(resources, eventTrigger.trigger(params.triggers[i].event)); break; case 'partnerEvent': resources = Object.assign(resources, partnerEventTrigger.trigger(params.triggers[i].partnerEvent)); break; case 'api': resources = Object.assign(resources, apiTrigger.trigger(params.triggers[i].api)); break; case 'websocket': resources = Object.assign(resources, websocketTrigger.trigger(params.triggers[i].websocket, flow)); this.addResourceAction(resourcesActions, "execute-api:ManageConnections", { "Fn::Sub": "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:" + (params.triggers[i].websocket.apiId === "create new"? "${ApiGW}": params.triggers[i].websocket.apiId) + "/*"}) break; case 'sqs': resources = Object.assign(resources, sqsTrigger.trigger(params.functionName, params.triggers[i])); for (let action of ['sqs:ReceiveMessage', 'sqs:DeleteMessage', 'sqs:GetQueueAttributes']) { this.addResourceAction(resourcesActions, action, params.triggers[i].sqs.queueArn); } break; case 'dynamodb': resources = Object.assign(resources, dynamodbTrigger.trigger(params.functionName, params.triggers[i])); for (let action of [ `dynamodb:ListShards`, `dynamodb:ListStreams`, `dynamodb:DescribeStream`, `dynamodb:GetRecords`, `dynamodb:GetShardIterator`]) { this.addResourceAction(resourcesActions, action, params.triggers[i].dynamodb.streamArn); } break; case 's3': resources = Object.assign(resources, s3Trigger.trigger(params.functionName, params.triggers[i])); break; case 'sns': resources = Object.assign(resources, snsTrigger.trigger(params.functionName, params.triggers[i])); break; case 'kinesis': for (let action of [ 'kinesis:DescribeStream', 'kinesis:DescribeStreamSummary', 'kinesis:GetRecords', 'kinesis:GetShardIterator', 'kinesis:ListShards', 'kinesis:ListStreams', 'kinesis:SubscribeToShard']) { this.addResourceAction(resourcesActions, action, params.triggers[i].kinesis.streamArn); } resources = Object.assign(resources, kinesisTrigger.trigger(params.functionName, params.triggers[i])); break; } } return resources; } validNodeType(node) { return node && node.type && ['Rekognition', 'S3', 'SQS', 'Cloudwatch', 'Dynamo DB', 'SNS', 'SES', 'SSM', 'Lambda'].includes(node.type); } /** * Creates Kumologica policy based on passed resources array. * Policy contains statements to allows specified actions * for each resource in array. * * @param {array} resources */ createPolicy(resources) { let policy = { PolicyName: "KumologicaPolicy", PolicyDocument: { Version: "2012-10-17", Statement: [] } }; if (resources) { policy.PolicyDocument.Statement = Object.entries(resources) .map(([resource, actions]) => ( { Effect: "Allow", Resource: [JSON.parse(resource)], Action: actions } )); } return policy; } mapQueueUrlToArn(queueUrl) { const q = this.mapValue(queueUrl); // https://sqs.region.amazonaws.com/account/queuename let urlparts = q.split('/'); if (urlparts.length != 5) { throw new Error(`Unable to handle queue url: ${q} expected format https://sqs.region.amazonaws.com/account/queuename`); } let region = urlparts[2].split('.')[1]; return `arn:aws:sqs:${region}:${urlparts[3]}:${urlparts[4]}`; } /** * Function adds new action for specified resource or * instantiates new resource with action if not yet provided * * @param {array} resources the buffer of resource actions (array) * @param {String} action Specific action (f.e. s3:GetObject) * @param {Object} resource Resource arn or formula (Sub, Ref ...) */ addResourceAction(resources, action, resource) { const resourceString = JSON.stringify(resource); const actions = resources[resourceString]; if (!actions) { resources[resourceString] = [action]; } else { if (!actions.includes(action)) { actions.push(action); } } } /** * This is dynamic expression evaluation function that uses jsonata. * It is simplified version of evaluateDynamicField function from runtime util.js. * The version below only supports environment variables in expressions. * * In strict mode if value contains references to msg. or vars. error is returned. * Non strict mode replaces such a value with '*' and displays warning. * * If any error occurs during evaluation or evaluation returns no value * the original value (key) is returned * * @param {String} value - either literal string or jsonata expression with env reference */ mapValue(value, node, strictMode) { // data contains all objects for sourcing data for expression: // in plugin case only env are supported var data = {}; data.env = this.env; // verify that expression has no msg. and vars. references nor is empty // in strict mode. If so reject the export if (!value || value === "" || value.includes('msg.') || value.includes('vars.')) { if (strictMode) { throw new Error(`Resource reference contains unknown parameters, unable to determine value. Node: id=${node.id} name: ${node.name} type: ${node.type} value: ${value}`); } else { // display warning console.warn(`Warning: Resource reference contains unknown parameters, strict mode disabled, replacing resource reference with "*", node: id=${node.id} name: ${node.name} type: ${node.type}`); return '*'; } } let response; try { let expression = jsonata(value); response = expression.evaluate(data); if (!response) { response = value; } } catch (Error) { response = value; } return this.sanitizeValue(response); } /** * docs: https://docs.aws.amazon.com/rekognition/latest/dg/api-permissions-reference.html * * @param {*} operation * @param {*} node */ mapRekognitionResource(operation, node) { switch (operation) { case 'CreateCollection': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/'+ node.CollectionId; case 'DeleteCollection': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/'+ node.CollectionId; case 'DeleteFaces': return 'arn:aws:rekognition:$${AWS::Region}:${AWS::AccountId}:collection/$' + node.CollectionId; case 'DescribeCollection': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/'+ node.CollectionId; case 'IndexFaces': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/' + node.CollectionId; case 'ListCollections': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:*'; case 'ListFaces': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/' + node.CollectionId; case 'SearchFaces': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/' + node.CollectionId; case 'SearchFacesByImage': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:collection/' + node.CollectionId; case 'CreateStreamProcessor': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:*'; //case 'CreateStreamProcessor': return `arn:aws:rekognition:${region}:${accountId}:collection/collection-id`; //`arn:aws:rekognition:${region}:${accountId}:streamprocessor/stream-processor-name` case 'DeleteStreamProcessor': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:streamprocessor/' + node.Name; case 'ListStreamProcessors': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:streamprocessor/' + node.Name; case 'StartStreamProcessor': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:streamprocessor/' + node.Name; case 'StopStreamProcessor': return 'arn:aws:rekognition:${AWS::Region}:${AWS::AccountId}:streamprocessor/' + node.Name; case 'CompareFaces': return '*'; case 'DetectFaces': return '*'; case 'DetectLabels': return '*'; case 'DetectModerationLabels': return '*'; case 'DetectText': return '*'; case 'GetCelebrityInfo': return '*'; case 'RecognizeCelebrities': return '*'; case 'GetCelebrityRecognition': return '*'; case 'GetContentModeration': return '*'; case 'GetFaceDetection': return '*'; case 'GetLabelDetection': return '*'; case 'GetPersonTracking': return '*'; case 'StartCelebrityRecognition': return '*'; case 'StartContentModeration': return '*'; case 'StartFaceDetection': return '*'; case 'StartFaceSearch': return '*'; case 'StartLabelDetection': return '*'; case 'StartPersonTracking': return "*"; default: return "*"; } } // Removes strings in quotes, they will cause cloudformation script to be invalid sanitizeValue(value) { if (value && value.trim) { let v = value.trim(); if (v.charAt(0) === '"' && v.charAt(v.length -1) === '"') { return v.substr(1,v.length -2); } } return value; } } module.exports = AWSCFTemplate;