diff --git a/services/web/app/src/Features/User/UserAuditLogHandler.js b/services/web/app/src/Features/User/UserAuditLogHandler.js index 23276c29d9..63babcd639 100644 --- a/services/web/app/src/Features/User/UserAuditLogHandler.js +++ b/services/web/app/src/Features/User/UserAuditLogHandler.js @@ -20,6 +20,7 @@ function _canHaveNoInitiatorId(operation, info) { if (operation === 'leave-group-subscription') return true if (operation === 'must-reset-password-set') return true if (operation === 'must-reset-password-unset') return true + if (operation === 'account-suspension' && info.script) return true } /** diff --git a/services/web/app/src/Features/User/UserUpdater.js b/services/web/app/src/Features/User/UserUpdater.js index abae551f03..09940fbc99 100644 --- a/services/web/app/src/Features/User/UserUpdater.js +++ b/services/web/app/src/Features/User/UserUpdater.js @@ -18,6 +18,7 @@ const SubscriptionLocator = require('../Subscription/SubscriptionLocator') const NotificationsBuilder = require('../Notifications/NotificationsBuilder') const _ = require('lodash') const Modules = require('../../infrastructure/Modules') +const UserSessionsManager = require('./UserSessionsManager') async function _sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email) { // Send email to the following: @@ -509,6 +510,29 @@ async function removeReconfirmFlag(userId, auditLog) { await updateUser(userId.toString(), { $set: { must_reconfirm: false } }) } +async function suspendUser(userId, auditLog = {}) { + const res = await updateUser( + { _id: userId, suspended: { $ne: true } }, + { $set: { suspended: true } } + ) + if (res.matchedCount !== 1) { + throw new Errors.NotFoundError('user id not found or already suspended') + } + await UserAuditLogHandler.promises.addEntry( + userId, + 'account-suspension', + auditLog.initiatorId, + auditLog.ip, + auditLog.info || {} + ) + await UserSessionsManager.promises.removeSessionsFromRedis({ _id: userId }) + await Modules.promises.hooks.fire( + 'removeDropbox', + userId, + 'account-suspension' + ) +} + function _securityAlertPrimaryEmailChangedExtraRecipients( emailsData, oldEmail, @@ -564,6 +588,7 @@ module.exports = { setDefaultEmailAddress: callbackify(setDefaultEmailAddress), migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress), updateUser: callbackify(updateUser), + suspendUser: callbackify(suspendUser), promises: { addAffiliationForNewUser, addEmailAddress, @@ -575,5 +600,6 @@ module.exports = { setDefaultEmailAddress, migrateDefaultEmailAddress, updateUser, + suspendUser, }, } diff --git a/services/web/scripts/suspend_users.mjs b/services/web/scripts/suspend_users.mjs new file mode 100644 index 0000000000..e54fbf8347 --- /dev/null +++ b/services/web/scripts/suspend_users.mjs @@ -0,0 +1,71 @@ +/* + * Read a list of user IDs from a file and suspend their accounts. + * + * Usage: node scripts/suspend_users.mjs + */ +import fs from 'node:fs' +import { ObjectId } from '../app/src/infrastructure/mongodb.js' +import UserUpdater from '../app/src/Features/User/UserUpdater.js' +import { promiseMapWithLimit } from '@overleaf/promise-utils' + +const ASYNC_LIMIT = 10 + +const processLogger = { + failed: [], + success: [], + printSummary: () => { + console.log( + { + success: processLogger.success, + failed: processLogger.failed, + }, + `\nDONE. ${processLogger.success.length} successful. ${processLogger.failed.length} failed to suspend.` + ) + }, +} + +function _validateUserIdList(userIds) { + if (!Array.isArray(userIds)) throw new Error('users is not an array') + + userIds.forEach(userId => { + if (!ObjectId.isValid(userId)) throw new Error('user ID not valid') + }) +} + +async function _handleUser(userId) { + try { + await UserUpdater.promises.suspendUser(userId, { + ip: '0.0.0.0', + info: { script: true }, + }) + } catch (error) { + console.log(`Failed to suspend ${userId}`, error) + processLogger.failed.push(userId) + return + } + processLogger.success.push(userId) +} + +async function _loopUsers(userIds) { + return promiseMapWithLimit(ASYNC_LIMIT, userIds, _handleUser) +} + +const fileName = process.argv[2] +if (!fileName) throw new Error('missing filename') +const usersFile = fs.readFileSync(fileName, 'utf8') +const userIds = usersFile + .trim() + .split('\n') + .map(id => id.trim()) + +async function processUsers(userIds) { + console.log('---Starting suspend_users script---') + _validateUserIdList(userIds) + console.log(`---Starting to process ${userIds.length} users---`) + await _loopUsers(userIds) + + processLogger.printSummary() + process.exit() +} + +processUsers(userIds) diff --git a/services/web/test/unit/src/User/UserUpdaterTests.js b/services/web/test/unit/src/User/UserUpdaterTests.js index f609530e33..5832bc4656 100644 --- a/services/web/test/unit/src/User/UserUpdaterTests.js +++ b/services/web/test/unit/src/User/UserUpdaterTests.js @@ -107,6 +107,20 @@ describe('UserUpdater', function () { }, } + this.Modules = { + promises: { + hooks: { + fire: sinon.stub().resolves([]), + }, + }, + } + + this.UserSessionsManager = { + promises: { + removeSessionsFromRedis: sinon.stub().resolves(), + }, + } + this.UserUpdater = SandboxedModule.require(MODULE_PATH, { requires: { '../Helpers/Mongo': { normalizeQuery }, @@ -124,6 +138,8 @@ describe('UserUpdater', function () { '../../Errors/Errors': Errors, '../Subscription/SubscriptionLocator': this.SubscriptionLocator, '../Notifications/NotificationsBuilder': this.NotificationsBuilder, + '../../infrastructure/Modules': this.Modules, + './UserSessionsManager': this.UserSessionsManager, }, }) @@ -1054,4 +1070,57 @@ describe('UserUpdater', function () { }) }) }) + + describe('suspendUser', function () { + beforeEach(function () { + this.auditLog = { + initiatorId: 'abc123', + ip: '0.0.0.0', + } + }) + + it('should suspend the user', async function () { + await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) + expect(this.db.users.updateOne).to.have.been.calledWith( + { _id: this.user._id, suspended: { $ne: true } }, + { $set: { suspended: true } } + ) + }) + + it('should remove sessions from redis', async function () { + await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) + expect( + this.UserSessionsManager.promises.removeSessionsFromRedis + ).to.have.been.calledWith({ _id: this.user._id }) + }) + + it('should log the suspension to the audit log', async function () { + await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) + expect( + this.UserAuditLogHandler.promises.addEntry + ).to.have.been.calledWith( + this.user._id, + 'account-suspension', + this.auditLog.initiatorId, + this.auditLog.ip, + {} + ) + }) + + it('should fire the removeDropbox hook', async function () { + await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'removeDropbox', + this.user._id, + 'account-suspension' + ) + }) + + it('should handle not finding a record to update', async function () { + this.db.users.updateOne.resolves({ matchedCount: 0 }) + await expect( + this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) + ).to.be.rejectedWith(Errors.NotFoundError) + }) + }) })