AWS Serverless: Padrões Serverless Implementados (Parte 2 de 2)

oieduardorabelo

Eduardo Rabelo

Posted on February 9, 2020

AWS Serverless: Padrões Serverless Implementados (Parte 2 de 2)

Continuaremos com a implementação de alguns padrões serverless descritos pelo Jeremy Daly.

Configuração Comum

Todos os projetos terão uma configuração comum, o que é bastante simples. Primeiro, inicialize um projeto NodeJS:

yarn

Em seguida, instale o Serverless Framework como uma dependência de desenvolvedor:

yarn add serverless --dev

E crie um script para fazer o deploy do projeto:

"scripts": {
    "deploy": "serverless deploy --aws-profile serverless-local"
}

Supondo que você tenha um perfil chamado serverless-local nas suas credências da AWS CLI (dentro de ~/.aws/credentials).

O Porteiro

Você pode ler a explicação aqui.

A grande diferença dos padrões anteriores é que precisamos de um autorizador Lambda personalizado. Vamos dar uma olhada no arquivo serverless.yml:

service: Gatekeeper

plugins:
  - serverless-pseudo-parameters
  - serverless-iam-roles-per-function

provider:
  name: aws
  runtime: nodejs10.x
  region: ${opt:region, self:custom.defaultRegion}
  logs:
    restApi: true

custom:
  defaultRegion: eu-west-1
  tableName: ${self:provider.region}-GatekeeperTable
  authorizerTableName: ${self:provider.region}-GatekeeperAuthorizerTable

functions:
  GetItem:
    handler: src/functions/getItem.handler
    events:
      - http:
          method: get
          path: item/{itemId}
          authorizer: 
            name: CustomAuthorizer
            resultTtlInSeconds: 0
            identitySource: method.request.header.Authorization
            type: token
    environment:
      tableName: ${self:custom.tableName}
    iamRoleStatements:
      - Effect: Allow
        Action: dynamodb:getItem
        Resource: !GetAtt GatekeeperTable.Arn
  PutItem:
    handler: src/functions/putItem.handler
    events:
      - http:
          method: post
          path: item
          authorizer: 
            name: CustomAuthorizer
            resultTtlInSeconds: 0
            identitySource: method.request.header.Authorization
            type: token
    environment:
      tableName: ${self:custom.tableName}
    iamRoleStatements:
      - Effect: Allow
        Action: dynamodb:putItem
        Resource: !GetAtt GatekeeperTable.Arn
  CustomAuthorizer:
    handler: src/functions/authorizer.handler
    environment:
      tableName: ${self:custom.authorizerTableName}
    iamRoleStatements:
      - Effect: Allow
        Action: dynamodb:getItem
        Resource: !GetAtt AuthorizationTable.Arn

resources:
  Resources:
    GatekeeperTable:
      Type: AWS::DynamoDB::Table
      Properties:
        KeySchema:
          - AttributeName: id
            KeyType: 'HASH'
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: 'N'
        BillingMode: PAY_PER_REQUEST
        TableName: ${self:custom.tableName}
    AuthorizationTable:
      Type: AWS::DynamoDB::Table
      Properties:
        KeySchema:
          - AttributeName: id
            KeyType: 'HASH'
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: 'N'
        BillingMode: PAY_PER_REQUEST
        TableName: ${self:custom.authorizerTableName}

Estamos criando uma nova tabela para a autorização, na qual você pode armazenar o que precisar para autorizar um usuário. O autorizador personalizado lambda é apenas outra função. A diferença nas outras funções é que agora estamos definindo o autorizador. Na propriedade authorizer.name, definimos o nome da função lambda que autorizará a solicitação, no authorizer.identitySource, definimos o cabeçalho que gostaríamos de usar da requisição e, na propriedade authorizer.type, queremos receber como tipo token. Se você quiser saber mais sobre autorizadores personalizados, consulte este artigo de Alex DeBrie.

Vamos dar uma olhada no código da função autorizador personalizado:

const AWS = require("aws-sdk");
const dynamodb = new AWS.DynamoDB.DocumentClient();
const tableName = process.env.tableName;

module.exports.handler = async (event, context) => {
  console.log(event);

  const id = event.authorizationToken;
  const req = {
    TableName: tableName,
    Key: {
        'id': parseInt(id)
      }
  };

  const dynamodbResp = await dynamodb.get(req).promise();

  console.log(dynamodbResp);

  if (!dynamodbResp.Item){
    // 401
    context.fail('Unauthorized');

    // 403
    // context.succeed({
    //   "policyDocument": {
    //     "Version": "2012-10-17",
    //     "Statement": [
    //       {
    //         "Action": "execute-api:Invoke",
    //         "Effect": "Deny",
    //         "Resource": [
    //           event.methodArn
    //         ]
    //       }
    //     ]
    //   }
    // })
  }
    context.succeed({
        "principalId": dynamodbResp.Item.name,
        "policyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Action": "execute-api:Invoke",
                    "Effect": "Allow",
                    "Resource": event.methodArn
                }
            ]
        },
        "context": {
            "org": "my-org",
            "role": "admin",
            "createdAt": "2019-11-11T12:15:42"
        }
    });
};

A lógica do autorizador não é importante aqui. O importante é que você receba o token (o valor do cabeçalho da autorização no nosso caso) no event.authorizationToken. A outra parte importante é o que devemos retornar em um autorizador personalizado. Existem três casos principais aqui:

  • 401: Você precisa chamar context.fail('Unauthorized');
  • Sucesso: Você precisa chamar context.success passando uma política. Nesta política, você precisa especificar o principalId do usuário e, como Statement, uma Política do IAM válida que permita o acesso ao endpoint. O arn do endpoint vem no evento na propriedade methodArn. Você pode adicionar propriedades no objeto context (para adicionar dados personalizados, etc) que serão passados para sua lambda interna.
  • 403: Você precisa chamar contex.success mas com um Política do IAM.

Você pode verificar o código final aqui.

A API Interna

Você pode ler a explicação aqui.

Esse padrão é muito mais simples, o que mudará é a maneira como o lambda é chamado. Portanto, o serverless.yml é bastante simples:

service: InternalAPI

plugins:
  - serverless-iam-roles-per-function

provider:
  name: aws
  runtime: nodejs10.x
  region: ${opt:region, self:custom.defaultRegion}

custom:
  defaultRegion: eu-west-1
  tableName: ${self:provider.region}-InternalAPITable

functions:
  GetItem:
    handler: src/functions/getItem.handler
    environment:
      tableName: ${self:custom.tableName}
    iamRoleStatements:
      - Effect: Allow
        Action: dynamodb:getItem
        Resource: !GetAtt InternalAPITable.Arn
  PutItem:
    handler: src/functions/putItem.handler
    environment:
      tableName: ${self:custom.tableName}
    iamRoleStatements:
      - Effect: Allow
        Action: dynamodb:putItem
        Resource: !GetAtt InternalAPITable.Arn

resources:
  Resources:
    InternalAPITable:
      Type: AWS::DynamoDB::Table
      Properties:
        KeySchema:
          - AttributeName: id
            KeyType: 'HASH'
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: 'N'
        BillingMode: PAY_PER_REQUEST
        TableName: ${self:custom.tableName}

Observe que agora as funções Lambda não têm nenhum evento. Então, como podemos chamar essas lambdas? Vamos criar um script para chamar a função PutItem.

const AWS = require('aws-sdk');

AWS.config.region = "eu-west-1";

const lambda = new AWS.Lambda();

const base64data = Buffer.from('{"AppName" : "InternalAPIApp"}').toString('base64');

var params = {
    ClientContext: base64data, 
    FunctionName: "InternalAPI-dev-PutItem", 
    InvocationType: " RequestResponse", 
    // Escolhemos "Tails" para incluir o log de execução na resposta.
    LogType: "Tail",
    Payload: '{"id": 1, "name": "test1"}'
};

lambda.invoke(params, function(err, data) {
    if (err) console.log(err, err.stack); // um erro ocorreu
    else     console.log(data);           // resposta com sucesso
});

Como você pode ver, precisamos usar o AWS SDK. Você pode instalá-lo usando o yarn add aws-sdk --dev. O importante aqui é que precisamos especificar o nome da função. O tipo de chamada agora é RequestResponse que significa que aguardamos uma resposta da lambda (veremos o outro tipo no próximo padrão).

Portanto, supondo que você nomeie esse arquivo como callPutItems.js e que tenha um perfil chamado serverless-local você pode usar esse script como AWS_PROFILE=serverless-local node callPutItem.js.

A Entrega Interna

Você pode ler a explicação aqui.

Esse padrão é bastante semelhante ao anterior, com algumas diferenças:

  • Iremos chamar a lambda com um tipo de invocação chamado Event, invocando a lambda de modo assíncrono.
  • Vamos adicionar um DLQ (no nosso caso, um tópico SNS) para as mensagens com falha.
service: InternalHandoff

plugins:
  - serverless-iam-roles-per-function
  - serverless-pseudo-parameters

provider:
  name: aws
  runtime: nodejs10.x
  region: ${opt:region, self:custom.defaultRegion}
  stage: ${opt:stage, self:custom.defaultStage}

custom:
  defaultRegion: eu-west-1
  defaultStage: dev
  tableName: ${self:provider.stage}-InternalHandofffTable
  dlqTopicName: ${self:provider.stage}-DLQTopicName
  dlqTopicArn: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:${self:custom.dlqTopicName}

functions:
  GetItem:
    handler: src/functions/getItem.handler
    environment:
      tableName: ${self:custom.tableName}
    onError: ${self:custom.dlqTopicArn}
    iamRoleStatements:
      - Effect: Allow
        Action: dynamodb:getItem
        Resource: !GetAtt InternalHandofffTable.Arn
      - Effect: Allow
        Action: sns:Publish
        Resource: ${self:custom.dlqTopicArn}
  PutItem:
    handler: src/functions/putItem.handler
    environment:
      tableName: ${self:custom.tableName}
    onError: ${self:custom.dlqTopicArn}
    iamRoleStatements:
      - Effect: Allow
        Action: dynamodb:putItem
        Resource: !GetAtt InternalHandofffTable.Arn
      - Effect: Allow
        Action: sns:Publish
        Resource: ${self:custom.dlqTopicArn}
  ReadErrors:
    handler: src/functions/readErrors.handler
    events:
      - sns: ${self:custom.dlqTopicName}

resources:
  Resources:
    InternalHandofffTable:
      Type: AWS::DynamoDB::Table
      Properties:
        KeySchema:
          - AttributeName: id
            KeyType: 'HASH'
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: 'N'
        BillingMode: PAY_PER_REQUEST
        TableName: ${self:custom.tableName}

A principal diferença nas funções é que definimos a propriedade onError. Definimos essa propriedade apontando para um arn de um tópico SNS (por hora, não podemos usar SQS, verifique o porquê aqui). Quando fazemos isso, precisamos adicionar uma permissão para poder escrever nesse tópico.

Por fim, estamos criando uma função que lê esse tópico. Quando fizermos isso, o Serverless Framework criará o tópico do SNS para nós. Se não especificarmos nenhuma função, precisaremos criar o tópico na seção de resources:.

Para testar a DLQ, estamos definindo uma condição no código de nossas funções Lambdas para gerar um erro se o nome do item for "erro". Vamos ver como invocar essas lambdas com nosso script anterior e ver o que mudou:

const AWS = require('aws-sdk');

AWS.config.region = "eu-west-1";

const lambda = new AWS.Lambda();
const name = process.argv.slice(2)[0];

const base64data = Buffer.from('{"AppName" : "InternalAPIApp"}').toString('base64');

const params = {
    ClientContext: base64data, 
    FunctionName: "InternalHandoff-dev-PutItem", 
    InvocationType: "Event", 
    LogType: "Tail",
    Payload: `{"id": 1, "name": "${name}"}`
};

lambda.invoke(params, function(err, data) {
    if (err) console.log(err, err.stack); // com erro
    else     console.log(data);           // com sucesso
});

Como você pode ver, o tipo de chamada agora é Event. Fazendo isso, receberemos um 202 da função ao invés de um 200.

Para chamar esse script para gerar um erro, você pode fazer:

AWS_PROFILE=serverless-local node callPutItem.js error

Use qualquer outro nome se não quiser gerar um erro.

Quando fizermos isso, a AWS tentará entregar a mensagem três vezes (aproximadamente uma vez por minuto). Se falharmos nas três vezes, a mensagem será enviada para o DLQ.

Você pode verificar o código final aqui.

Finalizando

Neste artigo, implementamos mais três padrões serverless deste ótimo artigo de Jeremy Daly. Continuaremos com isso no artigo a seguir.

Espero que tenha te ajudado!!

💖 💪 🙅 🚩
oieduardorabelo
Eduardo Rabelo

Posted on February 9, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related