Skip to content

Commit

Permalink
Merge pull request #4330 from FlowFuse/audit-log-export
Browse files Browse the repository at this point in the history
Audit log export
  • Loading branch information
Steve-Mcl authored Aug 23, 2024
2 parents 53be495 + 2c33a34 commit 8f23297
Show file tree
Hide file tree
Showing 6 changed files with 338 additions and 0 deletions.
50 changes: 50 additions & 0 deletions forge/routes/api/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,56 @@ module.exports = async function (app) {
reply.send(result)
})

/**
* Get platform audit logs as CSV
* @name /api/v1/admin/audit-log/export
* @memberof forge.routes.api.admin
*/
app.get('/audit-log/export', {
preHandler: app.needsPermission('platform:audit-log'),
schema: {
summary: 'Gets platform audit events as CSV - admin-only',
tags: ['Platform'],
query: {
allOf: [
{ $ref: 'PaginationParams' },
{ $ref: 'AuditLogQueryParams' }
]
},
response: {
200: {
content: {
'text/csv': {
schema: {
type: 'string'
}
}
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const logEntries = await app.db.models.AuditLog.forPlatform(paginationOptions)
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.type('text/csv').send([
['id', 'event', 'body', 'scope', 'trigger', 'createdAt'],
...result.log.map(row => [
row.id,
row.event,
`"${row.body ? JSON.stringify(row.body).replace(/"/g, '""') : ''}"`,
`"${JSON.stringify(row.scope).replace(/"/g, '""')}"`,
`"${JSON.stringify(row.trigger).replace(/"/g, '""')}"`,
row.createdAt?.toISOString()
])
]
.map(row => row.join(','))
.join('\r\n'))
})

app.post('/stats-token', {
preHandler: app.needsPermission('platform:stats:token'),
schema: {
Expand Down
56 changes: 56 additions & 0 deletions forge/routes/api/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,4 +477,60 @@ module.exports = async function (app) {
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.send(result)
})

/**
* Get the application audit log
* @name /api/v1/application/:applicationId/audit-log/export
* @memberof forge.routes.api.project
*/
app.get('/:applicationId/audit-log/export', {
preHandler: app.needsPermission('application:audit-log'),
schema: {
summary: 'Get application audit event entries',
tags: ['Applications'],
query: {
allOf: [
{ $ref: 'PaginationParams' },
{ $ref: 'AuditLogQueryParams' }
]
},
params: {
type: 'object',
properties: {
applicationId: { type: 'string' }
}
},
response: {
200: {
content: {
'text/csv': {
schema: {
type: 'string'
}
}
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const logEntries = await app.db.models.AuditLog.forApplication(request.application.id, paginationOptions)
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.type('text/csv').send([
['id', 'event', 'body', 'scope', 'trigger', 'createdAt'],
...result.log.map(row => [
row.id,
row.event,
`"${row.body ? JSON.stringify(row.body).replace(/"/g, '""') : ''}"`,
`"${JSON.stringify(row.scope).replace(/"/g, '""')}"`,
`"${JSON.stringify(row.trigger).replace(/"/g, '""')}"`,
row.createdAt?.toISOString()
])
]
.map(row => row.join(','))
.join('\r\n'))
})
}
55 changes: 55 additions & 0 deletions forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,61 @@ module.exports = async function (app) {
reply.send(result)
})

/**
* @name /api/v1/devices/:id/audit-log/export
* @memberof forge.routes.api.devices
*/
app.get('/:deviceId/audit-log/export', {
preHandler: app.needsPermission('device:audit-log'),
schema: {
summary: '',
tags: ['Devices'],
params: {
type: 'object',
properties: {
deviceId: { type: 'string' }
}
},
query: {
allOf: [
{ $ref: 'PaginationParams' },
{ $ref: 'AuditLogQueryParams' }
]
},
response: {
200: {
content: {
'text/csv': {
schema: {
type: 'string'
}
}
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const logEntries = await app.db.models.AuditLog.forDevice(request.device.id, paginationOptions)
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.type('text/csv').send([
['id', 'event', 'body', 'scope', 'trigger', 'createdAt'],
...result.log.map(row => [
row.id,
row.event,
`"${row.body ? JSON.stringify(row.body).replace(/"/g, '""') : ''}"`,
`"${JSON.stringify(row.scope).replace(/"/g, '""')}"`,
`"${JSON.stringify(row.trigger).replace(/"/g, '""')}"`,
row.createdAt?.toISOString()
])
]
.map(row => row.join(','))
.join('\r\n'))
})

async function assignDeviceToProject (device, project) {
await device.setProject(project)
// Set the target snapshot to match the project's one
Expand Down
56 changes: 56 additions & 0 deletions forge/routes/api/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,62 @@ module.exports = async function (app) {
reply.send(result)
})

/**
* TODO: Add support for filtering by instance param when this is migrated to application API
* Export logs as CSV
* @name /api/v1/projects/:id/audit-log/export
* @memberof forge.routes.api.project
*/
app.get('/:instanceId/audit-log/export', {
preHandler: app.needsPermission('project:audit-log'),
schema: {
summary: 'Get instance audit event entries',
tags: ['Instances'],
params: {
type: 'object',
properties: {
instanceId: { type: 'string' }
}
},
query: {
allOf: [
{ $ref: 'PaginationParams' },
{ $ref: 'AuditLogQueryParams' }
]
},
response: {
200: {
content: {
'text/csv': {
schema: {
type: 'string'
}
}
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const logEntries = await app.db.models.AuditLog.forProject(request.project.id, paginationOptions)
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.type('text/csv').send([
['id', 'event', 'body', 'scope', 'trigger', 'createdAt'],
...result.log.map(row => [
row.id,
row.event,
`"${row.body ? JSON.stringify(row.body).replace(/"/g, '""') : ''}"`,
`"${JSON.stringify(row.scope).replace(/"/g, '""')}"`,
`"${JSON.stringify(row.trigger).replace(/"/g, '""')}"`,
row.createdAt?.toISOString()
])
]
.map(row => row.join(','))
.join('\r\n'))
})
/**
*
* @name /api/v1/projects/:id/import
Expand Down
56 changes: 56 additions & 0 deletions forge/routes/api/team.js
Original file line number Diff line number Diff line change
Expand Up @@ -737,4 +737,60 @@ module.exports = async function (app) {
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.send(result)
})

/**
* Get the team audit log
* @name /api/v1/team/:teamId/audit-log/export
* @memberof forge.routes.api.project
*/
app.get('/:teamId/audit-log/export', {
preHandler: app.needsPermission('team:audit-log'),
schema: {
summary: 'Get team audit event entries',
tags: ['Teams'],
query: {
allOf: [
{ $ref: 'PaginationParams' },
{ $ref: 'AuditLogQueryParams' }
]
},
params: {
type: 'object',
properties: {
teamId: { type: 'string' }
}
},
response: {
200: {
content: {
'text/csv': {
schema: {
type: 'string'
}
}
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.getPaginationOptions(request)
const logEntries = await app.db.models.AuditLog.forTeam(request.team.id, paginationOptions)
const result = app.db.views.AuditLog.auditLog(logEntries)
reply.type('text/csv').send([
['id', 'event', 'body', 'scope', 'trigger', 'createdAt'],
...result.log.map(row => [
row.id,
row.event,
`"${row.body ? JSON.stringify(row.body).replace(/"/g, '""') : ''}"`,
`"${JSON.stringify(row.scope).replace(/"/g, '""')}"`,
`"${JSON.stringify(row.trigger).replace(/"/g, '""')}"`,
row.createdAt?.toISOString()
])
]
.map(row => row.join(','))
.join('\r\n'))
})
}
65 changes: 65 additions & 0 deletions test/unit/forge/auditLog/platform_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,69 @@ describe('Audit Log > Platform', async function () {
logEntry.body.team.should.have.property('id', TEAM.hashid)
logEntry.body.team.should.have.property('name', TEAM.name)
})

describe('audit-log/export', async function () {
const TestObjects = {
tokens: {}
}

async function login (username, password) {
const response = await app.inject({
method: 'POST',
url: '/account/login',
payload: { username, password, remember: false }
})
response.cookies.should.have.length(1)
response.cookies[0].should.have.property('name', 'sid')
TestObjects.tokens[username] = response.cookies[0].value
}

before(async function () {
TestObjects.alice = await app.db.models.User.byUsername('alice')
TestObjects.bob = await app.db.models.User.create({ username: 'bob', name: 'Bob Solo', email: '[email protected]', email_verified: true, password: 'bbPassword' })
await login('alice', 'aaPassword')
await login('bob', 'bbPassword')
})

it('Audit log should be accessible to admin', async function () {
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit-log/export',
query: {
limit: 5
},
cookies: { sid: TestObjects.tokens.alice }
})
response.statusCode.should.equal(200)
})

it('Audit log should not be accessible to non admin', async function () {
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit-log/export',
query: {
limit: 5
},
cookies: { sid: TestObjects.tokens.bob }
})
response.statusCode.should.equal(403)
})

it('Audit log should have header', async function () {
await platformLogger.platform.stack.created(0, null, STACK)
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit-log/export',
query: {
limit: 5
},
cookies: { sid: TestObjects.tokens.alice }
})
response.statusCode.should.equal(200)
const body = response.body
const rows = body.split('\r\n')
rows.should.have.length(2)
rows[0].should.equal('id,event,body,scope,trigger,createdAt')
})
})
})

0 comments on commit 8f23297

Please sign in to comment.