Jelajahi Sumber

[web] Add User logs to Group Audit Logs view (#29480)

* Revert "Revert "[web] Add User logs to Group Audit Logs view (#29155)" (#29479)"

This reverts commit 40a1516ab9cec690d0487a0a870b9fab17598d60.

* Fix `managedUsersEnabled` flag in frontend

GitOrigin-RevId: ae3edf5bcbc01ec46bc18028e758d3364072c307
Miguel Serrano 9 bulan lalu
induk
melakukan
a4d9d5789a

+ 7 - 0
services/web/app/src/Features/Subscription/SubscriptionLocator.js

@@ -98,6 +98,13 @@ const SubscriptionLocator = {
     )
   },
 
+  async getUniqueManagedSubscriptionMemberOf(userId) {
+    return await Subscription.findOne(
+      { member_ids: userId, managedUsersEnabled: true },
+      { _id: 1 }
+    )
+  },
+
   async getGroupsWithEmailInvite(email) {
     return await Subscription.find({ invited_emails: email }).exec()
   },

+ 20 - 0
services/web/app/src/Features/User/UserAuditLogHandler.js

@@ -1,6 +1,8 @@
 const OError = require('@overleaf/o-error')
+const logger = require('@overleaf/logger')
 const { UserAuditLogEntry } = require('../../models/UserAuditLogEntry')
 const { callbackify } = require('util')
+const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
 
 function _canHaveNoIpAddressId(operation, info) {
   if (operation === 'join-group-subscription') return true
@@ -27,6 +29,9 @@ function _canHaveNoInitiatorId(operation, info) {
   if (operation === 'release-managed-user' && info.script) return true
 }
 
+// events that are visible to managed user admins in Group Audit Logs view
+const MANAGED_GROUP_USER_EVENTS = ['login', 'reset-password', 'update-password']
+
 /**
  * Add an audit log entry
  *
@@ -68,10 +73,25 @@ async function addEntry(userId, operation, initiatorId, ipAddress, info = {}) {
     ipAddress,
   }
 
+  if (MANAGED_GROUP_USER_EVENTS.includes(operation)) {
+    try {
+      const managedSubscription =
+        await SubscriptionLocator.promises.getUniqueManagedSubscriptionMemberOf(
+          userId
+        )
+      if (managedSubscription) {
+        entry.managedSubscriptionId = managedSubscription._id
+      }
+    } catch (err) {
+      logger.error({ err, userId }, 'failed to lookup managed subscription')
+    }
+  }
+
   await UserAuditLogEntry.create(entry)
 }
 
 const UserAuditLogHandler = {
+  MANAGED_GROUP_USER_EVENTS,
   addEntry: callbackify(addEntry),
   promises: {
     addEntry,

+ 1 - 0
services/web/app/src/models/UserAuditLogEntry.js

@@ -4,6 +4,7 @@ const { Schema } = mongoose
 const UserAuditLogEntrySchema = new Schema(
   {
     userId: { type: Schema.Types.ObjectId, index: true },
+    managedSubscriptionId: { type: Schema.Types.ObjectId, index: true },
     info: { type: Object },
     initiatorId: { type: Schema.Types.ObjectId },
     ipAddress: { type: String },

+ 50 - 21
services/web/test/unit/src/User/UserAuditLogHandlerTests.js

@@ -10,6 +10,7 @@ describe('UserAuditLogHandler', function () {
   beforeEach(function () {
     this.userId = new ObjectId()
     this.initiatorId = new ObjectId()
+    this.subscriptionId = new ObjectId()
     this.action = {
       operation: 'clear-sessions',
       initiatorId: this.initiatorId,
@@ -24,9 +25,16 @@ describe('UserAuditLogHandler', function () {
       ip: '0:0:0:0',
     }
     this.UserAuditLogEntryMock = sinon.mock(UserAuditLogEntry)
+    this.getUniqueManagedSubscriptionMemberOfMock = sinon.stub().resolves()
     this.UserAuditLogHandler = SandboxedModule.require(MODULE_PATH, {
       requires: {
         '../../models/UserAuditLogEntry': { UserAuditLogEntry },
+        '../Subscription/SubscriptionLocator': {
+          promises: {
+            getUniqueManagedSubscriptionMemberOf:
+              this.getUniqueManagedSubscriptionMemberOfMock,
+          },
+        },
       },
     })
   })
@@ -53,37 +61,33 @@ describe('UserAuditLogHandler', function () {
         this.UserAuditLogEntryMock.verify()
       })
 
-      it('updates the log for password reset operation witout a initiatorId', async function () {
-        await expect(
-          this.UserAuditLogHandler.promises.addEntry(
-            this.userId,
-            'reset-password',
-            undefined,
-            this.action.ip,
-            this.action.info
-          )
+      it('updates the log for password reset operation without a initiatorId', async function () {
+        await this.UserAuditLogHandler.promises.addEntry(
+          this.userId,
+          'reset-password',
+          undefined,
+          this.action.ip,
+          this.action.info
         )
         this.UserAuditLogEntryMock.verify()
       })
 
       it('updates the log for a email removal via script', async function () {
-        await expect(
-          this.UserAuditLogHandler.promises.addEntry(
-            this.userId,
-            'remove-email',
-            undefined,
-            this.action.ip,
-            {
-              removedEmail: 'foo',
-              script: true,
-            }
-          )
+        await this.UserAuditLogHandler.promises.addEntry(
+          this.userId,
+          'remove-email',
+          undefined,
+          this.action.ip,
+          {
+            removedEmail: 'foo',
+            script: true,
+          }
         )
         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(
+        await this.UserAuditLogHandler.promises.addEntry(
           this.userId,
           'join-group-subscription',
           undefined,
@@ -92,6 +96,31 @@ describe('UserAuditLogHandler', function () {
             subscriptionId: 'foo',
           }
         )
+        this.UserAuditLogEntryMock.verify()
+      })
+
+      it('includes managedSubscriptionId for managed group user events ', async function () {
+        await this.UserAuditLogHandler.promises.addEntry(
+          this.userId,
+          'reset-password',
+          undefined,
+          this.action.ip
+        )
+        this.UserAuditLogEntryMock.verify()
+        expect(this.getUniqueManagedSubscriptionMemberOfMock).to.have.been
+          .called
+      })
+
+      it('does not includes managedSubscriptionId for events not in the managed group event list', async function () {
+        await this.UserAuditLogHandler.promises.addEntry(
+          this.userId,
+          'foo',
+          this.action.initiatorId,
+          this.action.ip
+        )
+        this.UserAuditLogEntryMock.verify()
+        expect(this.getUniqueManagedSubscriptionMemberOfMock).not.to.have.been
+          .called
       })
     })
 

+ 35 - 0
tools/migrations/20251016092030_user_audit_log_managed_group_index.mjs

@@ -0,0 +1,35 @@
+/* eslint-disable no-unused-vars */
+
+import Helpers from './lib/helpers.mjs'
+
+const tags = ['saas']
+
+const indexes = [
+  {
+    key: {
+      managedSubscriptionId: 1,
+      timestamp: 1,
+    },
+    name: 'managedSubscriptionId_1_timestamp_1',
+  },
+]
+
+const migrate = async client => {
+  const { db } = client
+  await Helpers.addIndexesToCollection(db.userAuditLogEntries, indexes)
+}
+
+const rollback = async client => {
+  const { db } = client
+  try {
+    await Helpers.dropIndexesFromCollection(db.userAuditLogEntries, indexes)
+  } catch (err) {
+    console.error('Something went wrong rolling back the migrations', err)
+  }
+}
+
+export default {
+  tags,
+  migrate,
+  rollback,
+}