diff --git a/CHANGELOG.md b/CHANGELOG.md index 7651049..5cbc6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). The format is based on [Keep a Changelog](http://keepachangelog.com/). +## Version 0.8.3 - 2024-11-28 + +### Fixed + +- Rewrite subselects to use path expressions on @cap-js databases + ## Version 0.8.2 - 2024-11-27 ### Fixed diff --git a/lib/utils.js b/lib/utils.js index d3b0df5..9eee66f 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -141,7 +141,7 @@ const _buildSubSelect = (model, { entity, relative, element, next }, row, previo return childCqn } -const _getDataSubjectIdQuery = ({ dataSubjectEntity, subs }, row, model) => { +const _old_getDataSubjectIdQuery = ({ dataSubjectEntity, subs }, row, model) => { const keys = Object.values(dataSubjectEntity.keys) const as = _alias(dataSubjectEntity) @@ -156,6 +156,93 @@ const _getDataSubjectIdQuery = ({ dataSubjectEntity, subs }, row, model) => { return cqn } +const _getRelation = (left, right, abort) => { + let a + for (const assoc in left.associations) { + if (left.associations[assoc].target === right.name) { + a = left.associations[assoc] + break + } + } + if (a) { + let backlink + for (const assoc in right.associations) { + if (right.associations[assoc].target === left.name) { + backlink = right.associations[assoc] + break + } + } + return { base: left, target: right, assoc: a, backlink } + } + return abort ? undefined : _getRelation(right, left, true) +} + +const _new_getDataSubjectIdQuery = ({ dataSubjectEntity, subs }, row) => { + const qs = [] + + // multiple subs => entity reused in different branches => must check all + for (const sub of subs) { + const path = [] + let s = sub + while (s) { + if (!path.length) { + // the known entity instance as starting point + const kp = Object.keys(s.entity.keys).reduce((acc, cur) => { + if (cur !== 'IsActiveEntity') acc.push(`${cur}='${row[cur]}'`) + return acc + }, []) + path.push({ id: s.entity.name, where: kp }) + } + + let relation = _getRelation(s.entity, s.next?.entity || dataSubjectEntity) + if (!relation) { + // TODO: no relation found + } else if (relation.base === s.entity) { + // assoc in base + if (relation.assoc === s.element) { + // forwards + path.push({ to: relation.assoc.name }) + } else { + // backwards + path[0].id = relation.backlink?.name || s.element.name + path.unshift({ id: relation.target.name }) + } + } else { + // assoc in target + path[0].id = s.element.name + path.unshift({ id: relation.base.name }) + } + + s = s.next + } + + // construct path as string + const p = path.reduce((acc, cur) => { + if (!acc) { + acc += `${cur.id}${cur.where ? `[${cur.where.join(' and ')}]` : ''}` + } 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 + }, '') + + qs.push(SELECT.one.from(p).columns(...Object.keys(dataSubjectEntity.keys))) + } + + // merge queries, if necessary + const q = qs[0] + for (let i = 1; i < qs.length; i++) q.SELECT.from.ref[0].where.push('or', ...qs[i].SELECT.from.ref[0].where) + return q +} + const _getUps = (entity, model) => { if (entity.own($parents) == null) { const ups = [] @@ -246,7 +333,12 @@ 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 { + module.exports._getDataSubjectIdQuery ??= cds.env.requires.db?.impl?.startsWith('@cap-js/') + ? _new_getDataSubjectIdQuery + : _old_getDataSubjectIdQuery + map.set(role, module.exports._getDataSubjectIdQuery(dataSubjectInfo, row, model)) + } } const resolveDataSubjects = (logs, req) => { diff --git a/package.json b/package.json index 4c552ce..c8cd434 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cap-js/audit-logging", - "version": "0.8.2", + "version": "0.8.3", "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.", "repository": "cap-js/audit-logging", "author": "SAP SE (https://www.sap.com)", diff --git a/test/personal-data-complex/crud.test.js b/test/personal-data-complex/crud.test.js new file mode 100644 index 0000000..23b9767 --- /dev/null +++ b/test/personal-data-complex/crud.test.js @@ -0,0 +1,60 @@ +const cds = require('@sap/cds') + +const { POST: _POST, /* PATCH: _PATCH, GET: _GET, DELETE: _DELETE, */ data } = cds.test().in(__dirname) + +// the persistent outbox adds a delay +const wait = require('util').promisify(setTimeout) +const DELAY = process.env.CI ? 42 : 7 +const POST = (...args) => _POST(...args).then(async res => (await wait(DELAY), res)) +// const PATCH = (...args) => _PATCH(...args).then(async res => (await wait(DELAY), res)) +// const GET = (...args) => _GET(...args).then(async res => (await wait(DELAY), res)) +// const DELETE = (...args) => _DELETE(...args).then(async res => (await wait(DELAY), res)) + +// TODO: @cap-js/sqlite doesn't support structured properties +// // needed for testing structured properties +// cds.env.odata.flavor = 'x4' + +const _logger = require('../utils/logger')({ debug: true }) +cds.log.Logger = _logger + +describe('personal data audit logging in CRUD with complex model', () => { + if (cds.version.split('.')[0] < 8) return test.skip('only for cds >= 8', () => {}) + + let __log, _logs + const _log = (...args) => { + if (!(args.length === 2 && typeof args[0] === 'string' && args[0].match(/\[audit-log\]/i))) { + // > not an audit log (most likely, anyway) + return __log(...args) + } + + _logs.push(args[1]) + } + + const ALICE = { username: 'alice', password: 'password' } + + beforeAll(() => { + __log = global.console.log + global.console.log = _log + }) + + afterAll(() => { + global.console.log = __log + }) + + beforeEach(async () => { + await data.reset() + _logs = [] + _logger._resetLogs() + }) + + describe('data deletion logging', () => { + test('Delete PII record in action', async () => { + const { status: statusLeave } = await POST( + '/collaborations/Collaborations(ID=36ca041a-a337-4d08-8099-c2a0980823a0,IsActiveEntity=true)/CollaborationsService.leave', + {}, + { auth: ALICE } + ) + expect(statusLeave).toEqual(204) + }) + }) +}) diff --git a/test/personal-data-complex/db/collaborations.cds b/test/personal-data-complex/db/collaborations.cds new file mode 100644 index 0000000..82fc0ae --- /dev/null +++ b/test/personal-data-complex/db/collaborations.cds @@ -0,0 +1,192 @@ +using { + cuid, + managed +} from '@sap/cds/common'; + +using { + sap.taco.core.Students, + sap.hcm.Employees +} from './schema'; + +namespace sap.taco.collaborations; + +entity Collaborations : cuid, managed { + title : String(100); + participants : Composition of many Participants + on participants.collaboration = $self; + applications : Composition of many Applications + on applications.collaboration = $self; + subCollaborations : Composition of many SubCollaborations + on subCollaborations.collaboration = $self; + leadAssignments : Composition of many CollaborationLeadAssignments + on leadAssignments.collaboration = $self; + collaborationLogs : Association to many CollaborationLogs + on collaborationLogs.collaboration = $self; + leads = participants[validFrom <= $now + and validTo >= $now + and isLead = true]; + activeParticipants = participants[validFrom <= $now + and validTo >= $now]; +} + +@assert.unique: {onlyOne: [ + collaboration, + student +], } +entity Participants : cuid { + collaboration : Association to one Collaborations; + student : Association to one Students; + employeeNav : Association to one Employees + on employeeNav.userID = student.userID; + validFrom : Date; + validTo : Date; + isLead : Boolean default false; + leadAssignment : Association to one CollaborationLeadAssignments + on leadAssignment.collaboration = collaboration + and leadAssignment.student = student; + subCollaborations : Composition of many SubCollaborationAssignments + on subCollaborations.participant = $self; + collaborationLogs : Association to many CollaborationLogs + on collaborationLogs.collaboration_ID = collaboration.ID + and student.userID = collaborationLogs.userID; +} + +@assert.unique: {onlyOne: [ + collaboration, + student +], } +entity CollaborationLeadAssignments : cuid { + collaboration : Association to one Collaborations; + student : Association to one Students; + employeeNav : Association to one Employees + on employeeNav.userID = student.userID; + validFrom : Date; + validTo : Date; +} + +@assert.unique: {onlyOne: [ + collaboration, + student +], } +entity Applications : cuid { + collaboration : Association to one Collaborations; + student : Association to one Students; + employeeNav : Association to one Employees + on employeeNav.userID = student.userID; + application : String(1000); + subCollaborationApplications : Composition of many SubCollaborationApplications + on subCollaborationApplications.application = $self; +} + +@assert.unique: {onlyOne: [ + subCollaboration, + application +], } +entity SubCollaborationApplications : cuid { + subCollaboration : Association to one SubCollaborations; + application : Association to one Applications; + leads : Association to many SubCollaborationLeads + on leads.subCollaboration_ID = subCollaboration.ID; +} + +entity SubCollaborations : cuid { + collaboration : Association to one Collaborations; + title : String(100); + participants : Association to many SubCollaborationAssignments + on participants.subCollaboration = $self @odata.draft.enclosed; + activeParticipants : Association to many ActiveSubCollaborationAssignments + on activeParticipants.subCollaboration = $self; + leads : Association to many SubCollaborationLeads + on leads.subCollaboration = $self /* and leads.isLead = true */; +} + +entity ActiveSubCollaborationAssignments as + select from SubCollaborationAssignments { + *, + participant.student.userID as student_userID @UI.Hidden, + } + where + validFrom <= $now + and validTo >= $now; + +entity SubCollaborationsVH as select from SubCollaborations; + +annotate SubCollaborationsVH with { + ID @UI.Hidden: false @Common.Text: title @Common.TextArrangement: #TextOnly +} + +@assert.unique: {onlyOne: [ + subCollaboration_ID, + participant +], } +entity SubCollaborationAssignments : cuid { + subCollaboration_ID : UUID; + subCollaboration : Association to one SubCollaborations + on subCollaboration.ID = subCollaboration_ID; + participant : Association to one Participants; + isLead : Boolean default false; + validFrom : Date; + validTo : Date; +} + +//REVISIT: Once isLead = true works also in associations and within exists navigations can be used +@readonly +entity SubCollaborationLeads as + projection on SubCollaborationAssignments { + *, + participant.student.userID as student_userID @readonly + } + where + isLead = true + and validFrom <= $now + and validTo >= $now; + +annotate SubCollaborationLeads with { + participant @readonly; +} + +entity CollaborationLogs : cuid { + userID : String; + student : Association to one Students + on student.userID = userID; + collaboration_ID : UUID; + collaboration : Association to one Collaborations + on collaboration.ID = collaboration_ID; + participant : Association to one Participants + on participant.student.userID = userID + and participant.collaboration.ID = collaboration_ID; + title : String(100); + approver : Association to one Employees; +} + +annotate Collaborations { + endDate @PersonalData.FieldSemantics: 'EndOfBusinessDate'; +} + +annotate Applications with @PersonalData : { + DataSubjectRole : 'Student', + EntitySemantics : 'Other' +} { + student @PersonalData.FieldSemantics: 'DataSubjectID'; + application @PersonalData.IsPotentiallyPersonal; +} + +annotate CollaborationLeadAssignments with @PersonalData : { + DataSubjectRole : 'Student', + EntitySemantics : 'Other' +} { + student @PersonalData.FieldSemantics: 'DataSubjectID'; +} + +annotate Participants with @PersonalData: { + DataSubjectRole: 'Student', + EntitySemantics: 'Other' +} { + student @PersonalData.FieldSemantics: 'DataSubjectID'; + validTo @PersonalData.FieldSemantics: 'EndOfBusinessDate'; +} + +annotate CollaborationLogs with { + userID @PersonalData.FieldSemantics: 'DataSubjectID'; + approver @PersonalData.FieldSemantics: 'UserID'; +} diff --git a/test/personal-data-complex/db/data/sap.hcm-Employees.csv b/test/personal-data-complex/db/data/sap.hcm-Employees.csv new file mode 100644 index 0000000..7cfa27f --- /dev/null +++ b/test/personal-data-complex/db/data/sap.hcm-Employees.csv @@ -0,0 +1,3 @@ +userID;displayName;firstName;lastName;initials;email;mobilePhone;officePhone;manager_userID +alice;Alice;Alice;;A;alice@sap.com;123;456;I123456 +I123456;Bettina;Bettina;;B;bettina@sap.com;789;101112; \ No newline at end of file diff --git a/test/personal-data-complex/db/data/sap.taco.collaborations-Collaborations.csv b/test/personal-data-complex/db/data/sap.taco.collaborations-Collaborations.csv new file mode 100644 index 0000000..bb0dd25 --- /dev/null +++ b/test/personal-data-complex/db/data/sap.taco.collaborations-Collaborations.csv @@ -0,0 +1,2 @@ +ID;title +36ca041a-a337-4d08-8099-c2a0980823a0;Test 1 \ No newline at end of file diff --git a/test/personal-data-complex/db/data/sap.taco.collaborations-Participants.csv b/test/personal-data-complex/db/data/sap.taco.collaborations-Participants.csv new file mode 100644 index 0000000..fd7aad2 --- /dev/null +++ b/test/personal-data-complex/db/data/sap.taco.collaborations-Participants.csv @@ -0,0 +1,2 @@ +ID;collaboration_ID;student_userID;isLead;validFrom;validTo +eea721d7-01cb-4862-a5fc-6e35cc55dd8f;36ca041a-a337-4d08-8099-c2a0980823a0;alice;true;2020-09-01;2023-09-30 \ No newline at end of file diff --git a/test/personal-data-complex/db/data/sap.taco.collaborations-SubCollaborationAssignments.csv b/test/personal-data-complex/db/data/sap.taco.collaborations-SubCollaborationAssignments.csv new file mode 100644 index 0000000..11a4bed --- /dev/null +++ b/test/personal-data-complex/db/data/sap.taco.collaborations-SubCollaborationAssignments.csv @@ -0,0 +1,2 @@ +ID;subCollaboration_ID;participant_ID;validFrom;validTo;isLead +9ced9799-d482-4f02-91ea-69457bc01d48;3913541a-5419-429c-b476-ed07ae8ed3b0;eea721d7-01cb-4862-a5fc-6e35cc55dd8f;2020-09-01;2023-09-30;false \ No newline at end of file diff --git a/test/personal-data-complex/db/data/sap.taco.collaborations-SubCollaborations.csv b/test/personal-data-complex/db/data/sap.taco.collaborations-SubCollaborations.csv new file mode 100644 index 0000000..0b41061 --- /dev/null +++ b/test/personal-data-complex/db/data/sap.taco.collaborations-SubCollaborations.csv @@ -0,0 +1,2 @@ +ID;collaboration_ID;title +3913541a-5419-429c-b476-ed07ae8ed3b0;36ca041a-a337-4d08-8099-c2a0980823a0;Test 1 subcollab \ No newline at end of file diff --git a/test/personal-data-complex/db/data/sap.taco.core-Students.csv b/test/personal-data-complex/db/data/sap.taco.core-Students.csv new file mode 100644 index 0000000..6b6ef9f --- /dev/null +++ b/test/personal-data-complex/db/data/sap.taco.core-Students.csv @@ -0,0 +1,2 @@ +userID;trainingHours;sumCompletedCollaborationLogs;validTo +alice;0;0;2023-09-30 \ No newline at end of file diff --git a/test/personal-data-complex/db/schema.cds b/test/personal-data-complex/db/schema.cds new file mode 100644 index 0000000..b79044e --- /dev/null +++ b/test/personal-data-complex/db/schema.cds @@ -0,0 +1,98 @@ +using { + cuid, + managed +} from '@sap/cds/common'; + +using { + sap.taco.collaborations.CollaborationLogs, + sap.taco.collaborations.CollaborationLeadAssignments, + sap.taco.collaborations.Participants, + sap.taco.collaborations.Applications, +} from './schema'; + +extend managed with { + createdByNav : Association to one sap.hcm.Employees on createdByNav.userID = createdBy; + modifiedByNav : Association to one sap.hcm.Employees on modifiedByNav.userID = modifiedBy; +}; + +context sap.hcm { + entity Employees { + key userID: String; + displayName: String; + firstName: String; + lastName: String; + initials: String; + email: String; + mobilePhone: String; + officePhone: String; + manager_userID: String; + } + + annotate Employees with @PersonalData : { + DataSubjectRole : 'Employee', + EntitySemantics : 'DataSubject', + } { + userID @PersonalData.FieldSemantics : 'DataSubjectID'; + displayName @PersonalData.IsPotentiallyPersonal; + firstName @PersonalData.IsPotentiallyPersonal; + lastName @PersonalData.IsPotentiallyPersonal; + initials @PersonalData.IsPotentiallyPersonal; + email @PersonalData.IsPotentiallyPersonal; + mobilePhone @PersonalData.IsPotentiallyPersonal; + officePhone @PersonalData.IsPotentiallyPersonal; + manager_userID @PersonalData.FieldSemantics : 'UserID' + }; +} + +context sap.taco.core { + entity Events : cuid, managed { + referenceID : UUID @UI.readonly; + students : Composition of many EventStudentAssignments + on students.event = $self; + title : String; + type_code : String; + collaborationLog : Association to one CollaborationLogs + on collaborationLog.ID = referenceID; + } + + entity EventStudentAssignments : cuid { + event : Association to one Events; + student : Association to one Students; + } + + entity Students { + key userID: String; + validTo: Date; + employeeNav : Association to one sap.hcm.Employees on employeeNav.userID = userID; + trainingHours: Decimal; + sumCompletedCollaborationLogs: Integer; + collaborations : Composition of many CollaborationLogs + on collaborations.userID = userID @UI.ExcludeFromNavigationContext; + collaborationApplications : Composition of many Applications + on collaborationApplications.student = $self; + leadAssignments : Composition of many CollaborationLeadAssignments + on leadAssignments.student = $self; + collaborationAssignments : Composition of many Participants + on collaborationAssignments.student = $self; + eventAssignments : Composition of many EventStudentAssignments + on eventAssignments.student = $self; + } + + annotate Events with { + createdBy @PersonalData.FieldSemantics: 'UserID'; + modifiedBy @PersonalData.FieldSemantics: 'UserID'; + } + + annotate EventStudentAssignments with { + student @PersonalData.FieldSemantics: 'DataSubjectID'; + } + + annotate Students with @PersonalData: { + DataSubjectRole: 'Student', + EntitySemantics: 'DataSubject', + } { + userID @PersonalData.FieldSemantics: 'DataSubjectID'; + trainingHours @PersonalData.IsPotentiallyPersonal; + openTasks @PersonalData.IsPotentiallyPersonal; + } +} \ No newline at end of file diff --git a/test/personal-data-complex/package.json b/test/personal-data-complex/package.json new file mode 100644 index 0000000..4318efe --- /dev/null +++ b/test/personal-data-complex/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@cap-js/audit-logging": "*" + } +} diff --git a/test/personal-data-complex/srv/crud-service.cds b/test/personal-data-complex/srv/crud-service.cds new file mode 100644 index 0000000..ed212dd --- /dev/null +++ b/test/personal-data-complex/srv/crud-service.cds @@ -0,0 +1,122 @@ +using {sap.taco.collaborations as persistence} from '../db/collaborations'; +using {sap.taco.core as core} from '../db/schema'; +using {sap.hcm as hcm} from '../db/schema'; + +@(requires: [ + 'admin' +]) +service CollaborationsService @(path: '/collaborations') { + + @cds.redirection.target + @odata.draft.enabled + entity Collaborations as projection on persistence.Collaborations + actions { + action leave(); + }; + + entity Applications as + projection on I_CollaborationApplications; + + entity CollaborationLeadAssignments as projection on persistence.CollaborationLeadAssignments; + + @readonly + entity Events as + projection on core.Events { + *, + } excluding { + students + }; + + @cds.redirection.target + entity Participants as + projection on persistence.Participants; + + @cds.redirection.target + entity SubCollaborationApplications as projection on persistence.SubCollaborationApplications; + + view SubCollaborationApplicationsView as + select from persistence.SubCollaborationApplications { + *, + subCollaboration.title as subcollabtitle + } + where + exists application.collaboration.leads[student.userID = $user.id] + or exists subCollaboration.leads[student_userID = $user.id]; + + @cds.redirection.target + entity SubCollaborations as + projection on I_SubCollaborations; + + @cds.redirection.target + entity SubCollaborationAssignments as + projection on persistence.SubCollaborationAssignments { + *, + participant.ID as participantID, + subCollaboration.ID as subCollaborationID, + }; + + entity SubCollaborationLeads as projection on persistence.SubCollaborationLeads + entity ActiveSubCollaborationAssignments as projection on persistence.ActiveSubCollaborationAssignments; + + @odata.draft.enabled + entity MyCollaborationLogs as + projection on persistence.CollaborationLogs { + *, + virtual null as certificate : LargeBinary @Core.MediaType: 'application/pdf' @title: '{i18n>CERTIFICATE}', + }; + + //That this comes after MyCollaborationLogs is important for audit-logging test + @cds.redirection.target + entity CollaborationLogs as + projection on I_CollaborationLogs + as ic { + *, + }; + + @cds.redirection.target + entity Students as + projection on core.Students { + userID, + employeeNav, + validTo, + eventAssignments, + collaborations + }; + + @cds.redirection.target + entity Employees as projection on hcm.Employees; +} + +entity I_CollaborationApplications as + projection on persistence.Applications { + *, + collaboration : redirected to CollaborationsService.Collaborations, + }; + +entity I_SubCollaborations as + projection on persistence.SubCollaborations { + *, + collaboration : redirected to CollaborationsService.Collaborations, + }; + +entity I_CollaborationLogs as + projection on persistence.CollaborationLogs { + *, + collaboration : redirected to CollaborationsService.Collaborations, + }; + +entity I_SubCollaborationAssignments as + projection on persistence.SubCollaborationAssignments + as a { + *, + participant.student.userID as student_userID, + (( + exists( + select subCollaboration.ID from persistence.SubCollaborationAssignments + where + participant.student.userID = $user.id + and isLead = true + and subCollaboration.ID = a.subCollaboration.ID + ) + ) ? true : false) as isSessionUserSubLead : Boolean, + }; diff --git a/test/personal-data-complex/srv/crud-service.js b/test/personal-data-complex/srv/crud-service.js new file mode 100644 index 0000000..b762a0d --- /dev/null +++ b/test/personal-data-complex/srv/crud-service.js @@ -0,0 +1,10 @@ +const cds = require('@sap/cds'); + +module.exports = async srv => { + const {Collaborations, Participants} = srv.entities; + srv.on('leave', Collaborations, async req => { + await DELETE.from(Participants).where({ + student_userID: req.user.id, + }); + }); +} \ No newline at end of file