@sap/cds 8.2.3 → 8.3.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +44 -3
  2. package/bin/test.js +1 -1
  3. package/lib/compile/etc/_localized.js +1 -0
  4. package/lib/compile/etc/csv.js +1 -1
  5. package/lib/compile/for/lean_drafts.js +5 -0
  6. package/lib/dbs/cds-deploy.js +8 -5
  7. package/lib/env/cds-requires.js +0 -13
  8. package/lib/linked/validate.js +11 -9
  9. package/lib/log/cds-error.js +10 -7
  10. package/lib/plugins.js +8 -3
  11. package/lib/srv/middlewares/cds-context.js +1 -1
  12. package/lib/srv/middlewares/errors.js +5 -3
  13. package/lib/srv/protocols/index.js +4 -4
  14. package/lib/srv/srv-methods.js +1 -0
  15. package/lib/utils/cds-test.js +2 -1
  16. package/lib/utils/cds-utils.js +14 -1
  17. package/lib/utils/colors.js +45 -44
  18. package/libx/_runtime/common/composition/data.js +4 -2
  19. package/libx/_runtime/common/composition/index.js +1 -2
  20. package/libx/_runtime/common/composition/tree.js +1 -24
  21. package/libx/_runtime/common/error/frontend.js +18 -4
  22. package/libx/_runtime/common/generic/auth/restrict.js +29 -4
  23. package/libx/_runtime/common/generic/auth/restrictions.js +29 -36
  24. package/libx/_runtime/common/i18n/messages.properties +1 -1
  25. package/libx/_runtime/common/utils/cqn.js +0 -26
  26. package/libx/_runtime/common/utils/csn.js +0 -14
  27. package/libx/_runtime/common/utils/differ.js +1 -0
  28. package/libx/_runtime/common/utils/resolveView.js +28 -9
  29. package/libx/_runtime/common/utils/templateProcessor.js +3 -0
  30. package/libx/_runtime/fiori/lean-draft.js +30 -12
  31. package/libx/_runtime/types/api.js +1 -1
  32. package/libx/_runtime/ucl/Service.js +2 -2
  33. package/libx/common/utils/path.js +1 -4
  34. package/libx/odata/ODataAdapter.js +6 -0
  35. package/libx/odata/middleware/batch.js +7 -9
  36. package/libx/odata/middleware/create.js +4 -2
  37. package/libx/odata/middleware/delete.js +3 -1
  38. package/libx/odata/middleware/operation.js +7 -5
  39. package/libx/odata/middleware/read.js +14 -10
  40. package/libx/odata/middleware/service-document.js +1 -1
  41. package/libx/odata/middleware/stream.js +1 -0
  42. package/libx/odata/middleware/update.js +5 -3
  43. package/libx/odata/parse/afterburner.js +37 -49
  44. package/libx/odata/utils/index.js +3 -2
  45. package/libx/odata/utils/postProcess.js +3 -8
  46. package/package.json +1 -1
  47. package/libx/_runtime/cds-services/services/utils/compareJson.js +0 -2
  48. package/libx/_runtime/messaging/event-broker.js +0 -317
@@ -1,4 +1,5 @@
1
- const cds = require('../../../cds')
1
+ const cds = require('../../../cds'),
2
+ LOG = cds.log('auth')
2
3
 
3
4
  const { reject, getRejectReason, resolveUserAttrs, getAuthRelevantEntity } = require('./utils')
4
5
  const { DRAFT_EVENTS, MOD_EVENTS } = require('./constants')
@@ -245,11 +246,12 @@ async function check_roles(req) {
245
246
  return
246
247
  }
247
248
 
249
+ //Instance based authorization for bound actions /functions
250
+ await restrictBoundActionFunctions(req, resolvedApplicables, definition, this)
251
+
248
252
  // no modification -> nothing more to do
249
253
  if (!MOD_EVENTS[req.event]) return
250
254
 
251
- // REVISIT: selected data could be used for etag check, diff, etc.
252
-
253
255
  /*
254
256
  * Here we check if UPDATE/DELETE requests add additional restrictions
255
257
  * Note: Needs to happen sequentially because of side effects
@@ -258,13 +260,36 @@ async function check_roles(req) {
258
260
  const unrestrictedCount = await _getUnrestrictedCount(req)
259
261
  if (unrestrictedCount === 0) req.reject(404)
260
262
 
261
- const restrictedCount = await _getRestrictedCount(req, this.model, resolvedApplicables)
263
+ // REVISIT: selected data could be used for etag check, diff, etc.
262
264
 
265
+ const restrictedCount = await _getRestrictedCount(req, this.model, resolvedApplicables)
263
266
  if (restrictedCount < unrestrictedCount) {
264
267
  reject(req, getRejectReason(req, '@restrict', definition, restrictedCount, unrestrictedCount))
265
268
  }
266
269
  }
267
270
 
271
+ const isBoundToCollection = action =>
272
+ action['@cds.odata.bindingparameter.collection'] ||
273
+ (action.params && Object.values(action.params).some(param => param?.items?.type === '$self'))
274
+
275
+ const restrictBoundActionFunctions = async (req, resolvedApplicables, definition, srv) => {
276
+ if (req.target?.actions?.[req.event] && !isBoundToCollection(req.target.actions[req.event])) {
277
+ //Clone to avoid target modification, which would cause a different query
278
+ const query = cds.ql.clone(req.query) ?? SELECT.from(req.subject)
279
+ _addRestrictionsToRead({ query: query, target: req.target }, cds.model, resolvedApplicables)
280
+ const result = await srv.run(query)
281
+ if (!result || result.length === 0) {
282
+ // If we got a result, we don't need to check for the existence, hence only in this special case we must determine if `404` or `403`.
283
+ const unrestrictedCount = await _getUnrestrictedCount(req)
284
+ if (unrestrictedCount === 0) req.reject(404)
285
+
286
+ if (LOG._debug) LOG.debug(`Restricted access on action ${req.event}`)
287
+ reject(req, getRejectReason(req, '@restrict', definition))
288
+ }
289
+ req._auth_query_result = result
290
+ }
291
+ }
292
+
268
293
  check_roles._initial = true
269
294
 
270
295
  module.exports = check_roles
@@ -1,6 +1,8 @@
1
+ const cds = require('../../../cds')
2
+
1
3
  const WRITE_EVENTS = { CREATE: 1, NEW: 1, UPDATE: 1, PATCH: 1, DELETE: 1, CANCEL: 1, EDIT: 1 }
2
4
  const CRUD = Object.assign({ READ: 1 }, WRITE_EVENTS)
3
-
5
+ const ACTION_TYPES = { action: 1, function: 1 }
4
6
  /**
5
7
  * Returns the applicable restrictions for the current request as follows:
6
8
  * - null: unrestricted access
@@ -15,24 +17,12 @@ const CRUD = Object.assign({ READ: 1 }, WRITE_EVENTS)
15
17
  * @returns {Promise | Array | null}
16
18
  */
17
19
  function getRestrictions(definition, event, user) {
18
- const { model } = this
19
- let restrictions = getNormalizedRestrictions(definition, model.definitions, event)
20
- if (!restrictions && (event in CRUD || !definition.parent)) {
21
- // > unrestricted entity or unbound
22
- return null
23
- }
20
+ let restrictions = getNormalizedRestrictions(definition)
21
+ if (!restrictions) return null
24
22
  if (event in CRUD && restrictions.length && restrictions.every(r => r.grant !== '*' && !(r.grant in CRUD))) {
25
23
  // > only bounds are restricted
26
24
  return null
27
25
  }
28
- if (!(event in CRUD) && !restrictions && definition.parent) {
29
- // > bound without own restrictions -> get from parent
30
- restrictions = getNormalizedRestrictions(definition.parent, model.definitions, event)
31
- if (!restrictions) {
32
- // > unrestricted bound
33
- return null
34
- }
35
- }
36
26
  // return the applicable restrictions (grant and to fit to request and user)
37
27
  return getApplicableRestrictions(restrictions, event, user)
38
28
  }
@@ -41,6 +31,13 @@ const _getLocalName = definition => {
41
31
  return definition._service ? definition.name.replace(`${definition._service.name}.`, '') : definition.name
42
32
  }
43
33
 
34
+ const _isStaticWhere = where => {
35
+ if (typeof where === 'string') where = cds.parse.expr(where)
36
+ return (
37
+ where?.xpr?.length === 3 && where.xpr.every(ele => typeof ele !== 'object' || ele.val || ele.ref?.[0] === '$user')
38
+ )
39
+ }
40
+
44
41
  const _getRestrictWithEventRewrite = (grant, to, where, target) => {
45
42
  // REVISIT: req.event should be 'SAVE' and 'PREPARE'
46
43
  if (grant === 'SAVE') grant = 'draftActivate'
@@ -72,35 +69,31 @@ const _addNormalizedRestrict = (restrict, restricts, definition) => {
72
69
  restrict.grant.forEach(grant => _addNormalizedRestrictPerGrant(grant, where, restrict, restricts, definition))
73
70
  }
74
71
 
75
- const getNormalizedRestrictions = (definition, definitions) => {
72
+ const getNormalizedRestrictions = definition => {
76
73
  const restricts = []
77
74
  let isRestricted = false
78
75
 
79
76
  // own
80
77
  if (definition['@restrict']) {
81
78
  isRestricted = true
82
- definition['@restrict'].forEach(restrict => _addNormalizedRestrict(restrict, restricts, definition))
83
- }
84
-
85
- // bounds
86
- const actions = definition.actions
87
-
88
- if (actions && Object.keys(actions).some(k => actions[k]['@restrict'])) {
89
- for (const k in actions) {
90
- const action = actions[k]
91
-
92
- if (action['@restrict']) {
93
- const restrictions = getNormalizedRestrictions(action, definitions)
94
- if (restrictions) {
95
- isRestricted = true
96
- restricts.push(...restrictions)
79
+ definition['@restrict'].forEach(restrict => {
80
+ if (definition.kind in ACTION_TYPES) {
81
+ const to = restrict.to ? (Array.isArray(restrict.to) ? restrict.to : [restrict.to]) : ['any']
82
+ if (definition.parent?.kind === 'entity') {
83
+ restrict = { grant: definition.name, to }
84
+ } else {
85
+ const where = _isStaticWhere(restrict.where) && restrict.where
86
+ restrict = { grant: _getLocalName(definition), to, where }
97
87
  }
98
- } else if (!definition['@restrict']) {
99
- // > no entity-level restrictions => unrestricted action
100
- isRestricted = true
101
- restricts.push({ grant: action.name, to: ['any'], target: action.parent })
102
88
  }
103
- }
89
+ _addNormalizedRestrict(restrict, restricts, definition)
90
+ })
91
+ }
92
+
93
+ // parent - in case of bound actions/functions
94
+ if (definition.kind in ACTION_TYPES && definition.parent && definition.parent['@restrict']) {
95
+ isRestricted = true
96
+ definition.parent['@restrict'].forEach(restrict => _addNormalizedRestrict(restrict, restricts, definition.parent))
104
97
  }
105
98
 
106
99
  return isRestricted ? restricts : null
@@ -91,7 +91,7 @@ BATCH_TOO_MANY_REQ=Batch request contains too many requests
91
91
  DRAFT_ALREADY_EXISTS=A draft for this entity already exists
92
92
  DRAFT_NOT_EXISTING=No draft for this entity exists
93
93
  DRAFT_LOCKED_BY_ANOTHER_USER=The entity is locked by user "{0}"
94
- DRAFT_MODIFICATION_ONLY_VIA_ROOT=A draft can only be modified via its root entity
94
+ DRAFT_MODIFICATION_ONLY_VIA_ROOT=A draft-enabled entity can only be modified via its root entity
95
95
  ACTIVE_MODIFICATION_VIA_DRAFT=Active entities cannot be modified via draft request
96
96
  DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS=Entity cannot be deleted because a draft exists
97
97
 
@@ -81,31 +81,6 @@ function targetFromPath(from, model) {
81
81
  return { last, path, target, isTargetComposition: isContained }
82
82
  }
83
83
 
84
- function isPathToDraft(path, model) {
85
- const { definitions } = model
86
- let draft = false
87
- let current
88
- for (const r of path) {
89
- if (r.id) {
90
- const isaIndex = r.where.findIndex(ele => ele.ref && ele.ref[0] === 'IsActiveEntity')
91
- if (isaIndex !== -1) draft = !r.where[isaIndex + 2].val
92
- current = current ? definitions[current.elements[r.id].target] : definitions[r.id]
93
- } else {
94
- if (r === 'SiblingEntity') draft = !!draft
95
- else {
96
- const next = current.elements[r]
97
- if (next.isAssociation) {
98
- if (next._isAssociationStrict) draft = false
99
- current = definitions[next.target]
100
- } else {
101
- current = next
102
- }
103
- }
104
- }
105
- }
106
- return draft
107
- }
108
-
109
84
  const resolveFromSelect = query => {
110
85
  const __protoSelect = Object.getPrototypeOf(SELECT())
111
86
  if (!(query instanceof __protoSelect.constructor)) Object.setPrototypeOf(query, __protoSelect)
@@ -118,6 +93,5 @@ module.exports = {
118
93
  getEntityNameFromUpdateCQN,
119
94
  where2obj,
120
95
  targetFromPath,
121
- isPathToDraft,
122
96
  resolveFromSelect
123
97
  }
@@ -1,9 +1,5 @@
1
1
  const cds = require('../../cds')
2
2
 
3
- const getEtagElement = entity => {
4
- return Object.values(entity.elements).find(element => element['@odata.etag'])
5
- }
6
-
7
3
  const getComp2oneParents = (entity, model) => {
8
4
  if (!entity) return []
9
5
  return _getUps(entity, model).filter(element => element.is2one && element.isComposition)
@@ -106,14 +102,6 @@ const findCsnTargetFor = (edmName, model, namespace) => {
106
102
  return edm2csnMap[edmName]
107
103
  }
108
104
 
109
- const getElementDeep = (entity, ref) => {
110
- let current = entity
111
- for (const r of ref) {
112
- current = current && current.elements && current.elements[r]
113
- }
114
- return current
115
- }
116
-
117
105
  const prefixForStruct = element => {
118
106
  const prefixes = []
119
107
  let parent = element.parent
@@ -153,9 +141,7 @@ function getDraftTreeRoot(entity, model) {
153
141
  }
154
142
 
155
143
  module.exports = {
156
- getEtagElement,
157
144
  findCsnTargetFor,
158
- getElementDeep,
159
145
  getComp2oneParents,
160
146
  prefixForStruct,
161
147
  getDraftTreeRoot,
@@ -54,6 +54,7 @@ module.exports = class Differ {
54
54
 
55
55
  async _diffUpdate(req, providedData) {
56
56
  if (cds.db) await this._addPartialPersistentState(req)
57
+ // REVISIT: this is done (better) in the new db services
57
58
  const newQuery = cqn2cqn4sql(req.query, this._srv.model)
58
59
  const combinedData = providedData || Object.assign({}, req.query.UPDATE.data || {}, req.query.UPDATE.with || {})
59
60
  enrichDataWithKeysFromWhere(combinedData, req, this._srv)
@@ -184,6 +184,8 @@ const _newColumns = (columns = [], transition, service, withAlias = false) => {
184
184
  newColumn = {}
185
185
  newColumn.as = column.ref[0]
186
186
  newColumn.val = mapped.val
187
+ } else if (column.ref && !transition.target.elements[column.ref[0]]) {
188
+ return // ignore columns which are not part of the entity
187
189
  } else {
188
190
  newColumn = column
189
191
  }
@@ -239,16 +241,33 @@ const _newInsertColumns = (columns = [], transition) => {
239
241
  const _newWhereRef = (newWhereElement, transition, alias, tableName) => {
240
242
  const newRef = Array.isArray(newWhereElement.ref) ? [...newWhereElement.ref] : [newWhereElement.ref]
241
243
 
242
- if (newRef.length > 1 && newRef[0] === alias) {
243
- const mapped = transition.mapping.get(newRef[1])
244
- if (mapped) newRef[1] = mapped.ref.join('_')
245
- } else if (newRef.length > 1 && newRef[0] === tableName) {
246
- newRef[0] = transition.target.name
247
- const mapped = transition.mapping.get(newRef[1])
248
- if (mapped) newRef[1] = mapped.ref.join('_')
244
+ const [firstRef, secondRef] = [newRef[0].id || newRef[0], newRef[1]?.id || newRef[1]]
245
+
246
+ const updateRef = (index, value) => {
247
+ // REVISIT: this should be done instead of ignoring where for a ref step
248
+ // --> there are no transitions calculated for infix filters though
249
+ // if (newRef[index].id) {
250
+ // newRef[index].id = value;
251
+ // } else {
252
+ // newRef[index] = value;
253
+ // }
254
+
255
+ newRef[index] = value
256
+ }
257
+
258
+ if (secondRef) {
259
+ if (firstRef === alias) {
260
+ const mapped = transition.mapping.get(secondRef)
261
+ if (mapped) updateRef(1, mapped.ref.join('_'))
262
+ } else if (firstRef === tableName) {
263
+ updateRef(0, transition.target.name)
264
+
265
+ const mapped = transition.mapping.get(secondRef)
266
+ if (mapped) updateRef(1, mapped.ref.join('_'))
267
+ }
249
268
  } else if (newRef.length === 1) {
250
- const mapped = transition.mapping.get(newRef[0])
251
- if (mapped) newRef[0] = mapped.ref.join('_')
269
+ const mapped = transition.mapping.get(firstRef)
270
+ if (mapped) updateRef(0, mapped.ref.join('_'))
252
271
  }
253
272
 
254
273
  newWhereElement.ref = newRef
@@ -63,6 +63,9 @@ const _processComplex = (processFn, row, template, key, pathOptions) => {
63
63
  }
64
64
  }
65
65
 
66
+ /**
67
+ * @param {import("../../types/api").TemplateProcessor} args
68
+ */
66
69
  const templateProcessor = ({ processFn, data, template, isRoot = true, pathOptions = {} }) => {
67
70
  if (!template || !template.elements.size || !data || typeof data !== 'object') return
68
71
  const dataArr = Array.isArray(data) ? data : [data]
@@ -226,17 +226,34 @@ const _cleanUpOldDrafts = (service, tenant) => {
226
226
  if (!expiredDrafts.length) return
227
227
 
228
228
  const expiredDraftsIds = expiredDrafts.map(el => el.DraftUUID)
229
- const cqns = []
229
+ const promises = []
230
230
 
231
- for (let name in service.model.definitions) {
231
+ const draftRoots = []
232
+
233
+ for (const name in service.model.definitions) {
232
234
  const target = service.model.definitions[name]
233
235
  if (target.drafts && target['@Common.DraftRoot.ActivationAction']) {
234
- cqns.push(DELETE.from(target.drafts).where(`DraftAdministrativeData_DraftUUID IN`, expiredDraftsIds))
236
+ draftRoots.push(target.drafts)
237
+ }
238
+ }
239
+
240
+ const draftRootIds = await Promise.all(
241
+ draftRoots.map(draftRoot =>
242
+ SELECT.from(draftRoot, entity_keys(draftRoot)).where(`DraftAdministrativeData_DraftUUID IN`, expiredDraftsIds)
243
+ )
244
+ )
245
+
246
+ for (let i = 0; i < draftRoots.length; i++) {
247
+ const ids = draftRootIds[i]
248
+ if (!ids.length) continue
249
+ const srv = await cds.connect.to(draftRoots[i]._service.name).catch(() => {})
250
+ if (!srv) continue // srv might not be loaded
251
+ for (const idObj of ids) {
252
+ promises.push(srv.send({ event: 'CANCEL', query: DELETE.from(draftRoots[i], idObj), data: idObj }))
235
253
  }
236
254
  }
237
255
 
238
- cqns.push(DELETE.from('DRAFT.DraftAdministrativeData').where(`DraftUUID IN`, expiredDraftsIds))
239
- return await _promiseAll(cqns)
256
+ await Promise.allSettled(promises)
240
257
  })
241
258
 
242
259
  lastCheckMap.set(tenant, Date.now())
@@ -523,7 +540,7 @@ cds.ApplicationService.prototype.handle = async function (req) {
523
540
  // Deletion of active instance inside draft tree, need to check that no draft exists
524
541
  const draft = await draftQuery
525
542
  const inProcessByUser = draft?.DraftAdministrativeData?.InProcessByUser
526
- if (inProcessByUser && inProcessByUser !== cds.context.user.id)
543
+ if (!cds.context.user._is_privileged && inProcessByUser && inProcessByUser !== cds.context.user.id)
527
544
  req.reject({ code: 403, statusCode: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [inProcessByUser] })
528
545
  if (draft) req.reject({ code: 403, statusCode: 403, message: 'DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS' })
529
546
  await run(query)
@@ -565,7 +582,7 @@ cds.ApplicationService.prototype.handle = async function (req) {
565
582
  const res = await run(draftQuery)
566
583
  if (!res)
567
584
  req.reject(_etagValidationType ? { code: 412, statusCode: 412 } : { code: 'DRAFT_NOT_EXISTING', statusCode: 404 })
568
- if (res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
585
+ if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
569
586
  req.reject({
570
587
  code: 403,
571
588
  statusCode: 403,
@@ -650,7 +667,7 @@ cds.ApplicationService.prototype.handle = async function (req) {
650
667
  expand: [{ ref: ['InProcessByUser'] }]
651
668
  })
652
669
  if (!res) req.reject({ code: 404, statusCode: 404 })
653
- if (res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
670
+ if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
654
671
  req.reject({
655
672
  code: 403,
656
673
  statusCode: 403,
@@ -1486,7 +1503,7 @@ async function onNew(req) {
1486
1503
  ])
1487
1504
  .where(req.query.INSERT.into.ref[0].where)
1488
1505
  if (!rootData) req.reject({ code: 404, statusCode: 404 })
1489
- if (rootData.DraftAdministrativeData?.InProcessByUser !== req.user.id)
1506
+ if (!cds.context.user._is_privileged && rootData.DraftAdministrativeData?.InProcessByUser !== req.user.id)
1490
1507
  req.reject({
1491
1508
  code: 403,
1492
1509
  statusCode: 403,
@@ -1756,7 +1773,7 @@ async function onCancel(req) {
1756
1773
  if (draftParams.IsActiveEntity === false && !draft) req.reject(req._etagValidationType ? 412 : 404)
1757
1774
  if (draft) {
1758
1775
  const processByUser = draft.DraftAdministrativeData?.InProcessByUser
1759
- if (processByUser && processByUser !== cds.context.user.id)
1776
+ if (!cds.context.user._is_privileged && processByUser && processByUser !== cds.context.user.id)
1760
1777
  req.reject({ code: 403, statusCode: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [processByUser] })
1761
1778
  }
1762
1779
  const draftDeleteQuery = DELETE.from({ ref: req.query.DELETE.from.ref })
@@ -1778,7 +1795,8 @@ async function onCancel(req) {
1778
1795
  })
1779
1796
  .where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID })
1780
1797
  )
1781
- if (draftParams.IsActiveEntity !== false) queries.push(this.run(DELETE.from({ ref: activeRef })))
1798
+ if (draftParams.IsActiveEntity !== false && !req.target.isDraft)
1799
+ queries.push(this.run(DELETE.from({ ref: activeRef })))
1782
1800
  await _promiseAll(queries)
1783
1801
  return req.data
1784
1802
  }
@@ -1804,7 +1822,7 @@ async function onPrepare(req) {
1804
1822
  draftQuery[DRAFT_PARAMS] = draftParams
1805
1823
  const data = await draftQuery
1806
1824
  if (!data) req.reject({ code: 404, statusCode: 404 })
1807
- if (data.DraftAdministrativeData?.InProcessByUser !== req.user.id)
1825
+ if (!cds.context.user._is_privileged && data.DraftAdministrativeData?.InProcessByUser !== req.user.id)
1808
1826
  req.reject({
1809
1827
  code: 403,
1810
1828
  statusCode: 403,
@@ -58,7 +58,7 @@
58
58
  /**
59
59
  * @typedef {object} TemplateProcessor
60
60
  * @property {Function} processFn
61
- * @property {object} row
61
+ * @property {object} data
62
62
  * @property {TemplateProcessorInfo} template
63
63
  * @property {boolean} [isRoot=true]
64
64
  * @property {TemplateProcessorPathOptions} [pathOptions=null]
@@ -95,7 +95,7 @@ class UCLService extends cds.Service {
95
95
  }
96
96
  placeholders: [
97
97
  { name: "subdomain", description: "The subdomain of the consumer tenant" }
98
- { name: "tenant-id", description: "The tenant id as it's known in the product's domain", jsonPath: "$.subscribedSubaccountId" }
98
+ { name: "tenant-id", description: "The tenant id as it's known in the product's domain", jsonPath: "$.subscribedTenantId" }
99
99
  ]
100
100
  labels: {
101
101
  managed_app_provisioning: true
@@ -203,7 +203,7 @@ class UCLService extends cds.Service {
203
203
  applicationNamespace: "${this.options.namespace}"
204
204
  placeholders: [
205
205
  { name: "subdomain", description: "The subdomain of the consumer tenant" }
206
- { name: "tenant-id", description: "The tenant id as it's known in the product's domain", jsonPath: "$.subscribedSubaccountId" }
206
+ { name: "tenant-id", description: "The tenant id as it's known in the product's domain", jsonPath: "$.subscribedTenantId" }
207
207
  ]
208
208
  accessLevel: GLOBAL
209
209
  }
@@ -1,17 +1,14 @@
1
- const cds = require('../../_runtime/cds')
2
1
  const { where2obj } = require('../../_runtime/common/utils/cqn')
3
2
  const { getKeysForNavigationFromRefPath } = require('../../_runtime/common/utils/keys')
4
3
 
5
4
  // REVISIT: do we already have something like this _without using okra api_?
6
5
  // REVISIT: should we still support process.env.CDS_FEATURES_PARAMS? probably nobody uses it...
7
- const getKeysAndParamsFromPath = (from, srv) => {
6
+ const getKeysAndParamsFromPath = (from, { model }) => {
8
7
  if (!from.ref || !from.ref.length) return {}
9
8
 
10
9
  const keys = {}
11
10
  const params = []
12
11
 
13
- const model = cds.context.model ?? srv.model
14
-
15
12
  let cur = model.definitions
16
13
  let lastElement
17
14
 
@@ -112,6 +112,12 @@ class ODataAdapter extends HttpAdapter {
112
112
 
113
113
  // REVISIT: ugly hack -> eliminate
114
114
  class NoaRequest extends cds.Request {
115
+ constructor(args) {
116
+ super(args)
117
+ this.req = args.req
118
+ this.res = args.res
119
+ }
120
+
115
121
  // REVISIT: all usages of .protocol are very bad style, violating modularization
116
122
  get protocol() {
117
123
  return 'odata'
@@ -9,6 +9,8 @@ const { URL } = require('url')
9
9
  const multipartToJson = require('../parse/multipartToJson')
10
10
  const { getBoundary } = require('../utils')
11
11
 
12
+ const { normalizeError } = require('../../_runtime/common/error/frontend')
13
+
12
14
  const HTTP_METHODS = { GET: 1, POST: 1, PUT: 1, PATCH: 1, DELETE: 1 }
13
15
  const CT = { JSON: 'application/json', MULTIPART: 'multipart/mixed' }
14
16
  const CRLF = '\r\n'
@@ -263,12 +265,8 @@ const _tx_done = async (tx, responses, isJson) => {
263
265
  } catch (e) {
264
266
  // here, the commit was rejected even though all requests were successful (e.g., by custom handler or db consistency check)
265
267
  rejected = 'rejected'
266
- // construct commit error
267
- let statusCode = e.statusCode || e.status || (e.code && Number(e.code))
268
- if (isNaN(statusCode)) statusCode = 500
269
- const code = String(e.code || statusCode)
270
- const message = e.message || 'Internal Server Error'
271
- const error = { error: { ...e, code, message } }
268
+ // construct commit error (without modifying original error)
269
+ const { error, statusCode } = normalizeError(Object.create(e), { locale: cds.context.locale })
272
270
  // replace all responses with commit error
273
271
  for (const res of responses) {
274
272
  res.status = 'fail'
@@ -278,15 +276,15 @@ const _tx_done = async (tx, responses, isJson) => {
278
276
  for (let i = 0; i < res.txt.length; i++) txt += Buffer.isBuffer(res.txt[i]) ? res.txt[i].toString() : res.txt[i]
279
277
  txt = JSON.parse(txt)
280
278
  txt.status = statusCode
281
- txt.body = error
279
+ txt.body = { error }
282
280
  // REVISIT: content-length needed? not there in multipart case...
283
281
  delete txt.headers['content-length']
284
282
  res.txt = [JSON.stringify(txt)]
285
283
  } else {
286
284
  let txt = res.txt[0]
287
- txt = txt.replace(/HTTP\/1\.1 \d\d\d \w+/, `HTTP/1.1 ${statusCode} ${message}`)
285
+ txt = txt.replace(/HTTP\/1\.1 \d\d\d \w+/, `HTTP/1.1 ${statusCode} ${STATUS_CODES[statusCode]}`)
288
286
  txt = txt.split(/\r\n/)
289
- txt.splice(-1, 1, JSON.stringify(error))
287
+ txt.splice(-1, 1, JSON.stringify({ error }))
290
288
  txt = txt.join('\r\n')
291
289
  res.txt = [txt]
292
290
  }
@@ -28,9 +28,11 @@ module.exports = (adapter, isUpsert) => {
28
28
  throw Object.assign(new Error(msg), { statusCode: 405 })
29
29
  }
30
30
 
31
+ const model = cds.context.model ?? service.model
32
+
31
33
  // payload & params
32
34
  const data = req.body
33
- const { keys, params } = getKeysAndParamsFromPath(from, service)
35
+ const { keys, params } = getKeysAndParamsFromPath(from, { model })
34
36
  // add keys from url into payload (overwriting if already present)
35
37
  Object.assign(data, keys)
36
38
 
@@ -74,7 +76,7 @@ module.exports = (adapter, isUpsert) => {
74
76
  }
75
77
 
76
78
  const isMinimal = getPreferReturnHeader(req) === 'minimal'
77
- postProcess(cdsReq.target, service, result, isMinimal)
79
+ postProcess(cdsReq.target, model, result, isMinimal)
78
80
  if (result?.$etag) res.set('ETag', result.$etag) //> must be done after post processing
79
81
  if (isMinimal) return res.sendStatus(204)
80
82
 
@@ -25,8 +25,10 @@ module.exports = adapter => {
25
25
  throw Object.assign(new Error('Method DELETE is not allowed for entity collections'), { statusCode: 405 })
26
26
  }
27
27
 
28
+ const model = cds.context.model ?? service.model
29
+
28
30
  // payload & params
29
- const { keys, params } = getKeysAndParamsFromPath(from, service)
31
+ const { keys, params } = getKeysAndParamsFromPath(from, { model })
30
32
  const data = keys //> for read and delete, we provide keys in req.data
31
33
  if (_propertyAccess) data[_propertyAccess] = null //> delete of property -> set to null
32
34
 
@@ -51,20 +51,22 @@ module.exports = adapter => {
51
51
  let { operation, args } = req._query.SELECT?.from.ref?.slice(-1)[0] || {}
52
52
  if (!operation) return next() //> create or read
53
53
 
54
+ const model = cds.context.model ?? service.model
55
+
54
56
  // unbound vs. bound
55
57
  let entity, params
56
- if (service.model.definitions[operation]) {
57
- operation = service.model.definitions[operation]
58
+ if (model.definitions[operation]) {
59
+ operation = model.definitions[operation]
58
60
  } else {
59
61
  req._query.SELECT.from.ref.pop()
60
- let cur = { elements: service.model.definitions }
62
+ let cur = { elements: model.definitions }
61
63
  for (const each of req._query.SELECT.from.ref) {
62
64
  cur = cur.elements[each.id || each]
63
65
  if (cur._target) cur = cur._target
64
66
  }
65
67
  operation = cur.actions[operation]
66
68
  entity = cur
67
- const keysAndParams = getKeysAndParamsFromPath(req._query.SELECT.from, service)
69
+ const keysAndParams = getKeysAndParamsFromPath(req._query.SELECT.from, { model })
68
70
  params = keysAndParams.params
69
71
  }
70
72
 
@@ -109,7 +111,7 @@ module.exports = adapter => {
109
111
  }
110
112
 
111
113
  if (operation.returns) {
112
- postProcess(operation.returns, service, result)
114
+ postProcess(operation.returns, model, result)
113
115
  if (result?.$etag) res.set('ETag', result.$etag) //> must be done after post processing
114
116
  }
115
117