Sfoglia il codice sorgente

Merge pull request #22433 from overleaf/tm-suspend-users-script

Add script for bulk account suspension

GitOrigin-RevId: 434ac819c12a2a33c26baf963d8d8874b1864928
Thomas 1 anno fa
parent
commit
d8840bfe7f

+ 1 - 0
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 === 'leave-group-subscription') return true
   if (operation === 'must-reset-password-set') return true
   if (operation === 'must-reset-password-set') return true
   if (operation === 'must-reset-password-unset') return true
   if (operation === 'must-reset-password-unset') return true
+  if (operation === 'account-suspension' && info.script) return true
 }
 }
 
 
 /**
 /**

+ 26 - 0
services/web/app/src/Features/User/UserUpdater.js

@@ -18,6 +18,7 @@ const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
 const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
 const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
 const _ = require('lodash')
 const _ = require('lodash')
 const Modules = require('../../infrastructure/Modules')
 const Modules = require('../../infrastructure/Modules')
+const UserSessionsManager = require('./UserSessionsManager')
 
 
 async function _sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email) {
 async function _sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email) {
   // Send email to the following:
   // Send email to the following:
@@ -509,6 +510,29 @@ async function removeReconfirmFlag(userId, auditLog) {
   await updateUser(userId.toString(), { $set: { must_reconfirm: false } })
   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(
 function _securityAlertPrimaryEmailChangedExtraRecipients(
   emailsData,
   emailsData,
   oldEmail,
   oldEmail,
@@ -564,6 +588,7 @@ module.exports = {
   setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
   setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
   migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
   migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
   updateUser: callbackify(updateUser),
   updateUser: callbackify(updateUser),
+  suspendUser: callbackify(suspendUser),
   promises: {
   promises: {
     addAffiliationForNewUser,
     addAffiliationForNewUser,
     addEmailAddress,
     addEmailAddress,
@@ -575,5 +600,6 @@ module.exports = {
     setDefaultEmailAddress,
     setDefaultEmailAddress,
     migrateDefaultEmailAddress,
     migrateDefaultEmailAddress,
     updateUser,
     updateUser,
+    suspendUser,
   },
   },
 }
 }

+ 71 - 0
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 <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)

+ 69 - 0
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, {
     this.UserUpdater = SandboxedModule.require(MODULE_PATH, {
       requires: {
       requires: {
         '../Helpers/Mongo': { normalizeQuery },
         '../Helpers/Mongo': { normalizeQuery },
@@ -124,6 +138,8 @@ describe('UserUpdater', function () {
         '../../Errors/Errors': Errors,
         '../../Errors/Errors': Errors,
         '../Subscription/SubscriptionLocator': this.SubscriptionLocator,
         '../Subscription/SubscriptionLocator': this.SubscriptionLocator,
         '../Notifications/NotificationsBuilder': this.NotificationsBuilder,
         '../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)
+    })
+  })
 })
 })