diff --git a/CHANGELOG.md b/CHANGELOG.md index e24ecc3..28f0ba6 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 1.1.2 - tbd + +### Changed + +- Removed alpha auto-detect kind `audit-log-to-als` to prevent failing lookup from now optional `VCAP_SERVICES` + ## Version 1.1.1 - 2025-10-27 ### Fixed diff --git a/lib/utils.js b/lib/utils.js index 102878e..752398a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,278 +1,330 @@ -const cds = require('@sap/cds') +const cds = require("@sap/cds"); -const { Relation, exposeRelation, relationHandler } = require('./_relation') +const { Relation, exposeRelation, relationHandler } = require("./_relation"); -const WRITE = { CREATE: 1, UPDATE: 1, DELETE: 1 } +const WRITE = { CREATE: 1, UPDATE: 1, DELETE: 1 }; -const $hasPersonalData = Symbol('@cap-js/audit-logging:hasPersonalData') -const $dataSubject = Symbol('@cap-js/audit-logging:dataSubject') -const $parents = Symbol('@cap-js/audit-logging:parents') -const $visitedUp = Symbol('@cap-js/audit-logging:visitedUp') -const $visitedDown = Symbol('@cap-js/audit-logging:visitedDown') +const $hasPersonalData = Symbol("@cap-js/audit-logging:hasPersonalData"); +const $dataSubject = Symbol("@cap-js/audit-logging:dataSubject"); +const $parents = Symbol("@cap-js/audit-logging:parents"); +const $visitedUp = Symbol("@cap-js/audit-logging:visitedUp"); +const $visitedDown = Symbol("@cap-js/audit-logging:visitedDown"); -const hasPersonalData = entity => { +const hasPersonalData = (entity) => { if (entity.own($hasPersonalData) == null) { - if (!entity['@PersonalData.EntitySemantics']) entity.set($hasPersonalData, false) + if (!entity["@PersonalData.EntitySemantics"]) + entity.set($hasPersonalData, false); else { // default role to entity name - if (entity['@PersonalData.EntitySemantics'] === 'DataSubject' && !entity['@PersonalData.DataSubjectRole']) - entity['@PersonalData.DataSubjectRole'] = entity.name.match(/\w+/g).pop() + if ( + entity["@PersonalData.EntitySemantics"] === "DataSubject" && + !entity["@PersonalData.DataSubjectRole"] + ) + entity["@PersonalData.DataSubjectRole"] = entity.name + .match(/\w+/g) + .pop(); // prettier-ignore const hasPersonalData = !!Object.values(entity.elements).some(element => element['@PersonalData.IsPotentiallyPersonal'] || element['@PersonalData.IsPotentiallySensitive'] || (element['@PersonalData.FieldSemantics'] && element['@PersonalData.FieldSemantics'] === 'DataSubjectID')) - entity.set($hasPersonalData, hasPersonalData) + entity.set($hasPersonalData, hasPersonalData); } } - return entity.own($hasPersonalData) -} + return entity.own($hasPersonalData); +}; -const getMapKeyForCurrentRequest = req => { +const getMapKeyForCurrentRequest = (req) => { // running in srv or db layer? -> srv's req.query used as key of diff and logs maps at req.context // REVISIT: req._tx should not be used like that! - return req.tx.isDatabaseService ? req._.query : req.query -} + return req.tx.isDatabaseService ? req._.query : req.query; +}; -const getRootEntity = element => { - let entity = element.parent - while (entity.kind !== 'entity') entity = entity.parent - return entity -} +const getRootEntity = (element) => { + let entity = element.parent; + while (entity.kind !== "entity") entity = entity.parent; + return entity; +}; const _isDataSubject = (element, target) => { return ( !element.isAssociation && - element['@PersonalData.FieldSemantics'] === 'DataSubjectID' && - target['@PersonalData.EntitySemantics'] === 'DataSubject' - ) -} + element["@PersonalData.FieldSemantics"] === "DataSubjectID" && + target["@PersonalData.EntitySemantics"] === "DataSubject" + ); +}; -const getPick = event => { +const getPick = (event) => { return (element, target) => { - if (!hasPersonalData(target)) return - const categories = [] - if (!element.isAssociation && element.key) categories.push('ObjectID') - if (_isDataSubject(element, target)) categories.push('DataSubjectID') - if (event in WRITE && element['@PersonalData.IsPotentiallyPersonal']) categories.push('IsPotentiallyPersonal') - if (element['@PersonalData.IsPotentiallySensitive']) categories.push('IsPotentiallySensitive') - if (categories.length) return { categories } - } -} + if (!hasPersonalData(target)) return; + const categories = []; + if (!element.isAssociation && element.key) categories.push("ObjectID"); + if (_isDataSubject(element, target)) categories.push("DataSubjectID"); + if (event in WRITE && element["@PersonalData.IsPotentiallyPersonal"]) + categories.push("IsPotentiallyPersonal"); + if (element["@PersonalData.IsPotentiallySensitive"]) + categories.push("IsPotentiallySensitive"); + if (categories.length) return { categories }; + }; +}; const _getHash = (entity, row) => { return `${entity.name}(${Object.keys(entity.keys) - .map(k => `${k}=${row[k]}`) - .join(',')})` -} + .map((k) => `${k}=${row[k]}`) + .join(",")})`; +}; const createLogEntry = (logs, entity, row) => { - const hash = _getHash(entity, row) - let log = logs[hash] + const hash = _getHash(entity, row); + let log = logs[hash]; if (!log) { logs[hash] = { - data_subject: { id: {}, role: entity['@PersonalData.DataSubjectRole'] }, + data_subject: { id: {}, role: entity["@PersonalData.DataSubjectRole"] }, object: { type: entity.name, id: {} }, - attributes: [] - } - log = logs[hash] + attributes: [], + }; + log = logs[hash]; } - return log -} + return log; +}; const addObjectID = (log, row, key) => { - if (!(key in log.object.id) && key !== 'IsActiveEntity') log.object.id[key] = row[key] || row._old?.[key] -} + if (!(key in log.object.id) && key !== "IsActiveEntity") + log.object.id[key] = row[key] || row._old?.[key]; +}; const addDataSubject = (log, row, key, entity) => { - if (!log.data_subject.type) log.data_subject.type = entity.name + if (!log.data_subject.type) log.data_subject.type = entity.name; if (!(key in log.data_subject.id)) { - const value = row[key] || row._old?.[key] - log.data_subject.id[key] = value + const value = row[key] || row._old?.[key]; + log.data_subject.id[key] = value; } -} +}; const _addKeysToWhere = (keys, row, alias) => { return keys - .filter(key => !key.isAssociation && key.name !== 'IsActiveEntity') + .filter((key) => !key.isAssociation && key.name !== "IsActiveEntity") .reduce((keys, key) => { - if (keys.length) keys.push('and') - keys.push({ ref: [alias, key.name] }, '=', { val: row[key.name] || row._old?.[key.name] }) - return keys - }, []) -} + if (keys.length) keys.push("and"); + keys.push({ ref: [alias, key.name] }, "=", { + val: row[key.name] || row._old?.[key.name], + }); + return keys; + }, []); +}; const _keyColumns = (keys, alias) => { return keys - .filter(key => !key.isAssociation && key.name !== 'IsActiveEntity') - .map(key => ({ ref: [alias, key.name] })) -} + .filter((key) => !key.isAssociation && key.name !== "IsActiveEntity") + .map((key) => ({ ref: [alias, key.name] })); +}; -const _alias = entity => { +const _alias = (entity) => { // REVISIT: we should not rely on entity._service (but I don't want to break existing behavior right now) - if (!entity._service) return `${entity}` - return entity.name.replace(`${entity._service.name}.`, '').replace(/\./g, '_') -} - -const _buildSubSelect = (model, { entity, relative, element, next }, row, previousCqn) => { + if (!entity._service) return `${entity}`; + return entity.name + .replace(`${entity._service.name}.`, "") + .replace(/\./g, "_"); +}; + +const _buildSubSelect = ( + model, + { entity, relative, element, next }, + row, + previousCqn, +) => { // relative is a parent or an entity itself - const keys = Object.values(entity.keys) + const keys = Object.values(entity.keys); - const entityName = entity.name - const as = _alias(entity) + const entityName = entity.name; + const as = _alias(entity); - const childCqn = SELECT.from({ ref: [entityName], as }).columns(_keyColumns(keys, as)) + const childCqn = SELECT.from({ ref: [entityName], as }).columns( + _keyColumns(keys, as), + ); - const targetAlias = _alias(element._target) - const relativeAlias = _alias(relative) + const targetAlias = _alias(element._target); + const relativeAlias = _alias(relative); - // REVISIT: there seems to be a caching issue in cds^9 when elemets are renamed - if (!('_relations' in relative) || !relative._relations[element.name]) { - const newRelation = Relation.to(relative) - relative._relations = new Proxy(exposeRelation(newRelation), relationHandler(newRelation)) + // REVISIT: there seems to be a caching issue in cds^9 when elements are renamed + if (!("_relations" in relative) || !relative._relations[element.name]) { + const newRelation = Relation.to(relative); + relative._relations = new Proxy( + exposeRelation(newRelation), + relationHandler(newRelation), + ); } - let w = relative._relations[element.name].join(targetAlias, relativeAlias) + let w = relative._relations[element.name].join(targetAlias, relativeAlias); // REVISIT: rewrite to path expression, if alias for relative is already used in subselect to avoid sql error if (previousCqn?._aliases.has(relativeAlias)) { - let t - for (const a in entity.associations) if (entity.associations[a].target === relative.name) t = entity.associations[a] - if (t && w[0]?.xpr) for (const ele of w[0].xpr) if (ele.ref?.[0] === relativeAlias) ele.ref.splice(0, 1, as, t.name) + let t; + for (const a in entity.associations) + if (entity.associations[a].target === relative.name) + t = entity.associations[a]; + if (t && w[0]?.xpr) + for (const ele of w[0].xpr) + if (ele.ref?.[0] === relativeAlias) ele.ref.splice(0, 1, as, t.name); } - childCqn._aliases = new Set(previousCqn ? [...previousCqn._aliases.values(), as] : [as]) + childCqn._aliases = new Set( + previousCqn ? [...previousCqn._aliases.values(), as] : [as], + ); - childCqn.where(w) + childCqn.where(w); - if (previousCqn) childCqn.where('exists', previousCqn) - else childCqn.where(_addKeysToWhere(keys, row, as)) + if (previousCqn) childCqn.where("exists", previousCqn); + else childCqn.where(_addKeysToWhere(keys, row, as)); - if (next) return _buildSubSelect(model, next, {}, childCqn) + if (next) return _buildSubSelect(model, next, {}, childCqn); - return childCqn -} + return childCqn; +}; const _getDataSubjectIdQuery = ({ dataSubjectEntity, subs }, row, model) => { - const keys = Object.values(dataSubjectEntity.keys) - const as = _alias(dataSubjectEntity) + const keys = Object.values(dataSubjectEntity.keys); + const as = _alias(dataSubjectEntity); const cqn = SELECT.one .from({ ref: [dataSubjectEntity.name], as }) .columns(_keyColumns(keys, as)) - .where(['exists', _buildSubSelect(model, subs[0], row)]) + .where(["exists", _buildSubSelect(model, subs[0], row)]); // entity reused in different branches => must check all - for (let i = 1; i < subs.length; i++) cqn.or(['exists', _buildSubSelect(model, subs[i], row)]) + for (let i = 1; i < subs.length; i++) + cqn.or(["exists", _buildSubSelect(model, subs[i], row)]); - return cqn -} + return cqn; +}; const _getUps = (entity, model) => { if (entity.own($parents) == null) { - const ups = [] + const ups = []; for (const def of Object.values(model.definitions)) { - if (def.kind !== 'entity' || !def.associations) continue + if (def.kind !== "entity" || !def.associations) continue; for (const element of Object.values(def.associations)) { - if (element.target !== entity.name || element._isBacklink || element.name === 'SiblingEntity') continue - ups.push(element) + if ( + element.target !== entity.name || + element._isBacklink || + element.name === "SiblingEntity" + ) + continue; + ups.push(element); } } - entity.set($parents, ups) + entity.set($parents, ups); } - return entity.own($parents) -} + return entity.own($parents); +}; const _getDataSubjectUp = (root, model, entity, prev, next, result) => { for (const element of _getUps(entity, model)) { // cycle detection - if (element.own($visitedUp) == null) element.set($visitedUp, new Set()) - if (element.own($visitedUp).has(root)) continue - element.own($visitedUp).add(root) - - const me = { entity, relative: element.parent, element } - if (prev) prev.next = me - if (element.parent['@PersonalData.EntitySemantics'] === 'DataSubject') { - if (!result) result = { dataSubjectEntity: element.parent, subs: [] } - result.subs.push(next || me) - return result + if (element.own($visitedUp) == null) element.set($visitedUp, new Set()); + if (element.own($visitedUp).has(root)) continue; + element.own($visitedUp).add(root); + + const me = { entity, relative: element.parent, element }; + if (prev) prev.next = me; + if (element.parent["@PersonalData.EntitySemantics"] === "DataSubject") { + if (!result) result = { dataSubjectEntity: element.parent, subs: [] }; + result.subs.push(next || me); + return result; } else { // dfs is a must here - result = _getDataSubjectUp(root, model, element.parent, me, next || me, result) + result = _getDataSubjectUp( + root, + model, + element.parent, + me, + next || me, + result, + ); } } - return result -} + return result; +}; const _getDataSubjectDown = (root, entity, prev, next) => { - const associations = Object.values(entity.associations || {}).filter(e => !e._isBacklink) + const associations = Object.values(entity.associations || {}).filter( + (e) => !e._isBacklink, + ); // bfs makes more sense here -> check all own assocs first before going deeper for (const element of associations) { - const me = { entity, relative: entity, element } - if (element._target['@PersonalData.EntitySemantics'] === 'DataSubject') { - if (prev) prev.next = me - return { dataSubjectEntity: element._target, subs: [next || me] } + const me = { entity, relative: entity, element }; + if (element._target["@PersonalData.EntitySemantics"] === "DataSubject") { + if (prev) prev.next = me; + return { dataSubjectEntity: element._target, subs: [next || me] }; } } for (const element of associations) { // cycle detection - if (element.own($visitedDown) == null) element.set($visitedDown, new Set()) - if (element.own($visitedDown).has(root)) continue - element.own($visitedDown).add(root) - - const me = { entity, relative: entity, element } - if (prev) prev.next = me - const dataSubject = _getDataSubjectDown(root, element._target, me, next || me) - if (dataSubject) return dataSubject + if (element.own($visitedDown) == null) element.set($visitedDown, new Set()); + if (element.own($visitedDown).has(root)) continue; + element.own($visitedDown).add(root); + + const me = { entity, relative: entity, element }; + if (prev) prev.next = me; + const dataSubject = _getDataSubjectDown( + root, + element._target, + me, + next || me, + ); + if (dataSubject) return dataSubject; } -} +}; const getDataSubject = (entity, model) => { if (entity.own($dataSubject) == null) { // entities with EntitySemantics 'DataSubjectDetails' or 'Other' must not necessarily // be always below or always above 'DataSubject' entity in CSN tree - let dataSubjectInfo = _getDataSubjectUp(entity.name, model, entity) - if (!dataSubjectInfo) dataSubjectInfo = _getDataSubjectDown(entity.name, entity) - entity.set($dataSubject, dataSubjectInfo) + let dataSubjectInfo = _getDataSubjectUp(entity.name, model, entity); + if (!dataSubjectInfo) + dataSubjectInfo = _getDataSubjectDown(entity.name, entity); + entity.set($dataSubject, dataSubjectInfo); } - return entity.own($dataSubject) -} - -const _getDataSubjectsMap = req => { - const mapKey = getMapKeyForCurrentRequest(req) - const _audit = (req.context._audit ??= {}) - if (!_audit.dataSubjects) _audit.dataSubjects = new Map() - if (!_audit.dataSubjects.has(mapKey)) _audit.dataSubjects.set(mapKey, new Map()) - return _audit.dataSubjects.get(mapKey) -} + return entity.own($dataSubject); +}; + +const _getDataSubjectsMap = (req) => { + const mapKey = getMapKeyForCurrentRequest(req); + const _audit = (req.context._audit ??= {}); + if (!_audit.dataSubjects) _audit.dataSubjects = new Map(); + if (!_audit.dataSubjects.has(mapKey)) + _audit.dataSubjects.set(mapKey, new Map()); + return _audit.dataSubjects.get(mapKey); +}; const addDataSubjectForDetailsEntity = (row, log, req, entity, model) => { - const dataSubjectInfo = getDataSubject(entity, model) - const role = dataSubjectInfo.dataSubjectEntity['@PersonalData.DataSubjectRole'] - log.data_subject.role ??= role - log.data_subject.type = dataSubjectInfo.dataSubjectEntity.name + const dataSubjectInfo = getDataSubject(entity, model); + const role = + dataSubjectInfo.dataSubjectEntity["@PersonalData.DataSubjectRole"]; + log.data_subject.role ??= role; + log.data_subject.type = dataSubjectInfo.dataSubjectEntity.name; /* * for each req (cf. $batch with atomicity) and data subject role (e.g., customer vs supplier), * store (in audit data structure at context) and reuse a single promise to look up the respective data subject */ - const map = _getDataSubjectsMap(req) - if (map.has(role)) log.data_subject.id = map.get(role) + 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 map.set(role, _getDataSubjectIdQuery(dataSubjectInfo, row, model)); +}; const resolveDataSubjects = (logs, req) => { - const ps = [] + const ps = []; - const map = _getDataSubjectsMap(req) + const map = _getDataSubjectsMap(req); for (const each of Object.values(logs)) { if (each.data_subject.id instanceof cds.ql.Query) { - const q = each.data_subject.id + const q = each.data_subject.id; if (!map.has(q)) { - const p = cds.run(q).then(res => map.set(q, res)) - map.set(q, p) - ps.push(p) + const p = cds.run(q).then((res) => map.set(q, res)); + map.set(q, p); + ps.push(p); } } } @@ -280,11 +332,41 @@ const resolveDataSubjects = (logs, req) => { return Promise.all(ps).then(() => { for (const each of Object.values(logs)) { if (each.data_subject.id instanceof cds.ql.Query) { - each.data_subject.id = map.get(each.data_subject.id) + each.data_subject.id = map.get(each.data_subject.id); } } - }) -} + }); +}; + +const getAppMetadata = () => { + const appMetadata = cds.env.app; + + if (appMetadata) { + return { + appID: appMetadata.id, + appName: appMetadata.name, + appURL: appMetadata.url, + organization_name: appMetadata.organization_name, + space_name: appMetadata.space_name, + }; + } + + // fallback: if the app metadata is undefined, then extract the metadata from the underlying environment (CF/Kyma/...) + const vcapApplication = + process.env.VCAP_APPLICATION && JSON.parse(process.env.VCAP_APPLICATION); + + return { + appID: vcapApplication && vcapApplication.application_id, + appName: vcapApplication && vcapApplication.application_name, + organization_name: vcapApplication && vcapApplication.organization_name, + space_name: vcapApplication && vcapApplication.space_name, + appURL: + vcapApplication && + vcapApplication.application_uris && + vcapApplication.application_uris[0] && + `https://${vcapApplication.application_uris[0].replace(/^https?:\/\//, "")}`, + }; +}; module.exports = { hasPersonalData, @@ -295,5 +377,6 @@ module.exports = { addObjectID, addDataSubject, addDataSubjectForDetailsEntity, - resolveDataSubjects -} + resolveDataSubjects, + appMetadata: getAppMetadata(), +}; diff --git a/package.json b/package.json index bdb4487..4da481b 100644 --- a/package.json +++ b/package.json @@ -51,17 +51,13 @@ "audit-log-to-console": { "impl": "@cap-js/audit-logging/srv/log2console" }, - "audit-log-to-als": { - "impl": "@cap-js/audit-logging/srv/log2als" - }, "audit-log-to-restv2": { "impl": "@cap-js/audit-logging/srv/log2restv2", - "vcap": { - "label": "auditlog" - } + "vcap": { "label": "auditlog" } }, "audit-log-to-alsng": { - "impl": "@cap-js/audit-logging/srv/log2alsng" + "impl": "@cap-js/audit-logging/srv/log2alsng", + "vcap": { "tag": "auditlog-ng" } } } } diff --git a/srv/log2als.js b/srv/log2als.js index 5ecb159..ef8ea95 100644 --- a/srv/log2als.js +++ b/srv/log2als.js @@ -1,4 +1,5 @@ -const credentials = JSON.parse(process.env.VCAP_SERVICES) || {} -const isV3 = credentials['user-provided']?.some(obj => obj.tags.includes('auditlog-ng')) +const cds = require("@sap/cds"); -module.exports = isV3 ? require('./log2alsng') : require('./log2restv2') +module.exports = cds.env.requires["audit-log"].vcap.tags.includes("auditlog-ng") + ? require("./log2alsng") + : require("./log2restv2"); diff --git a/srv/log2alsng.js b/srv/log2alsng.js index 1841488..3632f7d 100644 --- a/srv/log2alsng.js +++ b/srv/log2alsng.js @@ -1,214 +1,245 @@ -const cds = require('@sap/cds') -const LOG = cds.log('audit-log') +const cds = require("@sap/cds"); +const LOG = cds.log("audit-log"); +const { appMetadata } = require("../lib/utils"); +const https = require("https"); -const https = require('https') - -const AuditLogService = require('./service') +const AuditLogService = require("./service"); module.exports = class AuditLog2ALSNG extends AuditLogService { constructor() { - super() - this._vcap = JSON.parse(process.env.VCAP_SERVICES || '{}') - this._userProvided = this._vcap['user-provided']?.find(obj => obj.tags.includes('auditlog-ng')) || {} - if (!this._userProvided.credentials) throw new Error('No credentials found for SAP Audit Log Service NG') - this._vcapApplication = JSON.parse(process.env.VCAP_APPLICATION || '{}') + super(); + if (!cds.env.requires["audit-log"]?.credentials) + throw new Error("No credentials found for SAP Audit Log Service NG"); } async init() { - this.on('*', function (req) { - const { event, data } = req - return this.eventMapper(event, data) - }) - await super.init() + this.on("*", function (req) { + const { event, data } = req; + return this.eventMapper(event, data); + }); + await super.init(); } eventMapper(event, data) { const known = { - PersonalDataModified: () => this.logEvent('dppDataModification', data), - SensitiveDataRead: () => this.logEvent('dppDataAccess', data), - ConfigurationModified: () => this.logEvent('configurationChange', data), - SecurityEvent: () => this.logEvent('legacySecurityWrapper', data) - } - const dfault = () => this.logEvent(event, data) - return (known[event] ?? dfault)() + PersonalDataModified: () => this.logEvent("dppDataModification", data), + SensitiveDataRead: () => this.logEvent("dppDataAccess", data), + ConfigurationModified: () => this.logEvent("configurationChange", data), + SecurityEvent: () => this.logEvent("legacySecurityWrapper", data), + }; + const dfault = () => this.logEvent(event, data); + return (known[event] ?? dfault)(); } flattenAndSortIdObject(id) { - if (!id || !Object.keys(id).length) return 'not provided' + if (!id || !Object.keys(id).length) return "not provided"; - let s = '' - for (const k of Object.keys(id).sort()) s += `${k}:${id[k]} ` - return s.trim() + let s = ""; + for (const k of Object.keys(id).sort()) s += `${k}:${id[k]} `; + return s.trim(); } eventDataPayload(event, data) { - const object = data['object'] || { type: 'not provided', id: { ID: 'not provided' } } - const channel = data['channel'] || { type: 'not specified', id: 'not specified' } - const subject = data['data_subject'] || { type: 'not provided', id: { ID: 'not provided' } } - const attributes = data['attributes'] || [{ name: 'not provided', old: 'not provided', new: 'not provided' }] - const objectId = this.flattenAndSortIdObject(object['id']) - const oldValue = attributes[0]['old'] ?? '' - const newValue = attributes[0]['new'] ?? '' - const dataSubjectId = this.flattenAndSortIdObject(subject['id']) + const object = data["object"] || { + type: "not provided", + id: { ID: "not provided" }, + }; + const channel = data["channel"] || { + type: "not specified", + id: "not specified", + }; + const subject = data["data_subject"] || { + type: "not provided", + id: { ID: "not provided" }, + }; + const attributes = data["attributes"] || [ + { name: "not provided", old: "not provided", new: "not provided" }, + ]; + const objectId = this.flattenAndSortIdObject(object["id"]); + const oldValue = attributes[0]["old"] ?? ""; + const newValue = attributes[0]["new"] ?? ""; + const dataSubjectId = this.flattenAndSortIdObject(subject["id"]); const known = { dppDataModification: { - objectType: object['type'], + objectType: object["type"], objectId: objectId, - attribute: attributes[0]['name'], + attribute: attributes[0]["name"], oldValue: oldValue, newValue: newValue, - dataSubjectType: subject['type'], - dataSubjectId: dataSubjectId + dataSubjectType: subject["type"], + dataSubjectId: dataSubjectId, }, dppDataAccess: { - channelType: channel['type'], - channelId: channel['id'], - dataSubjectType: subject['type'], + channelType: channel["type"], + channelId: channel["id"], + dataSubjectType: subject["type"], dataSubjectId: dataSubjectId, - objectType: object['type'], + objectType: object["type"], objectId: objectId, - attribute: attributes[0]['name'] + attribute: attributes[0]["name"], }, configurationChange: { - propertyName: attributes[0]['name'], + propertyName: attributes[0]["name"], oldValue: oldValue, newValue: newValue, - objectType: object['type'], - objectId: objectId + objectType: object["type"], + objectId: objectId, }, legacySecurityWrapper: { origEvent: JSON.stringify({ ...data, data: - typeof data.data === 'object' && data.data !== null && !Array.isArray(data.data) + typeof data.data === "object" && + data.data !== null && + !Array.isArray(data.data) ? JSON.stringify(data.data) - : data.data - }) - } - } - if (event in known) return known[event] + : data.data, + }), + }, + }; + if (event in known) return known[event]; // For unknown events, remove common audit log entry fields from the event payload - if (typeof data === 'object' && data !== null) { - const rest = this.removeCommonAuditLogFields(data) - return rest + if (typeof data === "object" && data !== null) { + const rest = this.removeCommonAuditLogFields(data); + return rest; } - return data + return data; } removeCommonAuditLogFields(obj) { - if (typeof obj !== 'object' || obj === null) return obj - const { ...rest } = obj - delete rest.uuid - delete rest.user - delete rest.time - delete rest.tenant - return rest + if (typeof obj !== "object" || obj === null) return obj; + const { ...rest } = obj; + delete rest.uuid; + delete rest.user; + delete rest.time; + delete rest.tenant; + return rest; } eventPayload(event, data) { - const tenant = cds.context?.tenant || null - const timestamp = new Date().toISOString() + const tenant = cds.context?.tenant || null; + const timestamp = new Date().toISOString(); const eventData = { id: cds.utils.uuid(), specversion: 1, - source: `/${this._userProvided.credentials?.region}/${this._userProvided.credentials?.namespace}/${tenant}`, + source: `/${cds.env.requires["audit-log"].credentials?.region}/${cds.env.requires["audit-log"].credentials?.namespace}/${tenant}`, type: event, time: timestamp, data: { metadata: { ts: timestamp, - appId: this._vcapApplication.application_id || 'default app', + appId: appMetadata.appID || "default app", infrastructure: { other: { - runtimeType: 'Node.js' - } + runtimeType: "Node.js", + }, }, platform: { other: { - platformName: 'CAP' - } - } + platformName: "CAP", + }, + }, }, data: { - [event]: this.eventDataPayload(event, data) - } - } - } + [event]: this.eventDataPayload(event, data), + }, + }, + }; - return eventData + return eventData; } formatEventData(event, data) { - if (event === 'legacySecurityWrapper') { - return JSON.stringify([this.eventPayload(event, data)]) + if (event === "legacySecurityWrapper") { + return JSON.stringify([this.eventPayload(event, data)]); } - if (event in { dppDataModification: 1, dppDataAccess: 1, configurationChange: 1 }) { - const eventData = data['attributes']?.map(attr => { + if ( + event in + { dppDataModification: 1, dppDataAccess: 1, configurationChange: 1 } + ) { + const eventData = data["attributes"]?.map((attr) => { return this.eventPayload(event, { ...data, - attributes: [attr] - }) - }) - return JSON.stringify(eventData || []) + attributes: [attr], + }); + }); + return JSON.stringify(eventData || []); } // Always wrap event in an envelope for custom events - return JSON.stringify([this.eventPayload(event, data)]) + return JSON.stringify([this.eventPayload(event, data)]); } logEvent(event, data) { - const passphrase = this._userProvided.credentials?.keyPassphrase - const url = new URL(`${this._userProvided.credentials?.url}/ingestion/v1/events`) - const eventData = this.formatEventData(event, data) + const credentials = cds.env.requires["audit-log"].credentials; + if (!credentials) { + throw new Error("No credentials found for SAP Audit Log Service NG"); + } + + const passphrase = credentials.keyPassphrase; + const url = new URL(`${credentials.url}/ingestion/v1/events`); + const eventData = this.formatEventData(event, data); const options = { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(eventData) + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(eventData), }, - key: this._userProvided.credentials?.key, - cert: this._userProvided.credentials?.cert, - ...(passphrase !== undefined && { passphrase }) - } + key: credentials.key, + cert: credentials.cert, + ...(passphrase !== undefined && { passphrase }), + }; return new Promise((resolve, reject) => { - const req = https.request(url, options, res => { - LOG.trace('🛰️ Status Code:', res.statusCode) + const req = https.request(url, options, (res) => { + LOG.trace("🛰️ Status Code:", res.statusCode); - const chunks = [] - res.on('data', chunk => chunks.push(chunk)) + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); - res.on('end', () => { - const { statusCode, statusMessage } = res - let body = Buffer.concat(chunks).toString() - if (res.headers['content-type']?.match(/json/)) body = JSON.parse(body) + res.on("end", () => { + const { statusCode, statusMessage } = res; + let body = Buffer.concat(chunks).toString(); + if (res.headers["content-type"]?.match(/json/)) + body = JSON.parse(body); if (res.statusCode >= 400) { // prettier-ignore const err = new Error(`Request failed with${statusMessage ? `: ${statusCode} - ${statusMessage}` : ` status ${statusCode}`}`) - err.request = { method: options.method, url, headers: options.headers, body: data } + err.request = { + method: options.method, + url, + headers: options.headers, + body: data, + }; if (err.request.headers.authorization) - err.request.headers.authorization = err.request.headers.authorization.split(' ')[0] + ' ***' - err.response = { statusCode, statusMessage, headers: res.headers, body } - reject(err) + err.request.headers.authorization = + err.request.headers.authorization.split(" ")[0] + " ***"; + err.response = { + statusCode, + statusMessage, + headers: res.headers, + body, + }; + reject(err); } else { - resolve(body) + resolve(body); } - }) - }) + }); + }); - req.on('error', e => { - reject(e.message) - LOG.trace(`Problem with request: ${e.message}`) - }) + req.on("error", (e) => { + reject(e.message); + LOG.trace(`Problem with request: ${e.message}`); + }); - req.write(eventData) - req.end() - }) + req.write(eventData); + req.end(); + }); } -} +}; diff --git a/srv/log2restv2.js b/srv/log2restv2.js index 6fe69a0..63ab70c 100644 --- a/srv/log2restv2.js +++ b/srv/log2restv2.js @@ -1,126 +1,156 @@ -const cds = require('@sap/cds') +const cds = require("@sap/cds"); +const { appMetadata } = require("../lib/utils"); +const LOG = cds.log("audit-log"); -const LOG = cds.log('audit-log') - -const AuditLogService = require('./service') +const AuditLogService = require("./service"); module.exports = class AuditLog2RESTv2 extends AuditLogService { async init() { // credentials stuff - const { credentials } = this.options - if (!credentials) throw new Error('No or malformed credentials for "audit-log"') + const { credentials } = this.options; + if (!credentials) + throw new Error('No or malformed credentials for "audit-log"'); if (!credentials.uaa) { - this._plan = 'standard' - this._auth = 'Basic ' + Buffer.from(credentials.user + ':' + credentials.password).toString('base64') + this._plan = "standard"; + this._auth = + "Basic " + + Buffer.from(credentials.user + ":" + credentials.password).toString( + "base64", + ); } else { - this._plan = credentials.url.match(/6081/) ? 'premium' : 'oauth2' - this._tokens = new Map() - this._provider = credentials.uaa.tenantid + this._plan = credentials.url.match(/6081/) ? "premium" : "oauth2"; + this._tokens = new Map(); + this._provider = credentials.uaa.tenantid; } - this._vcap = process.env.VCAP_APPLICATION ? JSON.parse(process.env.VCAP_APPLICATION) : null - this.on('*', function (req) { - const { event, data } = req + this.on("*", function (req) { + const { event, data } = req; // event.match() is used to support the old event names - if (event === 'SensitiveDataRead' || event.match(/^dataAccess/i)) { - return this._handle(data, 'DATA_ACCESS') + if (event === "SensitiveDataRead" || event.match(/^dataAccess/i)) { + return this._handle(data, "DATA_ACCESS"); } - if (event === 'PersonalDataModified' || event.match(/^dataModification/i)) { - data.success = true - return this._handle(data, 'DATA_MODIFICATION') + if ( + event === "PersonalDataModified" || + event.match(/^dataModification/i) + ) { + data.success = true; + return this._handle(data, "DATA_MODIFICATION"); } - if (event === 'ConfigurationModified' || event.match(/^configChange/i)) { - data.success = true - return this._handle(data, 'CONFIGURATION_CHANGE') + if (event === "ConfigurationModified" || event.match(/^configChange/i)) { + data.success = true; + return this._handle(data, "CONFIGURATION_CHANGE"); } - if (event === 'SecurityEvent' || event.match(/^security/i)) { - if (typeof data.data === 'object') data.data = JSON.stringify(data.data) - return this._handle(data, 'SECURITY_EVENT') + if (event === "SecurityEvent" || event.match(/^security/i)) { + if (typeof data.data === "object") + data.data = JSON.stringify(data.data); + return this._handle(data, "SECURITY_EVENT"); } - LOG._warn && LOG.warn(`Event "${event}" is not implemented`) - }) + LOG._warn && LOG.warn(`Event "${event}" is not implemented`); + }); // call AuditLogService's init - await super.init() + await super.init(); } async _getToken(tenant) { - const { _tokens: tokens } = this - if (tokens.has(tenant)) return tokens.get(tenant) - - const { uaa } = this.options.credentials - const url = (uaa.certurl || uaa.url) + '/oauth/token' - const data = { grant_type: 'client_credentials', response_type: 'token', client_id: uaa.clientid } - const options = { headers: { 'content-type': 'application/x-www-form-urlencoded' } } - if (tenant !== this._provider) options.headers['x-zid'] = tenant + const { _tokens: tokens } = this; + if (tokens.has(tenant)) return tokens.get(tenant); + + const { uaa } = this.options.credentials; + const url = (uaa.certurl || uaa.url) + "/oauth/token"; + const data = { + grant_type: "client_credentials", + response_type: "token", + client_id: uaa.clientid, + }; + const options = { + headers: { "content-type": "application/x-www-form-urlencoded" }, + }; + if (tenant !== this._provider) options.headers["x-zid"] = tenant; // certificate or secret? - if (uaa['credential-type'] === 'x509') { - options.agent = new https.Agent({ cert: uaa.certificate, key: uaa.key }) + if (uaa["credential-type"] === "x509") { + options.agent = new https.Agent({ cert: uaa.certificate, key: uaa.key }); } else { - data.client_secret = uaa.clientsecret + data.client_secret = uaa.clientsecret; } const urlencoded = Object.keys(data).reduce((acc, cur) => { - acc += (acc ? '&' : '') + cur + '=' + data[cur] - return acc - }, '') + acc += (acc ? "&" : "") + cur + "=" + data[cur]; + return acc; + }, ""); try { - const { access_token, expires_in } = await _post(url, urlencoded, options) - tokens.set(tenant, access_token) + const { access_token, expires_in } = await _post( + url, + urlencoded, + options, + ); + tokens.set(tenant, access_token); // remove token from cache 60 seconds before it expires - setTimeout(() => tokens.delete(tenant), (expires_in - 60) * 1000) - return access_token + setTimeout(() => tokens.delete(tenant), (expires_in - 60) * 1000); + return access_token; } catch (err) { - LOG._trace && LOG.trace('error during token fetch:', err) + LOG._trace && LOG.trace("error during token fetch:", err); // 401 could also mean x-zid is not valid - if (String(err.response?.statusCode).match(/^4\d\d$/)) err.unrecoverable = true - throw err + if (String(err.response?.statusCode).match(/^4\d\d$/)) + err.unrecoverable = true; + throw err; } } async _send(data, path) { - const headers = { 'content-type': 'application/json;charset=utf-8' } - if (this._vcap) { - headers.XS_AUDIT_ORG = this._vcap.organization_name - headers.XS_AUDIT_SPACE = this._vcap.space_name - headers.XS_AUDIT_APP = this._vcap.application_name + const headers = { "content-type": "application/json;charset=utf-8" }; + if (appMetadata.appName) { + headers.XS_AUDIT_ORG = appMetadata.organization_name; + headers.XS_AUDIT_SPACE = appMetadata.space_name; + headers.XS_AUDIT_APP = appMetadata.appName; } - let url - if (this._plan === 'standard') { - url = this.options.credentials.url + PATHS.STANDARD[path] - headers.authorization = this._auth + let url; + if (this._plan === "standard") { + url = this.options.credentials.url + PATHS.STANDARD[path]; + headers.authorization = this._auth; } else { - url = this.options.credentials.url + PATHS.OAUTH2[path] - data.tenant ??= this._provider //> if request has no tenant, stay in provider account - if (data.tenant === '$PROVIDER') data.tenant = this._provider - headers.authorization = 'Bearer ' + (await this._getToken(data.tenant)) - data.tenant = data.tenant === this._provider ? '$PROVIDER' : '$SUBSCRIBER' + url = this.options.credentials.url + PATHS.OAUTH2[path]; + data.tenant ??= this._provider; //> if request has no tenant, stay in provider account + if (data.tenant === "$PROVIDER") data.tenant = this._provider; + headers.authorization = "Bearer " + (await this._getToken(data.tenant)); + data.tenant = + data.tenant === this._provider ? "$PROVIDER" : "$SUBSCRIBER"; } if (LOG._debug) { - const _headers = Object.assign({}, headers, { authorization: headers.authorization.split(' ')[0] + ' ***' }) - LOG.debug(`sending audit log to ${url} with tenant "${data.tenant}", user "${data.user}", and headers`, _headers) + const _headers = Object.assign({}, headers, { + authorization: headers.authorization.split(" ")[0] + " ***", + }); + LOG.debug( + `sending audit log to ${url} with tenant "${data.tenant}", user "${data.user}", and headers`, + _headers, + ); } try { - await _post(url, data, { headers }) + await _post(url, data, { headers }); } catch (err) { - LOG._trace && LOG.trace('error during log send:', err) + LOG._trace && LOG.trace("error during log send:", err); // 429 (rate limit) is not unrecoverable - if (String(err.response?.statusCode).match(/^4\d\d$/) && err.response?.statusCode !== 429) - err.unrecoverable = true - throw err + if ( + String(err.response?.statusCode).match(/^4\d\d$/) && + err.response?.statusCode !== 429 + ) + err.unrecoverable = true; + throw err; } } async _handle(logs, path) { - if (!Array.isArray(logs)) logs = [logs] + if (!Array.isArray(logs)) logs = [logs]; // write the logs - const errors = [] - await Promise.all(logs.map(log => this._send(log, path).catch(err => errors.push(err)))) - if (errors.length) throw _getErrorToThrow(errors) + const errors = []; + await Promise.all( + logs.map((log) => this._send(log, path).catch((err) => errors.push(err))), + ); + if (errors.length) throw _getErrorToThrow(errors); } -} +}; /* * consts @@ -128,58 +158,69 @@ module.exports = class AuditLog2RESTv2 extends AuditLogService { const PATHS = { STANDARD: { - DATA_ACCESS: '/audit-log/v2/data-accesses', - DATA_MODIFICATION: '/audit-log/v2/data-modifications', - CONFIGURATION_CHANGE: '/audit-log/v2/configuration-changes', - SECURITY_EVENT: '/audit-log/v2/security-events' + DATA_ACCESS: "/audit-log/v2/data-accesses", + DATA_MODIFICATION: "/audit-log/v2/data-modifications", + CONFIGURATION_CHANGE: "/audit-log/v2/configuration-changes", + SECURITY_EVENT: "/audit-log/v2/security-events", }, OAUTH2: { - DATA_ACCESS: '/audit-log/oauth2/v2/data-accesses', - DATA_MODIFICATION: '/audit-log/oauth2/v2/data-modifications', - CONFIGURATION_CHANGE: '/audit-log/oauth2/v2/configuration-changes', - SECURITY_EVENT: '/audit-log/oauth2/v2/security-events' - } -} + DATA_ACCESS: "/audit-log/oauth2/v2/data-accesses", + DATA_MODIFICATION: "/audit-log/oauth2/v2/data-modifications", + CONFIGURATION_CHANGE: "/audit-log/oauth2/v2/configuration-changes", + SECURITY_EVENT: "/audit-log/oauth2/v2/security-events", + }, +}; /* * utils */ -const https = require('https') +const https = require("https"); async function _post(url, data, options) { - options.method ??= 'POST' + options.method ??= "POST"; return new Promise((resolve, reject) => { - const req = https.request(url, options, res => { - const chunks = [] - res.on('data', chunk => chunks.push(chunk)) - res.on('end', () => { - const { statusCode, statusMessage } = res - let body = Buffer.concat(chunks).toString() - if (res.headers['content-type']?.match(/json/)) body = JSON.parse(body) + const req = https.request(url, options, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const { statusCode, statusMessage } = res; + let body = Buffer.concat(chunks).toString(); + if (res.headers["content-type"]?.match(/json/)) body = JSON.parse(body); if (res.statusCode >= 400) { // prettier-ignore const err = new Error(`Request failed with${statusMessage ? `: ${statusCode} - ${statusMessage}` : ` status ${statusCode}`}`) - err.request = { method: options.method, url, headers: options.headers, body: data } + err.request = { + method: options.method, + url, + headers: options.headers, + body: data, + }; if (err.request.headers.authorization) - err.request.headers.authorization = err.request.headers.authorization.split(' ')[0] + ' ***' - err.response = { statusCode, statusMessage, headers: res.headers, body } - reject(err) + err.request.headers.authorization = + err.request.headers.authorization.split(" ")[0] + " ***"; + err.response = { + statusCode, + statusMessage, + headers: res.headers, + body, + }; + reject(err); } else { - resolve(body) + resolve(body); } - }) - }) - req.on('error', reject) - req.write(typeof data === 'object' ? JSON.stringify(data) : data) - req.end() - }) + }); + }); + req.on("error", reject); + req.write(typeof data === "object" ? JSON.stringify(data) : data); + req.end(); + }); } function _getErrorToThrow(errors) { - if (errors.length === 1) return errors[0] - const error = new cds.error('MULTIPLE_ERRORS') - error.details = errors - if (errors.some(e => e.unrecoverable)) error.unrecoverable = true - return error + if (errors.length === 1) return errors[0]; + const error = new cds.error("MULTIPLE_ERRORS"); + error.details = errors; + if (errors.some((e) => e.unrecoverable)) error.unrecoverable = true; + return error; } diff --git a/test/integration/ng.test.js b/test/integration/ng.test.js index 4f7429b..dd49c9f 100644 --- a/test/integration/ng.test.js +++ b/test/integration/ng.test.js @@ -1,9 +1,5 @@ const cds = require('@sap/cds') -const { POST } = cds.test().in(__dirname) - -cds.env.requires['audit-log'].kind = 'audit-log-to-alsng' -cds.env.requires['audit-log'].impl = '@cap-js/audit-logging/srv/log2alsng' const VCAP_SERVICES = { 'user-provided': [ { @@ -14,6 +10,8 @@ const VCAP_SERVICES = { } process.env.VCAP_SERVICES = JSON.stringify(VCAP_SERVICES) +const { POST } = cds.test(__dirname, '--with-mocks', '--profile', 'audit-log-to-alsng') + describe('Log to Audit Log Service NG ', () => { if (!VCAP_SERVICES['user-provided'][0].credentials) return test.skip('Skipping tests due to missing credentials', () => {}) diff --git a/test/integration/oauth2.test.js b/test/integration/oauth2.test.js index 554ed7f..134cdc1 100644 --- a/test/integration/oauth2.test.js +++ b/test/integration/oauth2.test.js @@ -6,7 +6,7 @@ const log = cds.test.log() cds.env.requires['audit-log'].credentials = process.env.ALS_CREDS_OAUTH2 && JSON.parse(process.env.ALS_CREDS_OAUTH2) // stay in provider account (i.e., use "$PROVIDER" and avoid x-zid header when fetching oauth2 token) -cds.env.requires.auth.users.alice.tenant = cds.env.requires['audit-log'].credentials.uaa.tenantid +cds.env.requires.auth.users.alice.tenant = cds.env.requires['audit-log'].credentials?.uaa.tenantid cds.env.log.levels['audit-log'] = 'debug' diff --git a/test/integration/package.json b/test/integration/package.json index ff5a877..35bf7fb 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -4,7 +4,12 @@ }, "cds": { "requires": { - "audit-log": "audit-log-to-restv2" + "audit-log": "audit-log-to-restv2", + "[audit-log-to-alsng]": { + "audit-log": { + "kind": "audit-log-to-alsng" + } + } } } } diff --git a/test/integration/premium.test.js b/test/integration/premium.test.js index 80318bc..ca0dd9e 100644 --- a/test/integration/premium.test.js +++ b/test/integration/premium.test.js @@ -6,7 +6,7 @@ const log = cds.test.log() cds.env.requires['audit-log'].credentials = process.env.ALS_CREDS_PREMIUM && JSON.parse(process.env.ALS_CREDS_PREMIUM) // stay in provider account (i.e., use "$PROVIDER" and avoid x-zid header when fetching oauth2 token) -cds.env.requires.auth.users.alice.tenant = cds.env.requires['audit-log'].credentials.uaa.tenantid +cds.env.requires.auth.users.alice.tenant = cds.env.requires['audit-log'].credentials?.uaa.tenantid cds.env.log.levels['audit-log'] = 'debug'