@sap/cds-dk 10.1.0 → 10.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/bin/bind.js +4 -0
- package/bin/build.js +1 -0
- package/bin/compile/index.js +12 -11
- package/bin/completion/index.js +21 -10
- package/bin/completion/scripts/cds.fish +179 -127
- package/bin/completion/scripts/cds.ps1 +88 -14
- package/bin/completion/scripts/cds.sh +71 -5
- package/bin/completion/scripts/cds.zsh +81 -15
- package/bin/completion/templates/cds-tmpl.ps1 +15 -6
- package/bin/deploy/index.js +7 -3
- package/bin/env.js +4 -1
- package/bin/export.js +9 -1
- package/bin/import.js +4 -1
- package/bin/init.js +1 -0
- package/bin/inspect.js +3 -1
- package/bin/lint.js +5 -3
- package/bin/minify.js +6 -1
- package/bin/repl.js +11 -2
- package/bin/run.js +12 -1
- package/bin/serve.js +13 -2
- package/bin/up.js +4 -1
- package/bin/util/containerize.js +2 -0
- package/bin/watch/index.js +8 -1
- package/lib/build/util.js +2 -4
- package/lib/init/constants.js +1 -1
- package/lib/init/template/data/as-json.js +3 -0
- package/node_modules/@cap-js/db-service/CHANGELOG.md +17 -0
- package/node_modules/@cap-js/db-service/lib/common/generic-pool.js +4 -7
- package/node_modules/@cap-js/db-service/lib/cqn2pql.js +1 -1
- package/node_modules/@cap-js/db-service/lib/cqn2sql.js +15 -11
- package/node_modules/@cap-js/db-service/lib/cqn4sql.js +119 -19
- package/node_modules/@cap-js/db-service/lib/infer/index.js +27 -16
- package/node_modules/@cap-js/db-service/lib/recursion-guard.js +40 -0
- package/node_modules/@cap-js/db-service/lib/search.js +1 -1
- package/node_modules/@cap-js/db-service/package.json +1 -1
- package/node_modules/@cap-js/sqlite/CHANGELOG.md +19 -0
- package/node_modules/@cap-js/sqlite/lib/SQLiteService.js +110 -14
- package/node_modules/@cap-js/sqlite/package.json +3 -3
- package/node_modules/@sap/cds-compiler/CHANGELOG.md +15 -0
- package/node_modules/@sap/cds-compiler/lib/compiler/resolve.js +7 -3
- package/node_modules/@sap/cds-compiler/lib/model/csnUtils.js +2 -0
- package/node_modules/@sap/cds-compiler/lib/transform/db/applyTransformations.js +12 -0
- package/node_modules/@sap/cds-compiler/lib/transform/db/associations.js +5 -3
- package/node_modules/@sap/cds-compiler/lib/transform/db/flattening.js +4 -3
- package/node_modules/@sap/cds-compiler/lib/transform/effective/flattening.js +17 -1
- package/node_modules/@sap/cds-compiler/lib/transform/effective/main.js +19 -0
- package/node_modules/@sap/cds-compiler/lib/transform/fioriTreeViews.js +12 -1
- package/node_modules/@sap/cds-compiler/lib/transform/forRelationalDB.js +1 -1
- package/node_modules/@sap/cds-compiler/lib/transform/odata/adaptAnnotationRefs.js +11 -5
- package/node_modules/@sap/cds-compiler/lib/transform/translateAssocsToJoins.js +29 -2
- package/node_modules/@sap/cds-compiler/package.json +1 -1
- package/node_modules/@sap/cds-mtxs/CHANGELOG.md +34 -0
- package/node_modules/@sap/cds-mtxs/bin/cds-mtx-migrate.js +26 -5
- package/node_modules/@sap/cds-mtxs/bin/cds-mtx.js +3 -0
- package/node_modules/@sap/cds-mtxs/lib/utils.js +5 -4
- package/node_modules/@sap/cds-mtxs/package.json +1 -1
- package/node_modules/@sap/cds-mtxs/srv/cf/abstract-provisioning-service.js +62 -7
- package/node_modules/@sap/cds-mtxs/srv/cf/saas-provisioning-service.js +1 -1
- package/node_modules/@sap/cds-mtxs/srv/cf/sms-provisioning-service.js +23 -3
- package/node_modules/@sap/cds-mtxs/srv/extensibility/compat/update.js +15 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/annotations.js +2 -2
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/config.js +2 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/index.js +2 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/protected-namespace.js +45 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility-service.js +8 -3
- package/node_modules/@sap/cds-mtxs/srv/jobs-service.js +49 -10
- package/node_modules/@sap/cds-mtxs/srv/model-provider.js +11 -2
- package/node_modules/@sap/cds-mtxs/srv/plugins/common.js +19 -12
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana/hdi-ctnr-mgr.js +6 -3
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana/serverless.js +271 -0
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana.js +3 -2
- package/node_modules/@sap/cds-mtxs/srv/worker/job-worker-pool.js +212 -0
- package/node_modules/@sap/cds-mtxs/srv/worker/job-worker.js +61 -0
- package/package.json +2 -2
- /package/node_modules/@sap/cds-mtxs/srv/worker/{pool.js → model-worker-pool.js} +0 -0
- /package/node_modules/@sap/cds-mtxs/srv/worker/{load.js → model-worker.js} +0 -0
|
@@ -612,7 +612,7 @@ function translateAssocsToJoins(model, inputOptions = {}) {
|
|
|
612
612
|
args.push({
|
|
613
613
|
op: { val: '=' },
|
|
614
614
|
args: [ constructPathNode( [ srcAlias, prefixFK(assoc.$elementPrefix, assoc.$flatSrcFKs[i]) ] ),
|
|
615
|
-
constructPathNode( [ tgtAlias, assoc.$flatTgtFKs[i] ] ) ],
|
|
615
|
+
constructPathNode( [ tgtAlias, resolveTgtFKToAlias(assoc.$flatTgtFKs[i], tgtAlias) ] ) ],
|
|
616
616
|
});
|
|
617
617
|
}
|
|
618
618
|
return parenthesise((args.length > 1 ? { op: { val: 'and' }, args } : args[0] ));
|
|
@@ -808,7 +808,19 @@ function translateAssocsToJoins(model, inputOptions = {}) {
|
|
|
808
808
|
const assocSource = assoc._main;
|
|
809
809
|
if (assoc.kind === 'mixin' && (i > 0 || fwdTarget !== assocSource) && fwdOrigin.kind !== 'mixin') {
|
|
810
810
|
const tas = Object.values(env.lead.$tableAliases);
|
|
811
|
-
|
|
811
|
+
// The backlink association's target may be reached through a view
|
|
812
|
+
// over it, whose alias is not the target artifact itself; fall back
|
|
813
|
+
// to the alias whose `_origin` chain reaches the target.
|
|
814
|
+
let j = tas.findIndex(ta => ta._artifact === fwdOrigin.target._artifact);
|
|
815
|
+
if (j < 0) {
|
|
816
|
+
j = tas.findIndex((ta) => {
|
|
817
|
+
for (let a = ta._artifact; a; a = a._origin) {
|
|
818
|
+
if (a === fwdOrigin.target._artifact)
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
return false;
|
|
822
|
+
});
|
|
823
|
+
}
|
|
812
824
|
if (j >= 0 && tas[j].$QA) {
|
|
813
825
|
newTgtAlias.id = tas[j].$QA.name.id;
|
|
814
826
|
newTgtAlias._artifact = tas[j]._effectiveType;
|
|
@@ -1061,6 +1073,21 @@ function translateAssocsToJoins(model, inputOptions = {}) {
|
|
|
1061
1073
|
const backlink = backlinkAssoc.$backlink;
|
|
1062
1074
|
return backlink && (backlink.foreignKeys || backlink.on) ? backlink : fwdAssoc;
|
|
1063
1075
|
}
|
|
1076
|
+
|
|
1077
|
+
// Resolve the target foreign key to the alias of the target table.
|
|
1078
|
+
// If the target foreign key is not found in the scope of the target alias,
|
|
1079
|
+
// try to find it in the origin chain of the foreign key artifact.
|
|
1080
|
+
function resolveTgtFKToAlias( fk, tgtAlias ) {
|
|
1081
|
+
const env = tgtAlias && (tgtAlias._effectiveType || tgtAlias._artifact);
|
|
1082
|
+
const scope = env && env.elements;
|
|
1083
|
+
if (!scope || !fk || scope[fk.id] || !fk._artifact || !fk._artifact._origin)
|
|
1084
|
+
return fk;
|
|
1085
|
+
for (let o = fk._artifact._origin; o; o = o._origin) {
|
|
1086
|
+
if (o.name && scope[o.name.id])
|
|
1087
|
+
return { id: o.name.id, _artifact: fk._artifact, acc: fk.acc };
|
|
1088
|
+
}
|
|
1089
|
+
return fk;
|
|
1090
|
+
}
|
|
1064
1091
|
} // createOnCondition
|
|
1065
1092
|
} // createJoinQA
|
|
1066
1093
|
|
|
@@ -6,6 +6,39 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
8
8
|
|
|
9
|
+
## Version 4.1.0 - 2026-09-04
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Beta: Multitenant provisioning now supports HANA Cloud serverless, resolving one HDI container per tenant through the serverless Tenant API.
|
|
14
|
+
- Beta: The MTX job service can now use native javascript workers to make the MTX sidecar more responsive while jobs (e. g. tenant upgrade) are running.
|
|
15
|
+
Native workers can be enabled by `nativeWorkers` in the jobs configuration, e. g.
|
|
16
|
+
```jsonc
|
|
17
|
+
"cds.xt.SaasProvisioningService": {
|
|
18
|
+
"jobs": {
|
|
19
|
+
"nativeWorkers": true,
|
|
20
|
+
"workerSize": 3,
|
|
21
|
+
"clusterSize": 3
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
```
|
|
25
|
+
- With configuration `cds.requires['cds.xt.SaasProvisioning'].upgrade.skipAlreadyUpgraded: true` or `cds.requires['cds.xt.SmsProvisioning'].upgrade.skipAlreadyUpgraded: true`,
|
|
26
|
+
already upgraded tenants are skipped when running an upgrade of all tenants (`tenants: ['*']`). Tenants are considered as already upgraded if the last upgrade was run after the
|
|
27
|
+
last deployment of the application.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- `SmsProvisioningService` certificate check is now better aligned with the Java implementation.
|
|
32
|
+
- Extension validation checks have been refined.
|
|
33
|
+
- `POST /-/cds/model-provider/getEdmx` now checks the requested service name more exactly.
|
|
34
|
+
- Callback url for Saas Registry and Subscription Manager is now determined in a more robust way.
|
|
35
|
+
|
|
36
|
+
## Version 4.0.4 - 2026-08-21
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- Version bump
|
|
41
|
+
|
|
9
42
|
## Version 4.0.3 - 2026-08-21
|
|
10
43
|
|
|
11
44
|
### Added
|
|
@@ -20,6 +53,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
20
53
|
|
|
21
54
|
- HANA credential health-check now detects the available driver via `require.resolve()` instead of reading `package.json`, fixing `MODULE_NOT_FOUND` for apps that follow the "Plug & Play" guidance and don't declare `hdb` or `@sap/hana-client` directly.
|
|
22
55
|
- Tenant upgrade now reports a clear per-tenant `422` error when a stored extension is incompatible with the changed base model, instead of aborting with an uncaught compilation error.
|
|
56
|
+
- `cds-mtx-migrate` now runs more robust with uncommon model configurations.
|
|
23
57
|
|
|
24
58
|
## Version 4.0.2 - 2026-08-07
|
|
25
59
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* eslint-disable no-console */
|
|
3
3
|
|
|
4
|
+
const cds = require('@sap/cds')
|
|
5
|
+
const { _import, isfile, local, path } = cds.utils
|
|
6
|
+
|
|
4
7
|
async function run(param, options) {
|
|
5
8
|
const { dry, genTenantList, syncTenantList, cleanup, directory, tag, tagRule } = options
|
|
6
9
|
const skipVerification = options['skip-verification'] || options.skipVerification
|
|
@@ -26,7 +29,6 @@ async function run(param, options) {
|
|
|
26
29
|
_handleError(`The migration from @sap/cds-mtx to @sap/cds-mtxs has been removed. Only --init-tenant-list and --sync-tenant-list are still supported.`)
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
const cds = require('@sap/cds')
|
|
30
32
|
const { syncTenantList: synchronizeTenantList, _hasMtEnv } = require('../lib/migration/migration')
|
|
31
33
|
|
|
32
34
|
async function cds_mtx_migrate(tenants, options) {
|
|
@@ -45,11 +47,20 @@ async function run(param, options) {
|
|
|
45
47
|
services.push('@sap/cds-mtxs/srv/extensibility-service')
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
await
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
await _local_server_js()
|
|
51
|
+
cds.emit ('bootstrap')
|
|
52
|
+
const model = await cds.load([...services, '@sap/cds-mtxs/db/extensions'])
|
|
53
|
+
cds.model = cds.compile.for.nodejs(model)
|
|
54
|
+
const d = 'cds.xt.DeploymentService'
|
|
55
|
+
const unserved = !cds.services[d]
|
|
56
|
+
if (unserved) {
|
|
57
|
+
await cds.serve(services)
|
|
58
|
+
}
|
|
59
|
+
await cds.plugins
|
|
60
|
+
|
|
61
|
+
await cds.emit('served', cds.services)
|
|
51
62
|
|
|
52
|
-
|
|
63
|
+
cds.env.requires.multitenancy ??= true // needed for current sidecar config that only enables services
|
|
53
64
|
|
|
54
65
|
if (genTenantList) {
|
|
55
66
|
const tenantListResult = await synchronizeTenantList(tenantList, options)
|
|
@@ -73,6 +84,16 @@ async function run(param, options) {
|
|
|
73
84
|
await cds_mtx_migrate(param, options).catch(console.error)
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
// copied from cds.serve
|
|
88
|
+
async function _local_server_js() {
|
|
89
|
+
const _local = file => isfile(file) || isfile (path.join(cds.env.folders.srv,file))
|
|
90
|
+
const cli_js = process.env.CDS_TYPESCRIPT && _local('cli.ts') || _local('cli.js')
|
|
91
|
+
if (cli_js) {
|
|
92
|
+
console.log ('[cds] - loading server from', { file: local(cli_js) })
|
|
93
|
+
await _import(cli_js)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
function getParameterValue(options, parameter) {
|
|
77
98
|
const index = options.indexOf(parameter)
|
|
78
99
|
const value = (index > -1) ? options[index + 1] : undefined
|
|
@@ -104,14 +104,15 @@ function authUrl(baseUrl, subdomain, pathname) {
|
|
|
104
104
|
return u
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
async function token(credentials, { query = {}, form = {}, subdomain } = {}) {
|
|
107
|
+
async function token(credentials, { query = {}, form = {}, subdomain, path = '/oauth/token' } = {}) {
|
|
108
108
|
const { clientid, clientsecret, certurl, url, certificate, key } = credentials ?? {}
|
|
109
109
|
if (!form.grant_type) throw cds.error('grant_type is required', { status: 400 })
|
|
110
|
-
const u = authUrl(certurl || url, subdomain,
|
|
110
|
+
const u = authUrl(certurl || url, subdomain, path)
|
|
111
111
|
Object.entries(query).forEach(([k, v]) => u.searchParams.append(k, v))
|
|
112
112
|
const body = new URLSearchParams({ client_id: clientid, ...form })
|
|
113
113
|
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }
|
|
114
114
|
let raw
|
|
115
|
+
const _fail = (statusCode, respBody) => new Error(`token request to ${u.origin}${u.pathname} for client ${clientid} failed (${statusCode}): ${respBody}`, { status: statusCode })
|
|
115
116
|
if (certificate) {
|
|
116
117
|
raw = await new Promise((resolve, reject) => {
|
|
117
118
|
const req = require('node:https').request({
|
|
@@ -126,7 +127,7 @@ async function token(credentials, { query = {}, form = {}, subdomain } = {}) {
|
|
|
126
127
|
res.on('data', x => chunks.push(x))
|
|
127
128
|
res.on('end', () => {
|
|
128
129
|
if (res.statusCode >= 200 && res.statusCode < 300) resolve(Buffer.concat(chunks).toString())
|
|
129
|
-
else reject(
|
|
130
|
+
else reject(_fail(res.statusCode, Buffer.concat(chunks).toString()))
|
|
130
131
|
})
|
|
131
132
|
})
|
|
132
133
|
req.on('error', reject)
|
|
@@ -176,7 +177,7 @@ async function fetchResiliently(url, req, { retriesLeft = maxRetries, maxRetryAf
|
|
|
176
177
|
const data = contentType.includes('application/json') ? await res.json() : await res.text()
|
|
177
178
|
|
|
178
179
|
if (!res.ok) {
|
|
179
|
-
let msg = [status, statusText, data?.message, typeof data?.error === 'object' ? JSON.stringify(data.error) : data?.error, data?.description, data?.cause].filter(Boolean).join(' – ')
|
|
180
|
+
let msg = [status, statusText, data?.message, typeof data?.error === 'object' ? JSON.stringify(data.error) : data?.error, data?.description, data?.cause, typeof data?.details === 'object' ? JSON.stringify(data.details) : data?.details].filter(Boolean).join(' – ')
|
|
180
181
|
if (res.status === 429 && res.headers.get('retry-after')) msg += ` – retry after ${res.headers.get('retry-after')}`
|
|
181
182
|
throw Object.assign(new Error(msg), { status, statusText, headers, code: data?.error?.code })
|
|
182
183
|
}
|
|
@@ -5,6 +5,7 @@ const { fetchResiliently, token } = require('../../lib/utils')
|
|
|
5
5
|
const DeploymentService = 'cds.xt.DeploymentService'
|
|
6
6
|
const JobsService = 'cds.xt.JobsService'
|
|
7
7
|
const Tenants = 'cds.xt.Tenants'
|
|
8
|
+
const { SKIPPED_DB } = require('../jobs-service')
|
|
8
9
|
|
|
9
10
|
// we need to run in multitenancy mode for t0 ops
|
|
10
11
|
if (!cds.env.requires.multitenancy) cds.env.requires.multitenancy = true
|
|
@@ -112,9 +113,9 @@ module.exports = class ProvisioningService extends cds.ApplicationService {
|
|
|
112
113
|
)).map(({ ID, metadata, createdAt, modifiedAt }) => ({ subscribedTenantId: ID, ...this._toMetadataJson(metadata, ID), createdAt, modifiedAt }))
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
async _getTenants() {
|
|
116
|
+
async _getTenants(options) {
|
|
116
117
|
const ds = await cds.connect.to(DeploymentService)
|
|
117
|
-
const all = await ds.getTenants()
|
|
118
|
+
const all = await ds.getTenants(options)
|
|
118
119
|
if (cds.env.requires[this.name]?.upgrade?.ignoreNonExistingContainers) {
|
|
119
120
|
const containers = await ds.getContainers()
|
|
120
121
|
return all.filter(t => containers.includes(t))
|
|
@@ -129,9 +130,37 @@ module.exports = class ProvisioningService extends cds.ApplicationService {
|
|
|
129
130
|
const all = tenantIds.includes('*')
|
|
130
131
|
const sharedGenDir = !main.requires.extensibility
|
|
131
132
|
if (sharedGenDir) options.skipResources ??= sharedGenDir
|
|
132
|
-
|
|
133
|
+
|
|
134
|
+
// Merge static upgrade config with dynamic options (dynamic takes precedence)
|
|
135
|
+
const upgradeConfig = cds.env.requires[this.name]?.upgrade
|
|
136
|
+
if (upgradeConfig) {
|
|
137
|
+
options.upgrade = cds.utils.merge({ ...upgradeConfig }, options.upgrade)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Determine skipAfter: from explicit option or derived from package.json mtime
|
|
141
|
+
let skipAfter = options?.upgrade?.skipAfter
|
|
142
|
+
if (!skipAfter && options?.upgrade?.skipAlreadyUpgraded) {
|
|
143
|
+
const { stat } = require('fs/promises')
|
|
144
|
+
const { mtime } = await stat(cds.root + '/package.json')
|
|
145
|
+
skipAfter = mtime.toISOString()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const tenantsOptions = skipAfter ? { skipAfter } : undefined
|
|
149
|
+
let tenants
|
|
150
|
+
let skipped = []
|
|
151
|
+
if (all) {
|
|
152
|
+
tenants = await this._getTenants(tenantsOptions)
|
|
153
|
+
} else if (skipAfter) {
|
|
154
|
+
const upgradeable = await this._getTenants(tenantsOptions)
|
|
155
|
+
tenants = tenantIds.filter(t => upgradeable.includes(t))
|
|
156
|
+
// Requested tenants filtered out by skipAfter are considered already upgraded.
|
|
157
|
+
skipped = tenantIds.filter(t => !upgradeable.includes(t))
|
|
158
|
+
} else {
|
|
159
|
+
tenants = tenantIds
|
|
160
|
+
}
|
|
161
|
+
|
|
133
162
|
const { isSync } = this._parseHeaders(cds.context.http?.req.headers)
|
|
134
|
-
if (!tenants.length && isSync) return
|
|
163
|
+
if (!tenants.length && !skipped.length && isSync) return
|
|
135
164
|
if (sharedGenDir && cds.requires.db.kind === 'hana') { // REVISIT: Ideally part of HANA plugin
|
|
136
165
|
const { resources4, csvs4, directory4 } = require('../plugins/hana')
|
|
137
166
|
const out = await directory4('base', true)
|
|
@@ -141,13 +170,26 @@ module.exports = class ProvisioningService extends cds.ApplicationService {
|
|
|
141
170
|
const { 'cds.xt.DeploymentService': ds } = cds.services
|
|
142
171
|
const js = await cds.connect.to(JobsService)
|
|
143
172
|
const { clusterSize = 3 } = js._config()
|
|
144
|
-
const dbToTenants = (clusterSize > 1 && ds) ? await ds.tenantsByDb(tenants) : [new Set(tenants)]
|
|
145
|
-
|
|
173
|
+
const dbToTenants = (tenants.length && clusterSize > 1 && ds) ? await ds.tenantsByDb(tenants) : [new Set(tenants)]
|
|
174
|
+
// Skipped tenants go into a separate pseudo-db cluster so JobsService records them (and their
|
|
175
|
+
// tasks) as FINISHED without upgrading.
|
|
176
|
+
let clusters = dbToTenants
|
|
177
|
+
if (skipped.length) {
|
|
178
|
+
// Normalize array-form to a db-id-keyed object so skipped tenants get their own cluster.
|
|
179
|
+
clusters = { ...dbToTenants }
|
|
180
|
+
// Drop skipped tenants from regular clusters (some tenantsByDb impls ignore the tenant list).
|
|
181
|
+
const skippedSet = new Set(skipped)
|
|
182
|
+
for (const db of Object.keys(clusters)) {
|
|
183
|
+
clusters[db] = new Set([...clusters[db]].filter(t => tenants.includes(t) && !skippedSet.has(t)))
|
|
184
|
+
}
|
|
185
|
+
clusters[SKIPPED_DB] = skippedSet
|
|
186
|
+
}
|
|
187
|
+
LOG.info('upgrading', { tenants, skipped })
|
|
146
188
|
|
|
147
189
|
|
|
148
190
|
return await new Promise((resolve, reject) => {
|
|
149
191
|
const tx = js.tx({ tenant: cds.context.tenant, user: new cds.User.Privileged() })
|
|
150
|
-
const job = tx.enqueue(DeploymentService, 'upgrade',
|
|
192
|
+
const job = tx.enqueue(DeploymentService, 'upgrade', clusters, { options }, async error => {
|
|
151
193
|
if (error) {
|
|
152
194
|
await this._sendCallback('FAILED', `Tenant upgrade failed with error '${error}'`)
|
|
153
195
|
if (isSync) reject(new Error(error))
|
|
@@ -303,4 +345,17 @@ module.exports = class ProvisioningService extends cds.ApplicationService {
|
|
|
303
345
|
const cred = s?.credentials ?? multitenancy?.credentials ?? {}
|
|
304
346
|
return cred
|
|
305
347
|
}
|
|
348
|
+
|
|
349
|
+
_resolveCallbackUrl(callbackPath, baseUrl) {
|
|
350
|
+
if (!callbackPath || !baseUrl) return undefined
|
|
351
|
+
if (typeof callbackPath !== 'string' || !/^\/(?![/\\])/.test(callbackPath) || callbackPath.includes('\\')) {
|
|
352
|
+
throw cds.error(`Invalid callback path: ${callbackPath}`, { status: 400 })
|
|
353
|
+
}
|
|
354
|
+
const base = new URL(baseUrl)
|
|
355
|
+
const resolved = new URL(callbackPath, base)
|
|
356
|
+
if (resolved.origin !== base.origin) {
|
|
357
|
+
throw cds.error(`Invalid callback path: ${callbackPath}`, { status: 400 })
|
|
358
|
+
}
|
|
359
|
+
return resolved.toString()
|
|
360
|
+
}
|
|
306
361
|
}
|
|
@@ -28,7 +28,7 @@ module.exports = class SaasProvisioningService extends require('./abstract-provi
|
|
|
28
28
|
_parseHeaders(headers) {
|
|
29
29
|
const { prefer, status_callback, mtx_status_callback } = headers ?? {}
|
|
30
30
|
const { saas_registry_url } = this._getCredentials()
|
|
31
|
-
const callbackUrl = mtx_status_callback ?? (status_callback
|
|
31
|
+
const callbackUrl = mtx_status_callback ?? this._resolveCallbackUrl(status_callback, saas_registry_url)
|
|
32
32
|
return {
|
|
33
33
|
callbackUrl,
|
|
34
34
|
isCustomCallback: !!mtx_status_callback,
|
|
@@ -53,7 +53,7 @@ module.exports = class SmsProvisioningService extends require('./abstract-provis
|
|
|
53
53
|
_parseHeaders(headers) {
|
|
54
54
|
const { prefer, status_callback } = headers ?? {}
|
|
55
55
|
const { subscription_manager_url } = this._getCredentials()
|
|
56
|
-
const callbackUrl = (status_callback
|
|
56
|
+
const callbackUrl = this._resolveCallbackUrl(status_callback, subscription_manager_url)
|
|
57
57
|
return {
|
|
58
58
|
callbackUrl,
|
|
59
59
|
isSync: !(prefer?.includes('respond-async') || callbackUrl)
|
|
@@ -102,11 +102,31 @@ module.exports = class SmsProvisioningService extends require('./abstract-provis
|
|
|
102
102
|
const isIssuerValid = isValid(callback_certificate_issuer, cert.issuer)
|
|
103
103
|
const isSubjectValid = isValid(callback_certificate_subject, cert.subject)
|
|
104
104
|
|
|
105
|
+
function parseDN(dn) {
|
|
106
|
+
// Node's X509Certificate.issuer/subject uses newline as RDN separator.
|
|
107
|
+
// Each line is a single "attribute=value" pair. Multiple lines with the
|
|
108
|
+
// same attribute (e.g. multiple OU= entries) are all collected into an array.
|
|
109
|
+
const fields = {}
|
|
110
|
+
for (const line of dn.split('\n')) {
|
|
111
|
+
const eq = line.indexOf('=')
|
|
112
|
+
if (eq === -1) continue
|
|
113
|
+
const attr = line.slice(0, eq).trim().toUpperCase()
|
|
114
|
+
const val = line.slice(eq + 1).trim()
|
|
115
|
+
if (fields[attr]) fields[attr].push(val)
|
|
116
|
+
else fields[attr] = [val]
|
|
117
|
+
}
|
|
118
|
+
return fields
|
|
119
|
+
}
|
|
120
|
+
|
|
105
121
|
function isValid(expected, fromCert) {
|
|
122
|
+
const certFields = parseDN(fromCert)
|
|
106
123
|
return Object.entries(JSON.parse(expected)).every(([k, v]) => {
|
|
107
124
|
if (v === '*') return true
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
const certVals = certFields[k.toUpperCase()] ?? []
|
|
126
|
+
// For array expected values every element must be present in the cert.
|
|
127
|
+
// For a scalar expected value the cert must contain that exact value.
|
|
128
|
+
if (Array.isArray(v)) return v.every(allowed => certVals.includes(allowed))
|
|
129
|
+
else return certVals.includes(v)
|
|
110
130
|
})
|
|
111
131
|
}
|
|
112
132
|
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
const cds = require('@sap/cds')
|
|
2
|
+
const { path } = cds.utils
|
|
2
3
|
module.exports = (srv) => {
|
|
3
4
|
// mainly normalizing the input
|
|
4
5
|
srv.before(['UPDATE', 'UPSERT'], 'Extensions', req => { // must be synchronous to be handled first
|
|
5
6
|
const { csn: extension, i18n: bundles, csvs: csvData } = req.data
|
|
6
7
|
|
|
8
|
+
// reject file names with path components (path traversal, see DbAdapter.put)
|
|
9
|
+
for (const files of [bundles, csvData]) {
|
|
10
|
+
if (Array.isArray(files)) files.forEach(_assertPlainFileName)
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
// Validation of extension
|
|
8
14
|
if (!extension) throw new cds.error({ message: 'Property "extension" is missing', code: 400 })
|
|
9
15
|
if (Array.isArray(extension)) {
|
|
@@ -41,6 +47,15 @@ module.exports = (srv) => {
|
|
|
41
47
|
req.data.csn = extCsn
|
|
42
48
|
})
|
|
43
49
|
|
|
50
|
+
// rejects a FILE name that is not a plain file name (prevents path traversal)
|
|
51
|
+
const _assertPlainFileName = function (file) {
|
|
52
|
+
const name = file?.name
|
|
53
|
+
if (typeof name !== 'string' || !name.length) return
|
|
54
|
+
if (path.isAbsolute(name) || name.includes('/') || name.includes('\\') || path.basename(name) !== name) {
|
|
55
|
+
throw new cds.error({ message: `Invalid file name: "${name}"`, code: 400 })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
const _isCSN = str => str.substring(0, 1) === '{'
|
|
45
60
|
|
|
46
61
|
const _mergeCSN = function (extension, merged) {
|
|
@@ -143,8 +143,8 @@ module.exports = class AnnotationsChecker {
|
|
|
143
143
|
// get element permissions from allowlist
|
|
144
144
|
const kind = this._getExtendedKind(fullCsn, extCsn, entityOrService)
|
|
145
145
|
const permissions = allowList.getPermission(kind, entityOrService)
|
|
146
|
-
//
|
|
147
|
-
if (permissions && permissions.annotations && permissions.annotations.includes(annotationName))
|
|
146
|
+
// skip only this annotation; keep checking the remaining ones on the same element
|
|
147
|
+
if (permissions && permissions.annotations && permissions.annotations.includes(annotationName)) continue
|
|
148
148
|
|
|
149
149
|
const fn = checkedExtensionAnnotations.get(annotationName) ?? _createGenericAnnotationWarning
|
|
150
150
|
const warning = fn(annotationName, annotation);
|
|
@@ -2,6 +2,8 @@ const EXTENSION_ALLOWLIST = 'extension-allowlist'
|
|
|
2
2
|
|
|
3
3
|
const PROTECTED_NAMESPACES = ['cds.xt']
|
|
4
4
|
|
|
5
|
+
module.exports.PROTECTED_NAMESPACES = PROTECTED_NAMESPACES
|
|
6
|
+
|
|
5
7
|
const BUILT_IN_NAMESPACES = ['cds.core', 'cds.outbox'] //> cds.outbox for legacy apps
|
|
6
8
|
|
|
7
9
|
module.exports.isBuiltIn = (name) => {
|
|
@@ -4,6 +4,7 @@ const { deduplicateMessages } = require('@sap/cds-compiler')
|
|
|
4
4
|
const NamespaceChecker = require('./namespace')
|
|
5
5
|
const AnnotationsChecker = require('./annotations')
|
|
6
6
|
const AllowlistChecker = require('./allowlist')
|
|
7
|
+
const ProtectedNamespaceChecker = require('./protected-namespace')
|
|
7
8
|
|
|
8
9
|
const customLinters = []
|
|
9
10
|
|
|
@@ -39,6 +40,7 @@ module.exports.lint = (extCsn, fullCsn, env = cds.env, cloneExt = false) => {
|
|
|
39
40
|
const reflectedFullCsn = cds.reflect(fullCsn)
|
|
40
41
|
const compileBaseDir = cds.root
|
|
41
42
|
const messages = [
|
|
43
|
+
...new ProtectedNamespaceChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options),
|
|
42
44
|
...new NamespaceChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options),
|
|
43
45
|
...new AnnotationsChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options, extCsn),
|
|
44
46
|
...new AllowlistChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const LinterMessage = require('./message')
|
|
2
|
+
const { PROTECTED_NAMESPACES } = require('./config')
|
|
3
|
+
|
|
4
|
+
function _protectedNamespaceOf(name) {
|
|
5
|
+
if (!name) return null
|
|
6
|
+
return PROTECTED_NAMESPACES.find(ns => name === ns || name.startsWith(`${ns}.`)) ?? null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function locationString(element) {
|
|
10
|
+
const loc = element?.$location
|
|
11
|
+
if (!loc) return ''
|
|
12
|
+
const line = loc.line ? `${loc.line}:` : ''
|
|
13
|
+
return loc.col ? `${loc.file}:${line}${loc.col}:` : `${loc.file}:${line}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Rejects defining, extending or annotating artifacts in a protected namespace (e.g. 'cds.xt').
|
|
17
|
+
// Runs unconditionally, independent of linter configuration.
|
|
18
|
+
module.exports = class ProtectedNamespaceChecker {
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line no-unused-vars
|
|
21
|
+
check(reflectedCsn, fullCsn, compileDir, mtxConfig) {
|
|
22
|
+
const messages = []
|
|
23
|
+
|
|
24
|
+
for (const name of Object.keys(reflectedCsn.definitions ?? {})) {
|
|
25
|
+
if (_protectedNamespaceOf(name)) {
|
|
26
|
+
messages.push(this._createWarning(name, reflectedCsn.definitions[name]))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const extension of reflectedCsn.extensions ?? []) {
|
|
31
|
+
const target = extension.extend ?? extension.annotate
|
|
32
|
+
if (_protectedNamespaceOf(target)) {
|
|
33
|
+
messages.push(this._createWarning(target, extension))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return messages
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_createWarning(name, element) {
|
|
41
|
+
const namespace = _protectedNamespaceOf(name)
|
|
42
|
+
const message = `Extending, annotating or defining '${name}' in the protected namespace '${namespace}' is not supported in extensions`
|
|
43
|
+
return new LinterMessage(locationString(element) + message, element)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -58,7 +58,12 @@ class DbAdapter {
|
|
|
58
58
|
try {
|
|
59
59
|
const root = await mkdirp(path.join(temp, 'data'))
|
|
60
60
|
for (const csv in ext.csvs) {
|
|
61
|
-
|
|
61
|
+
// defense-in-depth: 'csv' is user-supplied and must not escape 'root'
|
|
62
|
+
const target = path.join(root, csv)
|
|
63
|
+
if (path.relative(root, target).match(/^\.\.($|[/\\])/) || path.isAbsolute(path.relative(root, target))) {
|
|
64
|
+
throw Error(`Invalid file name: "${csv}"`)
|
|
65
|
+
}
|
|
66
|
+
await write(target, ext.csvs[csv])
|
|
62
67
|
}
|
|
63
68
|
ext.archive = await tar.cz(temp)
|
|
64
69
|
} finally {
|
|
@@ -74,7 +79,7 @@ class DbAdapter {
|
|
|
74
79
|
const csn = JSON.stringify(ext.csn)
|
|
75
80
|
const i18n = ext.i18n ? JSON.stringify(ext.i18n) : null
|
|
76
81
|
const tag = ext.ID
|
|
77
|
-
await cds.tx({ tenant
|
|
82
|
+
await cds.tx({ tenant }, async tx => {
|
|
78
83
|
// remove current inactive extension with tag
|
|
79
84
|
if (tag) await tx.run(DELETE.from('cds.xt.Extensions').where({ tag, activated: { '!=': this.activated(ACTIVE) } }))
|
|
80
85
|
// will break with missing appid
|
|
@@ -455,7 +460,7 @@ module.exports = class ExtensibilityService extends cds.ApplicationService {
|
|
|
455
460
|
let extEntry
|
|
456
461
|
try {
|
|
457
462
|
// TODO: handle '*'?
|
|
458
|
-
await cds.tx(async () => {
|
|
463
|
+
await cds.tx({ tenant },async () => {
|
|
459
464
|
// use native table names to avoid issues with missing appid
|
|
460
465
|
extEntry = await SELECT(['ID','csn', 'tag','activated']).one.from('cds.xt.Extensions').where({ tag, activated: { '!=': 'database' } }).forUpdate() // select only entries with activated != 'database'
|
|
461
466
|
if (!extEntry) {
|