@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 +22 -0
- package/lib/access.js +6 -2
- package/lib/modification.js +2 -2
- package/lib/utils.js +39 -30
- package/package.json +5 -5
- package/srv/log2restv2.js +27 -23
- package/srv/service.js +14 -6
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
|
-
|
|
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
|
|
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'))
|
package/lib/modification.js
CHANGED
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
addObjectID,
|
|
13
13
|
addDataSubject,
|
|
14
14
|
addDataSubjectForDetailsEntity,
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
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
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
149
|
+
async function _post(url, data, options) {
|
|
150
|
+
options.method ??= 'POST'
|
|
147
151
|
return new Promise((resolve, reject) => {
|
|
148
|
-
const req = https.request(url,
|
|
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
|
-
|
|
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
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
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
|
}
|