diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7efb1f9..035a720 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: node-version: [22.x, 20.x] - cds-version: [8] + cds-version: [9, 8] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef1413e..538d0fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 20 registry-url: https://registry.npmjs.org/ - name: run tests run: | diff --git a/.gitignore b/.gitignore index 515e907..cef175f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# macOS +.DS_Store + # Logs logs *.log diff --git a/cds-plugin.js b/cds-plugin.js index 84ffcdb..3fc71f7 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -6,6 +6,8 @@ const { hasPersonalData } = require('./lib/utils') const WRITE = ['CREATE', 'UPDATE', 'DELETE'] +const $distance = Symbol('@cap-js/audit-logging:distance') + /* * Add generic audit logging handlers */ @@ -61,6 +63,98 @@ cds.on('served', services => { } }) +cds.on('served', services => { + // prettier-ignore + const { types, classes: { number } } = cds.builtin + + const recurse = (entity, ds, ds_keys, path, definitions) => { + // forwards + for (const assoc in entity.associations) { + const target = definitions[entity.associations[assoc].target] + + if (!target['@PersonalData.EntitySemantics']) continue + if (target['@PersonalData.EntitySemantics'] === 'DataSubject') continue + if (target.own($distance) && target[$distance] <= path.length) continue + + target.set($distance, path.length) + + // the known entity instance as starting point + const kp = Object.keys(target.keys).reduce((acc, cur) => { + if (cur !== 'IsActiveEntity') acc.push(`${cur}=%%%${cur}%%%`) + return acc + }, []) + // path.push({ id: target.name, where: kp }) + path.push({ id: assoc, where: kp }) + + // construct path as string + const p = path.reduce((acc, cur) => { + if (!acc) { + // acc += `${cur.id}${cur.where ? `[${cur.where.join(' and ')}]` : ''}` + acc += `${cur.id}` + } else { + if (cur.id) { + const close = acc.match(/([\]]+)$/)?.[1] + if (close) + acc = + acc.slice(0, close.length * -1) + + `[exists ${cur.id}${cur.where ? `[${cur.where.join(' and ')}]` : ''}]` + + close + else acc += `[exists ${cur.id}${cur.where ? `[${cur.where.join(' and ')}]` : ''}]` + } else if (cur.to) acc += `.${cur.to}` + } + return acc + }, '') + + target._getDataSubjectQuery = row => { + let path = `${p}` + for (const ph of path.match(/%%%(\w+)%%%/g)) { + const ref = ph.slice(3, -3) + const val = row[ref] + path = path.replace(ph, types[target.elements[ref]._type] instanceof number ? val : `'${val}'`) + } + return SELECT.one.from(path).columns(ds_keys) + } + + delete path.at(-1).where + + recurse(target, ds, ds_keys, path, definitions) + + path.pop() + } + + // backwards + const targets = Object.values(definitions).filter( + d => + d['@PersonalData.EntitySemantics'] && + d['@PersonalData.EntitySemantics'] !== 'DataSubject' && + !d.own($distance) && + Object.values(d.associations || {}).some( + a => a.target === entity.name && !Object.values(entity.associations || {}).some(b => b.target === d.name) + ) + ) + + for (const target of targets) { + // debugger + } + } + + for (const service of services) { + if (!(service instanceof cds.ApplicationService)) continue + + const dataSubjects = [] + for (const entity of service.entities) + if (entity['@PersonalData.EntitySemantics'] === 'DataSubject') dataSubjects.push(entity) + if (!dataSubjects.length) continue + + const definitions = service.model.definitions + for (const ds of dataSubjects) { + const ds_keys = Object.keys(ds.keys) + const path = [{ id: ds.name }] + recurse(ds, ds, ds_keys, path, definitions) + } + } +}) + /* * Export base class for extending in custom implementations */ diff --git a/lib/utils.js b/lib/utils.js index 47c00a0..130df31 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -253,7 +253,18 @@ const addDataSubjectForDetailsEntity = (row, log, req, entity, model) => { const map = _getDataSubjectsMap(req) if (map.has(role)) log.data_subject.id = map.get(role) // REVISIT by downward lookups row might already contain ID - some potential to optimize - else map.set(role, _getDataSubjectIdQuery(dataSubjectInfo, row, model)) + else { + // let q = _getDataSubjectIdQuery(dataSubjectInfo, row, model) + let q + if (entity._getDataSubjectQuery) { + q = entity._getDataSubjectQuery(row) + } else { + // debugger + q = _getDataSubjectIdQuery(dataSubjectInfo, row, model) + } + // q = _getDataSubjectIdQuery(dataSubjectInfo, row, model) + map.set(role, q) + } } const resolveDataSubjects = (logs, req) => { @@ -265,7 +276,17 @@ const resolveDataSubjects = (logs, req) => { if (each.data_subject.id instanceof cds.ql.Query) { const q = each.data_subject.id if (!map.has(q)) { - const p = cds.run(q).then(res => map.set(q, res)) + const p = cds + .run(q) + .then(res => { + // debugger + map.set(q, res) + }) + .catch(e => { + q + debugger + throw e + }) map.set(q, p) ps.push(p) } diff --git a/test/personal-data/crud.test.js b/test/personal-data/crud.test.js index 3bade6d..946690a 100644 --- a/test/personal-data/crud.test.js +++ b/test/personal-data/crud.test.js @@ -1462,6 +1462,9 @@ describe('personal data audit logging in CRUD', () => { }) // check only one select used to look up data subject + + const _selects = _logger._logs.debug.filter(l => typeof l === 'string' && l.match(/^SELECT/)) + const selects = _logger._logs.debug.filter( l => typeof l === 'string' && l.match(/^SELECT/) && l.match(/SELECT [Customers.]*ID FROM CRUD_1_Customers/) ) diff --git a/test/personal-data/package.json b/test/personal-data/package.json index 4318efe..43b4c69 100644 --- a/test/personal-data/package.json +++ b/test/personal-data/package.json @@ -1,5 +1,10 @@ { "dependencies": { "@cap-js/audit-logging": "*" + }, + "cds": { + "requires": { + "outbox": false + } } }