In this article I will continue with the implementation of some Serverless Patterns described in this article from Jeremy Daly about [Serverless Patterns]. Check the first post here Let’s start!
Common setup
All the projects will have a common setup, which is fairly simple. First, initialize a NodeJS project:
Then install the serverless framework as a dev dependency
1
|
yarn add serverless --dev
|
And finally create a script to deploy the project
1
2
3
|
"scripts": {
"deploy": "serverless deploy --aws-profile serverless-local"
}
|
(Assuming that you have a profile called serverless-local, of course)
The Gatekeeper
Read the article here.
The big difference with previous patterns is that we need a custom Lambda authorizer. Let’s take a look at the serverless.yml
file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
|
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}
|
We’re creating a new table for the authorization, where you can store what you need to authorize a user. The custom authorizer lambda is just another function. The difference in the other functions is that we’re now setting the authorizer. In the name property we’re setting the name of the lambda function that will authorize the request, in the identitySource we’re setting the header that we’d like to use and in the type property we’re it as a token, which is the simpler one. If you want to know more about custom authorizers, check this article from Alex DeBrie.
Let’s take a look at the code of the custom authorizer function now.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
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"
}
});
};
|
The logic of the authorizer is not important here. What is important is that you’ll receive the token (the value of the authorization header in our case) in the authorizationToken
of the event
. The other important bit is what do we have to return in a custom authorizer. There are three main cases here:
- 401: you need to call
context.fail('Unauthorized');
- Success: you need to call
context.success
passing a policy. In this policy you need to specify the principalId of the user, and as Statement a valid IAM Policy that allows access to the endpoint. The endpoint Arn comes in the event in the property methodArn
. You can pass an object in the context object to add custom data to the context object that you internal lambda will receive.
- 403: you need to call
contex.success
but with a Deny in the IAM Policy.
You can check the code here.
Internal API
You can read the article here.
This pattern is much simpler, but what will change is the way that the lambda is invoked. So, the serverless.yml
is fairly simple:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
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}
|
Notice that now the Lambda functions don’t have any event. So, how can we call those lambdas? Let’s create a script to call the PutItem
function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
const AWS = require('aws-sdk');
AWS.config.region = "eu-west-1";
var lambda = new AWS.Lambda();
const base64data = Buffer.from('{"AppName" : "InternalAPIApp"}').toString('base64');
var params = {
ClientContext: base64data,
FunctionName: "InternalAPI-dev-PutItem",
InvocationType: " RequestResponse",
LogType: "Tail", // Set to Tail to include the execution log in the response.
Payload: '{"id": 1, "name": "test1"}'
};
lambda.invoke(params, function(err, data) {
if (err) console.log(err, err.stack); // an error occurred
else console.log(data); // successful response
});
|
As you can see we need to use the AWS SDK. You can install it using yarn add aws-sdk --dev
. The important bits here is that we need to specify the name of the function. The invocation type righ now (we’ll see the other type in the next pattern) is RequestResponse
which means that we will wait for a response from the lambda.
So, assuming that you name this file callPutItems.js
and that you have a profile called serverless-local
you will need to call this script like AWS_PROFILE=serverless-local node callPutItem.js
.
You can check the code here.
The Internal Handoff
Read the article here.
This pattern is fairly similar to the previous one, with a couple of differences:
- We’re going to call the lambda with an invocation type of event, which will make the call asynchronous.
- We’re going to add a DLQ (in our case an SNS topic) for the failing messages.
Let’s take a look at the serverless.yml
file first:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
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}
|
The main difference in the functions is that we’ve set the property onError
. We need to set this property to the Arn of an SNS topic (you can’t use a SQS right now, chech the why here). When we do that, we need to add a permission to be able to write to that topic.
Finally, we’re creating a function that reads from that topic. When we do this, the framework will create the SNS topic for us. If we don’t specify any function, we will need to create the topic in the resources
section.
To check the DLQ, we’re setting a condition in the code of our functions to throw an error if the name of the item is error. Let’s see how the script that calls the function changes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
const AWS = require('aws-sdk');
const name = process.argv.slice(2)[0];
AWS.config.region = "eu-west-1";
var lambda = new AWS.Lambda();
const base64data = Buffer.from('{"AppName" : "InternalAPIApp"}').toString('base64');
var params = {
ClientContext: base64data,
FunctionName: "InternalHandoff-dev-PutItem",
InvocationType: "Event",
LogType: "Tail", // Set to Tail to include the execution log in the response.
Payload: `{"id": 1, "name": "${name}"}`
};
lambda.invoke(params, function(err, data) {
if (err) console.log(err, err.stack); // an error occurred
else console.log(data); // successful response
});
|
As you can see, the invocation type is now Event. Doing that, we’ll receive a 202 from the function instead of a 200.
To call this script to generate an error you need to call it this way:
1
|
AWS_PROFILE=serverless-local node callPutItem.js error
|
(Use any other name if you don’t want to generate an error.)
When we do that, AWS will try to deliver the message three times (aproximately once a minute). If we fail the three times, it will send the message to the DLQ.
You can check the code here.
Summary
In this article, we’ve seen the implementation of three more patterns from this great article from Jeremy Daly. We’ll continue with that in following article.
Hope it helps!!