Merge pull request #3023 from overleaf/jel-add-user-audit-log

User audit log

GitOrigin-RevId: 687448d5da5d783c6db0fadb53c020cc9c3876b9
This commit is contained in:
Jessica Lawshe 2020-07-21 09:29:08 -05:00 committed by Copybot
parent d8b2537f48
commit 8f773318c1
3 changed files with 140 additions and 1 deletions

View file

@ -0,0 +1,47 @@
const OError = require('@overleaf/o-error')
const { User } = require('../../models/User')
const MAX_AUDIT_LOG_ENTRIES = 200
/**
* Add an audit log entry
*
* The entry should include at least the following fields:
*
* - operation: a string identifying the type of operation
* - initiatorId: who performed the operation
* - info: an object detailing what happened
* - userId: the user on behalf of whom the operation was performed
*/
async function addEntry(userId, operation, initiatorId, ipAddress, info = {}) {
const timestamp = new Date()
const entry = {
operation,
initiatorId,
info,
ipAddress,
timestamp
}
const result = await User.updateOne(
{ _id: userId },
{
$push: {
auditLog: { $each: [entry], $slice: -MAX_AUDIT_LOG_ENTRIES }
}
}
).exec()
if (result.nModified === 0) {
throw new OError({
message: 'user not found',
info: { userId }
})
}
}
const UserAuditLogHandler = {
promises: {
addEntry
}
}
module.exports = UserAuditLogHandler

View file

@ -7,6 +7,16 @@ const { ObjectId } = Schema
// See https://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address/574698#574698
const MAX_EMAIL_LENGTH = 254
const AuditLogEntrySchema = new Schema({
_id: false,
info: { type: Object },
initiatorId: { type: Schema.Types.ObjectId },
ipAddress: { type: String },
operation: { type: String },
userId: { type: Schema.Types.ObjectId },
timestamp: { type: Date }
})
const UserSchema = new Schema({
email: { type: String, default: '', maxlength: MAX_EMAIL_LENGTH },
emails: [
@ -150,7 +160,8 @@ const UserSchema = new Schema({
enrolledAt: { type: Date },
secret: { type: String }
},
onboardingEmailSentAt: { type: Date }
onboardingEmailSentAt: { type: Date },
auditLog: [AuditLogEntrySchema]
})
exports.User = mongoose.model('User', UserSchema)

View file

@ -0,0 +1,81 @@
const sinon = require('sinon')
const { expect } = require('chai')
const { ObjectId } = require('mongodb')
const SandboxedModule = require('sandboxed-module')
const { User } = require('../helpers/models/User')
const MODULE_PATH = '../../../../app/src/Features/User/UserAuditLogHandler'
describe('UserAuditLogHandler', function() {
beforeEach(function() {
this.userId = ObjectId()
this.initiatorId = ObjectId()
this.action = {
operation: 'clear-sessions',
initiatorId: this.initiatorId,
info: {
sessions: [
{
ip_address: '0:0:0:0',
session_created: '2020-07-15T16:07:57.652Z'
}
]
},
ip: '0:0:0:0'
}
this.UserMock = sinon.mock(User)
this.UserAuditLogHandler = SandboxedModule.require(MODULE_PATH, {
globals: {
console: console
},
requires: {
'../../models/User': { User }
}
})
})
afterEach(function() {
this.UserMock.restore()
})
describe('addEntry', function() {
describe('success', function() {
beforeEach(async function() {
this.dbUpdate = this.UserMock.expects('updateOne')
.chain('exec')
.resolves({ nModified: 1 })
await this.UserAuditLogHandler.promises.addEntry(
this.userId,
this.action.operation,
this.action.initiatorId,
this.action.ip,
this.action.info
)
})
it('writes a log', async function() {
this.UserMock.verify()
})
})
describe('when the user does not exist', function() {
beforeEach(function() {
this.UserMock.expects('updateOne')
.chain('exec')
.resolves({ nModified: 0 })
})
it('throws an error', async function() {
await expect(
this.UserAuditLogHandler.promises.addEntry(
this.userId,
this.action.operation,
this.action.initiatorId,
this.action.ip,
this.action.info
)
).to.be.rejected
})
})
})
})