From 8f773318c15ee12edc8cb932f79e062388da2edb Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Tue, 21 Jul 2020 09:29:08 -0500 Subject: [PATCH] Merge pull request #3023 from overleaf/jel-add-user-audit-log User audit log GitOrigin-RevId: 687448d5da5d783c6db0fadb53c020cc9c3876b9 --- .../src/Features/User/UserAuditLogHandler.js | 47 +++++++++++ services/web/app/src/models/User.js | 13 ++- .../unit/src/User/UserAuditLogHandlerTests.js | 81 +++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 services/web/app/src/Features/User/UserAuditLogHandler.js create mode 100644 services/web/test/unit/src/User/UserAuditLogHandlerTests.js diff --git a/services/web/app/src/Features/User/UserAuditLogHandler.js b/services/web/app/src/Features/User/UserAuditLogHandler.js new file mode 100644 index 0000000000..68646f79c7 --- /dev/null +++ b/services/web/app/src/Features/User/UserAuditLogHandler.js @@ -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 diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index 0de0cc8621..1df6917937 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -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) diff --git a/services/web/test/unit/src/User/UserAuditLogHandlerTests.js b/services/web/test/unit/src/User/UserAuditLogHandlerTests.js new file mode 100644 index 0000000000..5e67452edb --- /dev/null +++ b/services/web/test/unit/src/User/UserAuditLogHandlerTests.js @@ -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 + }) + }) + }) +})