Technical Articles
Using Job Scheduler in SAP BTP [9]: Multitenancy (2): Sample With Job Generation
This blog post is part of a series that intends to show in an easy step-by-step way how to use the SAP Job Scheduling Service (Jobscheduler) running on SAP Business Technology Platform (aka SAP Cloud Platform).
In the current sub-series of the series, we’re trying to shed some light into the dark multi tenant space of multi confusancy.
Quicklinks:
Intro Blog Post
Sample Code
Prerequisites
See previous blog post for prerequisites.
Preparation
See previous blog post for project creation.
See previous blog post for service instance creation.
In short:
We need instances of xsuaa, saas reg, jobscheduler. Config files can be found in the appendix.
cf cs jobscheduler standard myJobschedulerInstance -c “{\”enable-xsuaa-support\”: true}”
cf cs xsuaa application muteteXsuaa -c xs-security.json
cf csk muteteXsuaa sk
cf service-key muteteXsuaa sk
cf cs saas-registry application muteteSaasreg -c config-saasreg.json
Overview
0. Intro
1. Create Application
2. Use Application
3. Delete Application
A1 Extract customer subdomain from JWT
A2 Sample code
0. Intro
In the previous blog post, we went through a minimalistic application, used to get started with multitenancy (MT) and Jobscheduler. That scenario didn’t make use of Jobscheduler’s multitenancy support. Today we’re going for that: create an app that demonstrates some jobscheduler’s MT features.
In this tutorial we’re going to create a sample app according to the following scenario (we described as use case 2 in the intro blog):
We have a multitenant application.
The application itself displays a list of products, which is equal for all customers (tenants).
In addition, each customer gets his own specific discount, according to his specific status.
Requirement: app has functionality to update the status of customers.
The regular update should be done with Jobscheduler.
Action
This action is customer-specific, because the status is specific for each customer.
As such, we cannot just create jobs in the dashboard.
Therefore, we need to offer a little functionality for job creation in our app.
This functionality, the update of status and discount-calculation, should be visible to customers.
Why?
Well, for instance because we’d like to offer flexibility to customers, with respect to frequency, etc
The following diagram is copied from the intro and shows the scenario.
The action endpoint is tenant-specific and Jobscheduler has a job which calls that endpoint.
Job creation
As mentioned, that job cannot be created in the dashboard.
Why?
To be honest, we couuuuld create a job in the dashboard and specify a tenant-specific URL.
Buuut: that works only without security (action-endpoint not protected).
Aaaand: in professional applications, we probably wouldn’t have those composed URLs anymore.
As such, we need to generate the jobs in our application code, using the REST API offered by Jobscheduler.
As mentioned, job creation is visible to customer.
Our sample doesn’t have UI, so we provide an endpoint (representing a job-creation screen).
Below diagram is copied from the intro and shows the customer-specific job creation endpoint:
Summary
Our app offers 3 endpoints, representing 3 aspects.
All of them are customer-specific:
Homepage: https://customer1-app.cf.apps.com/app
Endpoint for job creation: https://customer1-app.cf.apps.com/createJob
Endpoint for Jobscheduler: https://customer1-app.cf.apps.com/action
1. Create Multitenant Application
No description here, as everything is the same like described in previous blog post.
The only differences:
The code:
The action endpoint is now customer-specific.
We’re adding the job generation functionality, also customer-specific.
The route:
Since we’re in dummy dev testing mode, we know in advance that we have to create an additional route for the subscribing tenant, so we can already create it by adding it to the routes section of our manifest.
By the way, we’re still in early sampling mode, so we still don’t protect our action.
As usual, all required files can be found in the Appendix section.
The application code
Let’s quickly discuss the relevant code snippets.
Homepage
Our application’s entry point is the homepage which displays a list of products (empty list in our case).
In addition, it offers functionality to update customer status.
For simplicity, this functionality is just a hyperlink which points to an endpoint which creates a job.
The app URL is tenant-specific, as such we can easily compose the tenant-specific createJob endpoint URL:
app.get('/app', function(req, res){
const url = `https://${req.hostname}/createjob`
res.send(`<h1>Homepage</h1><h4>Products List</h4><p>...</p>Click <a href="${url}">here</a> to update your status.`);
Generate Job
Now we come to the interesting part of this tutorial: the programmatic job generation.
— Few words about job generation —
Jobscheduler offers a REST API which can be used instead of the dashboard:
Create or maintain jobs, view logs, view/set status, etc (example)
Access to the REST API endpoints is protected with OAuth.
The required credentials can be found in the binding, as usual.
This means:
Our app is bound to Jobscheduler and after deploy, we find the Jobscheduler-credentials in the app environment.
Credentials means:
The oauth-server-url and the required user/pwd (more precise: clientid/secret)
With these credentials we can call the mentioned oauth-server-url to obtain a JWT token.
As such, in our app code we can read the credentials from binding, fetch the token, and finally execute the REST call.
All this is just normal.
Goooood.
Buuuuut…..In case of multitenancyyyyyyy…… we have an important requirement:
We create a job on behalf of a tenant.
What does that mean?
It is not our app itself that creates a job.
Our (provider-)app is nothing without its subscribers.
As such, it is the subscriber (tenant) that should create the job.
Ehhmm – hoooow can the subscriber create a job??
That’s what I meant: we, as provider app, we create the job on behalf of the subscriber.
Again, hooooow can we do that?
The multitenancy mechanism has foreseen that situation.
Wise…
We need to fetch a tenant-specific JWT token, and…
And hooow…
patience, let me finish my sentence:
…and this is done by fetching the token from tenant-oauth-server instead of the oauth server of the binding.
Ehhh… what?
Example:
The oauth-server-url in our binding:
https://providersubdomain.authentication.eu10.hana.ondemand.com/oauth/token
The oauth-server-url of tenant:
https://cust1subdomain.authentication.eu10.hana.ondemand.com/oauth/token
We can see that the only difference is the fist segment:
https://providersubdomain.authentication.eu10.hana.ondemand.com/oauth/token
And….
Yes, I foresee the question….
Wise…
…how to authenticate?
Answer: We can use the clientid/clientsecret from our binding.
???
Really, that works.
Why?
Because the tenant has subscribed to our app along with its dependencies.
Cool.
OK.
Once we have the tenant-specific JWT token, we use it to call the REST API.
The rest is done by jobscheduler.
The REST?
No: the rest.
Which rest?
Jobscheduler will look into the JWT token and it will extract information about the tenant.
The dashboard will then display that info along with the generated job.
Cool
As promised: jobscheduler is tenant-aware.
— End of few words–
After these few words, we know what we have to do:
1. Retrieve the tenant-subdomain
2. Fetch tenant-specific JWT token
3. Generate tenant-specific job
Before we start, let’s view the new endpoint which takes care of creating a job:
app.get('/createjob', async function(req, res){
const hostname = req.hostname
const subdomain = hostname.substring(0,hostname.lastIndexOf('-'))
const jwtToken = await fetchJwtToken(subdomain)
const result = await createJob(jwtToken, subdomain)
res.send(`Job created for customer ${subdomain}. Check dashboard/CF logs. Result of job creation: ${JSON.stringify(result)}`)
});
1. Retrieve the tenant-subdomain
The tenant subdomain can be retrieved from the URL.
Why?
The job creation endpoint is tenant-specific. It is called via hyperlink from the app homepage endpoint, which itself has tenant-specific URL as well.
Example:
Our createJob endpoint is called like this:
https://jobschedulertest-mutete.cfapps.eu10.hana.ondemand.com/createjob
We only need to extract the first segment.
Note:
This procedure of reading the subdomain from URL works only in our dummy-dev-testing mode.
In prof mode, the endpoint is secured and the subdomain is extracted from the JWT token.
But let’s continue in dummy mode, to keep things simple.
Stay tuned for upcoming tutorials…
2. Fetch JWT token
Once we know the subdomain, we can use it to fetch the token.
How does the subdomain help?
We need it to build the URL of the token endpoint of the OAuth authorization server.
Example?
Normal URL:
providersubdomain.authentication.eu10.hana.ondemand.com
Tenant-specific URL:
customer1subdomain.authentication.eu10.hana.ondemand.com
As such, we can compose the tenant-specific oauth-server-URL as follows:
const uaadomain = JOBSCH_CREDENTIALS.uaa.uaadomain
const oauthEndpoint = `${subdomain}.${uaadomain}`
And then use it to fire the call to fetch a JWT token.
As mentioned before, we use the Jobscheduler credentials (clientid and clientsecret) to authenticate:
async function fetchJwtToken(subdomain) {
const uaadomain = JOBSCH_CREDENTIALS.uaa.uaadomain
const oauthEndpoint = `${subdomain}.${uaadomain}`
const options = {
host: oauthEndpoint, //'jobschedulertest.authentication.sap.hana.ondemand.com',
path: '/oauth/token?grant_type=client_credentials&response_type=token',
headers: {
Authorization: "Basic " + Buffer.from(JOBSCH_CLIENTID + ':' + JOBSCH_SECRET).toString("base64")
Note:
As you’ve noticed, we’re using the client-credentials flow to fetch the token.
This is legal way of fetching a token.
However, in a professional scenario, your application would be always protected with OAuth, as such it would be the preferred way to just re-use (somewhat) the existing token (see next blog).
Note:
I’ve added some more clarification text and diagrams in Appendix 3. Just in case…
Note:
If you’re interested to view the tenant-specific info in the JWT token, you can use the code snippet that can be found in the Appendix 1.
3. Generate tenant-specific job
Once we have that tenant-specific JWT token, we can use it for job creation.
The URL of the REST API can be found in the binding.
There is an endpoint for managing jobs.
In my example it looks like this:
https://jobscheduler-rest.cfapps.sap.hana.ondemand.com/scheduler/jobs
To create a new job, we fire a POST request and send the required data in the request body.
In our example, we create a job with meaningful and unique name and with schedule to run immediately:
const data = JSON.stringify({
name: `MuteteJob_${new Date().getMilliseconds()}`,
action: `https://${subdomain}-mutete.cfapps.sap.hana.ondemand.com/action`,
active: true,
httpMethod: 'GET',
schedules: [{
time: 'now',
active: 'true'
Important point to mention:
In our example, we generate a job that triggers an action endpoint that is tenant-specific.
How?
Again, we use the subdomain that we retrieved earlier, to compose the action-endpoint RL
action: `https://${subdomain}-mutete.cfapps.sap.hana.ondemand.com/action`
Note:
In professional scenario, here would be the place of flexibility, where you could point to a tenant-unspecific endpoint of your app, which reads the JWT token to handle tenant-specific data.
Note:
In our sample app, as usual, we’re adhering to native library to fire REST calls.
In your professional code, you would use helper libraries e.g. to fetch the JWT token.
And probably you would use the Jobscheduler libraries (node/java) to manage jobs.
Deploy
Now that we’ve gone through the application code, we <can not> deploy our MT sample app.
Not?
Before we deploy, we should make sure that we’ve adapted the tenant-specific route in the manifest file, to avoid manual route creation.
See the manifest.yml in the Appendix:
applications:
- name: mutete
routes:
- route: mutete.cfapps.eu10.hana.ondemand.com
- route: cust1subdomain-mutete.cfapps.eu10.hana.ondemand.com
Now we <can> deploy.
Really?
Yes: cf push
After push is ready, we ask Cloud Foundry to stream the logs for us:
cf logs mutete
2. Use the Application
After deployment, we’re suddenly looking like a customer admin that subscribes to the Mutete app.
Subscribe
And after subscription we do <not> click on the “gotoapp” button because first we want to check the logs.
To check the logs, we switch to developer mode and see that the console has printed the Tenant ID:
Open app
We remember the tenant ID and switch to end-user mode.
As an end-user, we open the app homepage, see the products list – still empty, but we can now update our poor status, as we have the create-job functionality.
Create job
We choose to cerate a job by clicking on the hyperlink.
Check Dashboard
Afterwards, we go to the Jobscheduler Dashboard to check the newly generated job (but not before having changed to developer mode) :
We can see that the Tenant ID is the same which we still remember and the Sub Domain is also well known to us.
Afterwards we should have a look at the Run Log, just to see our silly response from our stupid “/action” endpoint.
Unsubscribe
We dedicate an own chapter for unsubscribe.
Why? Unsubscribing is boring…
Yes, in fact the chapter is not only about unsubscribe…
already unsubscribed
…it is about the dashboard which we should visit afterwards.
That’s boring as well.
Why?
Because it is empty
Indeed – and that’s cool, because the previously generated job has now disappeared automatically.
Ohhh…now I consider it really amazing
No, it was expected, because mentioned in some earlier blog post. It’s just cool to see that it worked.
Cool
3. Delete the Application
There’s nothing amazing nor cool about deleting the app, we’re just nice cloud users who clean up before we go home.
cf d mutete -f -r
cf dsk muteteXsuaa sk -f
cf ds muteteXsuaa -f
cf ds muteteSaasreg -f
That’s it.
Now we can shut down the cloud.
Done. Can I go home now?
Sure.
Summary
In this blog post we’ve learned how to programmatically generate a job on behalf of a tenant.
This is a general requirement, when jobs are needed in multitenant scenarios.
In today’s sample we’ve ignored security considerations.
We’ve learned that we need to dynamically retrieve the subdomain of a tenant in order to generate a job for him.
We’ve learned how to use the REST API offered by Jobscheduler, including JWT token.
We’ve learned how to fetch a tenant-specific JWT token: fire request to tenant’s oauth server and use the credentials of our own binding (own=provider app)
The app scenario was designed according to use case 2. This was realized by using tenant-specific URLs for the involved endpoints.
Next Steps
Next tutorial will show how Jobscheduler manages authorization in case of tenant-specific action-endpoint which is secured with OAuth.
Links
Introduction to Job Scheduler in different multitenancy scenarios
Previous tutorial explaining the application skeleton.
Job Scheduler docu about REST API, to create and manage jobs remotely.
Jobscheduler client library docu.
See overview blog post for more links.
Appendix 1: Extract customer subdomain from JWT
If you’re curious how to read the customer subdomain which is contained in the JWT token, you can use the following snippet.
Note that the code crashes if a token is passed that hasn’t been enriched with the optional claim for external attributes
function readConsumerSubdomainFromJWT(jwtToken){
const jwtBase64Encoded = jwtToken.split('.')[1];
const jwtDecodedAsString = Buffer.from(jwtBase64Encoded, 'base64').toString('ascii')
console.log(`==> JWT token issued for subdomain: ${JSON.parse(jwtDecodedAsString).ext_attr.zdn}`)
}
Appendix 2: All Sample Project Files
Note:
Everything can be copy&pasted, only the following values need to be adapted:
property “appId” and app URLs in config-saasreg.json
property “name” and “routes” in manifest.yaml
xs-security.json
{
"xsappname": "mutetexsappname",
"tenant-mode": "shared"
}
config-saasreg.json
{
"appId": "mutetexsappname!t12345",
"appName": "muteteAppNameForSaasReg",
"appUrls": {
"getDependencies" : "https://mutete.cfapps.eu10.hana.ondemand.com/handleDependencies",
"onSubscription" : "https://mutete.cfapps.eu10.hana.ondemand.com/handleSubscription/{tenantId}"
},
"displayName": "MuTeTe with Jobscheduler"
}
manifest.yml
---
applications:
- name: mutete
routes:
- route: mutete.cfapps.eu10.hana.ondemand.com
- route: cust1subdomain-mutete.cfapps.eu10.hana.ondemand.com
memory: 128M
services:
- muteteSaasreg
- muteteXsuaa
- myJobschedulerInstance
package.json
{
"dependencies": {
"express": "^4.16.2"
}
}
server.js
const https = require('https');
const express = require('express');
const app = express()
app.use(express.json())
const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES);
const JOBSCH_CREDENTIALS = VCAP_SERVICES.jobscheduler[0].credentials;
const JOBSCH_CLIENTID = JOBSCH_CREDENTIALS.uaa.clientid
const JOBSCH_SECRET = JOBSCH_CREDENTIALS.uaa.clientsecret
const JOBSCH_URL = JOBSCH_CREDENTIALS.url //"https://jobscheduler-rest.cfapps.eu10.hana.ondemand.com"
/* App server */
app.listen(process.env.PORT, () => {});
/* App endpoints */
app.get('/app', function(req, res){
const url = `https://${req.hostname}/createjob`
res.send(`<h1>Homepage</h1><h4>Products List</h4><p>...</p>Click <a href="${url}">here</a> to update your status.`);
});
app.get('/createjob', async function(req, res){
const hostname = req.hostname
const subdomain = hostname.substring(0,hostname.lastIndexOf('-'))
const jwtToken = await fetchJwtToken(subdomain)
const result = await createJob(jwtToken, subdomain)
res.send(`Job created for customer ${subdomain}. Check dashboard/CF logs. Result of job creation: ${JSON.stringify(result)}`)
});
app.get('/action', function(req, res){
res.send(`"/action" endpoint invoked by jobscheduler. Customer status updated for ${req.hostname}.`)
});
/* Multi Tenancy callbacks */
app.get('/handleDependencies', (req, res) => {
res.status(200).json([{'xsappname': JOBSCH_CREDENTIALS.uaa.xsappname }]);
});
app.put('/handleSubscription/:myConsumer', (req, res) => {
console.log(`==> onSubscription: the TenantID of subscriber: ${req.body.subscribedTenantId}`)
const appHost = req.hostname
const subDomain = req.body.subscribedSubdomain
res.status(200).send(`https://${subDomain}-${appHost}/app`)
});
app.delete('/handleSubscription/:myConsumer', (req, res) => {
res.status(200).end('unsubscribed')
});
/* HELPER */
async function fetchJwtToken(subdomain) {
return new Promise ((resolve, reject) => {
const uaadomain = JOBSCH_CREDENTIALS.uaa.uaadomain
const oauthEndpoint = `${subdomain}.${uaadomain}`
const options = {
host: oauthEndpoint,
path: '/oauth/token?grant_type=client_credentials&response_type=token',
headers: {
Authorization: "Basic " + Buffer.from(JOBSCH_CLIENTID + ':' + JOBSCH_SECRET).toString("base64")
}
}
https.get(options, res => {
res.setEncoding('utf8')
let response = ''
res.on('data', chunk => {
response += chunk
})
res.on('end', () => {
try {
const responseAsJson = JSON.parse(response)
resolve(responseAsJson.access_token)
} catch (error) {}
})
})
})
}
async function createJob(jwtToken, subdomain){
return new Promise ((resolve, reject) => {
const options = {
host: JOBSCH_URL.replace('https://', ''),
path: `/scheduler/jobs`,
method: 'POST',
headers: {
Authorization: 'Bearer ' + jwtToken,
'Content-type': 'application/json'
}
}
const req = https.request(options, (res) => {
resolve({status: `Job result: ${res.statusCode} - ${res.statusMessage}`})
});
const data = JSON.stringify({
name: `MuteteJob_${new Date().getMilliseconds()}`,
action: `https://${subdomain}-mutete.cfapps.sap.hana.ondemand.com/action`,
active: true,
httpMethod: 'GET',
schedules: [{
time: 'now',
active: 'true'
}]
})
req.write(data)
req.end()
})
}
Appendix 3: Clarification on token for REST API
Still confused?
Let’s again look at the way of using the REST API.
First the normal way:
The app has binding to Jobscheduler instance.
Jobscheduler offers REST API which is protected itself.
Means it has its own little xsuaa, used for protection and issuing JWT tokens.
When our app wants to use the REST API, it fetches a token from that xsuaa:
The diagram shows how the createJob implementation reaches out to the Jobscheduler-xsuaa before it calls the REST API.
Now tenant-specific scenario:
The diagram below is meant to clarify about the different XSUAAs.
Our createJob implementation addresses the customer-specific XSUAA in order to get a JWT token which is then used to call the REST API. The REST API lives in the provider subaccount, whereas the token is fetched from the oauth server in the customer subaccount. This is important, because that way, the token will contain info about the customer subdomain. That info is required by Jobscheduler, to be tenant-aware.
The diagram is also meant to clarify that the XSUAA, which is bound to our application, is not involved in this task of job-generation.
Also now, the XSUAA which is bound to Jobscheduler itself, is not used in this tenant-specific scenario.
So we don’t mix the different XSUAAs.
Awesome blog series... Thanks for everything.
Small suggestion - Why not use Axios and make the code easier to read.
Hi Milton Chandradas ,
Thank you for the compliment, glad if it has been useful for you!
And thank you for the feedback - I agree with you.
Reason why I'm not using it: trying to avoid third-party libs, as they might become obsolete over time.
As happened with the "request" module, which I was using in earlier blog posts.
Now, after your suggestion, I'm thinking about an extra appendix for native code sample, which should last for decades....,-)