@cap-js/audit-logging 0.5.2 → 0.7.0

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
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
  This project adheres to [Semantic Versioning](http://semver.org/).
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/).
6
6
 
7
+ ## Version 0.7.0 - tbd
8
+
9
+ ### Added
10
+
11
+ - Automatically promote entities that are associated with data subjects
12
+
13
+ ## Version 0.6.0 - 2024-02-05
14
+
15
+ ### Added
16
+
17
+ - Support for @sap/cds^7.5
18
+
19
+ ### Fixed
20
+
21
+ - Automatic personal data modification logging for data subject details with renamed keys
22
+ - Data subject resolution in circular models
23
+
7
24
  ## Version 0.5.2 - 2023-12-08
8
25
 
9
26
  ### Fixed
package/cds-plugin.js CHANGED
@@ -19,6 +19,20 @@ cds.on('served', services => {
19
19
  for (const entity of service.entities) if (hasPersonalData(entity)) relevantEntities.push(entity)
20
20
  if (!relevantEntities.length) continue
21
21
 
22
+ // automatically promote entities that are associated with data subjects
23
+ for (const entity of relevantEntities) {
24
+ if (entity['@PersonalData.EntitySemantics'] !== 'DataSubject') continue
25
+ for (const e of service.entities) {
26
+ for (const k in e.associations) {
27
+ if (e.associations[k].target === entity.name && k !== 'SiblingEntity') {
28
+ e['@PersonalData.EntitySemantics'] ??= 'Other'
29
+ e.associations[k]['@PersonalData.FieldSemantics'] ??= 'DataSubjectID'
30
+ if (!relevantEntities.includes(e)) relevantEntities.push(e)
31
+ }
32
+ }
33
+ }
34
+ }
35
+
22
36
  for (const entity of relevantEntities) {
23
37
  /*
24
38
  * data access
package/lib/utils.js CHANGED
@@ -1,18 +1,27 @@
1
1
  const WRITE = { CREATE: 1, UPDATE: 1, DELETE: 1 }
2
2
 
3
+ const $hasPersonalData = Symbol('@cap-js/audit-logging:hasPersonalData')
4
+ const $dataSubject = Symbol('@cap-js/audit-logging:dataSubject')
5
+ const $parents = Symbol('@cap-js/audit-logging:parents')
6
+ const $visitedUp = Symbol('@cap-js/audit-logging:visitedUp')
7
+ const $visitedDown = Symbol('@cap-js/audit-logging:visitedDown')
8
+
3
9
  const hasPersonalData = entity => {
4
- if (!entity['@PersonalData.EntitySemantics']) return
5
- // default role to entity name
6
- if (entity['@PersonalData.EntitySemantics'] === 'DataSubject' && !entity['@PersonalData.DataSubjectRole'])
7
- entity['@PersonalData.DataSubjectRole'] = entity.name.match(/\w+/g).pop()
8
- // prettier-ignore
9
- const hasPersonalData = !!Object.values(entity.elements).some(element =>
10
- element['@PersonalData.IsPotentiallyPersonal'] ||
11
- element['@PersonalData.IsPotentiallySensitive'] ||
12
- (element['@PersonalData.FieldSemantics'] && element['@PersonalData.FieldSemantics'] === 'DataSubjectID'))
13
- // cache result
14
- entity.own('_hasPersonalData', () => hasPersonalData)
15
- return hasPersonalData
10
+ if (!entity.own($hasPersonalData)) {
11
+ if (!entity['@PersonalData.EntitySemantics']) entity.set($hasPersonalData, false)
12
+ else {
13
+ // default role to entity name
14
+ if (entity['@PersonalData.EntitySemantics'] === 'DataSubject' && !entity['@PersonalData.DataSubjectRole'])
15
+ entity['@PersonalData.DataSubjectRole'] = entity.name.match(/\w+/g).pop()
16
+ // prettier-ignore
17
+ const hasPersonalData = !!Object.values(entity.elements).some(element =>
18
+ element['@PersonalData.IsPotentiallyPersonal'] ||
19
+ element['@PersonalData.IsPotentiallySensitive'] ||
20
+ (element['@PersonalData.FieldSemantics'] && element['@PersonalData.FieldSemantics'] === 'DataSubjectID'))
21
+ entity.set($hasPersonalData, hasPersonalData)
22
+ }
23
+ }
24
+ return entity.own($hasPersonalData)
16
25
  }
17
26
 
18
27
  const getMapKeyForCurrentRequest = req => {
@@ -84,7 +93,7 @@ const _addKeysToWhere = (keys, row, alias) => {
84
93
  .filter(key => !key.isAssociation && key.name !== 'IsActiveEntity')
85
94
  .reduce((keys, key) => {
86
95
  if (keys.length) keys.push('and')
87
- keys.push({ ref: [alias, key.name] }, '=', { val: row[key.name] })
96
+ keys.push({ ref: [alias, key.name] }, '=', { val: row[key.name] || row._old?.[key.name] })
88
97
  return keys
89
98
  }, [])
90
99
  }
@@ -136,21 +145,25 @@ const _getDataSubjectIdQuery = ({ dataSubjectEntity, subs }, row, model) => {
136
145
  }
137
146
 
138
147
  const _getUps = (entity, model) => {
139
- if (entity.own('__parents')) return entity.__parents
148
+ if (entity.own($parents)) return entity[$parents]
140
149
  const ups = []
141
150
  for (const def of Object.values(model.definitions)) {
142
151
  if (def.kind !== 'entity' || !def.associations) continue
143
152
  for (const element of Object.values(def.associations)) {
144
- if (element.target !== entity.name || element._isBacklink) continue
145
- if (element.name === 'SiblingEntity') continue
153
+ if (element.target !== entity.name || element._isBacklink || element.name === 'SiblingEntity') continue
146
154
  ups.push(element)
147
155
  }
148
156
  }
149
- return entity.set('__parents', ups)
157
+ return entity.set($parents, ups)
150
158
  }
151
159
 
152
- const _getDataSubjectUp = (model, entity, prev, next, result) => {
160
+ const _getDataSubjectUp = (root, model, entity, prev, next, result) => {
153
161
  for (const element of _getUps(entity, model)) {
162
+ // cycle detection
163
+ if (!element.own($visitedUp)) element.set($visitedUp, new Set())
164
+ if (element.own($visitedUp).has(root)) continue
165
+ element.own($visitedUp).add(root)
166
+
154
167
  const me = { entity, relative: element.parent, element }
155
168
  if (prev) prev.next = me
156
169
  if (element.parent['@PersonalData.EntitySemantics'] === 'DataSubject') {
@@ -159,14 +172,15 @@ const _getDataSubjectUp = (model, entity, prev, next, result) => {
159
172
  return result
160
173
  } else {
161
174
  // dfs is a must here
162
- result = _getDataSubjectUp(model, element.parent, me, next || me, result)
175
+ result = _getDataSubjectUp(root, model, element.parent, me, next || me, result)
163
176
  }
164
177
  }
165
178
  return result
166
179
  }
167
180
 
168
- const _getDataSubjectDown = (entity, prev, next) => {
181
+ const _getDataSubjectDown = (root, entity, prev, next) => {
169
182
  const associations = Object.values(entity.associations || {}).filter(e => !e._isBacklink)
183
+ // bfs makes more sense here -> check all own assocs first before going deeper
170
184
  for (const element of associations) {
171
185
  const me = { entity, relative: entity, element }
172
186
  if (element._target['@PersonalData.EntitySemantics'] === 'DataSubject') {
@@ -174,23 +188,26 @@ const _getDataSubjectDown = (entity, prev, next) => {
174
188
  return { dataSubjectEntity: element._target, subs: [next || me] }
175
189
  }
176
190
  }
177
- // bfs makes more sense here
178
191
  for (const element of associations) {
192
+ // cycle detection
193
+ if (!element.own($visitedDown)) element.set($visitedDown, new Set())
194
+ if (element.own($visitedDown).has(root)) continue
195
+ element.own($visitedDown).add(root)
196
+
179
197
  const me = { entity, relative: entity, element }
180
198
  if (prev) prev.next = me
181
- const dataSubject = _getDataSubjectDown(element._target, me, next || me)
199
+ const dataSubject = _getDataSubjectDown(root, element._target, me, next || me)
182
200
  if (dataSubject) return dataSubject
183
201
  }
184
202
  }
185
203
 
186
204
  const getDataSubject = (entity, model) => {
187
- const hash = '__dataSubject'
188
- if (entity.own(hash)) return entity[hash]
205
+ if (entity.own($dataSubject)) return entity[$dataSubject]
189
206
  // entities with EntitySemantics 'DataSubjectDetails' or 'Other' must not necessarily
190
207
  // be always below or always above 'DataSubject' entity in CSN tree
191
- let dataSubjectInfo = _getDataSubjectUp(model, entity)
192
- if (!dataSubjectInfo) dataSubjectInfo = _getDataSubjectDown(entity)
193
- return entity.set(hash, dataSubjectInfo)
208
+ let dataSubjectInfo = _getDataSubjectUp(entity.name, model, entity)
209
+ if (!dataSubjectInfo) dataSubjectInfo = _getDataSubjectDown(entity.name, entity)
210
+ return entity.set($dataSubject, dataSubjectInfo)
194
211
  }
195
212
 
196
213
  const _getDataSubjectsMap = req => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/audit-logging",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "CDS plugin providing integration to the SAP Audit Log service as well as out-of-the-box personal data-related audit logging based on annotations.",
5
5
  "repository": "cap-js/audit-logging",
6
6
  "author": "SAP SE (https://www.sap.com)",
package/srv/service.js CHANGED
@@ -4,8 +4,6 @@ const Base = cds.outboxed ? cds.Service : require('@sap/cds/libx/_runtime/messag
4
4
 
5
5
  module.exports = class AuditLogService extends Base {
6
6
  async init() {
7
- const outboxed = this.immediate instanceof cds.Service
8
-
9
7
  // add common audit log entry fields
10
8
  this.before('*', req => {
11
9
  const { tenant, user, timestamp } = cds.context
@@ -22,8 +20,9 @@ module.exports = class AuditLogService extends Base {
22
20
  this.log = this.emit
23
21
  // NOTE: logSync is not a public API!
24
22
  this.logSync = (...args) => {
25
- if (outboxed) return this.immediate.send(...args)
26
- return this.send(...args)
23
+ if (cds.unboxed) return cds.unboxed(this).send(...args) //> cds >= 7.5
24
+ if (this.immediate instanceof cds.Service) return this.immediate.send(...args) //> cds ~ 7.4
25
+ return this.send(...args) //> cds <= 7.3
27
26
  }
28
27
  }
29
28
  }