AWS Developer Tools Blog

Serverless Service Discovery: Part 3: Registration

In this, the third part of our serverless service discovery series, we will show how to configure Amazon API Gateway to require AWS Identity and Access Management (IAM) for authentication and how to create a V4 signature to call our register and deregister methods.

We have created all the functions required to manage our API and code, so we can jump directly into creating our new functions.

Registering and Deregistering Services

We start by creating a Lambda function for registering a service:


def lambda_handler(api_parameters, context):
    """Lambda hander for registering a service."""
    logger.info("lambda_handler - service_name: %s"
                " service_version: %s"
                % (api_parameters["service_name"],
                   api_parameters["service_version"]))

    table = boto3.resource('dynamodb',
                           region_name='us-east-1').Table('Services')

    table.put_item(
           Item={
                'name': api_parameters["service_name"],
                'version': api_parameters["service_version"],
                'endpoint_url': api_parameters["endpoint_url"],
                'ttl': int(api_parameters["ttl"]),
                'status': api_parameters["status"],
            }
        )

This function takes the input and stores it in Amazon DynamoDB. If you call the function with the same service name and version (our DynamoDB key), then it will overwrite the existing item.

Followed by the function to deregister:


def lambda_handler(api_parameters, context):
    """Lambda hander for deregistering a service."""
    logger.info("lambda_handler - service_name: %s"
                " service_version: %s"
                % (api_parameters["service_name"],
                   api_parameters["service_version"]))

    table = boto3.resource('dynamodb',
                           region_name='us-east-1').Table('Services')

    table.delete_item(
            Key={
                'name': api_parameters["service_name"],
                'version': api_parameters["service_version"]
            }
        )

The function removes the item from the DynamoDB table based on the service name and version.

We need to add the new functions and API methods to the Swagger file:


{
  "swagger": "2.0",
  "info": {
    "title": "catalog_service",
    "version": "1.0.0"
  },
  "basePath": "/v1",
  "schemes": ["https"],
  "consumes": ["application/json"],
  "produces": ["application/json"],
  "paths": {
    "/catalog/{serviceName}/{serviceVersion}": {
      "parameters": [{
        "name": "serviceName",
        "in": "path",
        "description": "The name of the service to look up.",
        "required": true,
        "type": "string"
      },
      {
        "name": "serviceVersion",
        "in": "path",
        "description": "The version of the service to look up.",
        "required": true,
        "type": "string"
      }],
      "get": {
        "responses": {
          "200": {
            "description": "version information"
          },
          "404": {
            "description": "service not found"
          }
        },
        "x-amazon-apigateway-integration": {
          "type": "aws",
          "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/$catalog_serviceARN$/invocations",
          "httpMethod": "POST",
          "requestTemplates": {
            "application/json": "{\"service_name\": \"$input.params('serviceName')\",\"service_version\": \"$input.params('serviceVersion')\"}"
          },
          "responses": {
            "default": {
              "statusCode": "200",
              "schema": {
                "$ref": "#/definitions/CatalogServiceModel"
              }
            },
            ".*NotFound.*": {
              "statusCode": "404",
              "responseTemplates" : {
                 "application/json": "{\"error_message\":\"Service Not Found\"}"
                } 
            } 
          }
        }
      }
    },
    "/catalog/register": {
      "post": {
        "responses": {
          "201": {
            "description": "service registered"
          }
        },
        "parameters": [{
          "name": "body",
          "in": "body",
          "description": "body object",
          "required": true,
          "schema": {
            "$ref":"#/definitions/CatalogRegisterModel"
          }
        }],
        "x-amazon-apigateway-auth" : {
          "type" : "aws_iam" 
        },
        "x-amazon-apigateway-integration": {
          "type": "aws",
          "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/$catalog_registerARN$/invocations",
          "httpMethod": "POST",
          "requestTemplates": {
            "application/json": "$input.json('$')"
          },
          "responses": {
            "default": {
              "statusCode": "201"
            } 
          }
        }
      } 
    },
    "/catalog/deregister": {
      "post": {
        "responses": {
          "201": {
            "description": "service deregistered"
          }
        },
        "parameters": [{
          "name": "body",
          "in": "body",
          "description": "body object",
          "required": true,
          "schema": {
            "$ref":"#/definitions/CatalogDeregisterModel"
          }
        }],
        "x-amazon-apigateway-auth" : {
          "type" : "aws_iam" 
        },
        "x-amazon-apigateway-integration": {
          "type": "aws",
          "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/$catalog_deregisterARN$/invocations",
          "httpMethod": "POST",
          "requestTemplates": {
            "application/json": "$input.json('$')"
          },
          "responses": {
            "default": {
              "statusCode": "201"
            } 
          }
        }
      } 
    }
  },
  "definitions": {
    "CatalogServiceModel": {
      "type": "object",
      "properties": {
        "endpoint_url": {
          "type": "string"
        },
        "ttl": {
          "type": "integer"
        },
        "status": {
          "type": "string"
        }
      },
      "required": ["endpoint_url", "ttl", "status"]
    },
    "CatalogRegisterModel": {
      "type": "object",
      "properties": {
        "service_name": {
          "type": "string"
        },
        "service_version": {
          "type": "string"
        },
        "endpoint_url": {
          "type": "string"
        },
        "ttl": {
          "type": "integer"
        },
        "status": {
          "type": "string"
        }
      },
      "required": ["service_name","service_version","endpoint_url", "ttl", "status"]
    },
    "CatalogDeregisterModel": {
      "type": "object",
      "properties": {
        "service_name": {
          "type": "string"
        },
        "service_version": {
          "type": "string"
        }
      },
      "required": ["service_name","service_version"]
    }
  }
}

The new methods will be POST-based, so we need to define models (CatalogRegisterModel and CatalogDeregisterModel) for the data passed through the method body. After API Gateway processes the models, the JSON objects will be passed, as is, to the Lambda functions.

We set the x-amazon-apigateway-auth element to the type of aws_iam for the register and deregister methods, so API Gateway will require a V4 signature when we access them.

We can now deploy our new functions:


ACCOUNT_NUMBER = _your account number_

create_deployment_package("/tmp/catalog_register.zip", ["catalog_register.py"])
catalog_register_arn = create_lambda_function(
                       "/tmp/catalog_register.zip",
                       "catalog_register",
                       "arn:aws:iam::"+ACCOUNT_NUMBER+":role/lambda_s3",
                       "catalog_register.lambda_handler",
                       "Registering a service.",
                       ACCOUNT_NUMBER)
replace_instances_in_file("swagger.json",
                          "/tmp/swagger_with_arn.json",
                          "$catalog_registerARN$", catalog_register_arn)
create_deployment_package("/tmp/catalog_deregister.zip",
                          ["catalog_deregister.py"])
catalog_deregister_arn = create_lambda_function(
                       "/tmp/catalog_deregister.zip",
                       "catalog_deregister",
                       "arn:aws:iam::"+ACCOUNT_NUMBER+":role/lambda_s3",
                       "catalog_deregister.lambda_handler",
                       "Deregistering a service.",
                       ACCOUNT_NUMBER)
replace_instances_in_file("/tmp/swagger_with_arn.json",
                          "/tmp/swagger_with_arn.json",
                          "$catalog_deregisterARN$", catalog_deregister_arn)
catalog_service_arn = get_function_arn("catalog_service")
replace_instances_in_file("/tmp/swagger_with_arn.json",
                          "/tmp/swagger_with_arn.json",
                          "$catalog_serviceARN$", catalog_service_arn)
api_id = update_api("/tmp/swagger_with_arn.json")
deploy_api(api_id, "/tmp/swagger_with_arn.json", "dev")

We can try out the new register service like this:


json_body = {
            "service_name": "registerservice3",
            "service_version": "1.0",
            "endpoint_url": "notarealurlregister3",
            "ttl": "300",
            "status": "healthy"
            }
request_url = "https://yourrestapi.execute-api.us-east-1.amazonaws.com/"\
              "dev/catalog/register"
response = requests.post(
            request_url,
            data=json.dumps(json_body))
if(not response.ok):
    logger.error("Error code: %i" % (response.status_code,))


We should get something like this:


ERROR:root:Error code: 403

Signing a Request with Signature Version 4

To successfully call our new services, we need to implement a client that will sign the request to the API with a Version 4 signature. First we implement the functions that creates the signature:


from botocore.credentials import get_credentials
from botocore.session import get_session
import requests
import json
import logging
import sys
import datetime
import hashlib
import hmac
import urlparse
import urllib
from collections import OrderedDict

def sign(key, msg):
    """Sign string with key."""
    return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()


def getSignatureKey(key, dateStamp, regionName, serviceName):
    """Create signature key."""
    kDate = sign(('AWS4' + key).encode('utf-8'), dateStamp)
    kRegion = sign(kDate, regionName)
    kService = sign(kRegion, serviceName)
    kSigning = sign(kService, 'aws4_request')
    return kSigning


def create_canonical_querystring(params):
    """Create canonical query string."""
    ordered_params = OrderedDict(sorted(params.items(), key=lambda t: t[0]))
    canonical_querystring = ""
    for key, value in ordered_params.iteritems():
        if len(canonical_querystring) > 0:
            canonical_querystring += ","
        canonical_querystring += key+"="+value
    return canonical_querystring


def sign_request(method, url, credentials, region, service, body=''):
    """Sign a HTTP request with AWS V4 signature."""
    ###############################
    # 1. Create a Canonical Request
    ###############################
    t = datetime.datetime.utcnow()
    amzdate = t.strftime('%Y%m%dT%H%M%SZ')
    # Date w/o time, used in credential scope
    datestamp = t.strftime('%Y%m%d')

    # Create the different parts of the request, with content sorted
    # in the prescribed order
    parsed_url = urlparse.urlparse(url)
    canonical_uri = parsed_url.path
    canonical_querystring = create_canonical_querystring(
                              urlparse.parse_qs(parsed_url.query))
    canonical_headers = ("host:%s\n"
                         "x-amz-date:%s\n" %
                         (parsed_url.hostname, amzdate))
    signed_headers = 'host;x-amz-date'
    if (not (credentials.token is None)):
        canonical_headers += ("x-amz-security-token:%s\n") % (credentials.token,)
        signed_headers += ';x-amz-security-token'

    payload_hash = hashlib.sha256(body).hexdigest()
    canonical_request = ("%s\n%s\n%s\n%s\n%s\n%s" %
                         (method,
                          urllib.quote(canonical_uri),
                          canonical_querystring,
                          canonical_headers,
                          signed_headers,
                          payload_hash))

    #####################################
    # 2. Create a String to Sign
    #####################################
    algorithm = 'AWS4-HMAC-SHA256'
    credential_scope = ("%s/%s/%s/aws4_request" % 
                        (datestamp,
                         region,
                         service))
    string_to_sign = ("%s\n%s\n%s\n%s" %
                       (algorithm,
                        amzdate,
                        credential_scope,
                        hashlib.sha256(canonical_request).hexdigest()))
    #####################################
    # 3. Create a Signature
    #####################################
    signing_key = getSignatureKey(credentials.secret_key,
                                  datestamp, region, service)
    signature = hmac.new(signing_key, (string_to_sign).encode('utf-8'),
                         hashlib.sha256).hexdigest()

    ######################################################
    # 4. Assemble request to it can be used for submission
    ######################################################
    authorization_header = ("%s Credential=%s/%s, "
                            "SignedHeaders=%s, "
                            "Signature=%s" %
                            (algorithm,
                             credentials.access_key,
                             credential_scope,
                             signed_headers,
                             signature))
    headers = {'x-amz-date': amzdate, 'Authorization': authorization_header}
    if (not (credentials.token is None)):
        headers['x-amz-security-token'] = credentials.token
    request_url = ("%s://%s%s" % 
                   (parsed_url.scheme,parsed_url.netloc,canonical_uri))
    if (len(canonical_querystring) > 0):
        request_url += ("?%s" % (canonical_querystring,))

    return request_url, headers, body

The main function, sign_request, can sign requests for both POST and GET methods. It also works with both short and long term credentials. For more information about creating Signature Version 4 requests, see Signing Requests

We implement the following method to submit a POST request:


def signed_post(url, region, service, data, **kwargs):
    """Signed post with AWS V4 Signature."""
    credentials = get_credentials(get_session())

    request_url, headers, body = sign_request("POST", url, credentials, region,
                                              service, body=data)

    return requests.post(request_url, headers=headers, data=body, **kwargs)

We are using botocore functionality to get the configured keys on the instance we are running. If we are running this on an Amazon EC2 instance or AWS Lambda, botocore will use the configured IAM role.

We can now test the service by calling register:


json_body = {
            "service_name": "registerservice6",
            "service_version": "1.0",
            "endpoint_url": "notarealurlregister6",
            "ttl": "300",
            "status": "healthy"
            }
request_url = "https://yourrestapiid.execute-api.us-east-1.amazonaws.com/"\
              "dev/catalog/register"
response = signed_post(
            request_url,
            "us-east-1",
            "execute-api",
            json.dumps(json_body))
if(not response.ok):
    logger.error("Error code: %i" % (response.status_code,))
else:
    logger.info("Successfully registered the service.")

The test should complete without a failure. To test, look up this item:


request_url="https://your_rest_api_id.execute-api.us-east-1.amazonaws.com/"\
            "dev/v1/catalog/registerservice6/1.0"
response = requests.get(request_url)
json_response = json.loads(response.content)
logging.info("Endpoint URL: %s" % (json_response['endpoint_url'],))
logging.info("TTL: %i" % (json_response['ttl'],))
logging.info("Status: %s" % (json_response['status'],))

You should get the following output:


INFO:root:Endpoint URL: notarealurlregister6
INFO:root:TTL: 300
INFO:root:Status: healthy