@cap-js/audit-logging 0.3.0 → 0.3.2
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 +69 -0
- package/README.md +3 -2
- package/lib/utils.js +17 -18
- package/package.json +16 -17
- package/srv/log2restv2.js +8 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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.3.2 - 2023-10-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- If the request has no tenant (e.g., Unauthorized), the audit log shall be sent to the provider account
|
|
12
|
+
|
|
13
|
+
## Version 0.3.1 - 2023-09-25
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Defaulting of `@PersonalData.DataSubjectRole` to entity name
|
|
18
|
+
- Overriding service configuration
|
|
19
|
+
|
|
20
|
+
## Version 0.3.0 - 2023-09-05
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Default value for `cds.requires['audit-log'].handle` changed to `['READ', 'WRITE']`, i.e., accessing sensitive data is now logged by default.
|
|
25
|
+
|
|
26
|
+
## Version 0.2.0 - 2023-09-01
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Export class `AuditLogService` for extending in custom implementations as follows:
|
|
31
|
+
```js
|
|
32
|
+
const { AuditLogService } = require('@cap-js/audit-logging')
|
|
33
|
+
class MyAuditLogService extends AuditLogService {
|
|
34
|
+
async init() {
|
|
35
|
+
[...]
|
|
36
|
+
// call AuditLogService's init
|
|
37
|
+
await super.init()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
module.exports = MyAuditLogService
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Version 0.1.0 - 2023-08-18
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- New API:
|
|
48
|
+
- `await audit.log('<event>', <data>)` for asynchronous logs (cf. `emit`)
|
|
49
|
+
- `await audit.logSync('<event>', <data>)` for synchronous logs (cf. `send`)
|
|
50
|
+
- New REST API-based schema with auto-filled `LogEntry` aspect
|
|
51
|
+
- New events `SensitiveDataRead`, `PersonalDataModified`, `ConfigurationModified`, and `SecurityEvent`
|
|
52
|
+
- Full support for OAuth2 plan of SAP Audit Log Service
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- Whether reading sensitive data and modifying personal data is logged is determined by `cds.requires['audit-log'].handle: [...]`.
|
|
57
|
+
Possible values in the array are `READ` and/ or `WRITE`, with `WRITE` as the sole default entry.
|
|
58
|
+
Hence, accessing sensitive data is not logged by default.
|
|
59
|
+
- Integration with SAP Audit Log Service via REST API instead of client library (`@sap/audit-logging`)
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- Various glitches in log calculation
|
|
64
|
+
|
|
65
|
+
### Removed
|
|
66
|
+
|
|
67
|
+
- Old events `dataAccessLog`, `dataModificationLog`, `configChangeLog`, and `securityLog`
|
|
68
|
+
- `@AuditLog.Operation` annotations are ignored. Having the plugin as dependency signals the intent to audit log.
|
|
69
|
+
- `cds.features.audit_personal_data: true` is no longer necessary. Instead, simply add the plugin as a dependency.
|
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Welcome to @cap-js/audit-logging
|
|
2
|
+
[](https://api.reuse.software/info/github.com/cap-js/audit-logging)
|
|
2
3
|
|
|
3
4
|
## About this project
|
|
4
5
|
|
|
5
|
-
`@cap-js/audit-logging` is a CDS plugin providing integration to the SAP
|
|
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.
|
|
6
7
|
|
|
7
8
|
Documentation can be found at [cap.cloud.sap](https://cap.cloud.sap/docs/guides/data-privacy).
|
|
8
9
|
|
|
@@ -10,7 +11,7 @@ Documentation can be found at [cap.cloud.sap](https://cap.cloud.sap/docs/guides/
|
|
|
10
11
|
|
|
11
12
|
See [Getting Started](https://cap.cloud.sap/docs/get-started) on how to jumpstart your development and grow as you go with SAP Cloud Application Programming Model.
|
|
12
13
|
|
|
13
|
-
The end-to-end out-of-the-box functionality provided by this plugin requires a paid-for instance of the [SAP Audit
|
|
14
|
+
The end-to-end out-of-the-box functionality provided by this plugin requires a paid-for instance of the [SAP Audit Log service for customers](https://help.sap.com/docs/btp/sap-business-technology-platform/audit-log-write-api-for-customers?locale=en-US). However, it is possible to provide an own implementation that writes the audit logs to a custom store.
|
|
14
15
|
|
|
15
16
|
## Support, Feedback, Contributing
|
|
16
17
|
|
package/lib/utils.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
const WRITE = { CREATE: 1, UPDATE: 1, DELETE: 1 }
|
|
2
2
|
|
|
3
3
|
const hasPersonalData = entity => {
|
|
4
|
-
if (!entity['@PersonalData.DataSubjectRole']) return
|
|
5
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()
|
|
6
8
|
return !!Object.values(entity.elements).some(
|
|
7
9
|
element =>
|
|
8
10
|
element['@PersonalData.IsPotentiallyPersonal'] ||
|
|
@@ -148,31 +150,27 @@ const _getUps = (entity, model) => {
|
|
|
148
150
|
return entity.set('__parents', ups)
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
const
|
|
152
|
-
return entity['@PersonalData.EntitySemantics'] === 'DataSubject' && entity['@PersonalData.DataSubjectRole'] === role
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const _getDataSubjectUp = (role, model, entity, prev, next, result) => {
|
|
153
|
+
const _getDataSubjectUp = (model, entity, prev, next, result) => {
|
|
156
154
|
for (const element of _getUps(entity, model)) {
|
|
157
155
|
const me = { entity, relative: element.parent, element }
|
|
158
156
|
if (prev) prev.next = me
|
|
159
|
-
if (
|
|
157
|
+
if (element.parent['@PersonalData.EntitySemantics'] === 'DataSubject') {
|
|
160
158
|
if (!result) result = { dataSubjectEntity: element.parent, subs: [] }
|
|
161
159
|
result.subs.push(next || me)
|
|
162
160
|
return result
|
|
163
161
|
} else {
|
|
164
162
|
// dfs is a must here
|
|
165
|
-
result = _getDataSubjectUp(
|
|
163
|
+
result = _getDataSubjectUp(model, element.parent, me, next || me, result)
|
|
166
164
|
}
|
|
167
165
|
}
|
|
168
166
|
return result
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
const _getDataSubjectDown = (
|
|
169
|
+
const _getDataSubjectDown = (entity, prev, next) => {
|
|
172
170
|
const associations = Object.values(entity.associations || {}).filter(e => !e._isBacklink)
|
|
173
171
|
for (const element of associations) {
|
|
174
172
|
const me = { entity, relative: entity, element }
|
|
175
|
-
if (
|
|
173
|
+
if (element._target['@PersonalData.EntitySemantics'] === 'DataSubject') {
|
|
176
174
|
if (prev) prev.next = me
|
|
177
175
|
return { dataSubjectEntity: element._target, subs: [next || me] }
|
|
178
176
|
}
|
|
@@ -181,24 +179,25 @@ const _getDataSubjectDown = (role, entity, prev, next) => {
|
|
|
181
179
|
for (const element of associations) {
|
|
182
180
|
const me = { entity, relative: entity, element }
|
|
183
181
|
if (prev) prev.next = me
|
|
184
|
-
const dataSubject = _getDataSubjectDown(
|
|
182
|
+
const dataSubject = _getDataSubjectDown(element._target, me, next || me)
|
|
185
183
|
if (dataSubject) return dataSubject
|
|
186
184
|
}
|
|
187
185
|
}
|
|
188
186
|
|
|
189
|
-
const getDataSubject = (entity, model
|
|
190
|
-
const hash = '
|
|
187
|
+
const getDataSubject = (entity, model) => {
|
|
188
|
+
const hash = '__dataSubject'
|
|
191
189
|
if (entity.own(hash)) return entity[hash]
|
|
192
190
|
// entities with EntitySemantics 'DataSubjectDetails' or 'Other' must not necessarily
|
|
193
191
|
// be always below or always above 'DataSubject' entity in CSN tree
|
|
194
|
-
let
|
|
195
|
-
if (!
|
|
196
|
-
return entity.set(hash,
|
|
192
|
+
let dataSubjectInfo = _getDataSubjectUp(model, entity)
|
|
193
|
+
if (!dataSubjectInfo) dataSubjectInfo = _getDataSubjectDown(entity)
|
|
194
|
+
return entity.set(hash, dataSubjectInfo)
|
|
197
195
|
}
|
|
198
196
|
|
|
199
197
|
const addDataSubjectForDetailsEntity = (row, log, req, entity, model) => {
|
|
200
|
-
const
|
|
201
|
-
const
|
|
198
|
+
const dataSubjectInfo = getDataSubject(entity, model)
|
|
199
|
+
const role = dataSubjectInfo.dataSubjectEntity['@PersonalData.DataSubjectRole']
|
|
200
|
+
log.data_subject.role ??= role
|
|
202
201
|
log.data_subject.type = dataSubjectInfo.dataSubjectEntity.name
|
|
203
202
|
/*
|
|
204
203
|
* for each req (cf. $batch with atomicity) and data subject role (e.g., customer vs supplier),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/audit-logging",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "CDS plugin providing integration to the SAP
|
|
3
|
+
"version": "0.3.2",
|
|
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)",
|
|
7
7
|
"homepage": "https://cap.cloud.sap/",
|
|
@@ -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 .",
|
|
@@ -21,27 +22,25 @@
|
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@cap-js/sqlite": "^1",
|
|
23
24
|
"axios": "^1",
|
|
24
|
-
"better-sqlite3": "8.5.2",
|
|
25
25
|
"eslint": "^8",
|
|
26
26
|
"express": "^4",
|
|
27
27
|
"jest": "^29"
|
|
28
28
|
},
|
|
29
29
|
"cds": {
|
|
30
30
|
"requires": {
|
|
31
|
-
"audit-log":
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"[development]": {
|
|
39
|
-
"kind": "audit-log-to-console"
|
|
40
|
-
},
|
|
41
|
-
"[production]": {
|
|
42
|
-
"kind": "audit-log-to-restv2"
|
|
43
|
-
}
|
|
31
|
+
"audit-log": {
|
|
32
|
+
"handle": [
|
|
33
|
+
"READ",
|
|
34
|
+
"WRITE"
|
|
35
|
+
],
|
|
36
|
+
"[development]": {
|
|
37
|
+
"kind": "audit-log-to-console"
|
|
44
38
|
},
|
|
39
|
+
"[production]": {
|
|
40
|
+
"kind": "audit-log-to-restv2"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"kinds": {
|
|
45
44
|
"audit-log-to-console": {
|
|
46
45
|
"impl": "@cap-js/audit-logging/srv/log2console",
|
|
47
46
|
"outbox": false
|
package/srv/log2restv2.js
CHANGED
|
@@ -69,6 +69,7 @@ module.exports = class AuditLog2RESTv2 extends AuditLogService {
|
|
|
69
69
|
setTimeout(() => tokens.delete(tenant), (expires_in - 60) * 1000)
|
|
70
70
|
return access_token
|
|
71
71
|
} catch (err) {
|
|
72
|
+
LOG._trace && LOG.trace('error during token fetch:', err)
|
|
72
73
|
// 401 could also mean x-zid is not valid
|
|
73
74
|
if (String(err.response?.statusCode).match(/^4\d\d$/)) err.unrecoverable = true
|
|
74
75
|
throw err
|
|
@@ -76,25 +77,30 @@ module.exports = class AuditLog2RESTv2 extends AuditLogService {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
async _send(data, path) {
|
|
79
|
-
let url
|
|
80
80
|
const headers = { 'content-type': 'application/json' }
|
|
81
|
-
// TODO: what are these for?
|
|
82
81
|
if (this._vcap) {
|
|
83
82
|
headers.XS_AUDIT_ORG = this._vcap.organization_name
|
|
84
83
|
headers.XS_AUDIT_SPACE = this._vcap.space_name
|
|
85
84
|
headers.XS_AUDIT_APP = this._vcap.application_name
|
|
86
85
|
}
|
|
86
|
+
let url
|
|
87
87
|
if (this._oauth2) {
|
|
88
88
|
url = this.options.credentials.url + PATHS.OAUTH2[path]
|
|
89
|
+
data.tenant ??= this._providerTenant //> if request has no tenant, stay in provider account
|
|
89
90
|
headers.authorization = 'Bearer ' + (await this._getToken(data.tenant))
|
|
90
91
|
data.tenant = data.tenant === this._providerTenant ? '$PROVIDER' : '$SUBSCRIBER'
|
|
91
92
|
} else {
|
|
92
93
|
url = this.options.credentials.url + PATHS.STANDARD[path]
|
|
93
94
|
headers.authorization = this._auth
|
|
94
95
|
}
|
|
96
|
+
if (LOG._debug) {
|
|
97
|
+
const _headers = Object.assign({}, headers, { authorization: headers.authorization.split(' ')[0] + ' ***' })
|
|
98
|
+
LOG.debug(`sending audit log to ${url} with tenant "${data.tenant}", user "${data.user}", and headers`, _headers)
|
|
99
|
+
}
|
|
95
100
|
try {
|
|
96
101
|
await _post(url, data, headers)
|
|
97
102
|
} catch (err) {
|
|
103
|
+
LOG._trace && LOG.trace('error during log send:', err)
|
|
98
104
|
// 429 (rate limit) is not unrecoverable
|
|
99
105
|
if (String(err.response?.statusCode).match(/^4\d\d$/) && err.response?.statusCode !== 429)
|
|
100
106
|
err.unrecoverable = true
|