@cap-js/audit-logging 0.3.2 → 0.5.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,28 @@ 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.5.0 - 2023-11-22
8
+
9
+ ### Added
10
+
11
+ - Common log entry fields `uuid`, `tenant`, `user` and `time` can be provided manually
12
+
13
+ ## Version 0.4.0 - 2023-10-24
14
+
15
+ ### Added
16
+
17
+ - Support for Premium plan of SAP Audit Log Service
18
+ - Support for XSUAA credential type `x509`
19
+ - Support for generic outbox
20
+
21
+ ### Changed
22
+
23
+ - Always use outbox (as configured in project)
24
+
25
+ ### Fixed
26
+
27
+ - Avoid dangling `SELECT`s to resolve data subject IDs, which resulted in "Transaction already closed" errors
28
+
7
29
  ## Version 0.3.2 - 2023-10-11
8
30
 
9
31
  ### Fixed
package/lib/access.js CHANGED
@@ -11,7 +11,7 @@ const {
11
11
  addObjectID,
12
12
  addDataSubject,
13
13
  addDataSubjectForDetailsEntity,
14
- resolveDataSubjectPromises
14
+ resolveDataSubjects
15
15
  } = require('./utils')
16
16
 
17
17
  let audit
@@ -59,7 +59,11 @@ const auditAccess = async function (data, req) {
59
59
  const _data = Array.isArray(data) ? data : [data]
60
60
  _data.forEach(row => templateProcessor({ processFn: _processorFnAccess(accessLogs, this.model, req), row, template }))
61
61
 
62
- const accesses = (await resolveDataSubjectPromises(accessLogs)).filter(ele => ele.attributes.length)
62
+ for (const each of Object.keys(accessLogs)) if (!accessLogs[each].attributes.length) delete accessLogs[each]
63
+ if (!Object.keys(accessLogs).length) return
64
+
65
+ await resolveDataSubjects(accessLogs, req)
66
+ const accesses = Object.values(accessLogs).filter(ele => ele.attributes.length)
63
67
  if (!accesses.length) return
64
68
 
65
69
  audit = audit || (await cds.connect.to('audit-log'))
@@ -12,7 +12,7 @@ const {
12
12
  addObjectID,
13
13
  addDataSubject,
14
14
  addDataSubjectForDetailsEntity,
15
- resolveDataSubjectPromises
15
+ resolveDataSubjects
16
16
  } = require('./utils')
17
17
 
18
18
  let audit
@@ -152,7 +152,7 @@ const _calcModificationLogsHandler = async function (req, beforeWrite, that) {
152
152
 
153
153
  // execute the data subject promises before going along to on phase
154
154
  // guarantees that the reads are executed before the data is modified
155
- await resolveDataSubjectPromises(modificationLogs)
155
+ await resolveDataSubjects(modificationLogs, req)
156
156
  }
157
157
 
158
158
  const calcModLogs4Before = function (req) {
package/lib/utils.js CHANGED
@@ -5,12 +5,14 @@ const hasPersonalData = entity => {
5
5
  // default role to entity name
6
6
  if (entity['@PersonalData.EntitySemantics'] === 'DataSubject' && !entity['@PersonalData.DataSubjectRole'])
7
7
  entity['@PersonalData.DataSubjectRole'] = entity.name.match(/\w+/g).pop()
8
- return !!Object.values(entity.elements).some(
9
- element =>
10
- element['@PersonalData.IsPotentiallyPersonal'] ||
11
- element['@PersonalData.IsPotentiallySensitive'] ||
12
- (element['@PersonalData.FieldSemantics'] && element['@PersonalData.FieldSemantics'] === 'DataSubjectID')
13
- )
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
14
16
  }
15
17
 
16
18
  const getMapKeyForCurrentRequest = req => {
@@ -118,22 +120,19 @@ const _buildSubSelect = (model, { entity, relative, element, next }, row, previo
118
120
  return childCqn
119
121
  }
120
122
 
121
- const _getDataSubjectIdPromise = ({ dataSubjectEntity, subs }, row, model) => {
123
+ const _getDataSubjectIdQuery = ({ dataSubjectEntity, subs }, row, model) => {
122
124
  const keys = Object.values(dataSubjectEntity.keys)
123
125
  const as = _alias(dataSubjectEntity)
124
126
 
125
- const cqn = SELECT.from({ ref: [dataSubjectEntity.name], as })
127
+ const cqn = SELECT.one
128
+ .from({ ref: [dataSubjectEntity.name], as })
126
129
  .columns(_keyColumns(keys, as))
127
130
  .where(['exists', _buildSubSelect(model, subs[0], row)])
128
131
 
129
132
  // entity reused in different branches => must check all
130
133
  for (let i = 1; i < subs.length; i++) cqn.or(['exists', _buildSubSelect(model, subs[i], row)])
131
134
 
132
- return cqn.then(res => {
133
- const id = {}
134
- for (const k in res[0]) id[k] = res[0][k]
135
- return id
136
- })
135
+ return cqn
137
136
  }
138
137
 
139
138
  const _getUps = (entity, model) => {
@@ -194,6 +193,14 @@ const getDataSubject = (entity, model) => {
194
193
  return entity.set(hash, dataSubjectInfo)
195
194
  }
196
195
 
196
+ const _getDataSubjectsMap = req => {
197
+ const mapKey = getMapKeyForCurrentRequest(req)
198
+ const _audit = (req.context._audit ??= {})
199
+ if (!_audit.dataSubjects) _audit.dataSubjects = new Map()
200
+ if (!_audit.dataSubjects.has(mapKey)) _audit.dataSubjects.set(mapKey, new Map())
201
+ return _audit.dataSubjects.get(mapKey)
202
+ }
203
+
197
204
  const addDataSubjectForDetailsEntity = (row, log, req, entity, model) => {
198
205
  const dataSubjectInfo = getDataSubject(entity, model)
199
206
  const role = dataSubjectInfo.dataSubjectEntity['@PersonalData.DataSubjectRole']
@@ -203,24 +210,26 @@ const addDataSubjectForDetailsEntity = (row, log, req, entity, model) => {
203
210
  * for each req (cf. $batch with atomicity) and data subject role (e.g., customer vs supplier),
204
211
  * store (in audit data structure at context) and reuse a single promise to look up the respective data subject
205
212
  */
206
- const mapKey = getMapKeyForCurrentRequest(req)
207
- const _audit = (req.context._audit ??= {})
208
- if (!_audit.dataSubjects) _audit.dataSubjects = new Map()
209
- if (!_audit.dataSubjects.has(mapKey)) _audit.dataSubjects.set(mapKey, new Map())
210
- const map = _audit.dataSubjects.get(mapKey)
213
+ const map = _getDataSubjectsMap(req)
211
214
  if (map.has(role)) log.data_subject.id = map.get(role)
212
215
  // REVISIT by downward lookups row might already contain ID - some potential to optimize
213
- else map.set(role, _getDataSubjectIdPromise(dataSubjectInfo, row, model))
214
- }
215
-
216
- const resolveDataSubjectPromises = log => {
217
- const logs = Object.values(log)
218
- return Promise.all(logs.map(log => log.data_subject.id)).then(IDs =>
219
- logs.map((log, i) => {
220
- log.data_subject.id = IDs[i]
221
- return log
222
- })
223
- )
216
+ else map.set(role, _getDataSubjectIdQuery(dataSubjectInfo, row, model))
217
+ }
218
+
219
+ const resolveDataSubjects = async (logs, req) => {
220
+ const map = _getDataSubjectsMap(req)
221
+ for (const each of Object.values(logs)) {
222
+ if (each.data_subject.id instanceof cds.ql.Query) {
223
+ const q = each.data_subject.id
224
+ if (map.has(q)) {
225
+ each.data_subject.id = map.get(q)
226
+ } else {
227
+ const res = await q
228
+ map.set(q, res)
229
+ each.data_subject.id = res
230
+ }
231
+ }
232
+ }
224
233
  }
225
234
 
226
235
  module.exports = {
@@ -232,5 +241,5 @@ module.exports = {
232
241
  addObjectID,
233
242
  addDataSubject,
234
243
  addDataSubjectForDetailsEntity,
235
- resolveDataSubjectPromises
244
+ resolveDataSubjects
236
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/audit-logging",
3
- "version": "0.3.2",
3
+ "version": "0.5.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)",
@@ -20,6 +20,7 @@
20
20
  "@sap/cds": "^7"
21
21
  },
22
22
  "devDependencies": {
23
+ "@cap-js/audit-logging": "file:.",
23
24
  "@cap-js/sqlite": "^1",
24
25
  "axios": "^1",
25
26
  "eslint": "^8",
@@ -33,6 +34,7 @@
33
34
  "READ",
34
35
  "WRITE"
35
36
  ],
37
+ "outbox": true,
36
38
  "[development]": {
37
39
  "kind": "audit-log-to-console"
38
40
  },
@@ -42,15 +44,13 @@
42
44
  },
43
45
  "kinds": {
44
46
  "audit-log-to-console": {
45
- "impl": "@cap-js/audit-logging/srv/log2console",
46
- "outbox": false
47
+ "impl": "@cap-js/audit-logging/srv/log2console"
47
48
  },
48
49
  "audit-log-to-restv2": {
49
50
  "impl": "@cap-js/audit-logging/srv/log2restv2",
50
51
  "vcap": {
51
52
  "label": "auditlog"
52
- },
53
- "outbox": true
53
+ }
54
54
  }
55
55
  }
56
56
  }
package/srv/log2restv2.js CHANGED
@@ -9,12 +9,13 @@ module.exports = class AuditLog2RESTv2 extends AuditLogService {
9
9
  // credentials stuff
10
10
  const { credentials } = this.options
11
11
  if (!credentials) throw new Error('No or malformed credentials for "audit-log"')
12
- if (credentials.uaa) {
13
- this._oauth2 = true
14
- this._tokens = new Map()
15
- this._providerTenant = credentials.uaa.tenantid
16
- } else {
12
+ if (!credentials.uaa) {
13
+ this._plan = 'standard'
17
14
  this._auth = 'Basic ' + Buffer.from(credentials.user + ':' + credentials.password).toString('base64')
15
+ } else {
16
+ this._plan = credentials.url.match(/6081/) ? 'premium' : 'oauth2'
17
+ this._tokens = new Map()
18
+ this._provider = credentials.uaa.tenantid
18
19
  }
19
20
  this._vcap = process.env.VCAP_APPLICATION ? JSON.parse(process.env.VCAP_APPLICATION) : null
20
21
 
@@ -49,21 +50,23 @@ module.exports = class AuditLog2RESTv2 extends AuditLogService {
49
50
  const { _tokens: tokens } = this
50
51
  if (tokens.has(tenant)) return tokens.get(tenant)
51
52
 
52
- const url = this.options.credentials.uaa.url + '/oauth/token'
53
- const data = {
54
- grant_type: 'client_credentials',
55
- response_type: 'token',
56
- client_id: this.options.credentials.uaa.clientid,
57
- client_secret: this.options.credentials.uaa.clientsecret
53
+ const { uaa } = this.options.credentials
54
+ const url = (uaa.certurl || uaa.url) + '/oauth/token'
55
+ const data = { grant_type: 'client_credentials', response_type: 'token', client_id: uaa.clientid }
56
+ const options = { headers: { 'content-type': 'application/x-www-form-urlencoded' } }
57
+ if (tenant !== this._provider) options.headers['x-zid'] = tenant
58
+ // certificate or secret?
59
+ if (uaa['credential-type'] === 'x509') {
60
+ options.agent = new https.Agent({ cert: uaa.certificate, key: uaa.key })
61
+ } else {
62
+ data.client_secret = uaa.clientsecret
58
63
  }
59
64
  const urlencoded = Object.keys(data).reduce((acc, cur) => {
60
65
  acc += (acc ? '&' : '') + cur + '=' + data[cur]
61
66
  return acc
62
67
  }, '')
63
- const headers = { 'content-type': 'application/x-www-form-urlencoded' }
64
- if (tenant !== this._providerTenant) headers['x-zid'] = tenant
65
68
  try {
66
- const { access_token, expires_in } = await _post(url, urlencoded, headers)
69
+ const { access_token, expires_in } = await _post(url, urlencoded, options)
67
70
  tokens.set(tenant, access_token)
68
71
  // remove token from cache 60 seconds before it expires
69
72
  setTimeout(() => tokens.delete(tenant), (expires_in - 60) * 1000)
@@ -84,21 +87,21 @@ module.exports = class AuditLog2RESTv2 extends AuditLogService {
84
87
  headers.XS_AUDIT_APP = this._vcap.application_name
85
88
  }
86
89
  let url
87
- if (this._oauth2) {
88
- url = this.options.credentials.url + PATHS.OAUTH2[path]
89
- data.tenant ??= this._providerTenant //> if request has no tenant, stay in provider account
90
- headers.authorization = 'Bearer ' + (await this._getToken(data.tenant))
91
- data.tenant = data.tenant === this._providerTenant ? '$PROVIDER' : '$SUBSCRIBER'
92
- } else {
90
+ if (this._plan === 'standard') {
93
91
  url = this.options.credentials.url + PATHS.STANDARD[path]
94
92
  headers.authorization = this._auth
93
+ } else {
94
+ url = this.options.credentials.url + PATHS.OAUTH2[path]
95
+ data.tenant ??= this._provider //> if request has no tenant, stay in provider account
96
+ headers.authorization = 'Bearer ' + (await this._getToken(data.tenant))
97
+ data.tenant = data.tenant === this._provider ? '$PROVIDER' : '$SUBSCRIBER'
95
98
  }
96
99
  if (LOG._debug) {
97
100
  const _headers = Object.assign({}, headers, { authorization: headers.authorization.split(' ')[0] + ' ***' })
98
101
  LOG.debug(`sending audit log to ${url} with tenant "${data.tenant}", user "${data.user}", and headers`, _headers)
99
102
  }
100
103
  try {
101
- await _post(url, data, headers)
104
+ await _post(url, data, { headers })
102
105
  } catch (err) {
103
106
  LOG._trace && LOG.trace('error during log send:', err)
104
107
  // 429 (rate limit) is not unrecoverable
@@ -143,9 +146,10 @@ const PATHS = {
143
146
 
144
147
  const https = require('https')
145
148
 
146
- async function _post(url, data, headers) {
149
+ async function _post(url, data, options) {
150
+ options.method ??= 'POST'
147
151
  return new Promise((resolve, reject) => {
148
- const req = https.request(url, { method: 'POST', headers }, res => {
152
+ const req = https.request(url, options, res => {
149
153
  const chunks = []
150
154
  res.on('data', chunk => chunks.push(chunk))
151
155
  res.on('end', () => {
package/srv/service.js CHANGED
@@ -1,14 +1,18 @@
1
1
  const cds = require('@sap/cds')
2
2
 
3
- // REVISIT: cds.OutboxService or technique to avoid extending OutboxService
4
- const OutboxService = require('@sap/cds/libx/_runtime/messaging/Outbox')
3
+ const Base = cds.outboxed ? cds.Service : require('@sap/cds/libx/_runtime/messaging/Outbox')
5
4
 
6
- module.exports = class AuditLogService extends OutboxService {
5
+ module.exports = class AuditLogService extends Base {
7
6
  async init() {
7
+ const outboxed = this.immediate instanceof cds.Service
8
+
8
9
  // add common audit log entry fields
9
10
  this.before('*', req => {
10
- const { tenant, user, timestamp: time } = cds.context
11
- Object.assign(req.data, { uuid: cds.utils.uuid(), tenant, user: user.id, time })
11
+ const { tenant, user, timestamp } = cds.context
12
+ req.data.uuid ??= cds.utils.uuid()
13
+ req.data.tenant ??= tenant
14
+ req.data.user ??= user.id
15
+ req.data.time ??= timestamp
12
16
  })
13
17
 
14
18
  // call OutboxService's init
@@ -16,6 +20,10 @@ module.exports = class AuditLogService extends OutboxService {
16
20
 
17
21
  // add self-explanatory api (await audit.log/logSync(event, data))
18
22
  this.log = this.emit
19
- this.logSync = this.send
23
+ // NOTE: logSync is not a public API!
24
+ this.logSync = (...args) => {
25
+ if (outboxed) return this.immediate.send(...args)
26
+ return this.send(...args)
27
+ }
20
28
  }
21
29
  }