Merge pull request #15672 from overleaf/mj-group-subscription-audit-revival

[web] Add audit logs when user joins or leaves group subscription

GitOrigin-RevId: d64425f5a2434c60c89c297c9a51acae3b96c31e
This commit is contained in:
Mathias Jakobsen 2023-11-20 10:34:48 +00:00 committed by Copybot
parent 7c8014d791
commit 6e74a65758
8 changed files with 341 additions and 23 deletions

View file

@ -3,27 +3,44 @@ const OError = require('@overleaf/o-error')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionLocator = require('./SubscriptionLocator')
const SessionManager = require('../Authentication/SessionManager') const SessionManager = require('../Authentication/SessionManager')
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
function removeUserFromGroup(req, res, next) { function removeUserFromGroup(req, res, next) {
const subscription = req.entity const subscription = req.entity
const userToRemoveId = req.params.user_id const userToRemoveId = req.params.user_id
const loggedInUserId = SessionManager.getLoggedInUserId(req.session)
logger.debug( logger.debug(
{ subscriptionId: subscription._id, userToRemoveId }, { subscriptionId: subscription._id, userToRemoveId },
'removing user from group subscription' 'removing user from group subscription'
) )
SubscriptionGroupHandler.removeUserFromGroup( UserAuditLogHandler.addEntry(
subscription._id,
userToRemoveId, userToRemoveId,
function (error) { 'remove-from-group-subscription',
if (error) { loggedInUserId,
OError.tag(error, 'error removing user from group', { req.ip,
{ subscriptionId: subscription._id },
function (auditLogError) {
if (auditLogError) {
OError.tag(auditLogError, 'error adding audit log entry', {
userToRemoveId,
subscriptionId: subscription._id, subscriptionId: subscription._id,
userToRemove_id: userToRemoveId,
}) })
return next(error) return next(auditLogError)
} }
SubscriptionGroupHandler.removeUserFromGroup(
res.sendStatus(200) subscription._id,
userToRemoveId,
function (error) {
if (error) {
OError.tag(error, 'error removing user from group', {
subscriptionId: subscription._id,
userToRemove_id: userToRemoveId,
})
return next(error)
}
res.sendStatus(200)
}
)
} }
) )
} }
@ -38,18 +55,34 @@ function removeSelfFromGroup(req, res, next) {
return next(error) return next(error)
} }
SubscriptionGroupHandler.removeUserFromGroup( UserAuditLogHandler.addEntry(
subscription._id,
userToRemoveId, userToRemoveId,
function (error) { 'remove-from-group-subscription',
if (error) { userToRemoveId,
logger.err( req.ip,
{ err: error, userToRemoveId, subscriptionId }, { subscriptionId: subscription._id },
'error removing self from group' function (auditLogError) {
) if (auditLogError) {
return res.sendStatus(500) OError.tag(auditLogError, 'error adding audit log entry', {
userToRemoveId,
subscriptionId,
})
return next(auditLogError)
} }
res.sendStatus(200) SubscriptionGroupHandler.removeUserFromGroup(
subscription._id,
userToRemoveId,
function (error) {
if (error) {
logger.err(
{ err: error, userToRemoveId, subscriptionId },
'error removing self from group'
)
return res.sendStatus(500)
}
res.sendStatus(200)
}
)
} }
) )
} }

View file

@ -9,6 +9,7 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager')
const { DeletedSubscription } = require('../../models/DeletedSubscription') const { DeletedSubscription } = require('../../models/DeletedSubscription')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const Features = require('../../infrastructure/Features') const Features = require('../../infrastructure/Features')
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
/** /**
* Change the admin of the given subscription. * Change the admin of the given subscription.
@ -59,6 +60,13 @@ async function syncSubscription(
} }
async function addUserToGroup(subscriptionId, userId) { async function addUserToGroup(subscriptionId, userId) {
await UserAuditLogHandler.promises.addEntry(
userId,
'join-group-subscription',
undefined,
undefined,
{ subscriptionId }
)
await Subscription.updateOne( await Subscription.updateOne(
{ _id: subscriptionId }, { _id: subscriptionId },
{ $addToSet: { member_ids: userId } } { $addToSet: { member_ids: userId } }
@ -73,6 +81,13 @@ async function addUserToGroup(subscriptionId, userId) {
} }
async function removeUserFromGroup(subscriptionId, userId) { async function removeUserFromGroup(subscriptionId, userId) {
await UserAuditLogHandler.promises.addEntry(
userId,
'leave-group-subscription',
undefined,
undefined,
{ subscriptionId }
)
await Subscription.updateOne( await Subscription.updateOne(
{ _id: subscriptionId }, { _id: subscriptionId },
{ $pull: { member_ids: userId } } { $pull: { member_ids: userId } }
@ -97,6 +112,17 @@ async function removeUserFromAllGroups(userId) {
} }
const subscriptionIds = subscriptions.map(sub => sub._id) const subscriptionIds = subscriptions.map(sub => sub._id)
const removeOperation = { $pull: { member_ids: userId } } const removeOperation = { $pull: { member_ids: userId } }
for (const subscriptionId of subscriptionIds) {
await UserAuditLogHandler.promises.addEntry(
userId,
'leave-group-subscription',
undefined,
undefined,
{ subscriptionId }
)
}
await Subscription.updateMany( await Subscription.updateMany(
{ _id: subscriptionIds }, { _id: subscriptionIds },
removeOperation removeOperation

View file

@ -13,6 +13,7 @@ const PermissionsManager = require('../Authorization/PermissionsManager')
const EmailHandler = require('../Email/EmailHandler') const EmailHandler = require('../Email/EmailHandler')
const { RateLimiter } = require('../../infrastructure/RateLimiter') const { RateLimiter } = require('../../infrastructure/RateLimiter')
const Modules = require('../../infrastructure/Modules') const Modules = require('../../infrastructure/Modules')
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
const rateLimiters = { const rateLimiters = {
resendGroupInvite: new RateLimiter('resend-group-invite', { resendGroupInvite: new RateLimiter('resend-group-invite', {
@ -198,6 +199,21 @@ async function acceptInvite(req, res, next) {
await Modules.promises.hooks.fire('hasGroupSSOEnabled', subscription) await Modules.promises.hooks.fire('hasGroupSSOEnabled', subscription)
)?.[0] )?.[0]
try {
await UserAuditLogHandler.promises.addEntry(
userId,
'accept-group-invitation',
userId,
req.ip,
{ subscriptionId: subscription._id }
)
} catch (e) {
logger.error(
{ err: e, userId, subscriptionId: subscription._id },
'error adding audit log entry'
)
}
res.json({ groupSSOActive }) res.json({ groupSSOActive })
} }

View file

@ -2,12 +2,20 @@ const OError = require('@overleaf/o-error')
const { UserAuditLogEntry } = require('../../models/UserAuditLogEntry') const { UserAuditLogEntry } = require('../../models/UserAuditLogEntry')
const { callbackify } = require('util') const { callbackify } = require('util')
function _canHaveNoIpAddressId(operation) {
if (operation === 'join-group-subscription') return true
if (operation === 'leave-group-subscription') return true
return false
}
function _canHaveNoInitiatorId(operation, info) { function _canHaveNoInitiatorId(operation, info) {
if (operation === 'reset-password') return true if (operation === 'reset-password') return true
if (operation === 'unlink-sso' && info.providerId === 'collabratec') if (operation === 'unlink-sso' && info.providerId === 'collabratec')
return true return true
if (operation === 'unlink-institution-sso-not-migrated') return true if (operation === 'unlink-institution-sso-not-migrated') return true
if (operation === 'remove-email' && info.script) return true if (operation === 'remove-email' && info.script) return true
if (operation === 'join-group-subscription') return true
if (operation === 'leave-group-subscription') return true
} }
/** /**
@ -22,12 +30,19 @@ function _canHaveNoInitiatorId(operation, info) {
* - info: an object detailing what happened * - info: an object detailing what happened
*/ */
async function addEntry(userId, operation, initiatorId, ipAddress, info = {}) { async function addEntry(userId, operation, initiatorId, ipAddress, info = {}) {
if (!operation || !ipAddress) if (!operation) {
throw new OError('missing required audit log data', { throw new OError('missing operation for audit log', {
operation,
initiatorId, initiatorId,
ipAddress, ipAddress,
}) })
}
if (!ipAddress && !_canHaveNoIpAddressId(operation, info)) {
throw new OError('missing ipAddress for audit log', {
operation,
initiatorId,
})
}
if (!initiatorId && !_canHaveNoInitiatorId(operation, info)) { if (!initiatorId && !_canHaveNoInitiatorId(operation, info)) {
throw new OError('missing initiatorId for audit log', { throw new OError('missing initiatorId for audit log', {

View file

@ -41,11 +41,16 @@ describe('SubscriptionGroupController', function () {
}, },
} }
this.UserAuditLogHandler = {
addEntry: sinon.stub().callsArgWith(5),
}
this.Controller = SandboxedModule.require(modulePath, { this.Controller = SandboxedModule.require(modulePath, {
requires: { requires: {
'./SubscriptionGroupHandler': this.GroupHandler, './SubscriptionGroupHandler': this.GroupHandler,
'./SubscriptionLocator': this.SubscriptionLocator, './SubscriptionLocator': this.SubscriptionLocator,
'../Authentication/SessionManager': this.SessionManager, '../Authentication/SessionManager': this.SessionManager,
'../User/UserAuditLogHandler': this.UserAuditLogHandler,
}, },
}) })
}) })
@ -66,6 +71,27 @@ describe('SubscriptionGroupController', function () {
} }
this.Controller.removeUserFromGroup(this.req, res) this.Controller.removeUserFromGroup(this.req, res)
}) })
it('should log that the user has been removed', function (done) {
const userIdToRemove = '31231'
this.req.params = { user_id: userIdToRemove }
this.req.entity = this.subscription
const res = {
sendStatus: () => {
sinon.assert.calledWith(
this.UserAuditLogHandler.addEntry,
userIdToRemove,
'remove-from-group-subscription',
this.adminUserId,
this.req.ip,
{ subscriptionId: this.subscriptionId }
)
done()
},
}
this.Controller.removeUserFromGroup(this.req, res)
})
}) })
describe('removeSelfFromGroup', function () { describe('removeSelfFromGroup', function () {
@ -90,5 +116,26 @@ describe('SubscriptionGroupController', function () {
} }
this.Controller.removeSelfFromGroup(this.req, res) this.Controller.removeSelfFromGroup(this.req, res)
}) })
it('should log that the user has left the subscription', function (done) {
this.req.query = { subscriptionId: this.subscriptionId }
const memberUserIdToremove = 123456789
this.req.session.user._id = memberUserIdToremove
const res = {
sendStatus: () => {
sinon.assert.calledWith(
this.UserAuditLogHandler.addEntry,
memberUserIdToremove,
'remove-from-group-subscription',
memberUserIdToremove,
this.req.ip,
{ subscriptionId: this.subscriptionId }
)
done()
},
}
this.Controller.removeSelfFromGroup(this.req, res)
})
}) })
}) })

View file

@ -147,6 +147,12 @@ describe('SubscriptionUpdater', function () {
hasFeature: sinon.stub().returns(false), hasFeature: sinon.stub().returns(false),
} }
this.UserAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
}
this.SubscriptionUpdater = SandboxedModule.require(modulePath, { this.SubscriptionUpdater = SandboxedModule.require(modulePath, {
requires: { requires: {
'../../models/Subscription': { '../../models/Subscription': {
@ -162,6 +168,7 @@ describe('SubscriptionUpdater', function () {
}, },
'../Analytics/AnalyticsManager': this.AnalyticsManager, '../Analytics/AnalyticsManager': this.AnalyticsManager,
'../../infrastructure/Features': this.Features, '../../infrastructure/Features': this.Features,
'../User/UserAuditLogHandler': this.UserAuditLogHandler,
}, },
}) })
}) })
@ -487,6 +494,23 @@ describe('SubscriptionUpdater', function () {
'better_group_subscription' 'better_group_subscription'
) )
}) })
it('should add an entry to the user audit log when joining a group', async function () {
await this.SubscriptionUpdater.promises.addUserToGroup(
this.subscription._id,
this.otherUserId
)
sinon.assert.calledWith(
this.UserAuditLogHandler.promises.addEntry,
this.otherUserId,
'join-group-subscription',
undefined,
undefined,
{
subscriptionId: this.subscription._id,
}
)
})
}) })
describe('removeUserFromGroup', function () { describe('removeUserFromGroup', function () {
@ -554,6 +578,23 @@ describe('SubscriptionUpdater', function () {
.calledWith(this.otherUserId) .calledWith(this.otherUserId)
.should.equal(true) .should.equal(true)
}) })
it('should add an audit log when a user leaves a group', async function () {
await this.SubscriptionUpdater.promises.removeUserFromGroup(
this.subscription._id,
this.otherUserId
)
sinon.assert.calledWith(
this.UserAuditLogHandler.promises.addEntry,
this.otherUserId,
'leave-group-subscription',
undefined,
undefined,
{
subscriptionId: this.subscription._id,
}
)
})
}) })
describe('removeUserFromAllGroups', function () { describe('removeUserFromAllGroups', function () {
@ -638,6 +679,32 @@ describe('SubscriptionUpdater', function () {
} }
) )
}) })
it('should add an audit log entry for each group the user leaves', async function () {
await this.SubscriptionUpdater.promises.removeUserFromAllGroups(
this.otherUserId
)
sinon.assert.calledWith(
this.UserAuditLogHandler.promises.addEntry,
this.otherUserId,
'leave-group-subscription',
undefined,
undefined,
{
subscriptionId: 'fake-id-1',
}
)
sinon.assert.calledWith(
this.UserAuditLogHandler.promises.addEntry,
this.otherUserId,
'leave-group-subscription',
undefined,
undefined,
{
subscriptionId: 'fake-id-2',
}
)
})
}) })
describe('deleteSubscription', function () { describe('deleteSubscription', function () {

View file

@ -0,0 +1,102 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const modulePath =
'../../../../app/src/Features/Subscription/TeamInvitesController'
describe('TeamInvitesController', function () {
beforeEach(function () {
this.user = { _id: '!@312431', email: 'user@email.com' }
this.adminUserId = '123jlkj'
this.subscriptionId = '123434325412'
this.user_email = 'bob@gmail.com'
this.req = {
session: {
user: {
_id: this.adminUserId,
email: this.user_email,
},
},
params: {},
query: {},
ip: '0.0.0.0',
}
this.subscription = {
_id: this.subscriptionId,
}
this.TeamInvitesHandler = {
promises: { acceptInvite: sinon.stub().resolves(this.subscription) },
}
this.SubscriptionLocator = {
promises: {
hasSSOEnabled: sinon.stub().resolves(true),
},
}
this.ErrorController = { notFound: sinon.stub() }
this.SessionManager = {
getLoggedInUserId(session) {
return session.user._id
},
getSessionUser(session) {
return session.user
},
}
this.UserAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(this.user),
getUserByMainEmail: sinon.stub().resolves(this.user),
getUserByAnyEmail: sinon.stub().resolves(this.user),
},
}
this.EmailHandler = {
sendDeferredEmail: sinon.stub().resolves(),
}
this.RateLimiter = {
RateLimiter: class {},
}
this.Controller = SandboxedModule.require(modulePath, {
requires: {
'./TeamInvitesHandler': this.TeamInvitesHandler,
'../Authentication/SessionManager': this.SessionManager,
'./SubscriptionLocator': this.SubscriptionLocator,
'../User/UserAuditLogHandler': this.UserAuditLogHandler,
'../Errors/ErrorController': this.ErrorController,
'../User/UserGetter': this.UserGetter,
'../Email/EmailHandler': this.EmailHandler,
'../../infrastructure/RateLimiter': this.RateLimiter,
},
})
})
describe('acceptInvite', function () {
it('should add an audit log entry', function (done) {
this.req.params.token = 'foo'
this.req.session.user = this.user
const res = {
json: () => {
sinon.assert.calledWith(
this.UserAuditLogHandler.promises.addEntry,
this.user._id,
'accept-group-invitation',
this.user._id,
this.req.ip,
{ subscriptionId: this.subscriptionId }
)
done()
},
}
this.Controller.acceptInvite(this.req, res)
})
})
})

View file

@ -81,6 +81,18 @@ describe('UserAuditLogHandler', function () {
) )
this.UserAuditLogEntryMock.verify() this.UserAuditLogEntryMock.verify()
}) })
it('updates the log when no ip address or initiatorId is specified for a group join event', async function () {
this.UserAuditLogHandler.promises.addEntry(
this.userId,
'join-group-subscription',
undefined,
undefined,
{
subscriptionId: 'foo',
}
)
})
}) })
describe('errors', function () { describe('errors', function () {
@ -97,7 +109,7 @@ describe('UserAuditLogHandler', function () {
).to.be.rejected ).to.be.rejected
}) })
it('throws an error when no IP', async function () { it('throws an error when no IP and not excempt', async function () {
await expect( await expect(
this.UserAuditLogHandler.promises.addEntry( this.UserAuditLogHandler.promises.addEntry(
this.userId, this.userId,