Skip to content

Commit 569c58f

Browse files
authored
fix: abort search on path if cycle detected (#81)
1 parent 83bed48 commit 569c58f

File tree

6 files changed

+97
-34
lines changed

6 files changed

+97
-34
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
1313
### Fixed
1414

1515
- Automatic personal data modification logging for data subject details with renamed keys
16+
- Data subject resolution in circular models
1617

1718
## Version 0.5.2 - 2023-12-08
1819

lib/utils.js

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
const WRITE = { CREATE: 1, UPDATE: 1, DELETE: 1 }
22

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+
39
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)
1625
}
1726

1827
const getMapKeyForCurrentRequest = req => {
@@ -136,21 +145,25 @@ const _getDataSubjectIdQuery = ({ dataSubjectEntity, subs }, row, model) => {
136145
}
137146

138147
const _getUps = (entity, model) => {
139-
if (entity.own('__parents')) return entity.__parents
148+
if (entity.own($parents)) return entity[$parents]
140149
const ups = []
141150
for (const def of Object.values(model.definitions)) {
142151
if (def.kind !== 'entity' || !def.associations) continue
143152
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
146154
ups.push(element)
147155
}
148156
}
149-
return entity.set('__parents', ups)
157+
return entity.set($parents, ups)
150158
}
151159

152-
const _getDataSubjectUp = (model, entity, prev, next, result) => {
160+
const _getDataSubjectUp = (root, model, entity, prev, next, result) => {
153161
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+
154167
const me = { entity, relative: element.parent, element }
155168
if (prev) prev.next = me
156169
if (element.parent['@PersonalData.EntitySemantics'] === 'DataSubject') {
@@ -159,38 +172,42 @@ const _getDataSubjectUp = (model, entity, prev, next, result) => {
159172
return result
160173
} else {
161174
// 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)
163176
}
164177
}
165178
return result
166179
}
167180

168-
const _getDataSubjectDown = (entity, prev, next) => {
181+
const _getDataSubjectDown = (root, entity, prev, next) => {
169182
const associations = Object.values(entity.associations || {}).filter(e => !e._isBacklink)
183+
// bfs makes more sense here -> check all own assocs first before going deeper
170184
for (const element of associations) {
171185
const me = { entity, relative: entity, element }
172186
if (element._target['@PersonalData.EntitySemantics'] === 'DataSubject') {
173187
if (prev) prev.next = me
174188
return { dataSubjectEntity: element._target, subs: [next || me] }
175189
}
176190
}
177-
// bfs makes more sense here
178191
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+
179197
const me = { entity, relative: entity, element }
180198
if (prev) prev.next = me
181-
const dataSubject = _getDataSubjectDown(element._target, me, next || me)
199+
const dataSubject = _getDataSubjectDown(root, element._target, me, next || me)
182200
if (dataSubject) return dataSubject
183201
}
184202
}
185203

186204
const getDataSubject = (entity, model) => {
187-
const hash = '__dataSubject'
188-
if (entity.own(hash)) return entity[hash]
205+
if (entity.own($dataSubject)) return entity[$dataSubject]
189206
// entities with EntitySemantics 'DataSubjectDetails' or 'Other' must not necessarily
190207
// 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)
194211
}
195212

196213
const _getDataSubjectsMap = req => {

test/personal-data/crud.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2141,4 +2141,9 @@ describe('personal data audit logging in CRUD', () => {
21412141
})
21422142
})
21432143
})
2144+
2145+
test('with cycles', async () => {
2146+
await POST('/crud-5/A', { text: 'foo' }, { auth: ALICE })
2147+
expect(_logs.length).toBe(1)
2148+
})
21442149
})

test/personal-data/db/schema.cds

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,19 +137,49 @@ entity SubEntities {
137137
mainEntity : Association to MainEntities;
138138
}
139139

140-
141140
annotate MainEntities with @PersonalData: {
142141
EntitySemantics: 'DataSubject',
143-
DataSubjectRole: 'MainEntity',
142+
DataSubjectRole: 'MainEntity'
144143
} {
145144
ID @PersonalData.FieldSemantics : 'DataSubjectID';
146145
name @PersonalData.IsPotentiallyPersonal;
147146
}
148147

149-
annotate SubEntities with @PersonalData : {
150-
EntitySemantics: 'DataSubjectDetails',
151-
DataSubjectRole: 'MainEntity'
152-
} {
148+
annotate SubEntities with @PersonalData : {EntitySemantics: 'DataSubjectDetails'} {
153149
mainEntity @PersonalData.FieldSemantics: 'DataSubjectID';
154150
name @PersonalData.IsPotentiallyPersonal;
155151
}
152+
153+
entity A {
154+
key ID : UUID;
155+
text : String;
156+
b : Association to B;
157+
c : Association to C;
158+
}
159+
160+
entity B {
161+
key ID : UUID;
162+
text : String;
163+
a : Association to A;
164+
c : Association to C;
165+
}
166+
167+
entity C {
168+
key ID : UUID;
169+
text : String;
170+
}
171+
172+
annotate A with @PersonalData : {EntitySemantics: 'DataSubjectDetails'} {
173+
c @PersonalData.FieldSemantics: 'DataSubjectID';
174+
text @PersonalData.IsPotentiallyPersonal;
175+
}
176+
177+
annotate B with @PersonalData : {EntitySemantics: 'DataSubjectDetails'} {
178+
c @PersonalData.FieldSemantics: 'DataSubjectID';
179+
text @PersonalData.IsPotentiallyPersonal;
180+
}
181+
182+
annotate C with @PersonalData : {EntitySemantics: 'DataSubject'} {
183+
ID @PersonalData.FieldSemantics: 'DataSubjectID';
184+
text @PersonalData.IsPotentiallyPersonal;
185+
}

test/personal-data/fiori.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,13 +497,13 @@ describe('personal data audit logging in Fiori', () => {
497497
// reset logs
498498
_logs = []
499499

500-
// draft data is never logged. however, the read of the active data is logged.
500+
// draft data is never logged and since cds^7.6, we only read actives for which no draft exists -> now 0 logs
501501
response = await GET(
502502
'/fiori-1/Customers?$filter=(IsActiveEntity eq false or SiblingEntity/IsActiveEntity eq null)',
503503
{ auth: ALICE }
504504
)
505505
expect(response).toMatchObject({ status: 200 })
506-
expect(_logs.length).toBe(1)
506+
expect(_logs.length).toBe(0)
507507

508508
// reset logs
509509
_logs = []

test/personal-data/srv/crud-service.cds

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,13 @@ service CRUD_4 {
198198
};
199199

200200
}
201+
202+
@path : '/crud-5'
203+
@requires: 'admin'
204+
service CRUD_5 {
205+
206+
entity A as projection on db.A;
207+
entity B as projection on db.B;
208+
entity C as projection on db.C;
209+
210+
}

0 commit comments

Comments
 (0)