Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Device agent instance node-red audit logs #3447

Merged
merged 6 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions forge/auditLog/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ module.exports = {
}
await log('device.unassigned', actionedBy, device.id, generateBody(bodyData))
},
async startFailed (actionedBy, error, device) {
await log('device.start-failed', actionedBy || 0, device?.id, generateBody({ error }))
},
credentials: {
async generated (actionedBy, error, device) {
await log('device.credential.generated', actionedBy, device?.id, generateBody({ error, device }))
Expand Down
13 changes: 13 additions & 0 deletions forge/auditLog/formatters.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ const formatLogEntry = (auditLogDbRow) => {
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) {
Expand Down
96 changes: 79 additions & 17 deletions forge/routes/logging/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { getLoggers } = require('../../auditLog/project')
const { getLoggers: getDeviceLogger } = require('../../auditLog/device')
const { getLoggers: getProjectLogger } = require('../../auditLog/project')

/** Node-RED Audit Logging backend
*
Expand All @@ -9,23 +10,36 @@ const { getLoggers } = require('../../auditLog/project')
*/

module.exports = async function (app) {
const logger = getLoggers(app)
const deviceAuditLogger = getDeviceLogger(app)
const projectAuditLogger = getProjectLogger(app)
/** @type {import('../../db/controllers/AuditLog')} */
const auditLogController = app.db.controllers.AuditLog

app.addHook('preHandler', app.verifySession)
app.addHook('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
/**
* 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' })
}
response.status(404).send({ code: 'not_found', error: 'Not Found' })
})
app.post('/:projectId/audit', async (request, response) => {
},
async (request, response) => {
const projectId = request.params.projectId
const auditEvent = request.body
const event = auditEvent.event
Expand All @@ -34,15 +48,15 @@ module.exports = async function (app) {

// first check to see if the event is a known structured event
if (event === 'start-failed') {
await logger.project.startFailed(userId || 'system', error, { id: projectId })
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 app.db.controllers.AuditLog.projectLog(
await auditLogController.projectLog(
projectId,
userId,
event,
Expand All @@ -63,4 +77,52 @@ module.exports = async function (app) {

response.status(200).send()
})

/**
* 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
)
}

response.status(200).send()
})
}
16 changes: 13 additions & 3 deletions frontend/src/components/audit-log/AuditEntryVerbose.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@
</template>
<template v-else-if="entry.event === 'account.logout' || entry.event === 'auth.logout' || entry.event === 'auth.login.revoke'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.trigger?.name">User '{{ entry.trigger.name }}' has logged out.</span>
<span v-if="!error && entry.trigger?.id === null && entry.event === 'auth.login.revoke'">Node-RED user has logged out.</span>
<span v-else-if="!error && entry.trigger?.name">User '{{ entry.trigger.name }}' has logged out.</span>
<span v-else-if="!error">User data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'account.forgot-password'">
Expand Down Expand Up @@ -522,8 +523,17 @@
<span>Node-RED editor user settings have been updated.</span>
</template>
<template v-else-if="entry.event === 'flows.set'">
<label>{{ AuditEvents[entry.event] }}</label>
<span>A new flow has been deployed</span>
<template v-if="entry.body?.flowsSet?.type === 'reload'">
<label>{{ AuditEvents["flows.reloaded"] }}</label>
<span>Flows have been reloaded</span>
</template>
<template v-else>
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="entry.body?.flowsSet.type === 'full'">Deploy type 'full'</span>
<span v-else-if="entry.body?.flowsSet.type === 'flows'">Deploy type 'flows'</span>
<span v-else-if="entry.body?.flowsSet.type === 'nodes'">Deploy type 'nodes'</span>
<span v-else>Flows deployed</span>
</template>
</template>
<template v-else-if="entry.event === 'library.set'">
<label>{{ AuditEvents[entry.event] }}</label>
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/data/audit-events.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"safe-mode": "Node-RED has been placed in Safe Mode",
"settings.update": "Node-RED Settings Updated",
"flows.set": "Flow Deployed",
"flows.reloaded": "Flows Reloaded",
"library.set": "Saved to Library",
"nodes.install": "Third-Party Nodes Installed",
"nodes.remove": "Third-Party Nodes Removed",
Expand Down Expand Up @@ -138,9 +139,14 @@
"device.developer-mode.disabled": "Developer Mode Disabled",
"device.remote-access.enabled": "Remote Access Enabled",
"device.remote-access.disabled": "Remote Access Disabled",
"device.developer-mode.enabled": "Developer Mode Enabled",
"device.developer-mode.disabled": "Developer Mode Disabled",
"device.remote-access.enabled": "Remote Access Enabled",
"device.remote-access.disabled": "Remote Access Disabled"
"device.start-failed": "Device Start Failed",
"safe-mode": "Node-RED has been placed in Safe Mode",
"settings.update": "Node-RED Settings Updated",
"flows.set": "Flow Deployed",
"flows.reloaded": "Flows Reloaded",
"library.set": "Saved to Library",
"nodes.install": "Third-Party Nodes Installed",
"nodes.remove": "Third-Party Nodes Removed",
"context.delete": "Context Key Deleted"
}
}
23 changes: 23 additions & 0 deletions test/unit/forge/auditLog/formatters_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -466,4 +466,27 @@ describe('Audit Log > Formatters', async function () {
Formatters.updatesObject('key', undefined, 1, 'something_else')
}).throw()
})

describe('Formats Node-RED audit log entries', async function () {
it('Includes the event data for `flows.set` event', async function () {
// can get away with passing empty objects here
// as we test format for each object in following tests
const entry = Formatters.formatLogEntry({
hashid: '<hashid>',
UserId: {},
User: {},
event: 'flows.set',
createdAt: '<datetime>',
entityId: '<entityId>',
entityType: '<entityType>',
body: { type: 'full', ip: '127.0.0.1' }
})

should(entry).have.property('body')
should(entry.body).be.an.Object()
entry.body.should.have.property('flowsSet').and.be.an.Object()
entry.body.flowsSet.should.only.have.keys('type')
entry.body.flowsSet.type.should.equal('full')
})
})
})
Loading
Loading