EDUCAÇÃO E TECNOLOGIA

How to schedule AWS Lambda to call application on SAP BTP

The goal of this blog post is to describe a scenario where an application running on SAP Business Technology Platform (BTP, aka SAP Cloud Platform) can be scheduled to run from Amazon AWS.

The scenario which I tried and which I’d like to share with you is the following:
A scheduled event triggers an AWS Lambda Function which is implemented to call a protected REST endpoint of an app deployed on SAP BTP

Quicklinks:
Sample application
Sample Lambda

Prerequisites

To follow this tutorial, the following prerequisites are required:

  • Access to SAP Business Technology Platform (aka SAP Cloud Platform)
    Trial account is sufficient
    Basic knowledge about developing applications
  • Access to Amazon AWS cloud
    Free Tier is sufficient
  • Basic knowledge about Node.js
    However, it is not necessary to run the app locally, so Node.js doesn’t need to be installed.

Overview

As mentioned, our scenario consists of 3 components:

Scheduled Event
-> Lambda
-> Application

In our tutorial, we start from the end: the target app.
Then we create a Lambda function which calls the app.
Then we define a schedule which triggers the Lambda.

All lines of code can be found in the Appendix section at the end of this blog post.

  1. Create app on SAP BTP
  2. Create Lambda on AWS
  3. Define schedule on AWS
  4. Check results
  5. Optional: Homework

1. Create app on SAP BTP

As mentioned, we start from the end: the target application on SAP Business Technology Platform.
If you already have an application, you can use it.
The only requirement: your app should provide a service endpoint which can be called via HTTP.

For our tutorial, we create a new application.
To make the scenario a bit more interesting, we protect the endpoint with OAuth 2.0.
In addition, we define and enforce a dedicated scope for that endpoint.

1.1. Create Project

Trying to keep this section short.
We create the following folder structure on our local file system.

C:\tmp_sappapp
|- manifest.yml
|- package.json
|- server.js
|- xs-security.json

See screenshot:

Afterwards, we copy the content of the files from the Appendix

1.2. Configure Security

Our app provides an endpoint which is dedicated to be invoked on regular basis.
It should only be invoked by scheduled machine, as such it is protected with OAuth and it requires a scope which must be assigned to the calling job.

To handle security on SAP BTP, we use XSUAA in Cloud Foundry.

1.2.1. Create instance of XSUAA

We create an instance of XSUAA and configure it with parameters which are contained in the xs-security.json file.
It can be passed during creation in the cockpit, or on command line.
To create the service instance on command line using the CF CLI, we jump into the project folder and execute the following command:

cf create-service xsuaa application xsuaa_sappapp -c xs-security.json

Let’s have a quick look at the security configuration:

{ "xsappname" : "xsuaa_sappapp", "tenant-mode" : "dedicated", "scopes": [{ "name": "$XSAPPNAME.sappappscopp" }], "authorities":["$XSAPPNAME.sappappscopp"]
}

Note:
Above we define a scope.
Our application will check the incoming JWT token, to enforce that scope.
But how to assign the scope to the caller in client-credentials scenario?
Later on, we will use this instance of XSUAA to obtain a JWT token.
We want this instance to add the scope to that token.
For that purpose, we have to add the authorities statement.

Note:
We don’t define any role (on top of the scope).
This ensures that no human user can get the permission to call the endpoint.

This is very short explanation, for more details, please go through the following blog post.

Note:
After reading my other blog post, you might wonder, why we don’t create an additional instance of XSUAA which we use only for AWS Lambda and to which we “grant” the scope.
Yes, good catch, this is a good alternative, but it would make this blog post longer while not adding much benefit for the focused scenario.
If you like, please try it on your own and share your experience in the comments section

1.2.2. Create service key

After creating an instance of XSUAA, we can bind our application to it and use the binding for securing one of our endpoints (see next step).
During binding, the credentials of the service instance are made available to the app.
Credentials can be used to call the XSUAA authorization server to obtain a JWT token.
But in our case, we don’t need the credentials inside the app.
We need them externally.
Credentials are necessary e.g. if we want to call the protected endpoint from a local REST client, like postman.
Or from a test environment.
Or from an AWS Lambda function.

To get explicit service credentials, we need to create a Service Key.
This can be done in the cockpit, or with the following command:

cf csk xsuaa_sappapp service_key_sappapp

Afterwards we need to view the credentials.
To view the content of the service key, either use the cockpit or the following command.

cf service-key xsuaa_sappapp service_key_sappapp

From the bunch of information, only these 3 properties are of interest for us:

“clientid”: “sb-xsuaa_sappapp!t12345”
“clientsecret”: “12345abcdABCDk73suGmbk9BITs=”
“url”: “https://<subacc>.authentication.eu10.hana.ondemand.com”

We take a note of the values, because we’ll need them later, when creating our Lambda function.

1.3. Create Application

We quickly create a small Node.js app which exposes a REST service endpoint.
The implementation is not interesting, as it does nothing.

Let’s just have a brief look:

const xsuaaService = xsenv.getServices({ myXsuaa: { tag: 'xsuaa' }});
passport.use(new JWTStrategy(xsuaaService.myXsuaa)); app.use(passport.authenticate('JWT', { session: false }));
app.get('/prot', function(req, res){ . . . if(req.authInfo.checkScope(MY_SCOPE)){ res.send(`endpoint called from ${req.headers['user-agent']}, scope : ${jwtDecodedJson.scope}`); . . . 

The endpoint uses the node modules passport and @sap/xssec for protection and in addition, it checks for valid scope in the JWT token.
In the response, we send info about the available scopes found in the JWT token and about who has called the endpoint.

Deploy app

To deploy the app, we define the manifest.yml, as shown in the appendix and use the command cf push

Afterwards we can test our app endpoint

https://sappapp.cfapps.eu10.hana.ondemand.com/prot

in a browser window, just to see that it fails with the expected error Unauthorized with code 401

2. Create Lambda Function on AWS

In the first step, we created an application and deployed it to SAP Business Technology Platform (BTP) and we secured it with OAuth 2.0
We failed to call it with browser because we have to follow the OAuth flow.

Now we switch to Amazon Web Services (AWS) and create an AWS Lambda function with calls the REST service endpoint of that application.
Since the endpoint is protected with OAuth 2.0, the new function has to do the OAuth flow.
In order to do the OAuth flow, the function has to use the credentials which we received when we created the Service Key for the XSUAA service instance (step 1.2.2.).

2.1. Create Lambda Function

In this section, we enter the AWS portal and create a Lambda using the dashboard in our browser.
To enter the AWS Management Console, we can use the following URL:

console.aws.amazon.com

To open the Lambda Console, we go to

All Services -> Compute -> Lambda

Alternatively, the direct link: console.aws.amazon.com/lambda

In the Lambda console, we can press “Create Function”

We enter the following details:

We choose “Author from scratch”,
enter function name: “callSappApp”
and select a current version of runtime: “Node.js xx”

Finally we press “Create”.

After few seconds, the details screen of our new function is displayed and we can can enter our function code.
We double-click the generated index.js file, to open it in the editor:

We delete the generated sample code and replace it with the function code from the appendix section.

The sample function code is just sample code and meant to be short. In any case, you should improve it (see homework).
The function first calls the URL of the OAuth Authorization server on SAP BTP, Cloud Foundry, XSUAA, to get a valid JWT token.
In a second step, it calls the endpoint of our Node.js application, deployed on SAP BTP
The function just returns the response of the app, its status code and the response body.

. . .
exports.handler = async (event) => { const jwtToken = await _fetchJwtToken() const result = await _callSapEndpoint(jwtToken) return `Function called SAP app which responded: [status ${result.status}] '${result.message}'`
};
. . .

Note:
Make sure to replace the placeholders with the data retrieved from your service key

const ENDPOINT_HOST = 'https://sappapp.cfapps.eu10.hana.ondemand.com'
const ENDPOINT_PATH = '/prot'
const OA_URL = 'https://<subaccount>.authentication.eu10.hana.ondemand.com'
const CLIENT_ID = 'sb-xsuaa_sappapp!t11111'
const CLIENT_SECRET = 'yourclientsecret'

After pasting and adapting the function code, we save the code via File -> Save from the menu of the Function code editor.

Then we press the “Deploy” button.

2.2. Test run the Lambda function

To run the function, we press the “Test” button.
We’re asked to define a test event.
We can leave the default settings, because we don’t have any requirements to the event. Our event will be just a schedule which triggers our function. In our tutorial, we don’t need any parameters.
We only need to enter a name of our choice.
And press “Create”.

After the test event is created, we can finally run the “Test”.

A new tab is opened which gives little overview on the test run of the Lambda function.
We can see the response of our function which in turn consists of the response of our little Node.js app on SAP BTP

Good.
We have the protected SAP application on SAP BTP and we have the AWS Lambda function which does the OAuth flow and successfully calls the protected endpoint of the SAP app.
This makes us already very happy.

Now we want to define a schedule, to make sure that the AWS Lambda function is triggered on a regular basis.

3. Define schedule on AWS

To define a schedule, we use Amazon EventBridge.

Note:
An alternative option would be to use an Amazon CloudWatch event.
The procedure is very similar.

We open the AWS Management Console console.aws.amazon.com and find the EventBridge at

All Services -> Application Integration -> Amazon EventBridge

Once we’re there, we click on Events -> Rules
What we want to do is to create a rule which is based on a schedule.

The “Create Rule” screen requires the following input:

We enter a name and description of our choice.
The pattern is a “Schedule” and we define a fixed rate every 1 Minutes.
That makes testing easier, however, we need to make sure to change this setting afterwards

Next, we leave the default setting for event bus and we select our previously created Lambda function as “Target”.

No need to configure anything else, so we can go ahead and press “Create” at the bottom of the screen.

We can see that the rule is enabled and we can trust that it is doing its work.

4. Check results

This chapter is dedicated to those who don’t trust.

4.1. Check Amazon EventBridge

To check the execution of our scheduled rule, we go to the Amazon EventBridge dashboard and click on our rule.
This takes us to the details screen of the rule, where we can see the “Monitoring” section.
Clicking the hyperlink takes us to the CloudWatch Console
(direct access: console.aws.amazon.com/cloudwatch)

4.2. Check AWS Lambda

It makes sense to revisit our Lambda function.
So we go to console.aws.amazon.com/lambda and choose our function.
On the details screen, we can see that the “Designer” has added our EventBridge rule, as a trigger, to the diagram.

Nice, but we wanted to view the “Monitoring” tab.
We can scroll down to the recent invocations, where we can see that the function indeed has been invoked every minute:

4.3. SAP BTP

Last check for today is to view the logs of our target application on the SAP Business Technology Platform.
The logs can be found in the application details screen of the cloud cockpit, or on command line with the following command:

cf logs sappapp –recent

As a result, we get many log entries like this one:

So finally we can trust the setup of our scenario.

We’ve seen that we can define a schedule-event on EventBridge that triggers our lambda which calls our app.

4.4. Cleanup

To avoid unnecessary cost, we should stop the minute-by-minute-execution.
We can change or disable or delete the rule on Amazon EventBridge.
So we go to the EventBridge console at console.aws.amazon.com/events

There we can select our rule and e.g. click the “Disable” Button.

5. Optional: Homework

What we don’t cover in this blog post: how to react upon failures.
For instance, our Lambda calls our endpoint which fails to do its task.
Which in turn, causes our Lambda to fail.
In such case, an AWS health check could be created to raise an alert.

For instance CloudWatch Lambda Insights:
“CloudWatch Lambda Insights is a monitoring and troubleshooting solution… collects, aggregates, and summarizes diagnostic information…”

Or anything similar.
Your homework.

Please, once you’re done, raise your finger and share your result with us (in the comment section) – thanks !

Summary

In this blog post we started with the requirement:
We want to schedule a job that calls a secured endpoint on SAP BTP.
That job should run on AWS.
We created the app on SAP BTP.
We created an AWS Lambda Function which calls that app.
We defined an EventBridge schedule to trigger the function regularly.

Links

SAP BTP

AWS Lambda

Amazon EventBridge

Amazon Cloud Watch

Disclaimer

Please consider that the present blog post is not an official documentation nor recommendation.
I’m only describing what I found out.
There’s no guarantee that things will always work as described and look like shown in the screenshots.
Please accept my apologies…

Appendix: Sample Application Code

To follow the tutorial, you can use the following files to create an OAuth protected application on SAP Business Technology Platform, Cloud Foundry environment.

You need Node.js to run the app locally. However, to follow the tutorial it is enough to deploy the files, so you don’t need to install Node.js.

xs-security.json

{ "xsappname" : "xsuaa_sappapp", "tenant-mode" : "dedicated", "scopes": [{ "name": "$XSAPPNAME.sappappscopp" }], "authorities":["$XSAPPNAME.sappappscopp"]
}

manifest.yml

---
applications:
- name: sappapp path: . memory: 128M buildpacks: - nodejs_buildpack services: - xsuaa_sappapp

package.json

{ "main": "server.js", "dependencies": { "@sap/xsenv": "latest", "@sap/xssec": "latest", "express": "^4.16.3", "passport": "^0.4.1" }
}

server.js

const express = require('express');
const passport = require('passport');
const xsenv = require('@sap/xsenv');
const JWTStrategy = require('@sap/xssec').JWTStrategy; //configure passport
const xsuaaService = xsenv.getServices({ myXsuaa: { tag: 'xsuaa' }});
const xsuaaCredentials = xsuaaService.myXsuaa; const jwtStrategy = new JWTStrategy(xsuaaCredentials)
passport.use(jwtStrategy); const app = express(); // Middleware to read JWT function jwtLogger(req, res, next) { console.log(`===> [LOGGER]: user-agent header: ${req.headers['user-agent']}`) console.log('===> [LOGGER]: Decoding JWT...'); const authHeader = req.headers.authorization; if (authHeader){ const theJwtToken = authHeader.substring(7); if(theJwtToken){ console.log('===> [LOGGER] the received JWT token: ' + theJwtToken ) const jwtBase64Encoded = theJwtToken.split('.')[1]; if(jwtBase64Encoded){ const jwtDecoded = Buffer.from(jwtBase64Encoded, 'base64').toString('ascii'); if(jwtDecoded){ const jwtDecodedJson = JSON.parse(jwtDecoded); console.log('===> [LOGGER]: JWT: scopes: ' + jwtDecodedJson.scope); console.log('===> [LOGGER]: JWT: client_id: ' + jwtDecodedJson.client_id); console.log('===> [LOGGER]: JWT: audience: ' + jwtDecodedJson.aud); } } } }else{ console.log('===> no authorization header') } next()
} app.use(jwtLogger)
app.use(passport.initialize());
app.use(passport.authenticate('JWT', { session: false })); // app endpoint with authorization check
app.get('/prot', function(req, res){ console.log(`===> Protected endpoint invoked at ${new Date()} by ${req.headers['user-agent']}`) const MY_SCOPE = xsuaaCredentials.xsappname + '.sappappscopp' // copied from xs-security.json if(req.authInfo.checkScope(MY_SCOPE)){ res.send(`The protected endpoint was properly called, the required scope has been found in JWT token`); }else{ return res.status(403).json({ error: 'Unauthorized', message: 'The endpoint was called by user who does not have the required scope: <sappappscopp> ', }); }
}); const port = process.env.PORT || 3000;
app.listen(port, function(){})

Appendix: Sample Lambda Function Code

Once the Lambda function is created, the content of the generated index.js file can be replaced completely by the following file.

index.js

const https = require('https'); const ENDPOINT_HOST = 'https://sappapp.cfapps.eu10.hana.ondemand.com'
const ENDPOINT_PATH = '/prot'
const OA_URL = 'https://<acc>.authentication.eu10.hana.ondemand.com'
const CLIENT_ID = 'sb-xsuaa_sappapp!t11111'
const CLIENT_SECRET = 'yoursecret' exports.handler = async (event) => { const jwtToken = await _fetchJwtToken() const result = await _callSapEndpoint(jwtToken) return `Function called SAP app which responded: [status ${result.status}] '${result.message}'`
}; const _fetchJwtToken = async function() { return new Promise ((resolve, reject) => { const options = { host: OA_URL.replace('https://', ''), path: '/oauth/token?grant_type=client_credentials&response_type=token', headers: { Authorization: "Basic " + Buffer.from(CLIENT_ID + ':' + CLIENT_SECRET).toString("base64") } } https.get(options, res => { res.setEncoding('utf8') let response = '' res.on('data', chunk => { response += chunk }) res.on('end', () => { try { const jwtToken = JSON.parse(response).access_token resolve(jwtToken) } catch (error) { return reject(new Error('Error while fetching JWT token')) } }) }) .on("error", (error) => { console.log("Error: " + error.message); return reject({error: error}) }); }) } const _callSapEndpoint = async function(jwtToken){ return new Promise((resolve, reject) => { const options = { host: ENDPOINT_HOST.replace('https://', ''), path: ENDPOINT_PATH, headers: { Authorization: 'Bearer ' + jwtToken } } https.get(options, res => { res.setEncoding('utf8') let response = '' res.on('data', chunk => { response += chunk }) res.on('end', () => { resolve({message: response, status: res.statusCode}) }) }) .on("error", (error) => { reject({error: error}) }); })
}