mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-30 05:05:27 -05:00
Merge pull request #3023 from overleaf/jel-add-user-audit-log
User audit log GitOrigin-RevId: 687448d5da5d783c6db0fadb53c020cc9c3876b9
This commit is contained in:
parent
d8b2537f48
commit
8f773318c1
3 changed files with 140 additions and 1 deletions
47
services/web/app/src/Features/User/UserAuditLogHandler.js
Normal file
47
services/web/app/src/Features/User/UserAuditLogHandler.js
Normal 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
|
|
@ -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
|
// See https://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address/574698#574698
|
||||||
const MAX_EMAIL_LENGTH = 254
|
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({
|
const UserSchema = new Schema({
|
||||||
email: { type: String, default: '', maxlength: MAX_EMAIL_LENGTH },
|
email: { type: String, default: '', maxlength: MAX_EMAIL_LENGTH },
|
||||||
emails: [
|
emails: [
|
||||||
|
@ -150,7 +160,8 @@ const UserSchema = new Schema({
|
||||||
enrolledAt: { type: Date },
|
enrolledAt: { type: Date },
|
||||||
secret: { type: String }
|
secret: { type: String }
|
||||||
},
|
},
|
||||||
onboardingEmailSentAt: { type: Date }
|
onboardingEmailSentAt: { type: Date },
|
||||||
|
auditLog: [AuditLogEntrySchema]
|
||||||
})
|
})
|
||||||
|
|
||||||
exports.User = mongoose.model('User', UserSchema)
|
exports.User = mongoose.model('User', UserSchema)
|
||||||
|
|
81
services/web/test/unit/src/User/UserAuditLogHandlerTests.js
Normal file
81
services/web/test/unit/src/User/UserAuditLogHandlerTests.js
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue