@sap/cds-mtxs
Advanced tools
Comparing version 1.4.3 to 1.4.4
@@ -9,2 +9,56 @@ # Change Log | ||
## Version 1.4.4 - 2023-01-16 | ||
### Changed | ||
- `cds.xt.DeploymentService` configuration has been flattened. Instead of | ||
```js | ||
"hdi": { | ||
"create": { | ||
"provisioning_parameters": { | ||
"database_id": "<ID>" | ||
}, | ||
"binding_parameters": { | ||
"key": "value" | ||
} | ||
} | ||
} | ||
``` | ||
you can now also write | ||
```js | ||
"hdi": { | ||
"create": { | ||
"database_id": "<ID>" | ||
}, | ||
"bind": { | ||
"key": "value" | ||
} | ||
} | ||
``` | ||
The old configuration is still supported, but you're advised to migrate to the new configuration for improved readability. | ||
- `/-/cds/jobs/pollJob` now also returns a `tenants` field, so tenant-specific tasks don't have to be polled individually. An example response format looks like this: | ||
```js | ||
{ | ||
"status": "FAILED", | ||
"op": "upgrade", | ||
"tenants": { | ||
"non-existing-tenant": { | ||
"status": "FAILED", | ||
"error": "Tenant 'non-existing-tenant' does not exist" | ||
}, | ||
"existing-tenant": { | ||
"status": "FINISHED" | ||
} | ||
} | ||
} | ||
``` | ||
### Fixed | ||
- `cds.xt.SaasProvisioningService`: `*` is not allowed as a tenant name any more. | ||
- Namespace check for new entities in extensions now also covers new root entities. | ||
- Asynchronous operations now correctly send the callbacks defined via `status_callback` or `mtx_status_callback`. | ||
## Version 1.4.3 - 2022-12-28 | ||
@@ -11,0 +65,0 @@ |
{ | ||
"name": "@sap/cds-mtxs", | ||
"version": "1.4.3", | ||
"version": "1.4.4", | ||
"description": "SAP Cloud Application Programming Model - Multitenancy library", | ||
@@ -5,0 +5,0 @@ "homepage": "https://cap.cloud.sap/", |
@@ -14,15 +14,7 @@ const cds = require('@sap/cds/lib') | ||
async init() { | ||
// TODO: Support UPDATE event type for saas-registry | ||
//this.on('UPDATE', 'tenant', ({data}) => data?.eventType === 'UPDATE' ? this.update : this.create) | ||
this.on('UPDATE', 'tenant', this.create) | ||
this.on('READ', 'tenant', this.read) | ||
this.on('DELETE', 'tenant', this.delete) | ||
this.on('dependencies', this.getDependencies) | ||
this.on('getAppUrl', this.determineAppUrl) | ||
this.on('succeeded', this.succeeded) | ||
this.on('failed', this.failed) | ||
this.on('upgrade', this.update) | ||
this.on('upgradeAll', this.updateAll) | ||
await super.init() // ensure to call super.init() | ||
await super.init() // ensure to call super.init() // REVISIT: Why? | ||
} | ||
@@ -55,5 +47,5 @@ | ||
async determineAppUrl(context) { | ||
return SaasRegistryUtil.getAppUrlFromHeaders(context.data.subscriptionHeaders) | ||
?? SaasRegistryUtil.getAppUrlFromEnv(context.data.subscriptionPayload) | ||
getAppUrl(subscriptionPayload, subscriptionHeaders) { | ||
return subscriptionHeaders?.application_url | ||
?? process.env.SUBSCRIPTION_URL?.replace(`\${tenant_subdomain}`, subscriptionPayload.subscribedSubdomain) | ||
?? 'Tenant successfully subscribed - no application URL provided' | ||
@@ -73,6 +65,6 @@ } | ||
const sps = await cds.connect.to('cds.xt.SaasProvisioningService') | ||
const appUrl = await sps.getAppUrl(context.data, context.headers) | ||
if (isSync) { | ||
LOG.info(`Subscribing tenant ${tenant}`) | ||
const sps = await cds.connect.to('cds.xt.SaasProvisioningService') | ||
const _appUrl = sps.getAppUrl(context.data, context.headers) | ||
try { | ||
@@ -83,13 +75,15 @@ const ds = await cds.connect.to(DeploymentService) | ||
LOG.info(`Successfully subscribed tenant ${tenant}`) | ||
await this.emit('succeeded', { task: 'subscribe', result: await _appUrl }) | ||
await this._sendCallback('SUCCEEDED', 'Tenant creation succeeded', appUrl) | ||
} catch (error) { | ||
await this.emit('failed', { task: 'subscribe', result: error }) | ||
await this._sendCallback('FAILED', 'Tenant creation failed') | ||
throw error | ||
} | ||
return _appUrl | ||
} else { | ||
const lcs = await cds.connect.to(JobsService) | ||
const tx = lcs.tx(context) | ||
return tx.enqueue([new Set([tenant])], 'subscribe', [context.data, this._options(context.data)]) | ||
return appUrl | ||
} | ||
const js = await cds.connect.to(JobsService) | ||
const tx = js.tx(context) | ||
return tx.enqueue([new Set([tenant])], 'subscribe', [context.data, this._options(context.data)], error => { | ||
if (error) this._sendCallback('FAILED', 'Tenant creation failed') | ||
else this._sendCallback('SUCCEEDED', 'Tenant creation succeeded', appUrl) | ||
}) | ||
} | ||
@@ -111,9 +105,9 @@ | ||
async update(context) { | ||
if (!context.data?.tenants?.length) return | ||
const tenantList = context.data.tenants.includes('*') ? undefined : context.data.tenants | ||
async upgrade(tenantsIds) { | ||
if (!tenantsIds?.length) return | ||
const tenantList = tenantsIds.includes('*') ? undefined : tenantsIds | ||
const tenants = tenantList ?? (await cds.tx({ tenant: t0 }, tx => | ||
tx.run(SELECT.from(Tenants, tenant => { tenant.ID })) | ||
)).map(({ ID }) => ID) | ||
const { isSync } = SaasRegistryUtil.getCallbackUrlsFromHeaders(context.http?.req) | ||
const { isSync } = SaasRegistryUtil.getCallbackUrlsFromHeaders(cds.context.http?.req) | ||
const { | ||
@@ -127,15 +121,19 @@ clusterSize = 1, workerSize = 1, poolSize = 1 | ||
const ds = await cds.connect.to(DeploymentService) | ||
const tx = await ds.tx(context) | ||
const tx = await ds.tx(cds.context) | ||
await this.limiter(clusterSize, dbToTenants, tenants => | ||
this.limiter(workerSize ?? poolSize, Array.from(tenants), t => tx.upgrade(t)) | ||
) | ||
await this.emit('succeeded', { task: 'upgrade' }) | ||
await this._sendCallback('SUCCEEDED', 'Tenant upgrade succeeded') | ||
} catch (error) { | ||
await this.emit('failed', { task: 'upgrade', result: error }) | ||
await this._sendCallback('FAILED', 'Tenant upgrade failed') | ||
throw error | ||
} | ||
} else { | ||
const lcs = await cds.connect.to(JobsService) | ||
const tx = lcs.tx(context) | ||
return tx.enqueue(dbToTenants, 'upgrade') | ||
const js = await cds.connect.to(JobsService) | ||
const tx = js.tx(cds.context) | ||
// REVISIT: use jobs service for sync and async operations (might also be interesting for concurrency control) | ||
return tx.enqueue(dbToTenants, 'upgrade', [], error => { | ||
if (error) this._sendCallback('FAILED', 'Tenant upgrade failed') | ||
else this._sendCallback('SUCCEEDED', 'Tenant upgrade succeeded') | ||
}) | ||
} | ||
@@ -161,3 +159,3 @@ } | ||
await tx.unsubscribe(tenant, { metadata }) | ||
await this.emit('succeeded', { task: 'unsubscribe' }) | ||
await this._sendCallback('SUCCEEDED', 'Tenant deletion succeeded') | ||
} catch (error) { | ||
@@ -167,3 +165,3 @@ if (error.statusCode === 404) { | ||
} else { | ||
await this.emit('failed', { task: 'unsubscribe', result: error }) | ||
await this._sendCallback('FAILED', 'Tenant deletion failed') | ||
throw error | ||
@@ -175,13 +173,15 @@ } | ||
const tx = lcs.tx(context) | ||
return tx.enqueue([new Set([tenant])], 'unsubscribe', [{ metadata }]) | ||
return tx.enqueue([new Set([tenant])], 'unsubscribe', [{ metadata }], error => { | ||
if (error) this._sendCallback('FAILED', 'Tenant deletion failed') | ||
else this._sendCallback('SUCCEEDED', 'Tenant deletion succeeded') | ||
}) | ||
} | ||
} | ||
updateAll(context) { | ||
LOG.warn(`updateAll is deprecated. Use /-/cds/saas-provisioning/upgrade instead.`) | ||
if (!context.data?.tenants) context.data.tenants = ['*'] | ||
return this.update(context) | ||
upgradeAll(tenants) { | ||
LOG.warn(`upgradeAll is deprecated. Use /-/cds/saas-provisioning/upgrade instead.`) | ||
return this.upgrade(tenants ?? ['*']) | ||
} | ||
getDependencies() { | ||
dependencies() { | ||
return cds.env.requires['cds.xt.SaasProvisioningService']?.dependencies?.map(d => ({ xsappname: d })) ?? [] | ||
@@ -198,3 +198,3 @@ } | ||
if (pending.length >= limit) { | ||
await Promise.race(pending) | ||
await Promise.race(pending) // eslint-disable-line no-await-in-loop | ||
} | ||
@@ -205,12 +205,4 @@ } | ||
async succeeded(event) { | ||
await this._sendCallback('SUCCEEDED', 'Succeeded', event.data.result) | ||
} | ||
async failed(event) { | ||
await this._sendCallback('FAILED', 'Failed', event.data.result) | ||
} | ||
async _sendCallback(status, message, subscriptionUrl) { | ||
const originalRequest = cds.context?._?.req | ||
const originalRequest = cds.context?.http?.req | ||
const { | ||
@@ -217,0 +209,0 @@ isSync, isInternalCallback, saasCallbackUrlPath, callbackUrl, noCallback |
const { URL } = require('url') | ||
const axios = require('axios') | ||
const cds = require('@sap/cds/lib')//, { axios } = cds.utils // REVISIT: Use axios helper in @sap/cds? | ||
const cds = require('@sap/cds/lib') | ||
const LOG = cds.log('mtx') | ||
@@ -8,10 +8,2 @@ | ||
static get SUCCEEDED() { | ||
return "SUCCEEDED" | ||
} | ||
static get FAILED() { | ||
return "FAILED" | ||
} | ||
// TODO find out purpose of the authHeader parameter - intended to be set with internal mtx callback? Is auth header set in that case? | ||
@@ -27,7 +19,6 @@ static async sendResult(callbackUrl, tenant, payload, authHeader) { | ||
let headers | ||
const headers = {} | ||
try { | ||
const authorization = authHeader ?? `Bearer ${await SaasRegistryUtil._getAuthToken()}` | ||
headers = { authorization } | ||
} catch(error) { | ||
headers.authorization = authHeader ?? `Bearer ${await SaasRegistryUtil._getAuthToken()}` | ||
} catch (error) { | ||
if (!authHeader) { | ||
@@ -60,16 +51,3 @@ LOG.warn('No authentication header for callback') | ||
static async _getAuthToken() { | ||
// TODO: find out why compat does not work | ||
const credentials = cds.env.requires?.multitenancy?.credentials | ||
// const credentials = typeof credentialsFromEnv === 'object' | ||
// ? credentialsFromEnv | ||
// : process.env.VCAP_SERVICES ? JSON.parse(process.env.VCAP_SERVICES)['saas-registry'][0].credentials : {} | ||
// TODO: throw cds.error? | ||
// TODO: Better error messages. 1. Diagnose -> 2. Support Proposal (i.e. tell how to fix the error) | ||
if (!credentials) { | ||
cds.error('No saas-registry credentials found', { status: 401 }) | ||
} | ||
const { clientid, clientsecret, url } = credentials | ||
const { clientid, clientsecret, url } = cds.env.requires?.multitenancy?.credentials ?? {} | ||
if (!clientid || !clientsecret || !url) { | ||
@@ -138,20 +116,2 @@ cds.error('No saas-registry credentials available from the application environment.', { status: 401 }) | ||
} | ||
static getAppUrlFromHeaders(headers) { | ||
return headers?.application_url | ||
} | ||
static get SUBDOMAIN_PLACEHOLDER() { | ||
return 'tenant_subdomain' | ||
} | ||
static get SUBSCRIPTION_URL() { | ||
return 'SUBSCRIPTION_URL' | ||
} | ||
static getAppUrlFromEnv(subscriptionPayload) { | ||
const { subscribedSubdomain } = subscriptionPayload | ||
const urlFromEnv = process.env[SaasRegistryUtil.SUBSCRIPTION_URL] | ||
return urlFromEnv ? urlFromEnv.replace(`\${${SaasRegistryUtil.SUBDOMAIN_PLACEHOLDER}}`, subscribedSubdomain) : undefined | ||
} | ||
} |
@@ -1,3 +0,1 @@ | ||
const Checker = require('./checker_base') | ||
const LABELS = { | ||
@@ -133,3 +131,3 @@ service: 'Service', | ||
class AllowlistChecker extends Checker { | ||
module.exports = class AllowlistChecker { | ||
_setupPermissionList(mtxConfig, fullCsn) { | ||
@@ -354,3 +352,1 @@ return new Allowlist(mtxConfig, fullCsn) | ||
} | ||
module.exports = AllowlistChecker |
@@ -1,3 +0,1 @@ | ||
const Checker = require('./checker_base') | ||
const AT_REQUIRES = '@requires' | ||
@@ -38,3 +36,3 @@ const AT_RESTRICT = '@restrict' | ||
class AnnotationsChecker extends Checker { | ||
module.exports = class AnnotationsChecker { | ||
check(reflectedCsn, compileDir) { | ||
@@ -112,3 +110,1 @@ if (!reflectedCsn.extensions) { | ||
} | ||
module.exports = AnnotationsChecker |
@@ -21,7 +21,8 @@ const cds = require('@sap/cds/lib') | ||
const reflectedCsn = cds.reflect(extCsn) | ||
const reflectedFullCsn = cds.reflect(fullCsn) | ||
const compileBaseDir = cds.root | ||
const findings = [ | ||
...new NamespaceChecker().check(reflectedCsn, fullCsn, compileBaseDir, linter_options), | ||
...new NamespaceChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options), | ||
...new AnnotationsChecker().check(reflectedCsn, compileBaseDir, linter_options), | ||
...new AllowlistChecker().check(reflectedCsn, fullCsn, compileBaseDir, linter_options) | ||
...new AllowlistChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options) | ||
] | ||
@@ -28,0 +29,0 @@ return findings |
@@ -1,4 +0,2 @@ | ||
const Checker = require('./checker_base') | ||
class NamespaceChecker extends Checker { | ||
module.exports = class NamespaceChecker { | ||
check(extensionCsn, fullCsn, compileDir, mtxConfig) { | ||
@@ -82,6 +80,2 @@ let elementPrefixes = mtxConfig['element-prefix'] | ||
if (!this._hasEnclosingEntity(reflectedCsn, element)) { | ||
return | ||
} | ||
const parent = this._getEnclosingEntity(reflectedCsn, element) | ||
@@ -94,13 +88,13 @@ | ||
// check full csn for parent - if it exists, continue | ||
// check full csn for parent | ||
let elementName | ||
const parentFromFullCsn = this._getEnclosingEntity(reflectedFullCsn, element) | ||
if (!parentFromFullCsn) { | ||
return | ||
elementName = element.name | ||
} else { | ||
elementName = this._getNestedEntityName(element,parentFromFullCsn.name) || element.name | ||
} | ||
// checks nested element - TODO determine real parent and split off parent name | ||
const nestedElementName = this._getNestedEntityName(element) // ,parent | ||
for (const elementPrefix of elementPrefixes) { | ||
if (nestedElementName.startsWith(elementPrefix)) { | ||
if (elementName.startsWith(elementPrefix)) { | ||
return | ||
@@ -122,2 +116,3 @@ } | ||
// TODO set parent entity name / check original test cases | ||
_getEnclosingEntity(reflectedCsn, element) { | ||
@@ -132,6 +127,4 @@ const splitEntityName = element.name.split('.') | ||
_getNestedEntityName(element) { | ||
const splitEntityName = element.name.split('.') | ||
splitEntityName.shift() | ||
return splitEntityName.join('.') | ||
_getNestedEntityName(element, parentName) { | ||
return element.name.replace(parentName + '.', '') | ||
} | ||
@@ -148,3 +141,3 @@ | ||
_createPrefixWarning(element, parent, prefixRule) { | ||
let message = `Element '${element.name}' in '${parent.extend || parent.name}' must start with ${prefixRule}` | ||
let message = `Element '${element.name}' ${parent ? `in '${parent.extend || parent.name}'` : ''} must start with ${prefixRule}` | ||
return { message, element } | ||
@@ -158,3 +151,1 @@ } | ||
} | ||
module.exports = NamespaceChecker |
@@ -48,3 +48,12 @@ const fs = require('fs').promises | ||
this.before(['getCsn', 'getEdmx', 'getExtCsn'], req => { | ||
const regex = /^[A-Za-z0-9_-]*$|^\$hash$|^\*$/ | ||
const toggles = req.data?.toggles | ||
const invalid = Array.isArray(toggles) ? toggles.find(t => !regex.test(t)) : | ||
typeof toggles === 'string' && !regex.test(toggles) && toggles | ||
if (invalid) return req.reject(400, `Unsupported input toggle param ${invalid}`) | ||
}) | ||
this.on('getCsn', req => _getCsn(req)) | ||
this.on('getExtCsn', req => { | ||
@@ -51,0 +60,0 @@ if (!main.requires.extensibility) return req.reject(400, 'Missing extensibility parameter') |
@@ -19,8 +19,8 @@ const cds = require('@sap/cds/lib') | ||
// REVISIT: Use UPSERT instead | ||
const { tenant:t, metadata } = req.data | ||
if (t === t0) return await next() | ||
const { tenant, metadata } = req.data | ||
if (tenant === t0) return next() | ||
await next() | ||
try { | ||
await cds.tx({ tenant: t0 }, tx => | ||
tx.run(INSERT.into(Tenants, { ID: t, metadata: JSON.stringify(metadata) })) | ||
tx.run(INSERT.into(Tenants, { ID: tenant, metadata: JSON.stringify(metadata) })) | ||
) | ||
@@ -33,5 +33,4 @@ } catch (e) { | ||
await next() | ||
const { tenant:t } = req.data | ||
await cds.tx({ tenant: t0 }, tx => | ||
tx.run(DELETE.from(Tenants).where({ID:{'=':t}})) | ||
tx.run(DELETE.from(Tenants).where({ ID: req.data.tenant })) | ||
) | ||
@@ -46,5 +45,5 @@ }) | ||
//const needsT0Redeployment = ['cds_xt_tenants', 'cds_xt_jobs', 'cds_xt_tasks'].some(t => !tables.includes(t)) | ||
const columns = await ds.getColumns(t0, cds.requires.db.kind === 'hana' ? 'CDS_XT_JOBS' : 'cds_xt_Jobs') | ||
const needsT0Redeployment = !columns.includes('error') && !columns.includes('ERROR') | ||
await ds.tx({ tenant: t0 }, async tx => { | ||
const columns = await tx.getColumns(t0, cds.requires.db.kind === 'hana' ? 'CDS_XT_JOBS' : 'cds_xt_Jobs') | ||
const needsT0Redeployment = !columns.includes('error') && !columns.includes('ERROR') | ||
if (!needsT0Redeployment) return | ||
@@ -51,0 +50,0 @@ const csn = await cds.load(`${__dirname}/../../../db/t0.cds`) |
@@ -1,2 +0,2 @@ | ||
const cds = require('@sap/cds/lib'), {db} = cds.requires | ||
const cds = require('@sap/cds/lib'), {db} = cds.env.requires | ||
const path = require('path') | ||
@@ -62,8 +62,21 @@ module.exports = exports = { resources4, build, _imCreateParams } | ||
function _imCreateParams(tenant, params = {}, metadata) { | ||
const paramsFromEnv = cds.env.requires['cds.xt.DeploymentService']?.hdi?.create ?? {} | ||
const paramsFromTenantOptions = cds.env.requires['cds.xt.DeploymentService']?.for?.[tenant]?.hdi?.create ?? {} | ||
const createParamsFromEnv = cds.env.requires['cds.xt.DeploymentService']?.hdi?.create ?? {} | ||
const createParamsFromTenantOptions = cds.env.requires['cds.xt.DeploymentService']?.for?.[tenant]?.hdi?.create ?? {} | ||
const createParams = { ...createParamsFromEnv, ...createParamsFromTenantOptions, ...params?.hdi?.create } | ||
const allParams = { ...paramsFromEnv, ...paramsFromTenantOptions, ...params?.hdi?.create } | ||
allParams.provisioning_parameters = { ..._encryptionParams(metadata), ...allParams.provisioning_parameters } | ||
return allParams | ||
// @sap/instance-manager compat | ||
const compat = 'provisioning_parameters' in createParams || 'binding_parameters' in createParams | ||
if (compat) { | ||
createParams.provisioning_parameters = { ..._encryptionParams(metadata), ...createParams.provisioning_parameters } | ||
return createParams | ||
} | ||
// flatter @sap/cds-mtxs config | ||
const bindParamsFromEnv = cds.env.requires['cds.xt.DeploymentService']?.hdi?.bind ?? {} | ||
const bindParamsFromTenantOptions = cds.env.requires['cds.xt.DeploymentService']?.for?.[tenant]?.hdi?.bind ?? {} | ||
const bindParams = { ...bindParamsFromEnv, ...bindParamsFromTenantOptions, ...params?.hdi?.bind } | ||
return { | ||
provisioning_parameters: { ..._encryptionParams(metadata), ...createParams }, | ||
binding_parameters: { ...bindParams } | ||
} | ||
} | ||
@@ -115,3 +128,3 @@ | ||
} | ||
cds.error(error) | ||
throw error | ||
} | ||
@@ -118,0 +131,0 @@ } |
@@ -120,3 +120,3 @@ const https = require('https') | ||
const auth = certificate ? { maxRedirects: 0, httpsAgent: new https.Agent({ cert: certificate, key }) } | ||
: { auth: { username: clientid, password: clientsecret } } | ||
: { auth: { username: clientid, password: clientsecret } } | ||
const authUrl = `${certurl ?? url}/oauth/token` | ||
@@ -123,0 +123,0 @@ const data = `grant_type=client_credentials&client_id=${encodeURI(clientid)}` |
Sorry, the diff of this file is not supported yet
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
150793
47
2855