From 1a044da08c59d1feeff2a99911c8cefd635596f4 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Wed, 11 Oct 2023 07:43:10 -0400 Subject: [PATCH] Merge pull request #15095 from overleaf/em-invite-audit-logs-3 Write audit logs when a user joins a project via token GitOrigin-RevId: 083fb7301ac2193c276a35bbf4dbf4f37d0ffa3b --- .../TokenAccess/TokenAccessController.js | 23 +++ .../TokenAccess/TokenAccessHandler.js | 2 + .../TokenAccess/TokenAccessControllerTests.js | 180 ++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessController.js b/services/web/app/src/Features/TokenAccess/TokenAccessController.js index cc8890321c..8b8b6409c8 100644 --- a/services/web/app/src/Features/TokenAccess/TokenAccessController.js +++ b/services/web/app/src/Features/TokenAccess/TokenAccessController.js @@ -11,6 +11,7 @@ const PrivilegeLevels = require('../Authorization/PrivilegeLevels') const { handleAdminDomainRedirect, } = require('../Authorization/AuthorizationMiddleware') +const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler') const orderedPrivilegeLevels = [ PrivilegeLevels.NONE, @@ -252,6 +253,17 @@ async function grantTokenAccessReadAndWrite(req, res, next) { }, }) } + + if (!project.tokenAccessReadAndWrite_refs.some(id => id.equals(userId))) { + await ProjectAuditLogHandler.promises.addEntry( + project._id, + 'join-via-token', + userId, + req.ip, + { privileges: 'readAndWrite' } + ) + } + await TokenAccessHandler.promises.addReadAndWriteUserToProject( userId, project._id @@ -306,6 +318,17 @@ async function grantTokenAccessReadOnly(req, res, next) { }, }) } + + if (!project.tokenAccessReadOnly_refs.some(id => id.equals(userId))) { + await ProjectAuditLogHandler.promises.addEntry( + project._id, + 'join-via-token', + userId, + req.ip, + { privileges: 'readOnly' } + ) + } + await TokenAccessHandler.promises.addReadOnlyUserToProject( userId, project._id diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js b/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js index 16ef4248f7..f51b5b46d8 100644 --- a/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js +++ b/services/web/app/src/Features/TokenAccess/TokenAccessHandler.js @@ -93,6 +93,8 @@ const TokenAccessHandler = { publicAccesLevel: 1, owner_ref: 1, name: 1, + tokenAccessReadOnly_refs: 1, + tokenAccessReadAndWrite_refs: 1, }, callback ) diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js b/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js new file mode 100644 index 0000000000..015550e956 --- /dev/null +++ b/services/web/test/unit/src/TokenAccess/TokenAccessControllerTests.js @@ -0,0 +1,180 @@ +const SandboxedModule = require('sandboxed-module') +const sinon = require('sinon') +const { expect } = require('chai') +const { ObjectId } = require('mongodb') +const MockRequest = require('../helpers/MockRequest') +const MockResponse = require('../helpers/MockResponse') +const PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels') + +const MODULE_PATH = + '../../../../app/src/Features/TokenAccess/TokenAccessController' + +describe('TokenAccessController', function () { + beforeEach(function () { + this.user = { _id: ObjectId() } + this.project = { + _id: ObjectId(), + tokenAccessReadAndWrite_refs: [], + tokenAccessReadOnly_refs: [], + } + this.req = new MockRequest() + this.res = new MockResponse() + + this.Settings = {} + this.TokenAccessHandler = { + TOKEN_TYPES: { + READ_ONLY: 'readOnly', + READ_AND_WRITE: 'readAndWrite', + }, + isReadAndWriteToken: sinon.stub().returns(true), + isReadOnlyToken: sinon.stub().returns(true), + tokenAccessEnabledForProject: sinon.stub().returns(true), + promises: { + addReadAndWriteUserToProject: sinon.stub().resolves(), + addReadOnlyUserToProject: sinon.stub().resolves(), + getProjectByToken: sinon.stub().resolves(this.project), + getV1DocPublishedInfo: sinon.stub().resolves({ allow: true }), + }, + } + + this.SessionManager = { + getLoggedInUserId: sinon.stub().returns(this.user._id), + } + + this.AuthenticationController = {} + + this.AuthorizationManager = { + promises: { + getPrivilegeLevelForProject: sinon + .stub() + .resolves(PrivilegeLevels.NONE), + }, + } + + this.AuthorizationMiddleware = {} + + this.ProjectAuditLogHandler = { + promises: { + addEntry: sinon.stub().resolves(), + }, + } + + this.TokenAccessController = SandboxedModule.require(MODULE_PATH, { + requires: { + '@overleaf/settings': this.Settings, + './TokenAccessHandler': this.TokenAccessHandler, + '../Authentication/AuthenticationController': + this.AuthenticationController, + '../Authentication/SessionManager': this.SessionManager, + '../Authorization/AuthorizationManager': this.AuthorizationManager, + '../Authorization/AuthorizationMiddleware': + this.AuthorizationMiddleware, + '../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler, + }, + }) + }) + + describe('grantTokenAccessReadAndWrite', function () { + describe('normal case', function () { + beforeEach(function (done) { + this.req.params = { token: this.token } + this.req.body = { confirmedByUser: true } + this.res.callback = done + this.TokenAccessController.grantTokenAccessReadAndWrite( + this.req, + this.res, + done + ) + }) + + it('grants read and write access', function () { + expect( + this.TokenAccessHandler.promises.addReadAndWriteUserToProject + ).to.have.been.calledWith(this.user._id, this.project._id) + }) + + it('writes a project audit log', function () { + expect( + this.ProjectAuditLogHandler.promises.addEntry + ).to.have.been.calledWith( + this.project._id, + 'join-via-token', + this.user._id, + this.req.ip, + { privileges: 'readAndWrite' } + ) + }) + }) + + describe('when the access was already granted', function () { + beforeEach(function (done) { + this.project.tokenAccessReadAndWrite_refs.push(this.user._id) + this.req.params = { token: this.token } + this.req.body = { confirmedByUser: true } + this.res.callback = done + this.TokenAccessController.grantTokenAccessReadAndWrite( + this.req, + this.res, + done + ) + }) + + it("doesn't write a project audit log", function () { + expect(this.ProjectAuditLogHandler.promises.addEntry).to.not.have.been + .called + }) + }) + }) + + describe('grantTokenAccessReadOnly', function () { + describe('normal case', function () { + beforeEach(function (done) { + this.req.params = { token: this.token } + this.req.body = { confirmedByUser: true } + this.res.callback = done + this.TokenAccessController.grantTokenAccessReadOnly( + this.req, + this.res, + done + ) + }) + + it('grants read-only access', function () { + expect( + this.TokenAccessHandler.promises.addReadOnlyUserToProject + ).to.have.been.calledWith(this.user._id, this.project._id) + }) + + it('writes a project audit log', function () { + expect( + this.ProjectAuditLogHandler.promises.addEntry + ).to.have.been.calledWith( + this.project._id, + 'join-via-token', + this.user._id, + this.req.ip, + { privileges: 'readOnly' } + ) + }) + }) + + describe('when the access was already granted', function () { + beforeEach(function (done) { + this.project.tokenAccessReadOnly_refs.push(this.user._id) + this.req.params = { token: this.token } + this.req.body = { confirmedByUser: true } + this.res.callback = done + this.TokenAccessController.grantTokenAccessReadOnly( + this.req, + this.res, + done + ) + }) + + it("doesn't write a project audit log", function () { + expect(this.ProjectAuditLogHandler.promises.addEntry).to.not.have.been + .called + }) + }) + }) +})