mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-26 14:02:09 +00:00
Merge pull request #22433 from overleaf/tm-suspend-users-script
Add script for bulk account suspension GitOrigin-RevId: 434ac819c12a2a33c26baf963d8d8874b1864928
This commit is contained in:
parent
ea96947df1
commit
d8840bfe7f
4 changed files with 167 additions and 0 deletions
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
71
services/web/scripts/suspend_users.mjs
Normal file
71
services/web/scripts/suspend_users.mjs
Normal file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Read a list of user IDs from a file and suspend their accounts.
|
||||
*
|
||||
* Usage: node scripts/suspend_users.mjs <filename>
|
||||
*/
|
||||
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)
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Add table
Reference in a new issue