@flowfuse/flowfuse
Version:
An open source low-code development platform
312 lines (286 loc) • 13.4 kB
JavaScript
const { getLoggers: getDeviceLogger } = require('../../auditLog/device')
const { getLoggers: getProjectLogger } = require('../../auditLog/project')
const { getLoggers: getTeamLogger } = require('../../auditLog/team')
const { Roles } = require('../../lib/roles')
// email alerts and notification settings lookup
const alertsAndNotifications = {
crashed: {
email: true,
notification: 'instance-crashed'
},
'safe-mode': {
email: true,
notification: 'instance-safe-mode'
},
'resource.cpu': {
email: true,
notification: 'instance-resource-cpu'
},
'resource.memory': {
email: true,
notification: 'instance-resource-memory'
}
}
/** Node-RED Audit Logging backend
*
* - /audit
*
* @namespace audit
* @memberof forge.logging
*/
module.exports = async function (app) {
const deviceAuditLogger = getDeviceLogger(app)
const projectAuditLogger = getProjectLogger(app)
const teamAuditLogger = getTeamLogger(app)
/** @type {import('../../db/controllers/AuditLog')} */
const auditLogController = app.db.controllers.AuditLog
/** @type {import('../../db/controllers/ProjectSnapshot')} */
const snapshotController = app.db.controllers.ProjectSnapshot
app.addHook('preHandler', app.verifySession)
/**
* Post route for node-red _cloud_ instance audit log events
* @method POST
* @name /logging/:projectId/audit
* @memberof forge.routes.logging
*/
app.post('/:projectId/audit', {
preHandler: async (request, response) => {
// The request has a valid token, but need to check the token is allowed
// to access the project
const id = request.params.projectId
// Check if the project exists first
const project = await app.db.models.Project.byId(id)
if (project && request.session.ownerType === 'project' && request.session.ownerId === id) {
// Project exists and the auth token is for this project
request.project = project
return
}
response.status(404).send({ code: 'not_found', error: 'Not Found' })
}
},
async (request, response) => {
const projectId = request.params.projectId
const auditEvent = request.body
const event = auditEvent.event
const error = auditEvent.error
const __launcherLog = auditEvent.__launcherLog || []
delete auditEvent.__launcherLog // dont add this to the audit log
// Some node-red audit events are not useful to expose to the end user - filter them out here
// api.error:version_mismatch - normal part of collision detection when trying to deploy flows
if (event === 'api.error' && error === 'version_mismatch') {
response.status(200).send()
}
let user = request.session?.User || null
if (!user && auditEvent?.user && typeof auditEvent.user === 'string') {
user = await app.db.models.User.byId(auditEvent.user) || null
}
const userId = user?.id || null
// first check to see if the event is a known structured event
if (event === 'start-failed') {
await projectAuditLogger.project.startFailed(userId || 'system', error, { id: projectId })
} else {
// otherwise, just log it
delete auditEvent.event
delete auditEvent.user
delete auditEvent.path
delete auditEvent.timestamp
await auditLogController.projectLog(
projectId,
userId,
event,
auditEvent
)
}
// handle some special cases: install/remove modules
if (event === 'nodes.install' && !error) {
await app.db.controllers.Project.addProjectModule(request.project, auditEvent.module, auditEvent.version)
} else if (event === 'nodes.remove' && !error) {
await app.db.controllers.Project.removeProjectModule(request.project, auditEvent.module)
} else if (event === 'modules.install' && !error) {
await app.db.controllers.Project.addProjectModule(request.project, auditEvent.module, auditEvent.version || '*')
}
const alertCondition = alertsAndNotifications[event]
if (alertCondition) {
if (alertCondition.email && app.config.features.enabled('emailAlerts')) {
const data = event === 'crashed'
? {
exitCode: auditEvent.info?.code,
exitSignal: auditEvent.info?.signal,
exitInfo: auditEvent.info?.info,
log: __launcherLog
}
: undefined
await app.auditLog.alerts.generate(projectId, event, data)
}
// send notification to all members and owners in the team
const teamMembersAndOwners = await request.project.Team.getTeamMembers([Roles.Member, Roles.Owner])
if (teamMembersAndOwners && teamMembersAndOwners.length > 0) {
const reference = `${alertCondition.notification}:${projectId}`
const data = {
instance: {
id: projectId,
name: request.project.name
},
meta: {
severity: event === 'crashed' ? 'error' : 'warning'
}
}
for (const user of teamMembersAndOwners) {
await app.notifications.send(user, alertCondition.notification, data, reference, { upsert: true })
}
}
}
response.status(200).send() // early response
// perform an auto snapshot
if (event === 'flows.set' && ['full', 'flows', 'nodes'].includes(auditEvent.type)) {
if (!app.config.features.enabled('instanceAutoSnapshot')) {
return // device auto snapshot feature is not available
}
const teamType = await request.project.Team.getTeamType()
const instanceAutoSnapshotEnabledForTeam = teamType.getFeatureProperty('instanceAutoSnapshot', false)
if (!instanceAutoSnapshotEnabledForTeam) {
return // not enabled for team
}
const instanceAutoSnapshotEnabledForProject = true // FUTURE: await request.project.getSetting('autoSnapshot')
if (instanceAutoSnapshotEnabledForProject === true) {
setImmediate(async () => {
// when after the response is sent & IO is done, perform the snapshot
try {
const meta = { user }
const options = { clean: true, setAsTarget: false }
const snapshot = await snapshotController.doInstanceAutoSnapshot(request.project, auditEvent.type, options, meta)
if (!snapshot) {
throw new Error('Auto snapshot was not successful')
}
} catch (error) {
app.log.error('Error occurred during auto snapshot', error)
}
})
}
}
})
/**
* Post route for node_red device audit log events
* @method POST
* @name /logging/device/:deviceId/audit
* @memberof forge.routes.logging
*/
app.post('/device/:deviceId/audit', {
preHandler: async (request, response) => {
// The request has a valid token, but need to check the token is allowed
// to access the device
const id = request.params.deviceId
// Check if the device exists first
const device = await app.db.models.Device.byId(id)
if (device && request.session.ownerType === 'device' && +request.session.ownerId === device.id) {
// device exists and the auth token is for this device
request.device = device
return
}
response.status(404).send({ code: 'not_found', error: 'Not Found' })
}
}, async (request, response) => {
const deviceId = request.params.deviceId
const auditEvent = request.body
const event = auditEvent.event
const error = auditEvent.error
const userId = auditEvent.user ? app.db.models.User.decodeHashid(auditEvent.user) : undefined
// first check to see if the event is a known structured event
if (event === 'start-failed') {
await deviceAuditLogger.device.startFailed(userId || 'system', error, { id: deviceId })
} else {
// otherwise, just log it
delete auditEvent.event
delete auditEvent.user
delete auditEvent.path
delete auditEvent.timestamp
await auditLogController.deviceLog(
request.device.id,
userId,
event,
auditEvent
)
}
if (event === 'crashed' || event === 'safe-mode') {
// send notification to all members and owners in the team
const teamMembersAndOwners = await request.device.Team.getTeamMembers([Roles.Member, Roles.Owner])
if (teamMembersAndOwners && teamMembersAndOwners.length > 0) {
const notificationType = event === 'crashed' ? 'device-crashed' : 'device-safe-mode'
const reference = `${notificationType}:${deviceId}`
const data = {
device: {
id: deviceId,
name: request.device.name
},
meta: {
severity: event === 'crashed' ? 'error' : 'warning'
}
}
for (const user of teamMembersAndOwners) {
await app.notifications.send(user, notificationType, data, reference, { upsert: true })
}
}
}
response.status(200).send()
// For application owned devices, perform an auto snapshot
if (request.device.isApplicationOwned) {
if (event === 'flows.set' && ['full', 'flows', 'nodes'].includes(auditEvent.type)) {
if (!app.config.features.enabled('deviceAutoSnapshot')) {
return // device auto snapshot feature is not available
}
const teamType = await request.device.Team.getTeamType()
const deviceAutoSnapshotEnabledForTeam = teamType.getFeatureProperty('deviceAutoSnapshot', false)
if (!deviceAutoSnapshotEnabledForTeam) {
return // not enabled for team
}
const deviceAutoSnapshotEnabledForDevice = await request.device.getSetting('autoSnapshot')
if (deviceAutoSnapshotEnabledForDevice === true) {
setImmediate(async () => {
// when after the response is sent & IO is done, perform the snapshot
try {
const meta = { user: request.session.User }
const options = { clean: true, setAsTarget: false }
const snapshot = await snapshotController.doDeviceAutoSnapshot(request.device, auditEvent.type, options, meta)
if (!snapshot) {
throw new Error('Auto snapshot was not successful')
}
} catch (error) {
app.log.error('Error occurred during auto snapshot', error)
}
})
}
}
}
})
/**
* Post route for team audit log events
* @method POST
* @name /logging/team/:teamId/audit
* @memberof forge.routes.logging
*/
app.post('/team/:teamId/audit', {
preHandler: async (request, reply) => {
// check user is in the Team they are reporting for
// only npm is generating team audit log message
if (request.session.ownerType === 'npm') {
const user = await app.db.models.User.byUsername(request.session.ownerId)
if (user && await user.getTeamMembership(request.params.teamId)) {
request.user = user
request.team = await app.db.models.Team.byId(request.params.teamId)
return
}
}
reply.status(404).send({ code: 'not_found', error: 'Not Found' })
}
}, async (request, reply) => {
if (request.body.action === 'publish') {
teamAuditLogger.team.package.published(request.user, null, request.team, { name: request.body.name, version: request.body.version })
} else if (request.body.action === 'unpublish') {
teamAuditLogger.team.package.unpublished(request.user, null, request.team, { name: request.body.name, version: request.body.version })
} else {
reply.status(404).send()
return
}
reply.status(200).send({})
})
}