@sap/eslint-plugin-cds 3.1.0 → 3.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 CHANGED
@@ -6,6 +6,23 @@ This project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
7
  The format is based on [Keep a Changelog](http://keepachangelog.com/).
8
8
 
9
+ ## [3.1.1] - 2024-10-08
10
+
11
+ ### Changed
12
+
13
+ - `no-db-keywords` is no longer part of the 'recommended' rules,
14
+ as the cds-compiler takes care of quoting SQL keywords, if they are used as identifiers.
15
+
16
+ ### Fixed
17
+
18
+ - `auth-restrict-grant-service` can now handle invalid values for `@restrict`
19
+ - `auth-use-requires` now handles `null` values for `@restrict.grant.to`
20
+ - `auth-valid-restrict-to` is now more robust against invalid properties such as `__proto__`
21
+ and reduces the number of false positives
22
+ - `auth-valid-restrict-where` now handles and reports invalid value `@restrict: [{where: null}]`
23
+ - `auth-no-empty-restrictions` now handles invalid value `@restrict: [null]`
24
+
25
+
9
26
  ## [3.1.0] - 2024-09-26
10
27
 
11
28
  ### Added
@@ -9,7 +9,6 @@ module.exports = {
9
9
  '@sap/cds/auth-valid-restrict-keys': 'warn',
10
10
  '@sap/cds/auth-valid-restrict-to': 'warn',
11
11
  '@sap/cds/auth-valid-restrict-where': 'warn',
12
- '@sap/cds/no-db-keywords': 'warn',
13
12
  '@sap/cds/no-dollar-prefixed-names': 'warn',
14
13
  '@sap/cds/no-join-on-draft': 'warn',
15
14
  '@sap/cds/sql-cast-suggestion': 'warn',
@@ -58,7 +58,9 @@ function isEmptyRestriction(obj) {
58
58
  return obj === ''
59
59
  if (Array.isArray(obj))
60
60
  return obj.length === 0 || obj.some(isEmptyRestriction)
61
- if (typeof obj === 'object')
62
- return isEmptyRestriction(obj.to)
61
+ if (typeof obj === 'object') {
62
+ // handle `null` as non-empty (i.e. ignore)
63
+ return obj && isEmptyRestriction(obj.to)
64
+ }
63
65
  return false
64
66
  }
@@ -22,29 +22,30 @@ module.exports = {
22
22
  service: checkRestrictGrant
23
23
  }
24
24
 
25
- function checkRestrictGrant (d) {
26
- const node = context.getNode(d)
27
- const file = d.$location.file
28
- if (d['@restrict']) {
29
- for (const entry of d['@restrict']) {
30
- if (Object.keys(entry).includes('grant')) {
31
- const grantValue = entry.grant
32
- const messageId = 'limitedGrant'
33
- const data = { kind: d.kind, name: d.name }
34
- switch (typeof grantValue) {
35
- case 'string':
36
- if (grantValue !== '*') {
37
- context.report({ messageId, data, node, file })
38
- }
39
- break
40
- case 'object':
41
- if (grantValue.length > 1 || grantValue[0] !== '*') {
42
- context.report({ messageId, data, node, file })
43
- }
44
- break
45
- }
25
+ function checkRestrictGrant(def) {
26
+ if (!Array.isArray(def['@restrict']))
27
+ return
28
+
29
+ const node = context.getNode(def)
30
+ const file = def.$location.file
31
+ const data = { kind: def.kind, name: def.name }
32
+
33
+ for (const entry of def['@restrict']) {
34
+ if (entry?.grant !== undefined) {
35
+
36
+ if (typeof entry.grant === 'string') {
37
+ if (entry.grant !== '*')
38
+ context.report({ messageId: 'limitedGrant', data, node, file })
39
+
40
+ } else if (Array.isArray(entry.grant)) {
41
+ if (entry.grant.length === 0 || !entry.grant.some(val => val === '*'))
42
+ context.report({ messageId: 'limitedGrant', data, node, file })
43
+
44
+ } else {
45
+ // invalid grant value; ignored by this rule
46
46
  }
47
47
  }
48
+
48
49
  }
49
50
  }
50
51
  }
@@ -24,23 +24,25 @@ module.exports = {
24
24
  }
25
25
 
26
26
  function checkRestrict (e) {
27
- if (!e?.['@restrict'] || !Array.isArray(e['@restrict']))
27
+ if (!Array.isArray(e?.['@restrict']))
28
28
  return
29
29
 
30
30
  for (const entry of e['@restrict']) {
31
31
  // Scenario: `@restrict: [ { to: 'Foo', grant: '*' } ]`
32
32
  // There must be no `where` condition, as otherwise it wouldn't be equivalent
33
- // to `@requires`.
33
+ // to `@requires`. `to` must not be `null`, as `@requires: null` is not the
34
+ // same as `@restrict: [{to:null}]`.
34
35
  // See https://cap.cloud.sap/docs/guides/security/authorization#supported-combinations-with-cds-resources
35
36
  // for documentation.
36
- if (entry?.to !== undefined && (entry.grant === '*' || !entry.grant) && entry.where === undefined) {
37
+ if (entry?.to !== undefined && entry.to !== null &&
38
+ (entry.grant === '*' || !entry.grant) && entry.where === undefined) {
37
39
  context.report({
38
40
  messageId: 'useRequires',
39
41
  data: { kind: e.kind, name: e.name },
40
42
  node: context.getNode(e),
41
43
  file: e.$location.file
42
44
  })
43
- return // max one report per annotation
45
+ break // max one report per annotation
44
46
  }
45
47
  }
46
48
  }
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { findFuzzy, isEmptyString, isEmptyObject, isStringInArray } = require('../utils/rules')
3
+ const { findFuzzy } = require('../utils/rules')
4
4
 
5
5
  // See https://cap.cloud.sap/docs/guides/security/authorization#restrict-annotation
6
6
  // The combination of these events is equivalent to the virtual 'WRITE' event.
@@ -40,7 +40,7 @@ module.exports = {
40
40
  }
41
41
 
42
42
  function checkRestrictGrant(e) {
43
- if (!e?.['@restrict'] || !Array.isArray(e['@restrict']))
43
+ if (!Array.isArray(e?.['@restrict']))
44
44
  return
45
45
 
46
46
  const node = context.getNode(e)
@@ -24,7 +24,7 @@ module.exports = {
24
24
  },
25
25
  create (context) {
26
26
  return {
27
- any: checkRestrictKeys
27
+ any: checkRestrictKeys,
28
28
  }
29
29
 
30
30
  function checkRestrictKeys (e) {
@@ -32,7 +32,7 @@ module.exports = {
32
32
  return
33
33
 
34
34
  for (const entry of e['@restrict']) {
35
- if (typeof entry === 'object' && !isEmptyObject(entry)) {
35
+ if (entry && typeof entry === 'object' && !isEmptyObject(entry)) {
36
36
  for (const key of Object.keys(entry)) {
37
37
  if (VALID_RESTRICT_PROPERTIES.includes(key))
38
38
  continue
@@ -1,8 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { isEmptyString, isStringInArray, findFuzzy, isEmptyObject } = require('../utils/rules')
4
-
5
- const VALID_PSEUDO_ROLES = ['authenticated-user', 'system-user', 'any']
3
+ const VALID_PSEUDO_ROLES = new Set(['authenticated-user', 'system-user', 'internal-user', 'any'])
6
4
 
7
5
  module.exports = {
8
6
  meta: {
@@ -14,126 +12,72 @@ module.exports = {
14
12
  url: 'https://cap.cloud.sap/docs/tools/cds-lint/meta/auth-valid-restrict-to',
15
13
  },
16
14
  messages: {
17
- invalidItem: "Invalid item '{{invalid}}'. Did you mean '{{candidates}}'?",
18
- missingRole: "Missing role on '{{name}}' for `@restrict.to`.",
19
- missingRoles: "Missing roles on '{{name}}' for `@restrict.to`."
15
+ invalidType: 'Invalid type for value of `@restrict.to`. Must either be string or array of strings.',
16
+ pseudoRoleTypo: "Did you mean pseudo-role '{{valid}}' instead of '{{role}}'?",
17
+ any: 'Role \'any\' overrides all other roles. Replace by \'any\' only.'
20
18
  },
21
19
  type: 'problem',
22
20
  model: 'inferred'
23
21
  },
24
22
  create (context) {
25
23
  return {
26
- entity: checkRestrictTo
24
+ entity: checkRestrictTo,
25
+ service: checkRestrictTo,
26
+ action: checkRestrictTo,
27
+ function: checkRestrictTo,
27
28
  }
28
29
 
29
- function checkRestrictTo (e) {
30
- const USER_ROLES = []
31
- const model = context.getModel()
32
- if (!model)
30
+ function checkRestrictTo(def) {
31
+ // TODO: This check also applies to `@requires`. Test that.
32
+ if (!Array.isArray(def?.['@restrict']))
33
33
  return
34
34
 
35
- model.foreach('entity', e => {
36
- if (e['@restrict']) {
37
- e['@restrict'].forEach(p => {
38
- if (p.to) {
39
- switch (typeof p.to) {
40
- case 'string':
41
- if (p.to !== p.to.toLowerCase() && !USER_ROLES.includes(p.to)) {
42
- USER_ROLES.push(p.to)
43
- }
44
- break
45
- case 'object':
46
- for (const r in p.to) {
47
- if (r !== r.toLowerCase() && !USER_ROLES.includes(r)) {
48
- USER_ROLES.push(r)
49
- }
50
- }
51
- }
52
- }
53
- })
54
- }
55
- })
56
- const ROLES = USER_ROLES.concat(VALID_PSEUDO_ROLES)
57
-
58
- if (e['@restrict']) {
59
- const node = context.getNode(e)
60
- const file = e.$location.file
35
+ const node = context.getNode(def)
36
+ const file = def.$location.file
37
+ def['@restrict'].forEach(checkRestrictEntry)
61
38
 
62
- // TODO: For hierachies, check whether service restriction exists
63
- // const { prefix } = splitDefName(e)
64
- // const prefixSplit = prefix.split('.')
65
- // const serviceName = prefixSplit[prefixSplit.length - 1]
66
- // const services = model.services
67
- // let grantAllTo;
68
- // Object.values(services).map((s) => {
69
- // if (s.name === serviceName && s['@requires']) {
70
- // grantAllTo = s['@requires'];
71
- // }
72
- // })
73
-
74
- for (const entry of e['@restrict']) {
75
- if (Object.keys(entry).includes('to')) {
76
- const toValue = entry.to
39
+ function checkRestrictEntry(entry) {
40
+ if (entry?.to !== undefined) {
41
+ const roles = getUserRoles(entry)
42
+ if (roles.every(checkRole)) {
43
+ // all roles are valid
44
+ if (roles.length > 1 && roles.includes('any'))
45
+ context.report({ messageId: 'any', node, file })
46
+ }
47
+ }
48
+ }
77
49
 
78
- switch (typeof toValue) {
79
- case 'string': {
80
- if (isEmptyString(toValue)) {
81
- context.report({
82
- messageId: 'missingRole',
83
- data: { name: e.name },
84
- node,
85
- file,
86
- })
87
- } else {
88
- const isPseudoRole = entry.to && entry.to === entry.to.toLowerCase()
89
- if (!isStringInArray(toValue, ROLES, isPseudoRole)) {
90
- const candidates = findFuzzy(toValue, ROLES.sort())
91
- context.report({
92
- messageId: 'invalidItem',
93
- data: { invalid: toValue, candidates },
94
- node,
95
- file,
96
- })
97
- }
98
- }
99
- break
100
- }
50
+ function getUserRoles(entry) {
51
+ if (typeof entry.to === 'string') {
52
+ return [ entry.to ]
53
+ }
54
+ else if (Array.isArray(entry.to)) {
55
+ return entry.to
56
+ }
57
+ else {
58
+ // neither string nor array: report invalid type
59
+ context.report({ messageId: 'invalidType', node, file })
60
+ return []
61
+ }
62
+ }
101
63
 
102
- case 'object':
103
- if (isEmptyObject(toValue)) {
104
- context.report({
105
- messageId: 'missingRoles',
106
- data: { name: e.name },
107
- node,
108
- file,
109
- })
110
- } else {
111
- // If values contain 'any', 'any' only is enough
112
- if (toValue.length > 1 && toValue.includes('any')) {
113
- context.report({
114
- messageId: 'invalidItem',
115
- data: { invalid: `[${toValue}]`, candidates: ['["any"]'] },
116
- node,
117
- file,
118
- })
119
- }
120
- toValue.forEach(value => {
121
- if (!ROLES.includes(value)) {
122
- const candidates = findFuzzy(value, ROLES.sort(), undefined, false, 2)
123
- if (candidates.length > 0) {
124
- context.report({
125
- messageId: 'invalidItem',
126
- data: { invalid: value, candidates },
127
- node,
128
- file,
129
- })
130
- }
131
- }
132
- })
133
- }
134
- break
135
- }
64
+ function checkRole(role) {
65
+ if (typeof role !== 'string') {
66
+ context.report({ messageId: 'invalidType', node, file })
67
+ return false
68
+ }
69
+ else if (role !== '') {
70
+ // empty roles handled by auth-no-empty-restrictions
71
+ const roleLowercase = role.toLowerCase()
72
+ if (roleLowercase !== role && VALID_PSEUDO_ROLES.has(roleLowercase)) {
73
+ context.report({
74
+ messageId: 'pseudoRoleTypo',
75
+ data: { role, valid: roleLowercase },
76
+ node, file,
77
+ })
78
+ return false
136
79
  }
80
+ return true
137
81
  }
138
82
  }
139
83
  }
@@ -13,7 +13,8 @@ module.exports = {
13
13
  },
14
14
  severity: 'error',
15
15
  messages: {
16
- CompilationFailed: 'Invalid `where` expression, CDS compilation failed.',
16
+ compilationFailed: 'Invalid `where` expression, CDS compilation failed.',
17
+ invalidType: 'Invalid `where` type. Must be a string or expression.',
17
18
  },
18
19
  type: 'problem',
19
20
  model: 'inferred'
@@ -32,22 +33,35 @@ module.exports = {
32
33
  return
33
34
 
34
35
  for (const entry of def['@restrict']) {
35
- if (entry.where) {
36
+ if (entry?.where !== undefined) {
36
37
  // TODO: Check return value (where xpr)
37
38
  checkAndCompileWhereExpression(entry.where)
38
39
  }
39
40
  }
40
41
 
41
42
  function checkAndCompileWhereExpression(where) {
43
+ if (where === null || Array.isArray(where) ||
44
+ (typeof where !== 'object' && typeof where !== 'string')) {
45
+ context.report({
46
+ messageId: 'invalidType',
47
+ node: context.getNode(def),
48
+ file: def.$location.file,
49
+ })
50
+ return null
51
+ }
52
+
42
53
  try {
43
54
  const compileOptions = {}
44
55
  return typeof where === 'string'
45
56
  ? cds.parse.expr(where, compileOptions)
46
57
  : where
47
58
 
48
- } catch {
59
+ } catch(e) {
60
+ if (e.code !== 'ERR_CDS_COMPILATION_FAILURE')
61
+ throw e
62
+
49
63
  context.report({
50
- messageId: 'CompilationFailed',
64
+ messageId: 'compilationFailed',
51
65
  node: context.getNode(def),
52
66
  file: def.$location.file,
53
67
  })
@@ -17,7 +17,7 @@ module.exports = {
17
17
  type: 'problem',
18
18
  model: 'parsed',
19
19
  messages: {
20
- nullComparison: `Comparisons against 'null' are always false. Did you mean 'is not null'?`,
20
+ nullComparison: `Comparisons against 'null' are always null. Did you mean 'is not null'?`,
21
21
  }
22
22
  },
23
23
  create(context) {
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const cache = {}
3
+ const cache = new Map()
4
4
 
5
5
  /**
6
6
  * Levenshtein distance algorithm using recursive calls and cache
@@ -65,9 +65,9 @@ module.exports = function findFuzzy(input, list, log = null, keepCase = false, t
65
65
  }
66
66
 
67
67
  function levDistance(a, b) {
68
- if (cache[a] && cache[a][b]) {
69
- return cache[a][b]
70
- }
68
+ const cachedObj = cache.get(a)?.get(b)
69
+ if (cachedObj)
70
+ return cachedObj
71
71
 
72
72
  if (a.length === 0) {
73
73
  return addToCache(a, b, b.length)
@@ -93,7 +93,8 @@ function levDistance(a, b) {
93
93
  }
94
94
 
95
95
  function addToCache(a, b, value) {
96
- cache[a] = cache[a] || {}
97
- cache[a][b] = value
96
+ if (!cache.has(a))
97
+ cache.set(a, new Map())
98
+ cache.get(a).set(b, value)
98
99
  return value
99
100
  }
@@ -60,7 +60,7 @@ module.exports = {
60
60
  * @param {string} value
61
61
  * @returns {boolean}
62
62
  */
63
- isEmptyString: function (value) {
63
+ isEmptyString(value) {
64
64
  return value?.trim() === ''
65
65
  },
66
66
 
@@ -71,7 +71,7 @@ module.exports = {
71
71
  }
72
72
  return true
73
73
  }
74
- if (typeof value !== 'object' || (typeof value === 'object' && !isEmpty(value)) ||
74
+ if (!value || typeof value !== 'object' || (typeof value === 'object' && !isEmpty(value)) ||
75
75
  (typeof value === 'object' && value && value.length > 0)) {
76
76
  return false
77
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/eslint-plugin-cds",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "ESLint plugin including recommended SAP Cloud Application Programming model and environment rules",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [