UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

629 lines (598 loc) • 21.9 kB
const { RoleNames } = require('../lib/roles') const isObject = (obj) => { return obj !== null && typeof obj === 'object' } /** * Generate a standard format body for the audit log display and database. * Any items null or missing must not generate a property in the body * @param {{ error?, team?, project?, sourceProject?, targetProject?, device?, sourceDevice?, targetDevice?, user?, stack?, billingSession?, subscription?, license?, updates?, snapshot?, pipeline?, pipelineStage?, pipelineStageTarget?, role?, projectType?, info?, deviceGroup?, interval?, threshold?, token?, pkg? } == {}} objects objects to include in body * @returns {{ error?, team?, project?, sourceProject?, targetProject?, device?, user?, stack?, billingSession?, subscription?, license?, updates?, snapshot?, pipeline?, pipelineStage?, pipelineStageTarget?, role?, projectType? info?, deviceGroup?, interval?, threshold?, token?, pkg? }} */ const generateBody = ({ error, team, application, project, sourceProject, targetProject, device, sourceDevice, targetDevice, user, stack, billingSession, subscription, license, updates, snapshot, pipeline, pipelineStage, pipelineStageTarget, role, projectType, info, deviceGroup, interval, threshold, token, pkg } = {}) => { const body = {} if (isObject(error) || typeof error === 'string') { body.error = errorObject(error) } if (isObject(team)) { body.team = teamObject(team) } if (isObject(application)) { body.application = applicationObject(application) } if (isObject(project)) { body.project = projectObject(project) } if (isObject(sourceProject)) { body.sourceProject = projectObject(sourceProject) } if (isObject(targetProject)) { body.targetProject = projectObject(targetProject) } if (isObject(device)) { body.device = deviceObject(device) } if (isObject(sourceDevice)) { body.sourceDevice = deviceObject(sourceDevice) } if (isObject(targetDevice)) { body.targetDevice = deviceObject(targetDevice) } if (isObject(user)) { body.user = userObject(user) } if (isObject(stack)) { body.stack = stackObject(stack) } if (isObject(billingSession)) { body.billingSession = billingSessionObject(billingSession) } if (isObject(subscription)) { body.subscription = subscriptionObject(subscription) } if (isObject(license) || (typeof license === 'string')) { body.license = licenseObject(license) } if (updates && updates instanceof UpdatesCollection && updates.length > 0) { body.updates = updates.toArray() } else if (updates && Array.isArray(updates) && updates.length > 0) { body.updates = [...updates] } if (isObject(snapshot)) { body.snapshot = snapshotObject(snapshot) } if (isObject(pipeline)) { body.pipeline = pipelineObject(pipeline) } if (isObject(pipelineStage)) { body.pipelineStage = pipelineStageObject(pipelineStage) } if (isObject(pipelineStageTarget)) { body.pipelineStageTarget = pipelineStageObject(pipelineStageTarget) } if (isObject(role) || typeof role === 'number') { body.role = roleObject(role) } if (isObject(projectType)) { body.projectType = projectTypeObject(projectType) } if (isObject(info)) { body.info = info } else if (isStringWithLength(info)) { body.info = { info } } if (isObject(deviceGroup)) { body.deviceGroup = deviceGroupObject(deviceGroup) } if (isObject(token)) { body.token = token } if (isObject(pkg)) { body.pkg = pkg } if (interval) { body.interval = interval } if (threshold) { body.threshold = threshold } return body } const sanitiseObjectIds = (obj) => { if (obj && obj.hashid !== undefined) { if (obj.hashid) { obj.id = obj.hashid } delete obj.hashid } return obj } const formatLogEntry = (auditLogDbRow) => { // Format an audit log DB row to specification described in #1183 const formatted = { hashid: auditLogDbRow.hashid, // Required for pagination / table key User: auditLogDbRow.User, // TODO: Kept for compatibility. Remove once Audit Log UI overhaul complete event: auditLogDbRow.event, createdAt: auditLogDbRow.createdAt, scope: { id: auditLogDbRow.entityId, type: auditLogDbRow.entityType }, trigger: triggerObject(auditLogDbRow.UserId, auditLogDbRow.User) } if (auditLogDbRow.body) { let body try { if (auditLogDbRow.body) { body = auditLogDbRow.body if (typeof body !== 'object') { body = JSON.parse(body) } } // if the User is null, see if the body has details of who triggered the event if (!formatted.User && auditLogDbRow.UserId == null && body?.trigger?.id != null) { formatted.trigger = triggerObject(body.trigger.id, body.trigger) formatted.User = { username: formatted.trigger.name } // TODO: Kept for compatibility. Remove once Audit Log UI overhaul complete } formatted.body = generateBody({ error: body?.error, team: body?.team, application: body?.application, project: body?.project, sourceProject: body?.sourceProject, targetProject: body?.targetProject, user: body?.user, stack: body?.stack, billingSession: body?.billingSession, subscription: body?.subscription, license: body?.license, snapshot: body?.snapshot, updates: body?.updates, device: body?.device, deviceGroup: body?.deviceGroup, sourceDevice: body?.sourceDevice, targetDevice: body?.targetDevice, projectType: body?.projectType, info: body?.info, pipeline: body?.pipeline, pipelineStage: body?.pipelineStage, pipelineStageTarget: body?.pipelineStageTarget, interval: body?.interval, threshold: body?.threshold, token: body?.token, pkg: body?.pkg }) // if body has the keys: `key`, `scope`, and `store` AND `store` === 'memory' or 'persistent' // this is a Node-RED context store event if (body?.key && body?.scope && body?.store && (body.store === 'memory' || body.store === 'persistent')) { formatted.body = formatted.body || {} formatted.body.context = { key: body.key, scope: body.scope, store: body.store } } // format log entries for know Node-RED audit events if (formatted.event === 'flows.set') { formatted.body = formatted.body || {} formatted.body.flowsSet = formatted.body?.flowsSet || { type: body.type } } // TODO: Add other known Node-RED audit events // including: 'nodes.install', 'nodes.remove', 'library.set' // this will permit audit viewer to access the details of the event // via the body.xxx object and thus permit the UI to display the details // instead of the current generic message // e.g. to show _which_ module was installed or removed // e.g. to show _which_ library was set const roleObj = body?.role && roleObject(body.role) if (roleObj) { if (formatted.body?.user) { formatted.body.user.role = roleObj.role } else { formatted.body.role = roleObj } } // For compatibility. Grab error in old style log entry for correct display if (body?.code && body?.error) { formatted.body.error = errorObject({ code: body.code, error: body.error }) } for (const [key, value] of Object.entries(formatted.body)) { formatted.body[key] = sanitiseObjectIds(value) } } catch (_err) { console.warn('Error parsing audit log body', _err) } } return formatted } // #region Log entry formatters const errorObject = (error) => { if (!error) { return null } let err = error if (typeof error !== 'object') { err = { error } } const errObject = { code: err.code || 'unexpected_error', message: err.error || err.message || 'unexpected error' } if (err instanceof Error) { errObject.stack = err.stack } return errObject } const teamObject = (team, unknownValue = null) => { return { id: team?.id || null, hashid: team?.hashid || null, name: team?.name || unknownValue, slug: team?.slug || unknownValue, type: team?.type || unknownValue } } const userObject = (user, unknownValue = null) => { const { id, hashid, name } = triggerObject(user?.id, user, unknownValue) || {} return { id, hashid, name: user?.name || name || unknownValue, username: id === 0 ? 'system' : (user?.username || unknownValue), email: user?.email || unknownValue } } const applicationObject = (application, unknownValue = null) => { return { id: application?.id || null, name: application?.name || unknownValue } } const projectObject = (project, unknownValue = null) => { return { id: project?.id || null, name: project?.name || unknownValue } } const deviceObject = (device, unknownValue = null) => { return { id: device?.id || null, hashid: device?.hashid || null, name: device?.name || unknownValue } } const stackObject = (stack, unknownValue = null) => { return { id: stack?.id || null, hashid: stack?.hashid || null, name: stack?.name || unknownValue } } const billingSessionObject = (session) => { return { id: session?.id || null } } const subscriptionObject = (subscription) => { return { subscription: subscription?.subscription || null } } const licenseObject = (license) => { if (typeof license === 'string') { return { key: license } } else { return license } } const snapshotObject = (snapshot) => { return { id: snapshot?.id || null, hashid: snapshot?.hashid || null, name: snapshot?.name || null, description: snapshot?.description } } const pipelineObject = (pipeline) => { return { id: pipeline?.id || null, hashid: pipeline?.hashid || null, name: pipeline?.name || null } } const pipelineStageObject = (stage) => { return { id: stage?.id || null, hashid: stage?.hashid || null, name: stage?.name || null } } const roleObject = (role) => { if (typeof role === 'number') { if (RoleNames[role]) { role = RoleNames[role] } else { role = `Unknown Role: ${role}` } return { role } } else if (typeof role === 'string') { return { role } } return role } const projectTypeObject = (projectType) => { return { id: projectType?.id || null, hashid: projectType?.hashid || null, name: projectType?.name || null } } const deviceGroupObject = (deviceGroup) => { return { id: deviceGroup?.id || null, hashid: deviceGroup?.hashid || null, name: deviceGroup?.name || null } } /** * Generates the `trigger` part of the audit log report * @param {object|number|'system'} actionedBy A user object or a user id. NOTE: 0 or 'system' can be used to indicate "system" triggered the event * @param {*} [user] If `actionedBy` is an ID, passing a the user object will permit the username to be rendered * @param {*} [unknownValue] If `unknownValue` is provided, it will be used for name and type if they are null * @returns {{ id:number, hashid: string, type:string, name:string }} { id, hashid, type, name } */ function triggerObject (actionedBy, user, unknownValue = 'unknown') { let id = null let hashid = null let type = unknownValue let name = unknownValue if (actionedBy == null) { actionedBy = user user = null } if (isNumber(actionedBy)) { id = +actionedBy if (id === 0) { type = 'system' hashid = 'system' name = 'FlowFuse Platform' } else if (id > 0) { type = 'user' if (user) { hashid = user.hashid || null name = user?.name || user?.username || (user?.email || '').split('@')[0] || unknownValue || null } } } else if (isStringWithLength(actionedBy)) { if (actionedBy === 'system') { return triggerObject({ id: 0 }, user) } else { id = isNumber(user?.id) ? +user.id : null hashid = actionedBy name = user?.name || user?.username || (user?.email || '').split('@')[0] || unknownValue || null type = 'user' } } else if (looksLikeUserObject(actionedBy)) { type = 'user' if (actionedBy.id != null) { return triggerObject(actionedBy.id, actionedBy) } else if (actionedBy.hashid != null) { return triggerObject(actionedBy.hashid, actionedBy) } } else if (looksLikeUserObject(user)) { type = 'user' if (user.id != null) { return triggerObject(user.id, user) } else if (user.hashid != null) { return triggerObject(user.hashid, user) } } return { id, hashid, type, name } } // #endregion (Log entry formatters) // #region Helpers function isStringWithLength (str) { return typeof str === 'string' && str.length > 0 } function isNumber (num) { return (typeof num === 'number' && !isNaN(num)) || (typeof num === 'string' && !isNaN(+num)) } function looksLikeUserObject (obj) { return (obj && typeof obj === 'object' && (isNumber(obj.id) || isStringWithLength(obj.hashid) || isStringWithLength(obj.id /* could be a hash */))) } // #endregion (Helpers) // #region Updates formatter /** * Creates and `updateObject` for pushing to an `UpdatesCollection` * @returns {updateObject} */ const DIFF_TYPES = { VALUE_CREATED: 'created', VALUE_UPDATED: 'updated', VALUE_DELETED: 'deleted', VALUE_UNCHANGED: '---' } const updatesObject = (key, oldValue, newValue, diffKind = DIFF_TYPES.VALUE_UPDATED) => { // check if valid type if (Object.values(DIFF_TYPES).includes(diffKind)) { return { key, old: oldValue, new: newValue, dif: diffKind } } else { const err = Error(`${diffKind} is not a valid value of diffKind`) err.code = 'invalid_value' throw err } } class UpdatesCollection { constructor () { this.updates = [] } get length () { return this.updates.length } toArray () { return [...this.updates] } pushDifferences (oldObject, newObject, sensitiveKeys = ['pass', 'password', 'token', 'secret', 'credentials', 'credentialSecret', 'cookieSecret']) { this.updates.push(...generateUpdates(oldObject, newObject, sensitiveKeys) || []) } /** * A update object * @typedef {{ key: string, old: any, new: any }} updateObject */ /** * Record a property update * @param {string|updateObject} key The property name (alternatively, this can be an `updateObject` ) * @param {*} oldValue The old value * @param {*} newValue The new value * @param {'updated'|'created'|'deleted'} diffKind The new value */ push (key, oldValue, newValue, diffKind = 'updated') { if (typeof key === 'object') { this.updates.push(key) } else { this.updates.push(updatesObject(key, oldValue, newValue, diffKind)) } } } function generateUpdates (o1, o2, sensitiveKeys) { sensitiveKeys = sensitiveKeys || [] const deepDiffMapper = (() => { return { ...DIFF_TYPES, map: function (obj1, obj2) { if (this.isFunction(obj1) || this.isFunction(obj2)) { throw new Error('Invalid argument. Function given, object expected.') } if (this.isValue(obj1) || this.isValue(obj2)) { const returnObj = { dif: this.compareValues(obj1, obj2), old: obj1, new: obj2 } if (returnObj.dif !== this.VALUE_UNCHANGED) { return returnObj } return undefined } const diff = {} const foundKeys = {} for (const key in obj1) { if (this.isFunction(obj1[key])) { continue } let value2 if (obj2[key] !== undefined) { value2 = obj2[key] } const mapValue = this.map(obj1[key], value2) foundKeys[key] = true if (mapValue) { diff[key] = mapValue } } for (const key in obj2) { if (this.isFunction(obj2[key]) || foundKeys[key] !== undefined) { continue } const mapValue = this.map(undefined, obj2[key]) if (mapValue) { diff[key] = mapValue } } if (Object.keys(diff).length > 0) { return diff } return undefined }, compareValues: function (value1, value2) { if (value1 === value2) { return this.VALUE_UNCHANGED } if (this.isDate(value1) && this.isDate(value2) && value1.getTime() === value2.getTime()) { return this.VALUE_UNCHANGED } if (value1 === undefined) { return this.VALUE_CREATED } if (value2 === undefined) { return this.VALUE_DELETED } return this.VALUE_UPDATED }, isFunction: function (x) { return Object.prototype.toString.call(x) === '[object Function]' }, isArray: function (x) { return Object.prototype.toString.call(x) === '[object Array]' }, isDate: function (x) { return Object.prototype.toString.call(x) === '[object Date]' }, isObject: function (x) { return Object.prototype.toString.call(x) === '[object Object]' }, isValue: function (x) { return !this.isObject(x) && !this.isArray(x) } } })() const isInt = (value) => { return !isNaN(value) && parseInt(Number(value)) == value && // eslint-disable-line eqeqeq !isNaN(parseInt(value, 10)) } const toFlatPropertyMap = (obj, keySeparator = '.') => { const flattenRecursive = (obj, parentProperty, propertyMap = {}) => { // TODO: Consider converting array of KV to object for better diffing and reporting for (const [key, value] of Object.entries(obj)) { let property if (isInt(key)) { property = parentProperty ? `${parentProperty}[${key}]` : key } else { property = parentProperty ? `${parentProperty}${keySeparator}${key}` : key } if (deepDiffMapper.isDate(value)) { propertyMap[property] = value } else if (value && typeof value === 'object') { flattenRecursive(value, property, propertyMap) } else if (value && typeof value === 'function') { // ignore functions } else { propertyMap[property] = value } } return propertyMap } return flattenRecursive(obj) } const diffToArray = (diff) => { return Object.entries(diff || {}).map(([key, val]) => { return updatesObject(key, val.old, val.new, val.dif) }) } const flat1 = toFlatPropertyMap(o1) const flat2 = toFlatPropertyMap(o2) const diff = deepDiffMapper.map(flat1, flat2) const diffSansSensitive = diffToArray(diff).map((update) => { const sensitive = sensitiveKeys.some(s => (update.key).endsWith(s)) if (sensitive) { update.old = '***' update.new = '***' } return update }) return diffSansSensitive } // #endregion (Updates formatter) module.exports = { errorObject, teamObject, projectObject, deviceObject, deviceGroupObject, userObject, stackObject, billingSessionObject, snapshotObject, roleObject, projectTypeObject, triggerObject, updatesObject, UpdatesCollection, generateUpdates, generateBody, formatLogEntry }