@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
394 lines (328 loc) • 15 kB
JavaScript
const cds = require('../cds')
const LOG = cds.log('ucl')
const fs = require('fs').promises
const https = require('https')
const { getCloudSdk } = require('../remote/utils/cloudSdkProvider')
const { READ_QUERY, CREATE_MUTATION, UPDATE_MUTATION, DELETE_MUTATION } = require('./queries')
const TRUSTED_CERT = {
CANARY: {
ISSUER: 'CN=SAP PKI Certificate Service Client CA,OU=SAP BTP Clients,O=SAP SE,L=cf-eu10-canary,C=DE',
SUBJECT: 'CN=cmp-stage,OU=SAP Cloud Platform Clients,OU=Canary,OU=cmp-cf-eu10-canary,O=SAP SE,L=Stage,C=DE'
},
LIVE: {
ISSUER: 'CN=SAP PKI Certificate Service Client CA,OU=SAP BTP Clients,O=SAP SE,L=cf-eu10,C=DE',
SUBJECT: 'CN=cmp-prod,OU=SAP Cloud Platform Clients,OU=cmp-cf-eu10,O=SAP SE,L=Prod,C=DE'
}
}
module.exports = class UCLService extends cds.Service {
constructor(...args) {
super(...args)
// REVISIT: cds.connect.to('ucl') should return this, but no cds.requires.kinds.ucl config achieved this
cds.services.ucl = this
}
async init() {
this.on('*', 'tenantMappings', async req => {
if (req.method !== 'PATCH') req.reject(405, `Method ${req.method} not allowed for tenant mapping notifications`)
await this._validateCertificate(req)
return await this._dispatchNotification(req)
})
if (cds.env.requires.ucl?.destination) {
LOG.debug('Destination for asynchronous UCL callbacks configured.')
this.after(
[`assign/#succeeded`, `unassign/#succeeded`],
async (res, req) => await this.schedule('successfulMapping', { res, req })
)
this.after(
[`assign/#failed`, `unassign/#failed`],
async (res, req) => await this.schedule('failedMapping', { res, req })
)
this.on('successfulMapping', req => this.handleSuccessfulMapping(req.data.res, req.data.req))
this.on('failedMapping', req => this.handleFailedMapping(req.data.res, req.data.req))
}
await super.init()
if (cds.env.requires.ucl?.applicationTemplate) await this._upsertApplicationTemplate(cds.env.requires.ucl)
}
async _validateCertificate(req) {
// Allow bypassing certificate validation in development
if (process.env.NODE_ENV !== 'production' && cds.env.requires.ucl?.skipCertValidation) return
// Check if the request uses a 'cert' or 'mesh.cf' domain
if (!req.http?.req.hostname.match(/\.cert\.|\.mesh\.cf\./)) req.reject(403)
// Verify presence of required .cert domain headers
const reqClientVerificationStatus = req.headers['x-ssl-client-verify']
if (reqClientVerificationStatus !== '0') throw new cds.error(401, 'Client certificate not verified')
const reqClientCertSubjectB64 = req.headers['x-ssl-client-subject-dn']
if (!reqClientCertSubjectB64) throw new cds.error(401, 'No client certificate subject provided')
const reqClientCertIssuerB64 = req.headers['x-ssl-client-issuer-dn']
if (!reqClientCertIssuerB64) throw new cds.error(401, 'No client certificate issuer provided')
// Extract tokens from base64 encoded subject and issuer .cert domain headers
const reqClientCertSubject = Buffer.from(reqClientCertSubjectB64, 'base64').toString('ascii')
const reqClientCertSubjectTokens = reqClientCertSubject
.replace('\n', '')
.split('/')
.filter(token => token)
const reqClientCertIssuer = Buffer.from(reqClientCertIssuerB64, 'base64').toString('ascii')
const reqClientCertIssuerTokens = reqClientCertIssuer
.replace('\n', '')
.split('/')
.filter(token => token)
// Determine trusted certificate subject and issuer information
let trustedCertSubject = process.env.CDS_UCL_X509_CERTSUBJECT || cds.env.requires.ucl.x509?.certSubject
let trustedCertIssuer = process.env.CDS_UCL_X509_CERTISSUER || cds.env.requires.ucl.x509?.certIssuer
if (!trustedCertIssuer?.length || !trustedCertSubject?.length) {
let stage = 'CANARY'
const VCAP_APPLICATION = JSON.parse(process.env.VCAP_APPLICATION || '{}')
if (VCAP_APPLICATION.cf_api && !VCAP_APPLICATION.cf_api.endsWith('.cf.sap.hana.ondemand.com')) stage = 'LIVE'
trustedCertIssuer = TRUSTED_CERT[stage].ISSUER
trustedCertSubject = TRUSTED_CERT[stage].SUBJECT
}
// Match received with trusted info - As done in UCL reference implementation
const matchesUclInfoSubject = trustedCertSubject
.split(',')
.map(token => token.trim())
.every(token => reqClientCertSubjectTokens.includes(token))
if (!matchesUclInfoSubject) {
LOG.debug('Received Request Subject Info:', reqClientCertSubject)
LOG.debug('Expected UCL Subject Info:', trustedCertSubject)
throw new cds.error(401, 'Received .cert subject does not match trusted UCL info subject')
}
const matchesUclInfoIssuer = trustedCertIssuer
.split(',')
.map(token => token.trim())
.every(token => reqClientCertIssuerTokens.includes(token))
if (!matchesUclInfoIssuer) {
LOG.debug('Received Request Issuer Info:', reqClientCertIssuer)
LOG.debug('Expected UCL Issuer Info:', trustedCertIssuer)
throw new cds.error(401, 'Received .cert issuer does not match trusted UCL info issuer')
}
}
async _dispatchNotification(req) {
// Call User defined handlers for tenant mapping notifications
LOG.debug('Tenant mapping notification: ', req.data)
const { operation } = req.data?.context ?? {}
if (operation !== 'assign' && operation !== 'unassign')
throw new cds.error(
400,
`Invalid operation "${operation}" in tenant mapping notification. Expected "assign" or "unassign".`
)
const locationUrl = req.headers.location
if (locationUrl?.length) return this._dispatchAsyncMappingNotification(req, operation, locationUrl)
return this._dispatchSyncMappingNotification(req, operation)
}
async _dispatchAsyncMappingNotification(req, operation, locationUrl) {
// Get destination based on configured destination name
const destinationName = cds.env.requires.ucl.destination
if (typeof destinationName != 'string' || !destinationName?.length)
throw new cds.error(500, 'UCL Notification includes location but no callback destination was configured')
const destination = await getCloudSdk().getDestination(destinationName)
// Make sure the received callback location matches the configured destination
let destinationHost, locationHost
try {
locationHost = new URL(locationUrl).host
} catch (e) {
throw new cds.error(400, `Failed parsing URL in location header: ${e.message}`)
}
try {
destinationHost = new URL(destination.url).host
} catch (e) {
throw new cds.error(500, `Failed parsing URL in destination "${destinationName}": ${e.message}`)
}
if (locationHost !== destinationHost) {
throw new cds.error(
400,
`Host mismatch: locationUrl host (${locationHost}) does not match destination.url host (${destinationHost})`
)
}
// Schedule the tenant mapping task
await this.schedule(operation, req.data, req.headers)
// Respond 202 Accepted to UCL
req.http.res.status(202)
return
}
async _dispatchSyncMappingNotification(req, operation) {
const response = (await this.send(operation, req.data)) ?? {}
if (response.error) req.http.res.status(400)
else response.state ??= operation === 'assign' ? 'CONFIG_PENDING' : 'READY'
return response
}
async handleSuccessfulMapping(callbackResult, tenantMapping) {
const { operation, operationId } = tenantMapping.data.context
LOG.debug(`UCL ${operation} operation succeeded:`, callbackResult, tenantMapping)
// SPII v3 (operationId exists) vs v2
const method = operationId ? 'PUT' : 'PATCH'
const data = {
...callbackResult,
state: callbackResult.state ?? (operation === 'assign' ? 'CONFIG_PENDING' : 'READY')
}
const response = await getCloudSdk().executeHttpRequest(
{ destinationName: cds.env.requires.ucl.destination },
{ method, url: tenantMapping.headers.location, data }
)
if (response.status >= 500)
cds.error(`Callback to UCL for successful ${operation} operation failed: ${response.data.error}`)
if (response.status >= 400)
cds.error(`UCL rejected successful ${operation} operation callback: ${response.data.error}`, {
unrecoverable: true
})
}
async handleFailedMapping(callbackError, tenantMapping) {
const { operation, operationId } = tenantMapping.data.context
LOG.error(`UCL ${operation} operation failed:`, callbackError, tenantMapping)
// SPII v3 (operationId exists) vs v2
const method = operationId ? 'PUT' : 'PATCH'
const response = await getCloudSdk().executeHttpRequest(
{ destinationName: cds.env.requires.ucl.destination },
{ method, url: tenantMapping.headers.location, data: { state: 'ERROR' } }
)
if (response.status >= 500)
cds.error(`Callback to UCL for failed ${operation} operation failed: ${response.data.error}`)
if (response.status >= 400)
cds.error(`UCL rejected failed ${operation} operation callback: ${response.data.error}`, {
unrecoverable: true
})
}
/*
* the rest is for upserting the application template
*/
async _upsertApplicationTemplate() {
const _getApplicationTemplate = options => {
let applicationTemplate = {
applicationInput: {
providerName: 'SAP',
localTenantID: '{{tenant-id}}',
labels: {
displayName: '{{subdomain}}'
}
},
labels: {
managed_app_provisioning: true,
xsappname: '${xsappname}'
},
placeholders: [
{ name: 'subdomain', description: 'The subdomain of the consumer tenant' },
{
name: 'tenant-id',
description: "The tenant id as it's known in the product's domain",
jsonPath: '$.subscribedTenantId'
}
],
accessLevel: 'GLOBAL'
}
applicationTemplate = cds.utils.merge(applicationTemplate, options.applicationTemplate)
const pkg = require(cds.root + '/package')
if (!applicationTemplate.name) applicationTemplate.name = pkg.name
if (!applicationTemplate.applicationInput.name) applicationTemplate.applicationInput.name = pkg.name
if (applicationTemplate.labels.xsappname === '${xsappname}')
applicationTemplate.labels.xsappname = options.credentials.xsappname
return applicationTemplate
}
this._applicationTemplate = _getApplicationTemplate(cds.env.requires.ucl)
if (!this._applicationTemplate.applicationNamespace) {
cds.error(
'The UCL service requires a valid `applicationTemplate`, please provide it as described in the documentation.'
)
}
if (!cds.requires.multitenancy && cds.env.profile !== 'mtx-sidecar')
cds.error(
'The UCL service requires multitenancy, please enable it in your cds configuration with `cds.requires.multitenancy` or by using the mtx sidecar.'
)
if (!cds.env.requires.ucl.credentials)
cds.error('No credentials found for the UCL service, please bind the service to your app.')
if (!cds.env.requires.ucl.x509?.cert && !cds.env.requires.ucl.x509?.certPath)
cds.error('UCL requires `x509.cert` or `x509.certPath`.')
if (!cds.env.requires.ucl.x509?.pkey && !cds.env.requires.ucl.x509?.pkeyPath)
cds.error('UCL requires `x509.pkey` or `x509.pkeyPath`.')
const [cert, key] = await Promise.all([
cds.env.requires.ucl.x509?.cert ??
fs.readFile(cds.utils.path.resolve(cds.root, cds.env.requires.ucl.x509?.certPath)),
cds.env.requires.ucl.x509?.pkey ??
fs.readFile(cds.utils.path.resolve(cds.root, cds.env.requires.ucl.x509?.pkeyPath))
])
this.agent = new https.Agent({ cert, key })
const existingTemplate = await this._readTemplate()
const template = existingTemplate ? await this._updateTemplate(existingTemplate) : await this._createTemplate() // TODO: Make sure return value is correct
if (!template) cds.error('The UCL service could not create an application template.')
cds.once('listening', async () => {
const provisioning = await cds.connect.to('cds.xt.SaasProvisioningService')
provisioning.prepend(() => {
provisioning.on('dependencies', async (_, next) => {
const dependencies = await next()
dependencies.push({ xsappname: template.labels.xsappnameCMPClone })
return dependencies
})
})
})
}
// REVISIT: Replace with fetch (?)
async _request(query, variables) {
// Query GraphQL API
const opts = {
host: cds.env.requires.ucl.host || 'compass-gateway-sap-mtls.mps.kyma.cloud.sap',
path: cds.env.requires.ucl.path || '/director/graphql',
agent: this.agent,
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}
return new Promise((resolve, reject) => {
const req = https.request(opts, res => {
const chunks = []
res.on('data', chunk => {
chunks.push(chunk)
})
res.on('end', () => {
const response = {
statusCode: res.statusCode,
headers: res.headers,
body: Buffer.concat(chunks).toString()
}
const body = JSON.parse(response.body)
if (body.errors) cds.error('Request to UCL service failed with:\n' + JSON.stringify(body.errors, null, 2))
resolve(body.data)
})
})
req.on('error', error => {
reject(error)
})
if (query) {
req.write(JSON.stringify({ query, variables }))
}
req.end()
})
}
_handleResponse(result) {
if (result.response && result.response.errors) {
let errorMessage = result.response.errors[0].message
throw new Error(errorMessage)
} else {
return result.result
}
}
async _readTemplate() {
const xsappname = cds.env.requires.ucl.credentials.xsappname
const variables = { key: 'xsappname', value: `"${xsappname}"` }
const res = await this._request(READ_QUERY, variables)
if (res) return res.applicationTemplates.data[0]
}
async _createTemplate() {
try {
return this._handleResponse(await this._request(CREATE_MUTATION, { input: this._applicationTemplate }))
} catch (e) {
this._handleResponse(e)
}
}
async _updateTemplate(template) {
try {
const input = { ...this._applicationTemplate }
delete input.labels
const response = this._handleResponse(await this._request(UPDATE_MUTATION, { id: template.id, input }))
LOG.info('Application template updated successfully.')
return response
} catch (e) {
this._handleResponse(e)
}
}
async _deleteTemplate() {
const template = await this._readTemplate()
if (!template) return
return this._handleResponse(await this._request(DELETE_MUTATION, { id: template.id }))
}
}