UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

394 lines (328 loc) 15 kB
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 })) } }