Răsfoiți Sursa

[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
Miguel Serrano 9 luni în urmă
părinte
comite
fe884195dc

+ 67 - 1
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'
+      )
+    }
+  )
+}

+ 7 - 0
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,

+ 1 - 0
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 },

+ 1 - 1
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": "",

+ 1 - 0
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</0> to everyone at __institutionName__?",

+ 1 - 0
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() {

+ 146 - 0
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
+      })
+    })
+  })
+})

+ 13 - 0
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 () {

+ 35 - 0
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,
+}