Skip to Content
Technical Articles

Cloud-Native Lab #4 – Multi-tenant Web Apps in SAP BTP

In this Cloud-Native Lab post, I’ll review multi-tenant development in SAP BTP. This blog post won’t go deep into the theory (I’ll sketch the rough idea and link some resources, though) but instead, focus on a sample guestbook application that can be understood and built quickly with minimal effort.

Two%20tenants%20of%20the%20guestbook%20sample%20project

Two tenants of the guestbook sample project

The image above shows two tenants of the guestbook application that you will build in this post. The idea of this guestbook is quite simple; each tenant will have its own guestbook that is available under a unique URL. The application comes with two role templates – reader and author. Readers can see all existing entries of that tenant, and authors can also add new entries. Once the project is deployed to the provider subaccount, you can create any number of tenants from the service marketplace.

To keep things simple, we won’t add any persistence layer. We will use a standard JSON object in our extended approuter to temporarily save some data. Consequently, all data is lost once the application restarts, but this is fine for our simple demo. If you would like to persist the data, please look at the multitenancy guide from CAP.

Multi-Tenancy

Multitenancy refers to software architecture, in which tenants share the same technical resources but keep the data separated and identity and access management for each tenant isolated

Using a single set of computing resources for multiple tenants (aka clients or customers) has several advantages, such as a reduced maintenance effort or a lower cost of ownership. In other words: You only need one approuter and one backend application (like a CAP app) to serve any number of clients. This works because these applications neither hold any user information nor any tenant data (as this data is stored in individual HDI containers). Therefore, every additional tenant only comes with the overhead of one additional HDI container. This step can easily be automated – but in our database-less Guestbook sample, it isn’t necessary at all.

The easiest way to build a multi-tenant project in SAP BTP is by using the  SAP SaaS Provisioning Service. An application can register with this service from a so-called “provider subaccount” to declare that they can serve multiple tenants. Once this registration happened, other (consumer) subaccounts that live in the same global account can subscribe to them via the service marketplace in the SAP BTP Cockpit.

Service%20Marketplace%20showing%20the%20Guestbook%20subscription

Service Marketplace showing the Guestbook subscription

There are already several good blog posts that explain the underlying concepts of multi-tenancy. Have a look at them if you want to dive deeper into this topic:

 Multi-Tenancy in SAP BTP Trial

One thing that isn’t highlighted in these posts is multi-tenancy in SAP BTP Trial. This is why I want to go a little bit deeper on this topic here.

Usually, you would use a dot (.) to separate the tenant id from the rest of the application URL in the tenant host pattern (like this ^(.*).application.cfapps.eu10.hana.ondemand.com). This configuration is not forbidden in the trial landscape and you  will even be able to deploy the project to the provider subaccount. But later, during the subscription, you will receive the following error message:

“No subject alternative DNS name matching <tenant id>.<app name>.cfapps.eu10.hana.ondemand.com found”

You’ll get this error because you are trying to define a route with multiple dots, but this is not possible in the free SAP BTP Trial.

In a productive multi-tenant approuter, you need to apply for a custom domain. In this scenario, you can create a Cloud Foundry route with a wildcard host. This wildcard enables calls to the approuter from subscriber subaccount without creating a new route for each subscriber.

For example:

1. You apply for custom domain application.company.com

2. You create a cf route *. application.company.com 

3. You set the tenant host pattern to ^(.*).application.company.com

4. onSubscription URL would be provider.application.company.com

5. Subscriber URLs will contain subscriber subdomain in the host, e.g., subscriber1.application.company.com

The advantage of this scenario is that you only need one cf route (with a wildcard) to cover all tenants.

In Trial, this looks different as the cf route cannot contain two dots in the subdomain. That’s why we’ll use a dash (-) instead of a dot (.) as a separator of the tenant host pattern ^(.*)-application.company.com. As a result of that pattern, the callback endpoints of the approuter should look like this:

onSubscription: https://provider-mtx-guestbook.cfapps.eu10.hana.ondemand.com/callback/v1.0/tenants/{tenantId}
getDependencies: https://provider-mtx-guestbook.cfapps.eu10.hana.ondemand.com/callback/v1.0/dependencies

As the wilcard option won’t work in the trial landscape, you need to add at least two fixed routes (one per tenant). While this is a little bit ugly, it’s not too bad as we’re just writing a demo app for the sake of learning. And for that, we use the free standard SAP domain “cfapps.eu10.hana.ondemand.com”. One route will contain the subdomain of the provider subaccount and then you need one more for each planned consumer subaccount. These routes can easily be defined with the mta.yaml parameter routes:

  - name: mtx-approuter
    type: approuter.nodejs
    path: router
    parameters:
      routes:
        - route: https://provider-application.company.com
        - route: https://consumer-application.company.com
        - route: https://secondConsumer-application.company.com

The credits for this great explanation go to Sergio Rozenszajn. Many thanks for your help!

 

So much for the theory. Let’s build this sample application!

Hands-on: Build a guestbook application

This section will show you the most important files of the guestbook sample project from above. Note that you can find the sample code on GitHub. Feel free to compare your files to the originals there or clone the repository as a whole.

0. Preparation

But before we start, you need to install a few tools. Please install Node.js, mbt, git, and the Cloud Foundry CLI (incl the MultiApps plugin) if you haven’t done so. In case you need help, I recommend following this tutorial group that contains step-by-step guides.

As we want to build a multi-tenant application, you need at least two subaccounts that run in the same region. The first one will be the provider subaccount that hosts the application and all service instances. The second one is the consumer subaccount. We will use this one to subscribe to the application – there is no need to activate any runtime or to assign entitlements to the consumer subaccount. A plain subaccount will do the job.

1. Build the web application

Let’s start with the web app HTML5Module/mainfest.json. We will deploy the app to the HTML5 application repository, so we need to include a name for the business service (next to the mandatory properties) of the web app to the manifest:

{
  "_version": "1.12.0",
  "sap.app": {
    "id": "mtx-guestbook",
    "type": "application",
    "applicationVersion": {
      "version": "1.0.0"
    }
  },
  "sap.cloud": {
    "service": "cloud.service"
  }
}

I dismissed the MVC pattern here to keep this demo as tiny as possible. Therefore, the entire web app is defined in the HTML5Module/index.html file.

<!DOCTYPE HTML>
<html>

<head>
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
  <link rel="shortcut icon"
    href="https://static.community.services.sap/com-hdr/v7/453.190.7/shared-ui/1dx-assets/images/favicon.png"
    type="image/x-icon">
  <title>MTX Guestbook</title>
  <script src="https://sapui5.hana.ondemand.com/resources/sap-ui-core.js" id="sap-ui-bootstrap" data-sap-ui-libs="sap.m"
    data-sap-ui-theme="sap_fiori_3_dark">
    </script>

  <style>
    .sapMPageEnableScrolling {
      padding: 35px;
    }
  </style>
  <script>

    const guestbook = new sap.ui.model.json.JSONModel({})

    fetch("/guestbook").then(async (res) => {
      if (res.ok) {
        const data = await res.json();
        guestbook.setData(data);
      }
    });

    new sap.m.App({
      pages: new sap.m.Page({
        title: {
          path: "/tenant",
          formatter: tenant => `Multi-tenant Guestbook (${tenant})`
        },
        headerContent: new sap.m.Button({
          icon: "sap-icon://log",
          tooltip: {
            path: "/user",
            formatter: user => `Logout ${user}`
          },
          press: function () {
            window.location.replace("/logout");
          }
        }),
        content: [
          new sap.m.List({
            showSeparators: "Inner",
            items: {
              path: "/entries",
              template: new sap.m.FeedListItem({
                showIcon: false,
                sender: "{author}",
                timestamp: {
                  path: "timestamp",
                  formatter: ts => new Date(ts)
                },
                text: "{content}",
                convertLinksToAnchorTags: "All"
              })
            }
          }),
          new sap.m.FeedInput({
            showIcon: false,
            enabled: {
              path: "/canWrite",
              formatter: scope => !!scope
            },
            post: async function (oEvent) {
              const input = oEvent.getParameter("value");
              const csrfRes = await fetch("/guestbook", {
                method: "HEAD",
                headers: { "x-csrf-token": "fetch" }
              });
              const res = await fetch(`/guestbook?content=${input}`, {
                method: 'POST',
                headers: { "x-csrf-token": csrfRes.headers.get("x-csrf-token") }
              });
              const newData = await res.json();
              guestbook.setData(newData);
            }
          })
        ]
      })
    }).setModel(guestbook)
      .placeAt("uiArea");
  </script>
</head>

<body class="sapUiBody">
  <div id="uiArea"></div>
</body>

</html>

Note that our entire app consists of only six ready-to-use SAPUI5 components and some trivial data binding. I think this example perfectly shows how (among other use-cases) SAPUI5/OpenUI5 can be used for rapid prototyping.  

As we want to upload the webapp to the HTML5 application repository, we need a HTML5Module/xs-app.json file. This file is mostly empty, but we need it anyway.

{
  "routes": []
}

The last substep for the web app will help us package all resources into one zip archive to push the app to the HTML5 application repository. Using npm scripts in the HTML5Module/package.json file is one of the most convenient ways to realize this:

{
  "name": "html5module",
  "version": "0.0.1",
  "scripts": {
    "build": "npm run clean && npm run zip",
    "zip": "npx bestzip HTML5Module-content.zip *",
    "clean": "npx rimraf HTML5Module-content.zip dist"
  }
}

2. Extend the default application router

Real persistency is no requirement for this guestbook, so it’s ok to lose data on every restart. This “requirement” gives us the freedom to store all data in a JSON object in the application memory. To store these values, we need to customize the approuter to add a new endpoint. Add the following router/extended-server.js file:

const approuter = require('@sap/approuter');

const ar = approuter(),
    entries = {};

ar.beforeRequestHandler.use('/guestbook', function myMiddleware(req, res) {
    if (req.isUnauthenticated()) {
        res.statusCode = 401;
        res.end("Unauthorized");
        return;
    }
    const canRead = req.user.scopes.find((scope => scope.includes("Read")));
    if (!canRead) {
        res.statusCode = 401;
        res.end("Unauthorized");
        return;
    }
    const canWrite = req.user.scopes.find((scope => scope.includes("Write")));
    const tenant = req.user.tenant;
    if (req.method === "POST" && canWrite) { // not the best permission check but ok for demo
        if (!entries[tenant]) {
            entries[tenant] = [];
        }
        entries[tenant].push({
            content: req.query.content,
            author: req.user.name,
            timestamp: new Date()
        })
    }
    res.end(JSON.stringify({
        tenant,
        canWrite,
        user: req.user.name,
        entries: entries[tenant]
    }));
});
ar.start();

This mini script starts an approuter that contains one additional endpoint (/guestbook). Authenticated users can use the endpoint to receive all entries (with HTTP GET) or add a new entry (with HTTP POST). 

Let’s say only users who have the scope Reader can access this web application. Besides, the approuter should also forward traffic to our web application and allow any users to see the logout page. All these features can be controlled with the router/xs-app.json configuration file:

{
  "welcomeFile": "/index.html",
  "authenticationMethod": "route",
  "logout": {
    "logoutEndpoint": "/logout",
    "logoutPage": "/logout.html"
  },
  "routes": [
    {
      "source": "^/logout-page.html$",
      "target": "/mtxguestbook/logout-page.html",
      "service": "html5-apps-repo-rt",
      "authenticationType": "none"
    },
    {
      "source": "(.*)",
      "scope": ["$XSAPPNAME.Read"],
      "target": "/mtxguestbook/$1",
      "service": "html5-apps-repo-rt"
    }
  ]
}

Add the router/package.json file to declare the dependency to the standard approuter and define the start script.

{
	"name": "appouter",
	"description": "Node.js based application router service for html5-apps",
	"dependencies": {
		"@sap/approuter": "^10.2.0"
	},
	"scripts": {
		"start": "node extended-server.js"
	}
}

3. Tieing it all together with the project manifest

This is the most critical part of this application. The project manifest connects all the dots that we discussed so far and creates the needed service bindings. Copy this snippet to a new file mta.yaml, and then let’s discuss the individual parts of it.

ID: mtx-guestbook
_schema-version: "2.1"
version: 1.0.0

parameters:
  appname: mtx-guestbook
  subdomain: <your provider subdomain>

modules:
  - name: mtx-approuter
    type: approuter.nodejs
    path: router
    parameters:
      routes:
        - route: https://${subdomain}-${appname}.${default-domain}
        - route: https://<your first consumer subdomain>-${appname}.${default-domain}
        - route: https://<your second consumer subdomain>-${appname}.${default-domain}
      disk-quota: 256M
      memory: 256M
      host: ${appname}
      domain: ${default-domain}
    requires:
      - name: html5-rt
      - name: uaa
      - name: saas-registry
    properties:
      TENANT_HOST_PATTERN: "^(.*)-${appname}.${default-domain}"
  - name: html5_deployer
    type: com.sap.application.content
    path: .
    requires:
      - name: html5-host
        parameters:
          content-target: true
    build-parameters:
      build-result: resources
      requires:
        - artifacts:
            - HTML5Module-content.zip
          name: HTML5Module
          target-path: resources/
  - name: HTML5Module
    type: html5
    path: HTML5Module
    build-parameters:
      builder: custom
      commands:
        - npm run build
      supported-platforms: []
resources:
  - name: html5-host
    type: org.cloudfoundry.managed-service
    parameters:
      service: html5-apps-repo
      service-plan: app-host
      service-name: ${appname}-html5-host
  - name: html5-rt
    parameters:
      service: html5-apps-repo
      service-plan: app-runtime
      service-name: ${appname}-html5-rt
    type: org.cloudfoundry.managed-service
  - name: uaa
    type: org.cloudfoundry.managed-service
    parameters:
      service: xsuaa
      service-plan: application
      service-name: ${appname}-uaa
      config:
        xsappname: ${appname}
        tenant-mode: shared
        scopes:
          - name: $XSAPPNAME.Read
            description: Read permission
          - name: $XSAPPNAME.Write
            description: Write permission
          - name: $XSAPPNAME.Callback
            description: With this scope set, the callbacks for tenant onboarding, offboarding and getDependencies can be called.
            grant-as-authority-to-apps:
              - $XSAPPNAME(application,sap-provisioning,tenant-onboarding)
        foreign-scope-references:
          - uaa.user
        role-templates:
          - name: Reader
            description: Can read
            scope-references:
              - $XSAPPNAME.Read
          - name: Author
            description: Can read and write
            scope-references:
              - $XSAPPNAME.Read
              - $XSAPPNAME.Write
  - name: saas-registry
    type: org.cloudfoundry.managed-service
    parameters:
      service: saas-registry
      service-plan: application
      service-name: ${appname}-saas-registry
      config:
        xsappname: ${appname}
        appName: ${appname}
        displayName: Guestbook
        description: A guestbook app to explain the concepts of Multitenancy
        category: Custom Apps
        appUrls:
          onSubscription: https://${subdomain}-${appname}.${default-domain}/callback/v1.0/tenants/{tenantId}
          getDependencies: https://${subdomain}-${appname}.${default-domain}/callback/v1.0/dependencies

The first thing you might notice is the usage of customer parameters (appname and subdomain). We can use these parameters as variables and reuse them throughout this file.

The first module is our extended approuter. Note that we use the routes parameter to define one route for the provider subdomain and two for the subdomains of the tenants. Change these values to the subdomains that you use in your setup. Besides this, we change the host parameter to get a URL that is a little bit shorter and easier to read than the default host. Besides the standard service bindings (html5-rt and uaa), you’ll also notice two multi-tenant-specific artifacts there: The service binding to the saas registry service and the tenant host pattern that we discussed above.

The second and third modules are business-as-usual. Here we trigger the zip of the content of the HTML5Module and pass the archive to a deployer module. This deployer will upload the static resources to the HTML5 application repository during deployment.

Let’s jump to the resources, aka the service instance definitions: The first two resources are the html5-apps-repo instances to upload the static resources (bound to the deployer) and to consume them (bound to the approuter).

The third service instance is mostly just a regular uaa service instance that defines three scopes and two role templates. Note the third scope $XSAPPNAME.Callback that is required to allow multiple tenants for the apps. It’s a little bit unusual that we defined the uaa service within the mta.yaml file, whereas you might be more familiar with the definition as an external JSON file. I decided to do it like this to be able to reuse the parameter appname here but the other way works here as well.

Last but not least, we got the saas-registry service definition. This definition uses the parameters to link to the uaa service (via the xsappname) and approuter (via the appUrls). It also contains other configuration parameters like appName, displayName, description, and categorythat will be used in the BTP Cockpit to identify the subscription service.

At this point, I recommend that you compare your project to the sample code on GitHub to make sure everything is ready for deployment.

4. Deploy the project to the provider subaccount

I’m sure you’ve done this plenty of times by now. Run the following commands to deploy your app:

mbt build
cf deploy mta_archives/mtx-guestbook_1.0.0.mtar

5. Subscribe to the Guestbook

1.%20Go%20to%20the%20consumer%20subaccount%20and%20find%20the%20subscription.%20Click%20on%20the%20three%20dots%20and%20select%20Create

1. Go to the consumer subaccount and find the subscription. Click on the three dots and select Create

2.%20Keep%20the%20default%20values%20and%20confirm%20with%20Create

2. Keep the default values and confirm with Create

3. Click View Subscription to open the new subscription

4. Once you are subscribed, use the + icon to add the Author role template to one of your role collections (create one if needed)

5. Hit Go To Application to open your tenant

6 Test the app

You can notice a redirect to the authentication service of SAP BTP if you watch the address bar of your browser closely. This redirect means that you are automatically logged on via SSO to your new tenant. Depending on the assigned role template, you can see and possibly add new entries to the guestbook. New entries are automatically associated with the email address of your user. This flow shows the power of SSO in combination with the UAA service.

Adding%20new%20entries%20to%20the%20guestbook

Adding new entries to the guestbook

From here, you could even create a second subaccount and subscribe there as well.

Troubleshooting

What to do when I get a red “Subscription failed” message?

There%20can%20be%20multiple%20reason%20for%20a%20Subscription%20failed%20error%20message

There can be multiple reasons for a “Subscription failed” error message

My tip would be to inspect the payload of the request that returned the error message:

  1. Open the network tab of the dev tools of your browser.
  2. Filter all requests for getCFSaaSApplications.
  3. Find the object within the array that represents your subscription.
  4. Inspect the property stateDetails.message. The cause is usually mentioned in the last sentence of this string:

This%20error%20message%2C%20for%20example%2C%20indicates%20that%20the%20application%20is%20not%20running. This error message, for example, indicates that the application is not running.

Summary

We saw that multi-tenant projects look almost like single-tenant projects. The main differences in the “knows components” are (a) the definition of the xsuaa service instance that uses the shared tenant-mode and contains an additional scope as well as (b) the TENANT_HOST_PATTERN environment variable of the application router. The component that we haven’t used before is the SaaS registry service instance. This instance registers the multi-tenant application in SAP BTP and contains all needed information to display the subscription in the BTP cockpit of the consumer subaccounts.

We also learned about the workaround with fixed routes that is required for multi-tenant applications that run in SAP BTP Trial.

Next Steps

Previous episode: Cloud-Native Lab #3 – Comparing Cloud Foundry and Kyma Clients

Next episode: TBD

 

6 Comments
You must be Logged on to comment or reply to a post.
  • Hi Marius,

    Great blog. Thanks for sharing. I have a good understanding on Multi-tenant apps now.

    I have a beginners question. You have explained the above multi-tenant application based on standalone app-router. How can i achieve multi-tenancy for application with managed app-router?

    Thanks,

    Kachin