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>