From fe884195dc2fd65c26d3362326ddea6d6a924bb8 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Tue, 11 Nov 2025 11:31:55 +0100 Subject: [PATCH] [web] Add Project logs to Group Audit Logs view (#29456) * Add `project-created` audit log only for managed users * Include project audit logs in group audit logs * Added details column in Group Audit Logs UI GitOrigin-RevId: 96c7a31b37270912df1629e27d905b692f28da46 --- .../Project/ProjectAuditLogHandler.mjs | 68 +++++++- .../Features/Project/ProjectController.mjs | 7 + .../app/src/models/ProjectAuditLogEntry.js | 1 + .../web/frontend/extracted-translations.json | 2 +- services/web/locales/en.json | 1 + .../acceptance/src/helpers/Subscription.mjs | 1 + .../Project/ProjectAuditLogHandler.test.mjs | 146 ++++++++++++++++++ .../src/Project/ProjectController.test.mjs | 13 ++ ..._project_audit_log_managed_group_index.mjs | 35 +++++ 9 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 services/web/test/unit/src/Project/ProjectAuditLogHandler.test.mjs create mode 100644 tools/migrations/20251027131430_project_audit_log_managed_group_index.mjs diff --git a/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs b/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs index 909e0fd8e1..8a594a7801 100644 --- a/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs @@ -1,13 +1,20 @@ import logger from '@overleaf/logger' import { ProjectAuditLogEntry } from '../../models/ProjectAuditLogEntry.js' import { callbackify } from '@overleaf/promise-utils' +import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs' + +const MANAGED_GROUP_PROJECT_EVENTS = ['accept-invite', 'project-created'] export default { promises: { addEntry, + addEntryIfManaged, }, - addEntry: callbackify(addEntry), // callback version of addEntry + addEntry: callbackify(addEntry), + addEntryIfManaged: callbackify(addEntryIfManaged), addEntryInBackground, + addEntryIfManagedInBackground, + MANAGED_GROUP_PROJECT_EVENTS, } /** @@ -33,6 +40,48 @@ async function addEntry( ipAddress, info, } + + if (MANAGED_GROUP_PROJECT_EVENTS.includes(operation)) { + const managedSubscription = + await SubscriptionLocator.promises.getUniqueManagedSubscriptionMemberOf( + info.userId || initiatorId + ) + + if (managedSubscription) { + entry.managedSubscriptionId = managedSubscription._id + } + } + await ProjectAuditLogEntry.create(entry) +} + +async function addEntryIfManaged( + projectId, + operation, + initiatorId, + ipAddress, + info = {} +) { + if (!MANAGED_GROUP_PROJECT_EVENTS.includes(operation)) { + return + } + + const managedSubscription = + await SubscriptionLocator.promises.getUniqueManagedSubscriptionMemberOf( + info.userId || initiatorId + ) + if (!managedSubscription) { + return + } + + const entry = { + projectId, + operation, + initiatorId, + ipAddress, + info, + managedSubscriptionId: managedSubscription._id, + } + await ProjectAuditLogEntry.create(entry) } @@ -55,3 +104,20 @@ function addEntryInBackground( ) }) } + +function addEntryIfManagedInBackground( + projectId, + operation, + initiatorId, + ipAddress, + info = {} +) { + addEntryIfManaged(projectId, operation, initiatorId, ipAddress, info).catch( + err => { + logger.error( + { err, projectId, operation, initiatorId, ipAddress, info }, + 'Failed to write audit log' + ) + } + ) +} diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index bcd7c11e5e..5d89b2c850 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -275,6 +275,13 @@ const _ProjectController = { ) : ProjectCreationHandler.promises.createBasicProject(userId, projectName)) + ProjectAuditLogHandler.addEntryIfManagedInBackground( + project._id, + 'project-created', + project.owner_ref, + req.ip + ) + res.json({ project_id: project._id, owner_ref: project.owner_ref, diff --git a/services/web/app/src/models/ProjectAuditLogEntry.js b/services/web/app/src/models/ProjectAuditLogEntry.js index fa13dbc19b..fb4a950a40 100644 --- a/services/web/app/src/models/ProjectAuditLogEntry.js +++ b/services/web/app/src/models/ProjectAuditLogEntry.js @@ -4,6 +4,7 @@ const { Schema } = mongoose const ProjectAuditLogEntrySchema = new Schema( { projectId: { type: Schema.Types.ObjectId, index: true }, + managedSubscriptionId: { type: Schema.Types.ObjectId, index: true }, operation: { type: String }, initiatorId: { type: Schema.Types.ObjectId }, ipAddress: { type: String }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 964bb22f60..77777de1a3 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -423,6 +423,7 @@ "demonstrating_track_changes_feature": "", "department": "", "description": "", + "details": "", "details_provided_by_google_explanation": "", "dictionary": "", "did_you_know_institution_providing_professional": "", @@ -859,7 +860,6 @@ "increase_indent": "", "increased_compile_timeout": "", "info": "", - "initiator": "", "inline": "", "inline_math": "", "inr_discount_modal_info": "", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index e2bc0fdc38..4aa75b8955 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -545,6 +545,7 @@ "department": "Department", "descending": "Descending", "description": "Description", + "details": "Details", "details_provided_by_google_explanation": "Your details were provided by your Google account. Please check you’re happy with them.", "dictionary": "Dictionary", "did_you_know_institution_providing_professional": "Did you know that __institutionName__ is providing <0>free __appName__ Professional features to everyone at __institutionName__?", diff --git a/services/web/test/acceptance/src/helpers/Subscription.mjs b/services/web/test/acceptance/src/helpers/Subscription.mjs index 7671056b8b..eec39c3bf6 100644 --- a/services/web/test/acceptance/src/helpers/Subscription.mjs +++ b/services/web/test/acceptance/src/helpers/Subscription.mjs @@ -26,6 +26,7 @@ class PromisifiedSubscription { this.groupPolicy = options.groupPolicy this.addOns = options.addOns this.paymentProvider = options.paymentProvider + this.managedUsersEnabled = options.managedUsersEnabled } async ensureExists() { diff --git a/services/web/test/unit/src/Project/ProjectAuditLogHandler.test.mjs b/services/web/test/unit/src/Project/ProjectAuditLogHandler.test.mjs new file mode 100644 index 0000000000..39702ff971 --- /dev/null +++ b/services/web/test/unit/src/Project/ProjectAuditLogHandler.test.mjs @@ -0,0 +1,146 @@ +import { vi, expect } from 'vitest' +import mongodb from 'mongodb-legacy' +import sinon from 'sinon' +const modulePath = + '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs' + +const { ObjectId } = mongodb + +const projectId = new ObjectId() +const userId = new ObjectId() +const subscriptionId = new ObjectId() + +describe('ProjectAuditLogHandler', function (ctx) { + beforeEach(async function (ctx) { + ctx.createEntryMock = sinon.stub().resolves() + vi.doMock('../../../../app/src/models/ProjectAuditLogEntry', () => ({ + ProjectAuditLogEntry: { + create: ctx.createEntryMock, + }, + })) + + ctx.getUniqueManagedSubscriptionMemberOfMock = sinon.stub().resolves() + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator.mjs', + () => ({ + default: { + promises: { + getUniqueManagedSubscriptionMemberOf: + ctx.getUniqueManagedSubscriptionMemberOfMock, + }, + }, + }) + ) + + ctx.ProjectAuditLogHandler = (await import(modulePath)).default + }) + + describe('addEntry', function () { + it('creates an entry in the database', async function (ctx) { + await ctx.ProjectAuditLogHandler.promises.addEntry( + projectId, + 'project-op', + userId, + '0:0:0:0' + ) + expect(ctx.createEntryMock).to.have.been.calledOnceWith({ + operation: 'project-op', + projectId, + initiatorId: userId, + ipAddress: '0:0:0:0', + info: {}, + }) + }) + + it('does not include managedSubscriptionId when the user is not managed ', async function (ctx) { + await ctx.ProjectAuditLogHandler.promises.addEntry( + projectId, + 'accept-invite', // this event logs managedSubscriptionId when available + userId, + '0:0:0:0' + ) + expect(ctx.createEntryMock).not.to.have.been.calledWithMatch({ + managedSubscriptionId: subscriptionId, + }) + }) + + it('includes managedSubscriptionId when the user is managed ', async function (ctx) { + ctx.getUniqueManagedSubscriptionMemberOfMock.resolves({ + _id: subscriptionId, + }) + await ctx.ProjectAuditLogHandler.promises.addEntry( + projectId, + 'accept-invite', // this event logs managedSubscriptionId when available + userId, + '0:0:0:0' + ) + expect(ctx.createEntryMock).to.have.been.calledWithMatch({ + managedSubscriptionId: subscriptionId, + }) + }) + + it('does not include managedSubscriptionId when the user is managed, but the event is not of managed group interest', async function (ctx) { + ctx.getUniqueManagedSubscriptionMemberOfMock.resolves({ + _id: subscriptionId, + }) + await ctx.ProjectAuditLogHandler.promises.addEntry( + projectId, + 'any-event', + userId, + '0:0:0:0' + ) + expect(ctx.createEntryMock).not.to.have.been.calledWithMatch({ + managedSubscriptionId: subscriptionId, + }) + }) + }) + + describe('addEntryIfManaged', function () { + describe('when the user is managed', function () { + beforeEach(function (ctx) { + ctx.getUniqueManagedSubscriptionMemberOfMock.resolves({ + _id: subscriptionId, + }) + }) + + it('adds an entry in the DB if the event is of interest of managed groups ', async function (ctx) { + await ctx.ProjectAuditLogHandler.promises.addEntryIfManaged( + projectId, + 'accept-invite', // this event logs managedSubscriptionId when available + userId, + '0:0:0:0' + ) + expect(ctx.createEntryMock).to.have.been.calledOnceWith({ + operation: 'accept-invite', + projectId, + initiatorId: userId, + ipAddress: '0:0:0:0', + info: {}, + managedSubscriptionId: subscriptionId, + }) + }) + + it('does not add an entry in the DB when the event is not of interest of managed groups ', async function (ctx) { + await ctx.ProjectAuditLogHandler.promises.addEntryIfManaged( + projectId, + 'foo', + userId, + '0:0:0:0' + ) + expect(ctx.createEntryMock).not.to.have.been.called + }) + }) + + describe('when the user is not managed', function () { + it('does not add an entry in the DB ', async function (ctx) { + await ctx.ProjectAuditLogHandler.promises.addEntryIfManaged( + projectId, + 'accept-invite', // this event logs managedSubscriptionId when available + userId, + '0:0:0:0' + ) + expect(ctx.createEntryMock).not.to.have.been.called + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Project/ProjectController.test.mjs b/services/web/test/unit/src/Project/ProjectController.test.mjs index 553b45bb58..8d66b039e3 100644 --- a/services/web/test/unit/src/Project/ProjectController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectController.test.mjs @@ -215,6 +215,7 @@ describe('ProjectController', function () { getSurvey: sinon.stub().yields(null, {}), } ctx.ProjectAuditLogHandler = { + addEntryIfManagedInBackground: sinon.stub().resolves(), promises: { addEntry: sinon.stub().resolves(), }, @@ -754,6 +755,18 @@ describe('ProjectController', function () { ctx.ProjectController.newProject(ctx.req, ctx.res) }) }) + + it('adds project audit log for managed for managed users', async function (ctx) { + await new Promise(resolve => { + ctx.req.body.template = 'basic' + ctx.res.json = () => { + expect(ctx.ProjectAuditLogHandler.addEntryIfManagedInBackground).to + .have.been.called + resolve() + } + ctx.ProjectController.newProject(ctx.req, ctx.res) + }) + }) }) describe('renameProject', function () { diff --git a/tools/migrations/20251027131430_project_audit_log_managed_group_index.mjs b/tools/migrations/20251027131430_project_audit_log_managed_group_index.mjs new file mode 100644 index 0000000000..8e5d4dffc8 --- /dev/null +++ b/tools/migrations/20251027131430_project_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.projectAuditLogEntries, indexes) +} + +const rollback = async client => { + const { db } = client + try { + await Helpers.dropIndexesFromCollection(db.projectAuditLogEntries, indexes) + } catch (err) { + console.error('Something went wrong rolling back the migrations', err) + } +} + +export default { + tags, + migrate, + rollback, +}