
Two tenants of the guestbook sample project
Multitenancy refers to software architecture, in which tenants share the same technical resources bu...
Service Marketplace showing the Guestbook subscription
^(.*).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: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 beprovider.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.
^(.*)-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
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
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"
}
}
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>
HTML5Module/xs-app.json
file. This file is mostly empty, but we need it anyway.{
"routes": []
}
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"
}
}
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();
/guestbook
). Authenticated users can use the endpoint to receive all entries (with HTTP GET
) or add a new entry (with HTTP POST
). 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"
}
]
}
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"
}
}
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
appname
and subdomain
). We can use these parameters as variables and reuse them throughout this file.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.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.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 category
that will be used in the BTP Cockpit to identify the subscription service.mbt build
cf deploy mta_archives/mtx-guestbook_1.0.0.mtar
1. Go to the consumer subaccount and find the subscription. Click on the three dots and select Create
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
UAA
service.Adding new entries to the guestbook
There can be multiple reasons for a "Subscription failed" error message
getCFSaaSApplications
.stateDetails.message
. The cause is usually mentioned in the last sentence of this string: This error message, for example, indicates that the application is not running.
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.You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
User | Count |
---|---|
24 | |
23 | |
22 | |
14 | |
12 | |
10 | |
9 | |
7 | |
7 | |
6 |