[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
This commit is contained in:
Miguel Serrano
2025-11-11 11:31:55 +01:00
committed by Copybot
parent 43c1ad2b5a
commit fe884195dc
9 changed files with 272 additions and 2 deletions

View File

@@ -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'
)
}
)
}

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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": "",

View File

@@ -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 youre 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__?",

View File

@@ -26,6 +26,7 @@ class PromisifiedSubscription {
this.groupPolicy = options.groupPolicy
this.addOns = options.addOns
this.paymentProvider = options.paymentProvider
this.managedUsersEnabled = options.managedUsersEnabled
}
async ensureExists() {

View File

@@ -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
})
})
})
})

View File

@@ -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 () {

View File

@@ -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,
}