Thursday, December 8, 2016

Slack slash integration to Lambda microservice

A recent personal project of mine was to create a slack slash integration with an AWS Lambda microservice.  My use case for this was to create a slack slash command called "healthcheck" to quickly check-in on an application’s health status (via a REST API endpoint that would be available for an application).

My local environment had the following: 
OSX El Capitan 10.11.6
node --version v7.2.0

Detailed Steps:
Create a slack slash-command
  1. Navigate to my.slack.com/services/new/slash-commands, choose the slack team you want to add the slash-command integration to, and login
  2. Name your custom command, mine was "/healthcheck"
  3. Configure Integration Settings
    • Command: /healthcheck
    • URL: Use a dummy test REST API for now, like https://jsonplaceholder.typicode.com/posts.  <Fill the real REST API URL later after we create the AWS Lambda microservice>
    • Method: POST
    • Token: <Generated from slack, will be used with AWS Lambda microservice>
    • Customize Name: healthcheckuser
    • Customize Icon: Used the cherryshoe icon
    • Click "Save Integration"
Prerequisites you'll need to do prior to creating Lambda microservice
  1. Create an IAM role to grant external identities access to your AWS account
    1. I created a role called "healthcheckrole"
  2. Create a Key Management Service (KMS) key.  Followed these instructions to create a KMS key:
    1. Create a new Customer Master Key (CMK key)
      1. Click on "Create key"
      2. Enter in key alias and description
        1. Alias: Slack_healthcheck_key
        2. Description: Slack health to lambda service key
        3. Key Material Design: KMS
        4. Click 'Next Step'
      3. Define Key Administrative Permissions.  Choose the userrole you created earlier, mine was healthcheckrole
      4. Click "Finish"
  3. Now we need to encrypt the Token created from Slack with the Slack_healthcheck_key we just created, using AWS CLI.  I am running on OSX, and already had python dependencies installed
    1. I ran through the CLI installation guide using pip and configured AWS CLI so I could run it locally
    2. Encrypted the Token created from Slack with command:
      aws kms encrypt --key-id alias/<KMS key name> --plaintext "<COMMAND_TOKEN FROM SLASH COMMAND>"

      We created the alias during the "Create a KMS key" step above, so the resulting command is:
      aws kms encrypt --key-id alias/Slack_healthcheck_key --plaintext "<COMMAND_TOKEN FROM SLASH COMMAND>"

      The json returned will have CiphertextBlob and KeyId attributes and values
    3. Grab the base-64 encoded encrypted key that was created under cipherTextBlob json attribute value from the encrypt command, we will use it in a later step
Create an AWS Lambda microservice
  1. Navigate to https://console.aws.amazon.com/lambda/ and login to your AWS account.  Click the “Get Started Now” button
  2. Click "Create a Lambda function" button
  3. Select blueprint, search for and select the "slack-echo-command"
  4. Configure triggers,
    1. API name: healthstatus
    2. Deployment stage: prod
    3. Security: Open <We will choose Open for now, we can add security at a later time>
  5. Configure function,
    1. Name: healthstatus
    2. Description: Function handles a Slack slash command and makes a REST call to health status api
    3. Runtime: Node.js 4.3

    4. Inline code is automatically created for you via the blueprint chosen, this is the original code:
    5. 'use strict';
      
      /*
      This function handles a Slack slash command and echoes the details back to the user.
      
      Follow these steps to configure the slash command in Slack:
      
        1. Navigate to https://<your-team-domain>.slack.com/services/new
      
        2. Search for and select "Slash Commands".
      
        3. Enter a name for your command and click "Add Slash Command Integration".
      
        4. Copy the token string from the integration settings and use it in the next section.
      
        5. After you complete this blueprint, enter the provided API endpoint URL in the URL field.
      
      
        To encrypt your secrets use the following steps:
      
        1. Create or use an existing KMS Key - http://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html
      
        2. Click the "Enable Encryption Helpers" checkbox
      
        3. Paste <COMMAND_TOKEN> into the kmsEncryptedToken environment variable and click encrypt
      
      Follow these steps to complete the configuration of your command API endpoint
      
        1. When completing the blueprint configuration select "Open" for security
           on the "Configure triggers" page.
      
        2. Enter a name for your execution role in the "Role name" field.
           Your function's execution role needs kms:Decrypt permissions. We have
           pre-selected the "KMS decryption permissions" policy template that will
           automatically add these permissions.
      
        3. Update the URL for your Slack slash command with the invocation URL for the
           created API resource in the prod stage.
      */
      
      const AWS = require('aws-sdk');
      const qs = require('querystring');
      
      const kmsEncryptedToken = process.env.kmsEncryptedToken;
      let token;
      
      
      function processEvent(event, callback) {
          const params = qs.parse(event.body);
          const requestToken = params.token;
          if (requestToken !== token) {
              console.error(`Request token (${requestToken}) does not match expected`);
              return callback('Invalid request token');
          }
      
          const user = params.user_name;
          const command = params.command;
          const channel = params.channel_name;
          const commandText = params.text;
      
          callback(null, `${user} invoked ${command} in ${channel} with the following text: ${commandText}`);
      }
      
      
      exports.handler = (event, context, callback) => {
          const done = (err, res) => callback(null, {
              statusCode: err ? '400' : '200',
              body: err ? (err.message || err) : JSON.stringify(res),
              headers: {
                  'Content-Type': 'application/json',
              },
          });
      
          if (token) {
              // Container reuse, simply process the event with the key in memory
              processEvent(event, done);
          } else if (kmsEncryptedToken && kmsEncryptedToken !== '<kmsEncryptedToken>') {
              const cipherText = { CiphertextBlob: new Buffer(kmsEncryptedToken, 'base64') };
              const kms = new AWS.KMS();
              kms.decrypt(cipherText, (err, data) => {
                  if (err) {
                      console.log('Decrypt error:', err);
                      return done(err);
                  }
                  token = data.Plaintext.toString('ascii');
                  processEvent(event, done);
              });
          } else {
              done('Token has not been set.');
          }
      };
      
      
      
    6. Here is the updated code to make a call to a REST endpoint to get the health status of an application.  I added the use of the "https" library, and in the processEvent function, made the https call to the health status endpoint.  In this example here, I was calling a health REST api to https://api.openaq.org/status

    7. 'use strict';
      
      /*
      This function handles a Slack slash command and echoes the details back to the user.
      
      Follow these steps to configure the slash command in Slack:
      
        1. Navigate to https://<your-team-domain>.slack.com/services/new
      
        2. Search for and select "Slash Commands".
      
        3. Enter a name for your command and click "Add Slash Command Integration".
      
        4. Copy the token string from the integration settings and use it in the next section.
      
        5. After you complete this blueprint, enter the provided API endpoint URL in the URL field.
      
      
        To encrypt your secrets use the following steps:
      
        1. Create or use an existing KMS Key - http://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html
      
        2. Click the "Enable Encryption Helpers" checkbox
      
        3. Paste <COMMAND_TOKEN> into the kmsEncryptedToken environment variable and click encrypt
      
      Follow these steps to complete the configuration of your command API endpoint
      
        1. When completing the blueprint configuration select "Open" for security
           on the "Configure triggers" page.
      
        2. Enter a name for your execution role in the "Role name" field.
           Your function's execution role needs kms:Decrypt permissions. We have
           pre-selected the "KMS decryption permissions" policy template that will
           automatically add these permissions.
      
        3. Update the URL for your Slack slash command with the invocation URL for the
           created API resource in the prod stage.
      */
      
      const AWS = require('aws-sdk');
      const qs = require('querystring');
      const client = require('https');
      
      const kmsEncryptedToken = process.env.kmsEncryptedToken;
      let token;
      
      
      function processEvent(event, callback) {
          const params = qs.parse(event.body);
          const requestToken = params.token;
          if (requestToken !== token) {
              console.error(`Request token (${requestToken}) does not match expected`);
              return callback('Invalid request token');
          }
      
          const user = params.user_name;
          const command = params.command;
          const channel = params.channel_name;
          const commandText = params.text;
      
          console.log(`${user} invoked ${command} in ${channel} with the following text: ${commandText}`);
      
          var options = {
              hostname: 'api.openaq.org',
              path: '/status',
              method: 'GET',
              protocol: 'https:'
          };
      
          var req = client.request(options, function(response){
            console.log("Code: "+response.statusCode+ "\n Headers: "+response.headers);
      
            var body = '';
            response.on('data', function (chunk) {
                body += chunk;
            });
            response.on('end',function(){
                var jsonResponse = JSON.parse(body);
                console.log("healthStatus:" + jsonResponse.results.healthStatus);
      
                callback(null, options.hostname + options.path + ' is [' + jsonResponse.results.healthStatus + ']');
            });
      
          });
      
          req.on('error', function(err){
              console.log("Error Occurred: "+err.message);
          });
      
          // With http.request() one must always call req.end() to signify that you're done with the request - even if there is no data being written to the request body.
          req.end();
      
      }
      
      
      exports.handler = (event, context, callback) => {
          const done = (err, res) => callback(null, {
              statusCode: err ? '400' : '200',
              body: err ? (err.message || err) : JSON.stringify(res),
              headers: {
                  'Content-Type': 'application/json',
              },
          });
      
          if (token) {
              // Container reuse, simply process the event with the key in memory
              processEvent(event, done);
          } else if (kmsEncryptedToken && kmsEncryptedToken !== '<kmsEncryptedToken>') {
              const cipherText = { CiphertextBlob: new Buffer(kmsEncryptedToken, 'base64') };
              const kms = new AWS.KMS();
              kms.decrypt(cipherText, (err, data) => {
                  if (err) {
                      console.log('Decrypt error:', err);
                      return done(err);
                  }
                  token = data.Plaintext.toString('ascii');
                  processEvent(event, done);
              });
          } else {
              done('Token has not been set.');
          }
      };
      
      
      
    8. Replace the “kmsEncryptedToken” environment variable value from <enter value here> to the cipherTextBlob value (This is the base-64 encoded encrypted <COMMAND_TOKEN FROM SLASH COMMAND>).  You can also check the "Enable encryption helpers" checkbox.
    9. Select the Role as "Choose existing role"
    10. Existing role should be the IAM role you granted earlier, mine is "healthcheckrole"
    11. Everything else leave as default
    12. Click "Next"
    13. Click "Create function"
  6. Click on "Test" button to test your function.  You should see an error "Request token (undefined) does not match expected", since it was not invoked from slack (or properly simulated)
  7. Let's go ahead and deploy the API now, so we can simulate the call externally
  8. Go to Actions -> Publish new version. Grab the AWS API Gateway Endpoint from the Triggers tag, it should look similar to https://<uniqueid>.execute-api.us-east-1.amazonaws.com/prod/healthCheck
  9. We can now simulate the call from slack by doing a curl invocation

    curl -v -X POST --data "token=<COMMAND_TOKEN FROM SLASH COMMAND>&user_name=healthcheckuser&command=healthcheck&text=commandText&channel_name=channel" https://<uniqueid>.execute-api.us-east-1.amazonaws.com/prod/healthCheck

    Received the following response: "api.openaq.org/status is [green]"
Go back to Slack 
  1. Since the curl invocation worked, now go back to the slack integration settings for your slash command and update the URL to the AWS API Gateway Endpoint
  2. Test your slack command from within slack: type in /healthcheck and hit Enter
Next steps to improve this integration
  1. Update with security
  2. Support parameters from slack on which API(s) you want to have health checks for

1 comment:

I appreciate your time in leaving a comment!