Add serverside checks for changing the user access level after link sharing changes (#19168)

* Add getEditInviteCount to count only edit collaborators

* Add getInvitedEditCollaboratorCount to count joined editors

* Add canAddXEditCollaborators to determine if owner can add more editors

* Update setCollaboratorInfo to check if editor slots are available

GitOrigin-RevId: a88707f102dfbde39322f5a7bbc79d47b6e810d5
This commit is contained in:
Thomas 2024-06-28 14:05:38 +02:00 committed by Copybot
parent 2ce71b0b4d
commit a047388b08
8 changed files with 389 additions and 3 deletions

View file

@ -13,6 +13,10 @@ const { expressify } = require('@overleaf/promise-utils')
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
const TokenAccessHandler = require('../TokenAccess/TokenAccessHandler') const TokenAccessHandler = require('../TokenAccess/TokenAccessHandler')
const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler') const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
const ProjectGetter = require('../Project/ProjectGetter')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const LimitationsManager = require('../Subscription/LimitationsManager')
const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
module.exports = { module.exports = {
removeUserFromProject: expressify(removeUserFromProject), removeUserFromProject: expressify(removeUserFromProject),
@ -75,6 +79,28 @@ async function setCollaboratorInfo(req, res, next) {
const projectId = req.params.Project_id const projectId = req.params.Project_id
const userId = req.params.user_id const userId = req.params.user_id
const { privilegeLevel } = req.body const { privilegeLevel } = req.body
if (privilegeLevel !== PrivilegeLevels.READ_ONLY) {
const project = await ProjectGetter.promises.getProject(projectId, {
owner_ref: 1,
})
const linkSharingChanges =
await SplitTestHandler.promises.getAssignmentForUser(
project.owner_ref,
'link-sharing-warning'
)
const allowed =
await LimitationsManager.promises.canAddXEditCollaborators(projectId, 1)
if (linkSharingChanges?.variant === 'active') {
if (!allowed) {
return HttpErrorHandler.forbidden(
req,
res,
'edit collaborator limit reached'
)
}
}
}
await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel( await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
projectId, projectId,
userId, userId,

View file

@ -39,6 +39,7 @@ module.exports = {
getInvitedMembersWithPrivilegeLevelsFromFields, getInvitedMembersWithPrivilegeLevelsFromFields,
getMemberIdPrivilegeLevel, getMemberIdPrivilegeLevel,
getInvitedCollaboratorCount, getInvitedCollaboratorCount,
getInvitedEditCollaboratorCount,
getProjectsUserIsMemberOf, getProjectsUserIsMemberOf,
dangerouslyGetAllProjectsUserIsMemberOf, dangerouslyGetAllProjectsUserIsMemberOf,
isUserInvitedMemberOfProject, isUserInvitedMemberOfProject,
@ -125,6 +126,16 @@ async function getInvitedCollaboratorCount(projectId) {
return count - 1 // Don't count project owner return count - 1 // Don't count project owner
} }
async function getInvitedEditCollaboratorCount(projectId) {
// Only counts invited members with readAndWrite privilege
const members = await getMemberIdsWithPrivilegeLevels(projectId)
return members.filter(
m =>
m.source === Sources.INVITE &&
m.privilegeLevel === PrivilegeLevels.READ_AND_WRITE
).length
}
async function isUserInvitedMemberOfProject(userId, projectId) { async function isUserInvitedMemberOfProject(userId, projectId) {
if (!userId) { if (!userId) {
return false return false

View file

@ -8,6 +8,7 @@ const UserGetter = require('../User/UserGetter')
const ProjectGetter = require('../Project/ProjectGetter') const ProjectGetter = require('../Project/ProjectGetter')
const Crypto = require('crypto') const Crypto = require('crypto')
const NotificationsBuilder = require('../Notifications/NotificationsBuilder') const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
const randomBytes = promisify(Crypto.randomBytes) const randomBytes = promisify(Crypto.randomBytes)
@ -28,6 +29,15 @@ const CollaboratorsInviteHandler = {
return count return count
}, },
async getEditInviteCount(projectId) {
logger.debug({ projectId }, 'counting edit invites for project')
const count = await ProjectInvite.countDocuments({
projectId,
privileges: { $ne: PrivilegeLevels.READ_ONLY },
}).exec()
return count
},
async _trySendInviteNotification(projectId, sendingUser, invite) { async _trySendInviteNotification(projectId, sendingUser, invite) {
const { email } = invite const { email } = invite
const existingUser = await UserGetter.promises.getUserByAnyEmail(email, { const existingUser = await UserGetter.promises.getUserByAnyEmail(email, {

View file

@ -36,7 +36,24 @@ async function canAddXCollaborators(projectId, numberOfNewCollaborators) {
await CollaboratorsInvitesHandler.promises.getInviteCount(projectId) await CollaboratorsInvitesHandler.promises.getInviteCount(projectId)
return ( return (
currentNumber + inviteCount + numberOfNewCollaborators <= allowedNumber || currentNumber + inviteCount + numberOfNewCollaborators <= allowedNumber ||
allowedNumber < 0 allowedNumber < 0 // -1 means unlimited
)
}
async function canAddXEditCollaborators(
projectId,
numberOfNewEditCollaborators
) {
const allowedNumber = await allowedNumberOfCollaboratorsInProject(projectId)
const currentEditors =
await CollaboratorsGetter.promises.getInvitedEditCollaboratorCount(
projectId
)
const editInviteCount =
await CollaboratorsInvitesHandler.promises.getEditInviteCount(projectId)
return (
currentEditors + editInviteCount + numberOfNewEditCollaborators <=
allowedNumber || allowedNumber < 0 // -1 means unlimited
) )
} }
@ -130,6 +147,7 @@ const LimitationsManager = {
allowedNumberOfCollaboratorsForUser allowedNumberOfCollaboratorsForUser
), ),
canAddXCollaborators: callbackify(canAddXCollaborators), canAddXCollaborators: callbackify(canAddXCollaborators),
canAddXEditCollaborators: callbackify(canAddXEditCollaborators),
hasPaidSubscription: callbackifyMultiResult(hasPaidSubscription, [ hasPaidSubscription: callbackifyMultiResult(hasPaidSubscription, [
'hasPaidSubscription', 'hasPaidSubscription',
'subscription', 'subscription',
@ -159,6 +177,7 @@ const LimitationsManager = {
allowedNumberOfCollaboratorsInProject, allowedNumberOfCollaboratorsInProject,
allowedNumberOfCollaboratorsForUser, allowedNumberOfCollaboratorsForUser,
canAddXCollaborators, canAddXCollaborators,
canAddXEditCollaborators,
hasPaidSubscription, hasPaidSubscription,
userHasSubscriptionOrIsGroupMember, userHasSubscriptionOrIsGroupMember,
userHasV2Subscription, userHasV2Subscription,

View file

@ -59,6 +59,24 @@ describe('CollaboratorsController', function () {
addEntryInBackground: sinon.stub(), addEntryInBackground: sinon.stub(),
} }
this.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves({ owner_ref: this.user._id }),
},
}
this.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
},
}
this.LimitationsManager = {
promises: {
canAddXEditCollaborators: sinon.stub().resolves(),
},
}
this.CollaboratorsController = SandboxedModule.require(MODULE_PATH, { this.CollaboratorsController = SandboxedModule.require(MODULE_PATH, {
requires: { requires: {
mongodb: { ObjectId }, mongodb: { ObjectId },
@ -71,6 +89,9 @@ describe('CollaboratorsController', function () {
'../Authentication/SessionManager': this.SessionManager, '../Authentication/SessionManager': this.SessionManager,
'../TokenAccess/TokenAccessHandler': this.TokenAccessHandler, '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
'../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler, '../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler,
'../Project/ProjectGetter': this.ProjectGetter,
'../SplitTests/SplitTestHandler': this.SplitTestHandler,
'../Subscription/LimitationsManager': this.LimitationsManager,
}, },
}) })
}) })
@ -271,6 +292,93 @@ describe('CollaboratorsController', function () {
this.next this.next
) )
}) })
describe('when link-sharing-warning test active', function () {
beforeEach(function () {
this.SplitTestHandler.promises.getAssignmentForUser.resolves({
variant: 'active',
})
})
describe('when setting privilege level to readAndWrite', function () {
beforeEach(function () {
this.req.body = { privilegeLevel: 'readAndWrite' }
})
describe('when owner can add new edit collaborators', function () {
beforeEach(function () {
this.LimitationsManager.promises.canAddXEditCollaborators.resolves(
true
)
})
it('should set privilege level after checking collaborators can be added', function (done) {
this.res.sendStatus = status => {
expect(status).to.equal(204)
expect(
this.LimitationsManager.promises.canAddXEditCollaborators
).to.have.been.calledWith(this.projectId, 1)
done()
}
this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
})
})
describe('when owner cannot add edit collaborators', function () {
beforeEach(function () {
this.LimitationsManager.promises.canAddXEditCollaborators.resolves(
false
)
})
it('should return a 403 if trying to set a new edit collaborator', function (done) {
this.HttpErrorHandler.forbidden = sinon.spy((req, res) => {
expect(req).to.equal(this.req)
expect(res).to.equal(this.res)
expect(
this.LimitationsManager.promises.canAddXEditCollaborators
).to.have.been.calledWith(this.projectId, 1)
expect(
this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
).to.not.have.been.called
done()
})
this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
})
})
})
describe('when setting privilege level to readOnly', function () {
beforeEach(function () {
this.req.body = { privilegeLevel: 'readOnly' }
})
describe('when owner cannot add edit collaborators', function () {
beforeEach(function () {
this.LimitationsManager.promises.canAddXEditCollaborators.resolves(
false
)
})
it('should always allow setting a collaborator to viewer even if user cant add edit collaborators', function (done) {
this.res.sendStatus = status => {
expect(status).to.equal(204)
expect(this.LimitationsManager.promises.canAddXEditCollaborators)
.to.not.have.been.called
expect(
this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
).to.have.been.calledWith(
this.projectId,
this.user._id,
'readOnly'
)
done()
}
this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
})
})
})
})
}) })
describe('transferOwnership', function () { describe('transferOwnership', function () {

View file

@ -474,4 +474,14 @@ describe('CollaboratorsGetter', function () {
}) })
}) })
}) })
describe('getInvitedEditCollaboratorCount', function () {
it('should return the count of invited edit collaborators (token, readAndWrite)', async function () {
const count =
await this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount(
this.project._id
)
expect(count).to.equal(2)
})
})
}) })

View file

@ -117,6 +117,40 @@ describe('CollaboratorsInviteHandler', function () {
}) })
}) })
describe('getEditInviteCount', function () {
beforeEach(function () {
this.ProjectInvite.countDocuments.returns({
exec: sinon.stub().resolves(2),
})
this.call = async () => {
return await this.CollaboratorsInviteHandler.promises.getEditInviteCount(
this.projectId
)
}
})
it('should produce the count of documents', async function () {
const count = await this.call()
expect(this.ProjectInvite.countDocuments).to.be.calledWith({
projectId: this.projectId,
privileges: { $ne: 'readOnly' },
})
expect(count).to.equal(2)
})
describe('when model.countDocuments produces an error', function () {
beforeEach(function () {
this.ProjectInvite.countDocuments.returns({
exec: sinon.stub().rejects(new Error('woops')),
})
})
it('should produce an error', async function () {
await expect(this.call()).to.be.rejectedWith(Error)
})
})
})
describe('getAllInvites', function () { describe('getAllInvites', function () {
beforeEach(function () { beforeEach(function () {
this.fakeInvites = [ this.fakeInvites = [

View file

@ -54,11 +54,17 @@ describe('LimitationsManager', function () {
} }
this.CollaboratorsGetter = { this.CollaboratorsGetter = {
promises: { getInvitedCollaboratorCount: sinon.stub().resolves() }, promises: {
getInvitedCollaboratorCount: sinon.stub().resolves(),
getInvitedEditCollaboratorCount: sinon.stub().resolves(),
},
} }
this.CollaboratorsInviteHandler = { this.CollaboratorsInviteHandler = {
promises: { getInviteCount: sinon.stub().resolves() }, promises: {
getInviteCount: sinon.stub().resolves(),
getEditInviteCount: sinon.stub().resolves(),
},
} }
this.LimitationsManager = SandboxedModule.require(modulePath, { this.LimitationsManager = SandboxedModule.require(modulePath, {
@ -320,6 +326,168 @@ describe('LimitationsManager', function () {
}) })
}) })
}) })
describe('canAddXEditCollaborators', function () {
describe('when the project has fewer collaborators than allowed', function () {
beforeEach(function (done) {
this.current_number = 1
this.user.features.collaborators = 2
this.invite_count = 0
this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount =
sinon.stub().resolves(this.current_number)
this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon
.stub()
.resolves(this.invite_count)
this.callback = sinon.stub().callsFake(() => done())
this.LimitationsManager.canAddXEditCollaborators(
this.projectId,
1,
this.callback
)
})
it('should return true', function () {
this.callback.calledWith(null, true).should.equal(true)
})
})
describe('when the project has fewer collaborators and invites than allowed', function () {
beforeEach(function (done) {
this.current_number = 1
this.user.features.collaborators = 4
this.invite_count = 1
this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount =
sinon.stub().resolves(this.current_number)
this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon
.stub()
.resolves(this.invite_count)
this.callback = sinon.stub().callsFake(() => done())
this.LimitationsManager.canAddXEditCollaborators(
this.projectId,
1,
this.callback
)
})
it('should return true', function () {
this.callback.calledWith(null, true).should.equal(true)
})
})
describe('when the project has fewer collaborators than allowed but I want to add more than allowed', function () {
beforeEach(function (done) {
this.current_number = 1
this.user.features.collaborators = 2
this.invite_count = 0
this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount =
sinon.stub().resolves(this.current_number)
this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon
.stub()
.resolves(this.invite_count)
this.callback = sinon.stub().callsFake(() => done())
this.LimitationsManager.canAddXEditCollaborators(
this.projectId,
2,
this.callback
)
})
it('should return false', function () {
this.callback.calledWith(null, false).should.equal(true)
})
})
describe('when the project has more collaborators than allowed', function () {
beforeEach(function (done) {
this.current_number = 3
this.user.features.collaborators = 2
this.invite_count = 0
this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount =
sinon.stub().resolves(this.current_number)
this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon
.stub()
.resolves(this.invite_count)
this.callback = sinon.stub().callsFake(() => done())
this.LimitationsManager.canAddXEditCollaborators(
this.projectId,
1,
this.callback
)
})
it('should return false', function () {
this.callback.calledWith(null, false).should.equal(true)
})
})
describe('when the project has infinite collaborators', function () {
beforeEach(function (done) {
this.current_number = 100
this.user.features.collaborators = -1
this.invite_count = 0
this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount =
sinon.stub().resolves(this.current_number)
this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon
.stub()
.resolves(this.invite_count)
this.callback = sinon.stub().callsFake(() => done())
this.LimitationsManager.canAddXEditCollaborators(
this.projectId,
1,
this.callback
)
})
it('should return true', function () {
this.callback.calledWith(null, true).should.equal(true)
})
})
describe('when the project has more invites than allowed', function () {
beforeEach(function (done) {
this.current_number = 0
this.user.features.collaborators = 2
this.invite_count = 2
this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount =
sinon.stub().resolves(this.current_number)
this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon
.stub()
.resolves(this.invite_count)
this.callback = sinon.stub().callsFake(() => done())
this.LimitationsManager.canAddXEditCollaborators(
this.projectId,
1,
this.callback
)
})
it('should return false', function () {
this.callback.calledWith(null, false).should.equal(true)
})
})
describe('when the project has more invites and collaborators than allowed', function () {
beforeEach(function (done) {
this.current_number = 1
this.user.features.collaborators = 2
this.invite_count = 1
this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount =
sinon.stub().resolves(this.current_number)
this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon
.stub()
.resolves(this.invite_count)
this.callback = sinon.stub().callsFake(() => done())
this.LimitationsManager.canAddXEditCollaborators(
this.projectId,
1,
this.callback
)
})
it('should return false', function () {
this.callback.calledWith(null, false).should.equal(true)
})
})
})
describe('userHasV2Subscription', function () { describe('userHasV2Subscription', function () {
beforeEach(function () { beforeEach(function () {