From a4d9d5789aa84d113e1d343054c893465f32e393 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Tue, 4 Nov 2025 12:50:31 +0100 Subject: [PATCH] [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 --- .../Subscription/SubscriptionLocator.js | 7 ++ .../src/Features/User/UserAuditLogHandler.js | 20 ++++++ .../web/app/src/models/UserAuditLogEntry.js | 1 + .../unit/src/User/UserAuditLogHandlerTests.js | 71 +++++++++++++------ ...030_user_audit_log_managed_group_index.mjs | 35 +++++++++ 5 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 tools/migrations/20251016092030_user_audit_log_managed_group_index.mjs diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js index cd3de5e44c..12d2cf1648 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js +++ b/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() }, diff --git a/services/web/app/src/Features/User/UserAuditLogHandler.js b/services/web/app/src/Features/User/UserAuditLogHandler.js index 87cd810161..0445363f01 100644 --- a/services/web/app/src/Features/User/UserAuditLogHandler.js +++ b/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, diff --git a/services/web/app/src/models/UserAuditLogEntry.js b/services/web/app/src/models/UserAuditLogEntry.js index c4355d26a5..f239adca2f 100644 --- a/services/web/app/src/models/UserAuditLogEntry.js +++ b/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 }, diff --git a/services/web/test/unit/src/User/UserAuditLogHandlerTests.js b/services/web/test/unit/src/User/UserAuditLogHandlerTests.js index 3bad884520..19f13c0b84 100644 --- a/services/web/test/unit/src/User/UserAuditLogHandlerTests.js +++ b/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 }) }) diff --git a/tools/migrations/20251016092030_user_audit_log_managed_group_index.mjs b/tools/migrations/20251016092030_user_audit_log_managed_group_index.mjs new file mode 100644 index 0000000000..e825a3a890 --- /dev/null +++ b/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, +}