diff --git a/forge/auditLog/device.js b/forge/auditLog/device.js
index b3978b0a1e..455633a403 100644
--- a/forge/auditLog/device.js
+++ b/forge/auditLog/device.js
@@ -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 }))
diff --git a/forge/auditLog/formatters.js b/forge/auditLog/formatters.js
index ae0ec9475a..3c9e48b2da 100644
--- a/forge/auditLog/formatters.js
+++ b/forge/auditLog/formatters.js
@@ -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) {
diff --git a/forge/routes/logging/index.js b/forge/routes/logging/index.js
index 50ebf23222..0b7ec4791f 100644
--- a/forge/routes/logging/index.js
+++ b/forge/routes/logging/index.js
@@ -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
*
@@ -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
@@ -34,7 +48,7 @@ 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
@@ -42,7 +56,7 @@ module.exports = async function (app) {
delete auditEvent.path
delete auditEvent.timestamp
- await app.db.controllers.AuditLog.projectLog(
+ await auditLogController.projectLog(
projectId,
userId,
event,
@@ -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()
+ })
}
diff --git a/frontend/src/components/audit-log/AuditEntryVerbose.vue b/frontend/src/components/audit-log/AuditEntryVerbose.vue
index 33ef1edc2c..d91d355f84 100644
--- a/frontend/src/components/audit-log/AuditEntryVerbose.vue
+++ b/frontend/src/components/audit-log/AuditEntryVerbose.vue
@@ -151,7 +151,8 @@
- User '{{ entry.trigger.name }}' has logged out.
+ Node-RED user has logged out.
+ User '{{ entry.trigger.name }}' has logged out.
User data not found in audit entry.
@@ -522,8 +523,17 @@
Node-RED editor user settings have been updated.
-
- A new flow has been deployed
+
+
+ Flows have been reloaded
+
+
+
+ Deploy type 'full'
+ Deploy type 'flows'
+ Deploy type 'nodes'
+ Flows deployed
+
diff --git a/frontend/src/data/audit-events.json b/frontend/src/data/audit-events.json
index 347cfd2d39..c2816e03f1 100644
--- a/frontend/src/data/audit-events.json
+++ b/frontend/src/data/audit-events.json
@@ -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",
@@ -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"
}
}
\ No newline at end of file
diff --git a/test/unit/forge/auditLog/formatters_spec.js b/test/unit/forge/auditLog/formatters_spec.js
index 89240dd025..48d9353d14 100644
--- a/test/unit/forge/auditLog/formatters_spec.js
+++ b/test/unit/forge/auditLog/formatters_spec.js
@@ -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: '',
+ UserId: {},
+ User: {},
+ event: 'flows.set',
+ createdAt: '',
+ entityId: '',
+ 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')
+ })
+ })
})
diff --git a/test/unit/forge/routes/logging/index_spec.js b/test/unit/forge/routes/logging/index_spec.js
index 849eaaea36..21d129fd30 100644
--- a/test/unit/forge/routes/logging/index_spec.js
+++ b/test/unit/forge/routes/logging/index_spec.js
@@ -5,27 +5,43 @@ const setup = require('../setup')
describe('Logging API', function () {
let app
+ /** @type {import('../../../../lib/TestModelFactory')} */
+ let factory
+ let objectCount = 0
+ const generateName = (root = 'object') => `${root}-${objectCount++}`
const TestObjects = {
tokens: {
alice: null,
project1: null,
- project2: null
+ project2: null,
+ device1: null,
+ device2: null
},
team1: null,
project1: null,
project2: null,
+ device1: null,
+ device2: null,
alice: null
}
before(async function () {
app = await setup({})
+ factory = app.factory
+ TestObjects.application = app.application
TestObjects.alice = await app.db.models.User.byUsername('alice')
TestObjects.team1 = app.team
TestObjects.project1 = app.project
TestObjects.project2 = await app.db.models.Project.create({ name: 'project2', type: '', url: '' })
+ const device1 = await factory.createDevice({ name: generateName('device-1') }, TestObjects.team1, null, TestObjects.application)
+ TestObjects.device1 = await app.db.models.Device.byId(device1.id)
+ const device2 = await factory.createDevice({ name: generateName('device-2') }, TestObjects.team1, null, TestObjects.application)
+ TestObjects.device2 = await app.db.models.Device.byId(device2.id)
await TestObjects.team1.addProject(TestObjects.project2)
TestObjects.tokens.project1 = (await TestObjects.project1.refreshAuthTokens()).token
TestObjects.tokens.project2 = (await TestObjects.project2.refreshAuthTokens()).token
+ TestObjects.tokens.device1 = (await TestObjects.device1.refreshAuthTokens()).token
+ TestObjects.tokens.device2 = (await TestObjects.device2.refreshAuthTokens()).token
sinon.stub(app.db.controllers.Project, 'addProjectModule')
sinon.stub(app.db.controllers.Project, 'removeProjectModule')
@@ -41,117 +57,177 @@ describe('Logging API', function () {
delete TestObjects.team1
delete TestObjects.project1
delete TestObjects.project2
+ delete TestObjects.device1
+ delete TestObjects.device2
delete TestObjects.alice
+ delete TestObjects.application
app.db.controllers.Project.addProjectModule.restore()
app.db.controllers.Project.removeProjectModule.restore()
})
+ describe('instance audit logging', function () {
+ it('Accepts valid token', async function () {
+ const url = `/logging/${TestObjects.project1.id}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.project1}`
+ },
+ payload: { event: 'started' }
+ })
+ response.should.have.property('statusCode', 200)
+ })
- it('Accepts valid token', async function () {
- const url = `/logging/${TestObjects.project1.id}/audit`
- const response = await app.inject({
- method: 'POST',
- url,
- headers: {
- authorization: `Bearer ${TestObjects.tokens.project1}`
- },
- payload: { event: 'started' }
+ it('Rejects invalid token', async function () {
+ const url = `/logging/${TestObjects.project1.id}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.project2}`
+ },
+ payload: {}
+ })
+ response.should.have.property('statusCode', 404)
})
- response.should.have.property('statusCode', 200)
- })
- it('Rejects invalid token', async function () {
- const url = `/logging/${TestObjects.project1.id}/audit`
- const response = await app.inject({
- method: 'POST',
- url,
- headers: {
- authorization: `Bearer ${TestObjects.tokens.project2}`
- },
- payload: {}
+ it('Allows error to be included in body', async function () {
+ const url = `/logging/${TestObjects.project1.id}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.project1}`
+ },
+ payload: { event: 'start-failed', error: { code: 'test_code', error: 'test_error' } }
+ })
+ response.should.have.property('statusCode', 200)
})
- response.should.have.property('statusCode', 404)
- })
- it('Allows error to be included in body', async function () {
- const url = `/logging/${TestObjects.project1.id}/audit`
- const response = await app.inject({
- method: 'POST',
- url,
- headers: {
- authorization: `Bearer ${TestObjects.tokens.project1}`
- },
- payload: { event: 'start-failed', error: { code: 'test_code', error: 'test_error' } }
+ it('Adds module to instance settings for nodes.install event', async function () {
+ const url = `/logging/${TestObjects.project1.id}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.project1}`
+ },
+ payload: { event: 'nodes.install', module: '@flowfuse/newmodule', version: '0.4.0', path: '/nodes' }
+ })
+ response.should.have.property('statusCode', 200)
+ app.db.controllers.Project.addProjectModule.called.should.be.true()
+ const args = app.db.controllers.Project.addProjectModule.lastCall.args
+ args.should.have.length(3)
+ args[0].should.have.property('id', TestObjects.project1.id)
+ args[1].should.equal('@flowfuse/newmodule')
+ args[2].should.equal('0.4.0')
})
- response.should.have.property('statusCode', 200)
- })
- it('Adds module to instance settings for nodes.install event', async function () {
- const url = `/logging/${TestObjects.project1.id}/audit`
- const response = await app.inject({
- method: 'POST',
- url,
- headers: {
- authorization: `Bearer ${TestObjects.tokens.project1}`
- },
- payload: { event: 'nodes.install', module: '@flowfuse/newmodule', version: '0.4.0', path: '/nodes' }
+ it('Does not add module to instance settings for nodes.install event with error', async function () {
+ const url = `/logging/${TestObjects.project1.id}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.project1}`
+ },
+ payload: { event: 'nodes.install', module: '@flowfuse/error', error: 'not_found', version: '0.4.0', path: '/nodes' }
+ })
+ response.should.have.property('statusCode', 200)
+ app.db.controllers.Project.addProjectModule.called.should.be.false()
})
- response.should.have.property('statusCode', 200)
- app.db.controllers.Project.addProjectModule.called.should.be.true()
- const args = app.db.controllers.Project.addProjectModule.lastCall.args
- args.should.have.length(3)
- args[0].should.have.property('id', TestObjects.project1.id)
- args[1].should.equal('@flowfuse/newmodule')
- args[2].should.equal('0.4.0')
- })
- it('Does not add module to instance settings for nodes.install event with error', async function () {
- const url = `/logging/${TestObjects.project1.id}/audit`
- const response = await app.inject({
- method: 'POST',
- url,
- headers: {
- authorization: `Bearer ${TestObjects.tokens.project1}`
- },
- payload: { event: 'nodes.install', module: '@flowfuse/error', error: 'not_found', version: '0.4.0', path: '/nodes' }
+ it('Removes module from instance settings for nodes.remove event', async function () {
+ const url = `/logging/${TestObjects.project1.id}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.project1}`
+ },
+ payload: { event: 'nodes.remove', module: '@flowfuse/removemodule', version: '0.4.0', path: '/nodes' }
+ })
+ response.should.have.property('statusCode', 200)
+ app.db.controllers.Project.removeProjectModule.called.should.be.true()
+ const args = app.db.controllers.Project.removeProjectModule.lastCall.args
+ args.should.have.length(2)
+ args[0].should.have.property('id', TestObjects.project1.id)
+ args[1].should.equal('@flowfuse/removemodule')
})
- response.should.have.property('statusCode', 200)
- app.db.controllers.Project.addProjectModule.called.should.be.false()
- })
- it('Removes module from instance settings for nodes.remove event', async function () {
- const url = `/logging/${TestObjects.project1.id}/audit`
- const response = await app.inject({
- method: 'POST',
- url,
- headers: {
- authorization: `Bearer ${TestObjects.tokens.project1}`
- },
- payload: { event: 'nodes.remove', module: '@flowfuse/removemodule', version: '0.4.0', path: '/nodes' }
+ it('Adds module to instance settings for modules.install event', async function () {
+ const url = `/logging/${TestObjects.project1.id}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.project1}`
+ },
+ payload: { event: 'modules.install', module: '@flowfuse/newmodule', path: '/nodes' }
+ })
+ response.should.have.property('statusCode', 200)
+ app.db.controllers.Project.addProjectModule.called.should.be.true()
+ const args = app.db.controllers.Project.addProjectModule.lastCall.args
+ args.should.have.length(3)
+ args[0].should.have.property('id', TestObjects.project1.id)
+ args[1].should.equal('@flowfuse/newmodule')
+ args[2].should.equal('*')
})
- response.should.have.property('statusCode', 200)
- app.db.controllers.Project.removeProjectModule.called.should.be.true()
- const args = app.db.controllers.Project.removeProjectModule.lastCall.args
- args.should.have.length(2)
- args[0].should.have.property('id', TestObjects.project1.id)
- args[1].should.equal('@flowfuse/removemodule')
})
+ describe('device instance audit logging', function () {
+ it('Accepts valid token', async function () {
+ const url = `/logging/device/${TestObjects.device1.hashid}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.device1}`
+ },
+ payload: { event: 'started' }
+ })
+ response.should.have.property('statusCode', 200)
+ })
+
+ it('Rejects invalid token', async function () {
+ const url = `/logging/device/${TestObjects.device1.hashid}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.device2}`
+ },
+ payload: {}
+ })
+ response.should.have.property('statusCode', 404)
+ })
+
+ it('Allows error to be included in body', async function () {
+ const url = `/logging/device/${TestObjects.device1.hashid}/audit`
+ const response = await app.inject({
+ method: 'POST',
+ url,
+ headers: {
+ authorization: `Bearer ${TestObjects.tokens.device1}`
+ },
+ payload: { event: 'start-failed', error: { code: 'test_code', error: 'test_error' } }
+ })
+ response.should.have.property('statusCode', 200)
+ })
+
+ it.skip('Adds module to instance settings for nodes.install event', async function () {
+ // future
+ })
+
+ it.skip('Does not add module to instance settings for nodes.install event with error', async function () {
+ // future
+ })
+
+ it.skip('Removes module from instance settings for nodes.remove event', async function () {
+ // future
+ })
- it('Adds module to instance settings for modules.install event', async function () {
- const url = `/logging/${TestObjects.project1.id}/audit`
- const response = await app.inject({
- method: 'POST',
- url,
- headers: {
- authorization: `Bearer ${TestObjects.tokens.project1}`
- },
- payload: { event: 'modules.install', module: '@flowfuse/newmodule', path: '/nodes' }
+ it.skip('Adds module to instance settings for modules.install event', async function () {
+ // future
})
- response.should.have.property('statusCode', 200)
- app.db.controllers.Project.addProjectModule.called.should.be.true()
- const args = app.db.controllers.Project.addProjectModule.lastCall.args
- args.should.have.length(3)
- args[0].should.have.property('id', TestObjects.project1.id)
- args[1].should.equal('@flowfuse/newmodule')
- args[2].should.equal('*')
})
})