[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 logger from '@overleaf/logger'
|
||||||
import { ProjectAuditLogEntry } from '../../models/ProjectAuditLogEntry.js'
|
import { ProjectAuditLogEntry } from '../../models/ProjectAuditLogEntry.js'
|
||||||
import { callbackify } from '@overleaf/promise-utils'
|
import { callbackify } from '@overleaf/promise-utils'
|
||||||
|
import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
|
||||||
|
|
||||||
|
const MANAGED_GROUP_PROJECT_EVENTS = ['accept-invite', 'project-created']
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
promises: {
|
promises: {
|
||||||
addEntry,
|
addEntry,
|
||||||
|
addEntryIfManaged,
|
||||||
},
|
},
|
||||||
addEntry: callbackify(addEntry), // callback version of addEntry
|
addEntry: callbackify(addEntry),
|
||||||
|
addEntryIfManaged: callbackify(addEntryIfManaged),
|
||||||
addEntryInBackground,
|
addEntryInBackground,
|
||||||
|
addEntryIfManagedInBackground,
|
||||||
|
MANAGED_GROUP_PROJECT_EVENTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +40,48 @@ async function addEntry(
|
|||||||
ipAddress,
|
ipAddress,
|
||||||
info,
|
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)
|
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))
|
: ProjectCreationHandler.promises.createBasicProject(userId, projectName))
|
||||||
|
|
||||||
|
ProjectAuditLogHandler.addEntryIfManagedInBackground(
|
||||||
|
project._id,
|
||||||
|
'project-created',
|
||||||
|
project.owner_ref,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
project_id: project._id,
|
project_id: project._id,
|
||||||
owner_ref: project.owner_ref,
|
owner_ref: project.owner_ref,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { Schema } = mongoose
|
|||||||
const ProjectAuditLogEntrySchema = new Schema(
|
const ProjectAuditLogEntrySchema = new Schema(
|
||||||
{
|
{
|
||||||
projectId: { type: Schema.Types.ObjectId, index: true },
|
projectId: { type: Schema.Types.ObjectId, index: true },
|
||||||
|
managedSubscriptionId: { type: Schema.Types.ObjectId, index: true },
|
||||||
operation: { type: String },
|
operation: { type: String },
|
||||||
initiatorId: { type: Schema.Types.ObjectId },
|
initiatorId: { type: Schema.Types.ObjectId },
|
||||||
ipAddress: { type: String },
|
ipAddress: { type: String },
|
||||||
|
|||||||
@@ -423,6 +423,7 @@
|
|||||||
"demonstrating_track_changes_feature": "",
|
"demonstrating_track_changes_feature": "",
|
||||||
"department": "",
|
"department": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"details": "",
|
||||||
"details_provided_by_google_explanation": "",
|
"details_provided_by_google_explanation": "",
|
||||||
"dictionary": "",
|
"dictionary": "",
|
||||||
"did_you_know_institution_providing_professional": "",
|
"did_you_know_institution_providing_professional": "",
|
||||||
@@ -859,7 +860,6 @@
|
|||||||
"increase_indent": "",
|
"increase_indent": "",
|
||||||
"increased_compile_timeout": "",
|
"increased_compile_timeout": "",
|
||||||
"info": "",
|
"info": "",
|
||||||
"initiator": "",
|
|
||||||
"inline": "",
|
"inline": "",
|
||||||
"inline_math": "",
|
"inline_math": "",
|
||||||
"inr_discount_modal_info": "",
|
"inr_discount_modal_info": "",
|
||||||
|
|||||||
@@ -545,6 +545,7 @@
|
|||||||
"department": "Department",
|
"department": "Department",
|
||||||
"descending": "Descending",
|
"descending": "Descending",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
"details": "Details",
|
||||||
"details_provided_by_google_explanation": "Your details were provided by your Google account. Please check you’re happy with them.",
|
"details_provided_by_google_explanation": "Your details were provided by your Google account. Please check you’re happy with them.",
|
||||||
"dictionary": "Dictionary",
|
"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__?",
|
"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.groupPolicy = options.groupPolicy
|
||||||
this.addOns = options.addOns
|
this.addOns = options.addOns
|
||||||
this.paymentProvider = options.paymentProvider
|
this.paymentProvider = options.paymentProvider
|
||||||
|
this.managedUsersEnabled = options.managedUsersEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureExists() {
|
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, {}),
|
getSurvey: sinon.stub().yields(null, {}),
|
||||||
}
|
}
|
||||||
ctx.ProjectAuditLogHandler = {
|
ctx.ProjectAuditLogHandler = {
|
||||||
|
addEntryIfManagedInBackground: sinon.stub().resolves(),
|
||||||
promises: {
|
promises: {
|
||||||
addEntry: sinon.stub().resolves(),
|
addEntry: sinon.stub().resolves(),
|
||||||
},
|
},
|
||||||
@@ -754,6 +755,18 @@ describe('ProjectController', function () {
|
|||||||
ctx.ProjectController.newProject(ctx.req, ctx.res)
|
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 () {
|
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