mirror of
https://github.com/overleaf/overleaf.git
synced 2025-12-05 01:10:29 +00:00
[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:
@@ -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'
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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__?",
|
||||
|
||||
@@ -26,6 +26,7 @@ class PromisifiedSubscription {
|
||||
this.groupPolicy = options.groupPolicy
|
||||
this.addOns = options.addOns
|
||||
this.paymentProvider = options.paymentProvider
|
||||
this.managedUsersEnabled = options.managedUsersEnabled
|
||||
}
|
||||
|
||||
async ensureExists() {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user