Technology Blogs by SAP
Learn how to extend and personalize SAP applications. Follow the SAP technology blog for insights into SAP BTP, ABAP, SAP Analytics Cloud, SAP HANA, and more.
cancel
Showing results for 
Search instead for 
Did you mean: 
CarlosRoggan
Product and Topic Expert
Product and Topic Expert
1,680
This blog post gives an example for an eventing scenario with a multitenant application.
It runs on SAP Business Technology Platform, Cloud Foundry environment and uses SAP Event Mesh.
This is not an official reference application, it is just some sample code to help you in quickly setting up your project.






Important Note:
The restricted usage of MT SaaS is suspended and further onboarding of new SaaS scenarios using the default plan is not possible.
Existing users of this restricted feature can continue to use.
SAP is working on productizing eventing usecases with different commercial offerings to suit various customers / partners.
Please expect an update on this soon.

 

Quicklinks:
Quick Guide
Sample Code


This sample scenario builds on top of the previous one.
There’s no difference, just a few additions for completing the scenario.
We’re adding a user interface, user login and security.
To do so, we’re adding approuter to both apps (sender and receiver).
In this blog post, we’re going through the coding, but won’t repeat explanations from previous post.
So if you miss any explanation, please follow the links to the previous post, before getting confused.

Introduction


See previous intro.
Quick recap:
Our example scenario is based on a soccerclub using an on-Premise system.
An extension app is required for handling all Fanshop stuff.
New Fans can register there and the info is sent to the onPrem system via Event Mesh.


The extension app is designed as multitenant app.


The multitenant app is deployed to a provider subaccount and is subscribed in a customer subaccount.
The onPrem Soccerclub system is represented by a fake cloud app, deployed to customer subaccount.

Above diagram shows the second version of our scenario with small enhancements:
Both apps consist of 2 components, UI and server. They can now display a basic UI which is accessed via approuter.
The Fanshop app has a dedicated static web app, which again is very simplistic (you’ll anyways replace it, so let’s keep it short, for better overview).
As a consequence, the multitenant app will now register the approuter-URL instead of the app-URL for main user entry (“Go to Application”).
The Soccerclub uses approuter only for handling user login.
Both apps are secured with OAuth 2.0
The webhook subscription is handling the security.

Below diagram shows the service instances created in provider subaccount and in customer subaccount, and it shows the Event Mesh dashboard (service plan standard) subscribed in customer subaccount.


Security
In this scenario, all endpoints are protected with OAuth 2.0.
Furthermore, all endpoints require a certain scope.
Some are assigned as roles to the end-user, others are required only in client-credentials requests.
Also the Webhook needs to be configured with security.
As a consequence, we need an instance of XSUAA in the customer subaccount as well.
User-login is handled by approuter

Content


1. Create Soccerclub App
2. Create Fanshop App
3. Run the Scenario
Appendix 1: Soccerclub App
Appendix 2: Fanshop App

Prerequisites


Access to SAP BTP Cloud Foundry environment, including 2 subaccounts.
Basic understanding of multitenancy and Cloud Foundry.

Preparation


Subaccounts

We need 2 subaccounts, as usual for multitenancy scenarios.
One of them will be the provider account and it needs to be entitled for creating an instance of SAP Event Mesh.

Create Project

We create a root project folder C:\club containing 2 subfolders for the 2 applications
C:\club
fanshop
app
approuter
soccerclub
app
approuter

Or this screenshot:


Each app folder contains a few files required for little node server apps:

C:\club
fanshop
app
package.json
server.js
approuter
resources
fanshopUi.html
package.json
xs-app.json
config-messaging.json
config-saasreg.json
config-security.json
manifest.yml
soccerclub
app
package.json
server.js
approuter
package.json
xs-app.json
config-security.json
manifest.yml

For your convenience please refer to below screenshot


The content of the files can be copied from the Appendix section.




1. Create Soccerclub App


The receiver app with webhook endpoint for incoming events.
More details here.

1.0. Environment


The following steps are done in the customer subaccount.

1.1. Create service instances


Our simple onPrem fake app requires an instance of XSUAA and the Event Mesh dashboard..

Subscribe to Event Mesh Dashboard

We go to our customer-subaccount and subscribe to Event Mesh “standard” plan.

Roles
We need to assign the required roles to our user:
To find the Event-Mesh-roles, we select “xbem-app” as filter for “Application Identifier”.

Create instance of XSUAA

Our soccerclub app provides 2 endpoints and we’re protecting them with OAuth 2.0, which will be ensured by our server implementation.
In addition, we define 2 scopes, one for each endpoint.
One endpoint represents the homepage of the app and is accessed by end-users.
As such, we define a scope $XSAPPNAME.scopeforhomepage and we wrap it in a role, such that it can be assigned to the end-user.
The second endpoint is the webhook that is called by Event Mesh.
As such, we don’t need a role for the scope $XSAPPNAME.scopeforwebhook

Important:
We need to declare an authorities statement and add the webhook-scope to it.
This is required for the Event Mesh to send a JWT token including the required scope.
The security configuration declares a scope, but that doesn’t mean that the oauth client (xsuaa instance) will receive that scope, when requesting a scope from the OAuth server (xsuaa).
To do so, it needs to “accept” the scope, which is done by the authorities statement.

For detailed explanation, refer to this webhook blog post - or this detailed blog post explaining the scope assignment in client-credentials flow.

config-security.json
. . .
“scopes”: [{
“name”: “$XSAPPNAME.scopeforhomepage”,
},{
“name”: “$XSAPPNAME.scopeforwebhook”,
}
],
“role-templates”: [{
“name”: “soccerclubAccessRole”,
“scope-references”: [“$XSAPPNAME.scopeforhomepage”, “uaa.user”]
}],
“authorities”:[“$XSAPPNAME.scopeforwebhook”]

See Appendix for full file content.
Creation command:
cf cs xsuaa application soccerclubXsuaa -c config-security.json

1.2. Create app


As mentioned, our app consists of 2 components:
The core component, which is the server app that we know from previous  blog.
The approuter, which is the main entry point for end-users.

1.2.1. The core app

The server app provides an endpoint for the webhook subscription, to be called by Event Mesh.
Today, we’re protecting it with OAuth and we’re enforcing the scope with below code:
app.post('/webhook/fanshopevents', passport.authenticate('JWT', {session: false}), (req, res) => {
const auth = req.authInfo
if (! auth.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforwebhook')) {

we’re reading the incoming events, but today, in addition, we’re storing them in a kind of fake database:
const event = req.body
DATABASE.push(`<br>New Fan "${event.data.user}" registered with email "${event.data.email}" (on ${event.header.date})</br>`)

Actually, we’re storing a line of text for each event, so we can easily display it in the UI.
Apropos UI…..
Although we’re adding the approuter component to our soccerclub app, we’re not adding a separate web app.
That is demoed in the fanshop application.
Here, we just provide an additional endpoint which represents the UI, the homepage of the app, which is as well protected with OAuth and scope:
app.get('/homepage', passport.authenticate('JWT', {session: false}), (req, res) => {

The homepage just displays some dummy text and the content of the fake database:
res.send(`<h1>Homepage Soccer Club</h1><h3>Hello ${user}</h3><h4>List of registered Fans:</h4>${DATABASE}`)

So how should an end-user access the OAuth-protected homepage URL, which requires a JWT token?
The answer is:
See next section:

1.2.2. The approuter app

We deploy an approuter configuration as standalone approuter app.
It has its own URL, so user will access this approuter URL, instead of the server app URL.
The configuration is such that it will redirect the user to the given destination:
"routes": [{
"source": "^/tohome/(.*)$",
"target": "$1",
"destination": "destination_soccerclub",

The destination is defined in our manifest:
env:
destinations: >
[
{
"name":"destination_soccerclub",
"url":https://soccerclubapp.cfapps.sap.hana.ondemand.com/homepage,
"forwardAuthToken": true

Note:
Defining a destination in manifest is a shortcut, only allowed for prototypes (or tutorials).

The destination and points to the /homepage endpoint of our server app.
All fine.

Still open question:
How could the user send the required JWT token?
This is one more thing that is handled by approuter configuration: Security
"authenticationType": "xsuaa",
"httpMethods": ["GET"],
"scope": "$XSAPPNAME.scopeforhomepage"

It will use the xsuaa instance which we created above and which is bound to both approuter and server app.
Approuter will take care of the complete OAuth flow (called "authorization code flow").
And it will forward the JWT token to the destination, because we’ve set the forwardAuthToken flag to true.
That’s the explanation
In addition, we specify that only GET requests are allowed
And, finally, we’re also specifying the scope which we require.
This is redundant, because we check the scope as well in our server implementation
But it shows that approuter can do the check in an early step as well (we will need this in the fanshop app).

Note:
Throughout my tutorials, I’m using silly names for artifacts and URLs etc.
I strongly believe that it makes things more clear, and faster to understand.
Obviously, it is not a recommendation nor a best practice.
Just useful for tutorials….
My apologies

The complete code can be found in the Appendix section.

1.3. Deploy


Both app modules are declared in one manifest, so we need only one deploy step.
Before we can open our homepage (approuter URL), we need to assign the role, which we require, to our test user.

Assign Role
Create Role Collection e.g. "soccerclub_roles".
Add role "soccerclubAccessRole".
Add user.

See previous blog for more detailed description

Open homepage
Finally we can open our homepage.
We access it via our approuter-URL:

https://soccerclubrouter.cfapps.sap.hana.ondemand.com/tohome/

Note:
Don't forget the trailing slash.

Thanks to approuter (and xsuaa), a login page is displayed and after entering our user credentials, we’re forwarded to our homepage where we see our name.

At the end of this chapter, we have the receiver app running, waiting for events to be sent to the webhook endpoint.




2. Create Fanshop App


Coming to the multitenant sender application (See previous blog for details).
Compared to previous blog, we’ve added a tiny web app with embedded approuter.
All endpoints are protected with OAuth 2.0 and require a scope.

2.0. Environment

We make sure that we're now working in provider subaccount.

2.1. Create service instances

We need instances of Event Mesh (for sending events), SaaS registry (for multitenancy) and XSUAA (required by SaaS Registry).
The creation command uses config files which we created in the preparation section and which can be found in the appendix.

Create Event Mesh instance

There’s no change to the configuration which we used in previous blog.
Let’s repeat that the important setting, to make the Event Mesh tenant-aware, is the instance-type property which needs to be set to reuse:
  "instanceType": "reuse"

Reminder:
make sure to switch to the provider subaccount before creating the service instances

The creation command:
cf cs enterprise-messaging default fanshopMsg -c config-messaging.json

Create instance of XSUAA

Similarly, the important setting for multitenant app in XSUAA:
"tenant-mode": "shared"

Today we’re defining a couple of scopes, to protect our endpoints:
The homepage of fanshop application can only be opened by users who have a role which contains the scope:
"name": "$XSAPPNAME.scopeforhomepage"

That’s all fine for users who just wish to read info from the homepage (assuming that the website will grow over time).
However, if a user wants to register, he needs an additional role, which contains this scope:
"name": "$XSAPPNAME.scopeforregister"

We also declare 2 role-templates, for the 2 user-relevant scopes.
These role-templates can be seen later in the cockpit, with the names that we give here:
"role-templates": [
{
"name": "Fanshop Access Role",
"scope-references": ["$XSAPPNAME.scopeforhomepage", "uaa.user"]
},
{
"name": "Fanshop Registration Role",
"scope-references": ["$XSAPPNAME.scopeforregister", "uaa.user"]

That's it for users.
We have still 3 more endpoints in our server app: the callbacks that are called by the SaaS Registry.
These endpoints should be protected as well with OAuth and scope, to make sure that these endpoints can only be called by SaaS Registry.
2 Steps are required here:
First, we define a scope, dedicated for the SaaS Registry:
"name": "$XSAPPNAME.scopeforsaas"

But how to achieve that the scope is really assigned to the SaaS Registry?
We cannot go to the cockpit and assign a role to a service, like we do with human users.
As such, in case of service (client-credentials), the mechanism is to define a grant statement:
"grant-as-authority-to-apps": [
"$XSAPPNAME(application,sap-provisioning,tenant-onboarding)"

The creation command:
cf cs xsuaa application fanshopXsuaa -c config-security.json

Create Service Key (because we need the xsappname)
cf csk fanshopXsuaa sk
View the content:
cf service-key fanshopXsuaa sk

copy xsappname to clipboard, we need it in the config file of SaaS Registry below.
In my example: fanshopxsappname!t14860

Create instance of SaaS Registry

Before we proceed, we have to copy&paste the value of property xsappname to the config file of saas registry, as value of property appid.
In my example:
{
"appId": "fanshopxsappname!t14860",
"appName": "fanshopSaasregAppname",
"appUrls": {
"getDependencies" : https://fanshopapp.cfapps.sap.hana.ondemand.com/mtcallback/dependencies,
"onSubscription" : https://fanshopapp.cfapps.sap.hana.ondemand.com/mtcallback/{tenantId}

Note:
The URLs need to be adapted to your landscape and don't forget to paste your xsappname.

So now we can create the saas-registry instance:
cf cs saas-registry application fanshopSaasreg -c config-saasreg.json

After these preparation steps, we can create our multitenant application

2.2. Create Application


Today, our multitenant fanshop app consists of 2 components:
1. The core component, which is the server app that we know from previous  blog.
2. The web app which is just a silly html page.
The web app contains the embedded approuter, which is the main entry point for end-users and takes care of user login.

2.2.1. The core app

Compared to previous blog, today's app doesn't have the  /app endpoint which represented the homepage in previous version.
We don’t need it, because we have the dedicated web app (the html page).

We have the 3 SaaS callbacks that are now protected with OAuth and scope:
app.put('/mtcallback/:tenant_id', passport.authenticate('JWT', {session: false}), (req, res) => {
if (!req.authInfo.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforsaas')) {
res.status(403).end('Forbidden')

One difference is in the subscription callback, where we have to return the main entry point for our application.
As mentioned earlier, we’re not targeting an endpoint in our server app.
Instead, we want to take the user to our html page.
This is done via approuter.
So we return an approuter URL in the callback.
We just need to give the approuter root URL, without html file name.
The file name is not necessary, because it is declared as default in the approuter config (we will see later).
To compose the URL, we just replace the fanshop hostname, which is called by SaaS Registry, with the approuter name:
const appHost = req.hostname.replace('fanshopapp','fanshoprouter')
const subDomain = req.body.subscribedSubdomain
res.status(200).send(`https://${subDomain}-${appHost}`)

Finally, we exposed the /register endpoint, which is again protected with OAuth and scope:
app.get('/register',  passport.authenticate('JWT', {session: false}), async (req, res) => {
const auth = req.authInfo
if (! auth.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforregister')) {
res.status(403).end('Forbidden. Authorization for registration is missing.')

It will send an event to the Event Mesh instance, just like in previous blog.

But there’s one difference:
We use different mechanism for fetching a token for the REST API.
Remember:
In previous version, we used the credentials from binding to fetch a JWT token.
Just normal client-credentials flow.

Today we want to learn how to use token exchange.
Why?
A professional application (like ours – ehem….) is typically secured, such that only authenticated users can access it.
After user-login, a JWT token is issued and this token carries information about the user.
This token is valid for accessing our app - but not valid for calling the REST API of Event Mesh.
BUT: we can use this token to fetch a new valid token (for the REST API).
The resulting token will still carry the info about the user.
Which is an advantage over client-credentials flow.

Let’s start from scratch.
Our end-user opens the URL of our web app, which is in fact an approuter URL
Approuter delegates to XSUAA which displays a logon screen.
During the logon process (authorization-code flow, handled by approuter) a JWT token is issued.
This token is kept in a session by the approuter.
The HTML web app has no access to it.
BUT: the web app uses approuter to call our /register endpoint, and the JWT token, which is peacefully waiting in the session, is forwarded to our endpoint.
Our /register endpoint is secured with libraries passport and xssec (JWTStrategy).
The xssec library validates the incoming JWT token and creates a convenience object and adds it to the request object: the authInfo.
The authInfo is useful and peacefully waiting for us to use it:
app.get('/register',  passport.authenticate('JWT', {session: false}), async (req, res) => {
const auth = req.authInfo

We use it for the scope check:
if (! auth.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforregister')) {

We use it for silly text:
res.send(`Thank you for your registration as new Fan, '${auth.getGivenName()}'

But we also use it to access the JWT token:
_sendMessage(msg, QUEUE_NAME_POSTFIX, MSG_CREDENTIALS, auth.getAppToken()...

and to access the subdomain
_sendMessage(... auth.getSubdomain())

The authInfo object provides convenience methods to directly access information contained in the token, like the subdomain.
Subdomain?
Remember that we’re in a multitenant scenario.
The app is opened in a subscribing customer subaccount (tenant).
The customer account has its own XSUAA server and subdomain.
As app developer, we cannot know the xsuaa server of the subscribing customers, obviously.
BUT: we can compose the URL if we know the subdomain.
We can access the subdomain in the JWT token, as shown above.
Why?
Because the end-user in the customer-subaccount has done the login via the customer-XSUAA.
As such, the end-user-token contains the customer-subdomain.
Confusing.

Let’s look again at the diagram:


The diagram shows the end-user on the left side accessing the subscribed web-app and there is the XSUAA instance.
I call it "delegate", because it was created by SaaS Registry during subscription.
This is the customer-XSUAA, in the customer-subaccount with customer-subdomain.
See previous blog for more explanations

And...ehmm.... now I’ve lost track….
The JWT
Oh yes, thank you.

We’ve got a hold of the end-user JWT  and we use it in the sendMessage function, to do the token exchange:
async function _sendMessage(msg, queueNamePostfix, msgCredentials, jwt, subdomain){   
const jwtToken = await _doTokenExchange(jwt, subdomain, msgCredentials.uaa)

To actually execute the token exchange, we use the friendly xssec module:
xssec.requests.requestUserToken(jwt, uaa, null, null, subdomain, null, (error, token)=>{
resolve(token)

Once we have exchanged the token, we can use the new token for calling the REST API.
We proceed sending the event, just like in previous blog.

2.2.2. The web app

Before we send anything, we need to go back to the beginning.
After subscription, the end-user presses “Go to Application” and the URL that is opened is the URL which we returned in the SaaS callback, as described above.
It is the root URL of approuter.
Why just root URL and not URL of homepage?
We need to know that approuter has a default:
We can specify the default in the configuration (xs-app.json).
If we specify a welcomeFile in the config, then approuter will open it by default, when the root URL is requested.

In our example:
"welcomeFile": "fanshopUi.html"

How does approuter find the file?
It is located in a local folder “resources”.
We’ve specified the route to it:
"source": "^/(.*)$",
"localDir": "resources",
"authenticationType": "xsuaa",
"scope": "$XSAPPNAME.scopeforhomepage"

See Appendix for full file content.

BTW, if we use "resources" as folder name, then we can skip this route definition, because it is the default folder where approuter will search for the welcomeFile.
I’ve specified the route explicitly, although not necessary, because I like it when things are clearly visible (in tutorials).

And there’s one more default, just for your information:
If the welcomeFile is named index.html, then it doesn’t need to be specified at all.
And that’s the reason why I named it differently: in tutorials I like everything to be written clearly and not happening by magic.

OK, the user is taken to the html file.
BUT: before it is displayed, user is taken to login screen.
That’s due to the authenticationType which we’ve set to xsuaa.
After login, a JWT token is sent to approuter - and approuter will check if the specified scope is available.
A nice feature: approuter supports the variable $XSAPPNAME which we used in the security configuration to make the scope name unique.

So finally the user has reached the UI, which is a very simple html page with an input field and a button.
The user can enter his mail address and press the button to submit his registration
<p>
Enter your email:
<input id="mailFieldId">
<button type="button" onclick="callRegister(mailFieldId.value)">Submit</button>
</p>

To submit the registration, our /register endpoint of our server app is called:
function callRegister(email){
$.ajax({
url: `/route-me-to/register?&email=${email}`,

As we can see above, the endpoint is not called directly, but instead the approuter is called with the following route:
"source": "^/route-me-to/(.*)$",
"target": "$1",
"destination": "destination_fanshop",
"authenticationType": "xsuaa",
"httpMethods": ["GET"],
"scope": "$XSAPPNAME.scopeforregister"

The route supports adding uri segments at the end (regular expression, added as variable to the target), so we can use it to pass the URL parameter:
?&email=${email}

The route will forward to the destination which we specified in our manifest file:
destinations: >
[
{
"name":"destination_fanshop",
"url":https://fanshopapp.cfapps.sap.hana.ondemand.com,
"forwardAuthToken": true

The route requires authentication with JWT token (OAuth 2).
"authenticationType": "xsuaa"

We cannot send a JWT token from our html page – but that’s not a problem, because we use the approuter.
As we know, the approuter has kept the JWT token in a session after user login, so it can use that token to forward it to the /register endpoint of our server app.
And the destination configuration has declared that forwarding the token is desired:
"forwardAuthToken": true

So everything’s fine and the /register endpoint can do its work.
Hope everything is clear. All explanations that have been skipped today can be found in yesterday's blog post.
If you're new to approuter, you might find this approuter tutorial useful.
The complete code can be found in the Appendix section.

Note:
As usual, the code is meant to be short for good overview, it needs rework obviously.

2.3. Deploy


So now we can deploy the fanshop app including the webapp with approuter.
Before we deploy, we make sure that our Cloud Foundry CLI is still targetting the provider account.




3. Run the Scenario


This chapter takes place in the customer account.
Our fanshop app is protected and requires that the user has certain roles, so let’s do this first.

Assign Roles

First we check the existing roles.
In the cloud cockpit, we go to Roles, then we use the search field for searching our roles:
"Soccerclub" role is available.
"Fanshopapp" role not available.
After wondering a second, we understand:
The "Fanshopapp" role is not available, because it is defined in the provider account. It will be propagated to the subscriber account after subscribing to the app that declares the roles.

Subscribe to Fanshop app

Details are explained in the previous blog post.
Again, after subscription, we do NOT press the button “Go to Application”

Assign Roles

We check again the existing roles and now we can see that the 2 fanshopapp roles are available.
Now we can proceed assigning the 2 Roles for Fanshop app to our user.
Create Role Collection -> add 2 Fanshop roles -> add user -> save.

Note:
It might sound strange that a fan needs roles in order to do registration.
Yes, I believe you’re right. But let’s ignore it, we just want to showcase security.

Now that we have the required roles, why not open the app?
No, we do NOT press “Go to Application”.
Not yet.
Reason:
We cannot send messages before we’ve created the queue.

Create Queue

We remember from previous blog post how to create the queue, so we just go ahead and create the queue with name : "fanshopQueue"

Send

Now we can “Go to Application” to open our Fanshop homepage.
We enter anything in the field and press "Submit".
Then we check in the Event Mesh dashboard, to see if a message has arrived.

Create Webhook Subscription

At this point, we've deployed the receiver app and the sender app.
We can send messages to Event Mesh.

Now the last step:
Event mesh has to forward the messages to soccerclubapp.

So now we go to the customer subaccount, open the Event Mesh Dashboard and create a webhook subscription.
Creating a webhook subscription was described in details in previous blog and in this blog post.
Today we're adding the OAuth 2 authentication.
We need the credentials which we get with this command:
cf env soccerclubapp

We find the following section:

VCAP_SERVICES: {
"xsuaa": [
"credentials":

We take a note of the following properties:

"url": https://consumer.authentication.sap.hana.ondemand.com,
"clientid": "sb-soccerclubxsappname!t12345",
"clientsecret": "AaBbCcDd123456789="

And enter these values in the dialog.
Note:
For the "Token URL", we need to append the token endpoint which is /oauth/token
So the correct "Token URL" would be:
https://consumer.authentication.sap.hana.ondemand.com/oauth/token

The endpoint in my example:
https://soccerclubapp.cfapps.sap.hana.ondemand.com/webhook/fanshopevents

Test

We can test the webhook in the dashboard with the "Test" tab and check the log  and check the soccerclub app for incoming messages.

Run the scenario

Finally, we can send events from our fanshop app and receive them in the soccerclub app.
To send events we use this URL:
https://consumer-fanshoprouter.cfapps.sap.hana.ondemand.com/fanshopUi.html

To receive events we use this URL:
https://soccerclubrouter.cfapps.sap.hana.ondemand.com/tohome/

Note that we need to press "Refresh", to see changes in the app.

Cleanup

First delete the subscription.
Afterwards, delete the apps and services in both subaccounts
For your convenience, the commands to clean up our space:

cf d soccerclubapp -f -r
cf d soccerclubrouter -f -r
cf ds soccerclubXsuaa -f
cf t -o providerOrg -s mySpace
cf d fanshopapp -f -r
cf d fanshoprouter -f -r
cf dsk fanshopXsuaa sk -f
cf ds fanshopXsuaa -f
cf ds fanshopSaasreg -f
cf ds fanshopMsg -f

Troubleshooting

If things don't work, we need to add error handling to our code.
First of all, we need to check if messages arrive in Event Mesh.
To do so, we can set the webhook subscription to "pause".
If messages don't arrive, we can check the response of the Event Mesh REST API in our fanshop app. The above Implementiation just returns the response status code.
To read the response body, we can add the following code to the end of the _sendMessage function:
console.log('=================>' + response.status + JSON.stringify(await response.json()) )

If there's an error saying that "queue not found", then the reason might be that the queue was not created in the customer subaccount.

One more hint: make sure to invoke the fanshop app in the customer subaccount, not the provider subaccount. The URL should look like this:
https://consumer-fanshopapp.cfapps.sap.hana.ondemand.com/register?name=test

Summary


In the present tutorial, we’ve created a little scenario, where 2 applications are connected via Event Mesh.
One of the applications is a multitenant app and it sends events to Event Mesh.
The receiver app is connected to Event Mesh via Webhook subscription.
The basic learning is: how to configure Event Mesh to support multitenancy.
In addition, we've gone through configuration of approuter and scopes and how to grant scopes.

Quick Guide


Using Event Mesh in a multitenant app requires the following property in the configuration:
"instanceType": "reuse"

Links


SAP Help Portal:
SAP Event Mesh landing page.
SAP Event Mesh docu about JSON params.
SAP Event Mesh documentation about REST API.
SAP Event Mesh documentation about Entitlements.

Previous blog post with simpler scenario and explanations.
Blog post about Webhook Subscription

Tutorial about approuter for beginners.
Tutorial about token exchange.

XSUAA documentation about the xs-security.json parameters.
Node.js module documentation;
https://github.com/bitinn/node-fetch
https://www.npmjs.com/package/@sap/xssec




Appendix 1: Soccerclub Application


app


config-security.json
{
"xsappname": "soccerclubxsappname",
"tenant-mode": "dedicated",
"scopes": [
{
"name": "$XSAPPNAME.scopeforhomepage",
"description": "Scope required for users to login to homepage"
},
{
"name": "$XSAPPNAME.scopeforwebhook",
"description": "Scope required for event mesh to send msg to webhook endpoint"
}
],
"role-templates": [
{
"name": "soccerclubAccessRole",
"description": "Role for end users, allows to login to soccer club homepage",
"scope-references": ["$XSAPPNAME.scopeforhomepage", "uaa.user"]
}
],
"authorities":["$XSAPPNAME.scopeforwebhook"]
}

manifest.yml
---
applications:
- name: soccerclubapp
path: app
memory: 64M
routes:
- route: soccerclubapp.cfapps.sap.hana.ondemand.com
services:
- soccerclubXsuaa
- name: soccerclubrouter
routes:
- route: soccerclubrouter.cfapps.sap.hana.ondemand.com
path: approuter
memory: 128M
buildpack: nodejs_buildpack
env:
destinations: >
[
{
"name":"destination_soccerclub",
"url":"https://soccerclubapp.cfapps.sap.hana.ondemand.com/homepage",
"forwardAuthToken": true
}
]
services:
- soccerclubXsuaa

package.json

{
"dependencies": {
"@sap/xsenv": "latest",
"@sap/xssec": "latest",
"express": "^4.17.1",
"passport": "^0.4.0"
}
}

server.js
const xsenv = require('@sap/xsenv')

const UAA_CREDENTIALS = xsenv.getServices({myXsuaa: {tag: 'xsuaa'}}).myXsuaa
const DATABASE = new Array() // Global variable representing database

const express = require('express')
const app = express();
const xssec = require('@sap/xssec')
const passport = require('passport')
const JWTStrategy = xssec.JWTStrategy
passport.use('JWT', new JWTStrategy(UAA_CREDENTIALS))
app.use(passport.initialize())
app.use(express.json())

// start server
app.listen(process.env.PORT)

// display frontend
app.get('/homepage', passport.authenticate('JWT', {session: false}), (req, res) => {
const auth = req.authInfo
const user = auth.getGivenName()
console.log(`===> [/homepage] called by user ${user} with oauth creds: '${auth.getClientId()}' - subdomain: ${auth.getSubdomain()}`)

if (! auth.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforhomepage')) {
res.status(403).end('Forbidden. Authorization for homepage access is missing.')
}else{
res.send(`<h1>Homepage Soccer Club</h1><h3>Hello ${user}</h3><h4>List of registered Fans:</h4>${DATABASE}`)
}
})

// webhook
app.post('/webhook/fanshopevents', passport.authenticate('JWT', {session: false}), (req, res) => {
const auth = req.authInfo
if (! auth.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforwebhook')) {
res.status(403).end('Forbidden. Authorization for webhook access is missing.')
}else{
// store the messages in fake-database
const event = req.body
DATABASE.push(`<br>New Fan "${event.data.user}" registered with email "${event.data.email}" (on ${event.header.date})</br>`)
res.status(201).send()
}
})

approuter


package.json

{
"dependencies": {
"@sap/approuter": "latest"
},
"scripts": {
"start": "node node_modules/@sap/approuter/approuter.js"
}
}

xs-app.json

{
"authenticationMethod": "route",
"routes": [
{
"source": "^/tohome/(.*)$",
"target": "$1",
"destination": "destination_soccerclub",
"authenticationType": "xsuaa",
"httpMethods": ["GET"],
"scope": "$XSAPPNAME.scopeforhomepage"
}
]
}

Appendix 2: Fanshop Application


app


config-messaging.json

{
"emname": "fanshopmessagingclient",
"namespace": "z/soccerclub.scenario/fanshop",
"version": "1.1.0",
"instanceType": "reuse",
"options": {
"management": true,
"messagingrest": true,
"messaging": true
},
"rules": {
"queueRules": {
"publishFilter": [
"${namespace}/*"
],
"subscribeFilter": [
"${namespace}/*"
]
},
"topicRules": {
"publishFilter": [
"${namespace}/*"
],
"subscribeFilter": [
"${namespace}/*"
]
}
}
}

config-saasreg.json

{
"appId": "fanshopxsappname!t14860",
"appName": "fanshopSaasregAppname",
"appUrls": {
"getDependencies" : "https://fanshopapp.cfapps.sap.hana.ondemand.com/mtcallback/dependencies",
"onSubscription" : "https://fanshopapp.cfapps.sap.hana.ondemand.com/mtcallback/{tenantId}"
},
"displayName": "Fanshop application"
}

config-security.json

{
"xsappname": "fanshopxsappname",
"tenant-mode": "shared",
"scopes": [
{
"name": "$XSAPPNAME.scopeforhomepage",
"description": "Scope required for user to login to homepage"
},
{
"name": "$XSAPPNAME.scopeforregister",
"description": "Scope required for user to register"
},
{
"name": "$XSAPPNAME.scopeforsaas",
"description": "Scope required by subscription callbacks",
"grant-as-authority-to-apps": [
"$XSAPPNAME(application,sap-provisioning,tenant-onboarding)"
]
}
],
"role-templates": [
{
"name": "FanshopAccessRole",
"description": "Role for end users, allows to login to fanshop homepage",
"scope-references": ["$XSAPPNAME.scopeforhomepage", "uaa.user"]
},
{
"name": "FanshopRegistrationRole",
"description": "Role for fanshop users, allows to do registration.",
"scope-references": ["$XSAPPNAME.scopeforregister", "uaa.user"]
}
]
}

manifest.yml

---
applications:
- name: fanshopapp
path: app
memory: 512M
routes:
- route: fanshopapp.cfapps.sap.hana.ondemand.com
services:
- fanshopMsg
- fanshopXsuaa
- fanshopSaasreg
- name: fanshoprouter
routes:
- route: fanshoprouter.cfapps.sap.hana.ondemand.com
- route: asincconsumer-fanshoprouter.cfapps.sap.hana.ondemand.com
path: approuter
env:
destinations: >
[
{
"name":"destination_fanshop",
"url":"https://fanshopapp.cfapps.sap.hana.ondemand.com",
"forwardAuthToken": true
}
]
TENANT_HOST_PATTERN: "^(.*)-fanshoprouter.cfapps.sap.hana.ondemand.com"
services:
- fanshopXsuaa

package.json

{
"dependencies": {
"@sap/xsenv": "latest",
"@sap/xssec": "latest",
"express": "^4.16.2",
"node-fetch": "2.6.2",
"passport": "^0.4.0"
}
}

server.js
const xsenv = require('@sap/xsenv')
const serviceBindings = xsenv.getServices({
myMessaging: { tag: 'enterprise-messaging' },
myXsuaa: {tag: 'xsuaa'}
})
const MSG_CREDENTIALS = serviceBindings.myMessaging
const UAA_CREDENTIALS = serviceBindings.myXsuaa
const QUEUE_NAME_POSTFIX = 'fanshopQueue'

const fetch = require('node-fetch')
const express = require('express')
const app = express()
const xssec = require('@sap/xssec')
const passport = require('passport')
const JWTStrategy = xssec.JWTStrategy
passport.use('JWT', new JWTStrategy(UAA_CREDENTIALS))
app.use(passport.initialize())
app.use(express.json())


/* App server */
app.listen(process.env.PORT)

/* App endpoints */

app.get('/register', passport.authenticate('JWT', {session: false}), async (req, res) => {
const auth = req.authInfo
if (! auth.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforregister')) {
res.status(403).end('Forbidden. Authorization for registration is missing.')
}else{
const msg = _composeMessage(req)
const response = await _sendMessage(msg, QUEUE_NAME_POSTFIX, MSG_CREDENTIALS, auth.getAppToken(), auth.getSubdomain())
res.send(`Thank you for your registration as new Fan, '${auth.getGivenName()}'. Registration submitted with status '${response}'`)
}
})

/* Multi Tenancy callbacks */

app.put('/mtcallback/:tenant_id', passport.authenticate('JWT', {session: false}), (req, res) => {
if (!req.authInfo.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforsaas')) {
res.status(403).end('Forbidden')
}else{
const appHost = req.hostname.replace('fanshopapp','fanshoprouter')
const subDomain = req.body.subscribedSubdomain
res.status(200).send(`https://${subDomain}-${appHost}`)
}
})

app.delete('/mtcallback/:tenant_id', passport.authenticate('JWT', {session: false}), (req, res) => {
if (!req.authInfo.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforsaas')) {
res.status(403).end('Forbidden')
}else{
res.status(200).end('unsubscribed')
}
})

app.get('/mtcallback/dependencies', passport.authenticate('JWT', {session: false}), (req, res) => {
if (!req.authInfo.checkScope(UAA_CREDENTIALS.xsappname + '.scopeforsaas')) {
res.status(403).end('Forbidden')
}else{
res.status(200).json([{'xsappname': MSG_CREDENTIALS.uaa.xsappname }])
}
})

/* HELPER */

function _composeMessage(req){
const messageJSON = {
"header": {
"date" : new Date().toJSON()
},
"data": {
"subdomain": req.authInfo.getSubdomain(),
"user" : req.authInfo.getGivenName(),
"email": req.query.email
}
}
return JSON.stringify(messageJSON)
}

async function _sendMessage(msg, queueNamePostfix, msgCredentials, jwt, subdomain){
const jwtToken = await _doTokenExchange(jwt, subdomain, msgCredentials.uaa) // tenant-specific
const messagingRestUrl = _composeMsgRestUrl(msgCredentials, queueNamePostfix)
const options = {
method: 'POST',
body: msg,
headers: {
Authorization: 'Bearer ' + jwtToken,
'Content-Type': 'application/json',
'x-qos' : 0 // or 1
}
}
const response = await fetch(messagingRestUrl, options)
return response.status
}

async function _doTokenExchange (jwt, subdomain, uaa){
return new Promise ((resolve, reject) => {
xssec.requests.requestUserToken(jwt, uaa, null, null, subdomain, null, (error, token)=>{
resolve(token)
})
})
}

function _composeMsgRestUrl (msgCredentials, queueNamePostfix){
const slash = '%2F'
const namespace = msgCredentials.namespace
const namespaceEncoded = namespace.replace(/\//g, slash)
const fullQueue = namespaceEncoded + slash + queueNamePostfix
const uri = msgCredentials.messaging[2].uri
return `${uri}/messagingrest/v1/queues/${fullQueue}/messages`
}

approuter


package.json

{
"dependencies": {
"@sap/approuter": "latest"
},
"scripts": {
"start": "node node_modules/@sap/approuter/approuter.js"
}
}

xs-app.json

{
"welcomeFile": "fanshopUi.html",
"authenticationMethod": "route",
"routes": [
{
"source": "^/route-me-to/(.*)$",
"target": "$1",
"destination": "destination_fanshop",
"authenticationType": "xsuaa",
"httpMethods": ["GET"],
"scope": "$XSAPPNAME.scopeforregister"
},
{
"source": "^/(.*)$",
"localDir": "resources",
"authenticationType": "xsuaa",
"scope": "$XSAPPNAME.scopeforhomepage"
}
]
}

fanshopUi.html

<html>
<head>
<script src = "https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
function write(text){
const p = document.createElement("P")
p.appendChild(document.createTextNode("-> " + text))
document.body.appendChild(p)
}
function callRegister(email){
$.ajax({
url: `/route-me-to/register?&email=${email}`,
success: function(result, status, xhr){
write(result)
},
error: function(xhr, status, error){
write(xhr.responseText)
}
})
}
</script>
</head>
<body>
<h1>Homepage Fan Shop</h1>
<h4>Register as Soccer Fan, to receive benefits like newsletter, gifts, lottery, and much more!</h4>
<p>Enter your email: <input id="mailFieldId"><button type="button" onclick="callRegister(mailFieldId.value)">Submit</button></p>
</body>
</html>