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

Add device group audit loggers #3236

Merged
merged 2 commits into from
Dec 20, 2023
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
14 changes: 14 additions & 0 deletions forge/auditLog/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ module.exports = {
await log('application.device.snapshot.device-target-set', actionedBy, application?.id, generateBody({ error, device, snapshot }))
}
}
},
deviceGroup: {
async created (actionedBy, error, application, deviceGroup) {
await log('application.deviceGroup.created', actionedBy, application?.id, generateBody({ error, application, deviceGroup }))
},
async updated (actionedBy, error, application, deviceGroup, updates) {
await log('application.deviceGroup.updated', actionedBy, application?.id, generateBody({ error, application, deviceGroup, updates }))
},
async deleted (actionedBy, error, application, deviceGroup) {
await log('application.deviceGroup.deleted', actionedBy, application?.id, generateBody({ error, application, deviceGroup }))
},
async membersChanged (actionedBy, error, application, deviceGroup, updates, info) {
await log('application.deviceGroup.members.changed', actionedBy, application?.id, generateBody({ error, application, deviceGroup, updates, info }))
}
}
}

Expand Down
18 changes: 15 additions & 3 deletions forge/auditLog/formatters.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ const isObject = (obj) => {
/**
* 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?, interval?, threshold? } == {}} 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?, interval?, threshold? }
* @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? } == {}} 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? }}
*/
const generateBody = ({ error, team, application, project, sourceProject, targetProject, device, sourceDevice, targetDevice, user, stack, billingSession, subscription, license, updates, snapshot, pipeline, pipelineStage, pipelineStageTarget, role, projectType, info, interval, threshold } = {}) => {
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 } = {}) => {
const body = {}

if (isObject(error) || typeof error === 'string') {
Expand Down Expand Up @@ -83,6 +83,9 @@ const generateBody = ({ error, team, application, project, sourceProject, target
} else if (isStringWithLength(info)) {
body.info = { info }
}
if (isObject(deviceGroup)) {
body.deviceGroup = deviceGroupObject(deviceGroup)
}

if (interval) {
body.interval = interval
Expand Down Expand Up @@ -149,6 +152,7 @@ const formatLogEntry = (auditLogDbRow) => {
snapshot: body?.snapshot,
updates: body?.updates,
device: body?.device,
deviceGroup: body?.deviceGroup,
sourceDevice: body?.sourceDevice,
targetDevice: body?.targetDevice,
projectType: body?.projectType,
Expand Down Expand Up @@ -310,6 +314,13 @@ const projectTypeObject = (projectType) => {
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
Expand Down Expand Up @@ -577,6 +588,7 @@ module.exports = {
teamObject,
projectObject,
deviceObject,
deviceGroupObject,
userObject,
stackObject,
billingSessionObject,
Expand Down
53 changes: 52 additions & 1 deletion forge/ee/routes/applicationDeviceGroups/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@

const { ValidationError } = require('sequelize')

const { UpdatesCollection } = require('../../../auditLog/formatters.js')
const { registerPermissions } = require('../../../lib/permissions.js')
const { Roles } = require('../../../lib/roles.js')
const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js')

// Declare getLogger function to provide type hints / quick code nav / code completion
/** @type {import('../../../../forge/auditLog/application').getLoggers} */
const getApplicationLogger = (app) => { return app.auditLog.Application }

/**
* @param {import('../../../forge.js').ForgeApplication} app The application instance
*/
module.exports = async function (app) {
const deviceGroupLogger = getApplicationLogger(app).application.deviceGroup

registerPermissions({
'application:device-group:create': { description: 'Create a device group', role: Roles.Owner },
'application:device-group:list': { description: 'List device groups', role: Roles.Member },
Expand Down Expand Up @@ -154,6 +161,7 @@ module.exports = async function (app) {
try {
const newGroup = await app.db.controllers.DeviceGroup.createDeviceGroup(name, { application, description })
const newGroupView = app.db.views.DeviceGroup.deviceGroupSummary(newGroup)
await deviceGroupLogger.created(request.session.User, null, application, newGroup)
reply.code(201).send(newGroupView)
} catch (error) {
return handleError(error, reply)
Expand Down Expand Up @@ -200,7 +208,12 @@ module.exports = async function (app) {
const name = request.body.name
const description = request.body.description
try {
await app.db.controllers.DeviceGroup.updateDeviceGroup(group, { name, description })
const originalDetails = { name: group.name, description: group.description }
const updatedGroup = await app.db.controllers.DeviceGroup.updateDeviceGroup(group, { name, description })
const newDetails = { name: updatedGroup.name, description: updatedGroup.description }
const updates = new UpdatesCollection()
updates.pushDifferences(originalDetails, newDetails)
await deviceGroupLogger.updated(request.session.User, null, request.application, group, updates)
reply.send({})
} catch (error) {
return handleError(error, reply)
Expand Down Expand Up @@ -282,7 +295,44 @@ module.exports = async function (app) {
const removeDevices = request.body.remove
const setDevices = request.body.set
try {
// get before state
const originalDevices = await group.getDevices()
const originalSorted = originalDevices.sort((a, b) => a.id - b.id)
// const originalMemberList = originalSorted.map(d => d.name).join(', ')

// update membership
await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(group, { addDevices, removeDevices, setDevices })

// get after state
const newDevices = await group.getDevices()
const newSorted = newDevices.sort((a, b) => a.id - b.id)
// const newMembers = newSorted.map(d => d.name).join(', ')

// compare original and new members, generate a list of added and a list of removed members
const added = new Set()
const removed = new Set()
for (const device of originalSorted) {
if (!newSorted.find(d => d.id === device.id)) {
removed.add(device)
}
}
for (const device of newSorted) {
if (!originalSorted.find(d => d.id === device.id)) {
added.add(device)
}
}

// generate audit log message e.g. "Added 2, removed 1"
const infoBuilder = []
if (added.size > 0) {
infoBuilder.push(`Added ${added.size}`)
}
if (removed.size > 0) {
infoBuilder.push(`Removed ${removed.size}`)
}

await deviceGroupLogger.membersChanged(request.session.User, null, request.application, group, null, { info: infoBuilder.join(', ') })

reply.send({})
} catch (err) {
return handleError(err, reply)
Expand Down Expand Up @@ -320,6 +370,7 @@ module.exports = async function (app) {
}, async (request, reply) => {
const group = request.deviceGroup
await group.destroy()
await deviceGroupLogger.deleted(request.session.User, null, request.application, group)
reply.send({})
})

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/audit-log/AuditEntryIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<DesktopComputerIcon v-if="icon === 'stacks'" class="ff-icon text-red-700" />
<ColorSwatchIcon v-if="icon === 'project-types'" class="ff-icon text-red-700" />
<ChipIcon v-if="icon === 'device'" class="ff-icon text-blue-700" />
<DeviceGroupSolidIcon v-if="icon === 'device-group'" class="ff-icon text-teal-700" />
<MailIcon v-if="icon === 'mail'" class="ff-icon text-green-700" />
<IdentificationIcon v-if="icon === 'user-profile'" class="ff-icon text-green-700" />
<UserIcon v-if="icon === 'user'" class="ff-icon text-green-700" />
Expand All @@ -34,6 +35,7 @@ import {
TemplateIcon, TicketIcon, UserGroupIcon, UserIcon
} from '@heroicons/vue/outline'

import DeviceGroupSolidIcon from '../icons/DeviceGroupSolid.js'
import NodeRedIcon from '../icons/NodeRed.js'
import PipelineIcon from '../icons/Pipelines.js'
import ProjectIcon from '../icons/Projects.js'
Expand Down Expand Up @@ -185,6 +187,12 @@ const iconMap = {
'team.device.credentialsGenerated', // legacy event
'project.snapshot.deviceTarget' // legacy event
],
'device-group': [
'application.deviceGroup.created',
'application.deviceGroup.updated',
'application.deviceGroup.deleted',
'application.deviceGroup.members.changed'
],
beaker: [
'team.device.developer-mode.enabled',
'team.device.developer-mode.disabled',
Expand Down Expand Up @@ -239,6 +247,7 @@ export default {
NodeRedIcon,
TemplateIcon,
ChipIcon,
DeviceGroupSolidIcon,
ColorSwatchIcon,
MailIcon,
CogIcon,
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/components/audit-log/AuditEntryVerbose.vue
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,28 @@
<span v-else-if="!error">Device data not found in audit entry.</span>
</template>

<!-- Application Device Group Events -->
<template v-else-if="entry.event === 'application.deviceGroup.updated'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body?.deviceGroup">Device Group '{{ entry.body.deviceGroup?.name }}' was updated for Application '{{ entry.body.application?.name }}' with the following changes: <AuditEntryUpdates :updates="entry.body.updates" /></span>
<span v-else-if="!error">Device Group data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'application.deviceGroup.created'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body?.deviceGroup">Device Group '{{ entry.body.deviceGroup?.name }}' was created for Application '{{ entry.body.application?.name }}'.</span>
<span v-else-if="!error">Device Group data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'application.deviceGroup.deleted'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body?.deviceGroup">Device Group '{{ entry.body.deviceGroup?.name }}' was deleted from Application '{{ entry.body.application?.name }}'.</span>
<span v-else-if="!error">Device Group data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'application.deviceGroup.members.changed'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body?.deviceGroup">Device Group '{{ entry.body.deviceGroup?.name }}' members in Application '{{ entry.body.application?.name }} updated: {{ entry.body?.info?.info ?? 'No changes' }}.</span>
<span v-else-if="!error">Device Group data not found in audit entry.</span>
</template>

<!-- Instance Events -->
<template v-else-if="entry.event === 'project.created'">
<label>{{ AuditEvents[entry.event] }}</label>
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/data/audit-events.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@
"application.device.unassigned": "Device Unassigned from Application",
"application.device.snapshot.created": "Device Snapshot Created",
"application.device.snapshot.deleted": "Device Snapshot Deleted",
"application.device.snapshot.device-target-set": "Device Target Snapshot Set"
"application.device.snapshot.device-target-set": "Device Target Snapshot Set",
"application.deviceGroup.created": "Device Group Created",
"application.deviceGroup.updated": "Device Group Updated",
"application.deviceGroup.deleted": "Device Group Deleted",
"application.deviceGroup.members.changed": "Device Group Members Changed"
},
"project": {
"project.created": "Instance Created",
Expand Down
70 changes: 70 additions & 0 deletions test/unit/forge/auditLog/application_spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const should = require('should') // eslint-disable-line
const { UpdatesCollection } = require('../../../../forge/auditLog/formatters')

const FF_UTIL = require('flowforge-test-utils')

// Declare a dummy getLoggers function for type hint only
/** @type {import('../../../../forge/auditLog/application').getLoggers} */
const getLoggers = (app) => { return {} }
Expand All @@ -10,6 +13,7 @@ describe('Audit Log > Application', async function () {
let TEAM
let APPLICATION
let DEVICE
let DEVICEGROUP

// temporarily assign the logger purely for type info & intellisense
// so that xxxxxLogger.yyy.zzz function parameters are offered
Expand All @@ -29,6 +33,8 @@ describe('Audit Log > Application', async function () {
APPLICATION = await app.db.models.Application.create({ name: 'application1', TeamId: TEAM.id })
// await TEAM.addProject(APPLICATION)
DEVICE = await app.db.models.Device.create({ name: 'deviceOne', type: 'something', credentialSecret: 'deviceKey' })

DEVICEGROUP = await app.db.models.DeviceGroup.create({ name: 'deviceGroupOne', ApplicationId: APPLICATION.id })
})
after(async () => {
await app.close()
Expand Down Expand Up @@ -121,5 +127,69 @@ describe('Audit Log > Application', async function () {
logEntry.body.device.id.should.equal(DEVICE.hashid)
})

it('Provides a logger for creating a device group', async function () {
await logger.application.deviceGroup.created(ACTIONED_BY, null, APPLICATION, DEVICEGROUP)
// check log stored
const logEntry = await getLog()
logEntry.should.have.property('event', 'application.deviceGroup.created')
logEntry.should.have.property('scope', { id: APPLICATION.hashid, type: 'application' })
logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username })
logEntry.should.have.property('body')
logEntry.body.should.only.have.keys('application', 'deviceGroup')
logEntry.body.application.should.only.have.keys('id', 'name')
logEntry.body.application.id.should.equal(APPLICATION.id)
logEntry.body.deviceGroup.should.only.have.keys('id', 'name')
})

it('Provides a logger for updating a device group', async function () {
const copyOfDeviceGroup = { ...DEVICEGROUP }
copyOfDeviceGroup.name = 'deviceGroupOne-new-name'
copyOfDeviceGroup.description = 'new-description'
const updates = new UpdatesCollection()
updates.pushDifferences(DEVICEGROUP, copyOfDeviceGroup)
await logger.application.deviceGroup.updated(ACTIONED_BY, null, APPLICATION, DEVICEGROUP, updates)
// check log stored
const logEntry = await getLog()
logEntry.should.have.property('event', 'application.deviceGroup.updated')
logEntry.should.have.property('scope', { id: APPLICATION.hashid, type: 'application' })
logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username })
logEntry.should.have.property('body')
logEntry.body.should.only.have.keys('application', 'deviceGroup', 'updates')
logEntry.body.application.should.only.have.keys('id', 'name')
logEntry.body.application.id.should.equal(APPLICATION.id)
logEntry.body.deviceGroup.should.only.have.keys('id', 'name')
logEntry.body.updates.should.be.an.Array().and.have.length(2)
})

it('Provides a logger for deleting a device group', async function () {
await logger.application.deviceGroup.deleted(ACTIONED_BY, null, APPLICATION, DEVICEGROUP)
// check log stored
const logEntry = await getLog()
logEntry.should.have.property('event', 'application.deviceGroup.deleted')
logEntry.should.have.property('scope', { id: APPLICATION.hashid, type: 'application' })
logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username })
logEntry.should.have.property('body')
logEntry.body.should.only.have.keys('application', 'deviceGroup')
logEntry.body.application.should.only.have.keys('id', 'name')
logEntry.body.application.id.should.equal(APPLICATION.id)
logEntry.body.deviceGroup.should.only.have.keys('id', 'name')
})

it('Provides a logger for changing device group members', async function () {
const info = 'Added 2, Removed 1'
await logger.application.deviceGroup.membersChanged(ACTIONED_BY, null, APPLICATION, DEVICEGROUP, null, info)
// check log stored
const logEntry = await getLog()
logEntry.should.have.property('event', 'application.deviceGroup.members.changed')
logEntry.should.have.property('scope', { id: APPLICATION.hashid, type: 'application' })
logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username })
logEntry.should.have.property('body')
logEntry.body.should.only.have.keys('application', 'deviceGroup', 'info')
logEntry.body.application.should.only.have.keys('id', 'name')
logEntry.body.application.id.should.equal(APPLICATION.id)
logEntry.body.deviceGroup.should.only.have.keys('id', 'name')
logEntry.body.info.should.have.property('info', info)
})

// #endregion
})
Loading