@cap-js/audit-logging 0.3.1 → 0.4.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 ADDED
@@ -0,0 +1,85 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ This project adheres to [Semantic Versioning](http://semver.org/).
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/).
6
+
7
+ ## Version 0.4.0 - 2023-10-24
8
+
9
+ ### Added
10
+
11
+ - Support for Premium plan of SAP Audit Log Service
12
+ - Support for XSUAA credential type `x509`
13
+ - Support for generic outbox
14
+
15
+ ### Changed
16
+
17
+ - Always use outbox (as configured in project)
18
+
19
+ ### Fixed
20
+
21
+ - Avoid dangling `SELECT`s to resolve data subject IDs, which resulted in "Transaction already closed" errors
22
+
23
+ ## Version 0.3.2 - 2023-10-11
24
+
25
+ ### Fixed
26
+
27
+ - If the request has no tenant (e.g., Unauthorized), the audit log shall be sent to the provider account
28
+
29
+ ## Version 0.3.1 - 2023-09-25
30
+
31
+ ### Fixed
32
+
33
+ - Defaulting of `@PersonalData.DataSubjectRole` to entity name
34
+ - Overriding service configuration
35
+
36
+ ## Version 0.3.0 - 2023-09-05
37
+
38
+ ### Changed
39
+
40
+ - Default value for `cds.requires['audit-log'].handle` changed to `['READ', 'WRITE']`, i.e., accessing sensitive data is now logged by default.
41
+
42
+ ## Version 0.2.0 - 2023-09-01
43
+
44
+ ### Added
45
+
46
+ - Export class `AuditLogService` for extending in custom implementations as follows:
47
+ ```js
48
+ const { AuditLogService } = require('@cap-js/audit-logging')
49
+ class MyAuditLogService extends AuditLogService {
50
+ async init() {
51
+ [...]
52
+ // call AuditLogService's init
53
+ await super.init()
54
+ }
55
+ }
56
+ module.exports = MyAuditLogService
57
+ ```
58
+
59
+ ## Version 0.1.0 - 2023-08-18
60
+
61
+ ### Added
62
+
63
+ - New API:
64
+ - `await audit.log('<event>', <data>)` for asynchronous logs (cf. `emit`)
65
+ - `await audit.logSync('<event>', <data>)` for synchronous logs (cf. `send`)
66
+ - New REST API-based schema with auto-filled `LogEntry` aspect
67
+ - New events `SensitiveDataRead`, `PersonalDataModified`, `ConfigurationModified`, and `SecurityEvent`
68
+ - Full support for OAuth2 plan of SAP Audit Log Service
69
+
70
+ ### Changed
71
+
72
+ - Whether reading sensitive data and modifying personal data is logged is determined by `cds.requires['audit-log'].handle: [...]`.
73
+ Possible values in the array are `READ` and/ or `WRITE`, with `WRITE` as the sole default entry.
74
+ Hence, accessing sensitive data is not logged by default.
75
+ - Integration with SAP Audit Log Service via REST API instead of client library (`@sap/audit-logging`)
76
+
77
+ ### Fixed
78
+
79
+ - Various glitches in log calculation
80
+
81
+ ### Removed
82
+
83
+ - Old events `dataAccessLog`, `dataModificationLog`, `configChangeLog`, and `securityLog`
84
+ - `@AuditLog.Operation` annotations are ignored. Having the plugin as dependency signals the intent to audit log.
85
+ - `cds.features.audit_personal_data: true` is no longer necessary. Instead, simply add the plugin as a dependency.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  ## About this project
5
5
 
6
- `@cap-js/audit-logging` is a CDS plugin providing integration to the SAP BTP Audit Logging Service as well as out-of-the-box personal data-related audit logging based on annotations.
6
+ `@cap-js/audit-logging` is a 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.
7
7
 
8
8
  Documentation can be found at [cap.cloud.sap](https://cap.cloud.sap/docs/guides/data-privacy).
9
9
 
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.1",
3
+ "version": "0.4.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)",
@@ -9,7 +9,8 @@
9
9
  "main": "cds-plugin.js",
10
10
  "files": [
11
11
  "lib",
12
- "srv"
12
+ "srv",
13
+ "CHANGELOG.md"
13
14
  ],
14
15
  "scripts": {
15
16
  "lint": "npx eslint .",
@@ -19,9 +20,9 @@
19
20
  "@sap/cds": "^7"
20
21
  },
21
22
  "devDependencies": {
23
+ "@cap-js/audit-logging": "file:.",
22
24
  "@cap-js/sqlite": "^1",
23
25
  "axios": "^1",
24
- "better-sqlite3": "8.5.2",
25
26
  "eslint": "^8",
26
27
  "express": "^4",
27
28
  "jest": "^29"
@@ -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,26 +50,29 @@ 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)
70
73
  return access_token
71
74
  } catch (err) {
75
+ LOG._trace && LOG.trace('error during token fetch:', err)
72
76
  // 401 could also mean x-zid is not valid
73
77
  if (String(err.response?.statusCode).match(/^4\d\d$/)) err.unrecoverable = true
74
78
  throw err
@@ -76,25 +80,30 @@ module.exports = class AuditLog2RESTv2 extends AuditLogService {
76
80
  }
77
81
 
78
82
  async _send(data, path) {
79
- let url
80
83
  const headers = { 'content-type': 'application/json' }
81
- // TODO: what are these for?
82
84
  if (this._vcap) {
83
85
  headers.XS_AUDIT_ORG = this._vcap.organization_name
84
86
  headers.XS_AUDIT_SPACE = this._vcap.space_name
85
87
  headers.XS_AUDIT_APP = this._vcap.application_name
86
88
  }
87
- if (this._oauth2) {
88
- url = this.options.credentials.url + PATHS.OAUTH2[path]
89
- headers.authorization = 'Bearer ' + (await this._getToken(data.tenant))
90
- data.tenant = data.tenant === this._providerTenant ? '$PROVIDER' : '$SUBSCRIBER'
91
- } else {
89
+ let url
90
+ if (this._plan === 'standard') {
92
91
  url = this.options.credentials.url + PATHS.STANDARD[path]
93
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'
98
+ }
99
+ if (LOG._debug) {
100
+ const _headers = Object.assign({}, headers, { authorization: headers.authorization.split(' ')[0] + ' ***' })
101
+ LOG.debug(`sending audit log to ${url} with tenant "${data.tenant}", user "${data.user}", and headers`, _headers)
94
102
  }
95
103
  try {
96
- await _post(url, data, headers)
104
+ await _post(url, data, { headers })
97
105
  } catch (err) {
106
+ LOG._trace && LOG.trace('error during log send:', err)
98
107
  // 429 (rate limit) is not unrecoverable
99
108
  if (String(err.response?.statusCode).match(/^4\d\d$/) && err.response?.statusCode !== 429)
100
109
  err.unrecoverable = true
@@ -137,9 +146,10 @@ const PATHS = {
137
146
 
138
147
  const https = require('https')
139
148
 
140
- async function _post(url, data, headers) {
149
+ async function _post(url, data, options) {
150
+ options.method ??= 'POST'
141
151
  return new Promise((resolve, reject) => {
142
- const req = https.request(url, { method: 'POST', headers }, res => {
152
+ const req = https.request(url, options, res => {
143
153
  const chunks = []
144
154
  res.on('data', chunk => chunks.push(chunk))
145
155
  res.on('end', () => {
package/srv/service.js CHANGED
@@ -1,10 +1,11 @@
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
11
  const { tenant, user, timestamp: time } = cds.context
@@ -16,6 +17,10 @@ module.exports = class AuditLogService extends OutboxService {
16
17
 
17
18
  // add self-explanatory api (await audit.log/logSync(event, data))
18
19
  this.log = this.emit
19
- this.logSync = this.send
20
+ // NOTE: logSync is not a public API!
21
+ this.logSync = (...args) => {
22
+ if (outboxed) return this.immediate.send(...args)
23
+ return this.send(...args)
24
+ }
20
25
  }
21
26
  }