Merge pull request #20036 from overleaf/tm-collab-limit-link-sharing

Enforce collaborator limit for link sharing

GitOrigin-RevId: b724dca0c616ef15e5bd6d07e9d898d34dd46acd
This commit is contained in:
Thomas 2024-08-22 12:49:35 +02:00 committed by Copybot
parent 98a914bb94
commit abf04c5d6c
4 changed files with 343 additions and 12 deletions

View file

@ -230,7 +230,8 @@ async function transferProjects(fromUserId, toUserId) {
async function setCollaboratorPrivilegeLevel( async function setCollaboratorPrivilegeLevel(
projectId, projectId,
userId, userId,
privilegeLevel privilegeLevel,
{ pendingEditor } = {}
) { ) {
// Make sure we're only updating the project if the user is already a // Make sure we're only updating the project if the user is already a
// collaborator // collaborator
@ -249,9 +250,14 @@ async function setCollaboratorPrivilegeLevel(
} }
case PrivilegeLevels.READ_ONLY: { case PrivilegeLevels.READ_ONLY: {
update = { update = {
$pull: { collaberator_refs: userId, pendingEditor_refs: userId }, $pull: { collaberator_refs: userId },
$addToSet: { readOnly_refs: userId }, $addToSet: { readOnly_refs: userId },
} }
if (pendingEditor) {
update.$addToSet.pendingEditor_refs = userId
} else {
update.$pull.pendingEditor_refs = userId
}
break break
} }
default: { default: {

View file

@ -22,6 +22,7 @@ const {
const { getSafeAdminDomainRedirect } = require('../Helpers/UrlHelper') const { getSafeAdminDomainRedirect } = require('../Helpers/UrlHelper')
const UserGetter = require('../User/UserGetter') const UserGetter = require('../User/UserGetter')
const Settings = require('@overleaf/settings') const Settings = require('@overleaf/settings')
const LimitationsManager = require('../Subscription/LimitationsManager')
const orderedPrivilegeLevels = [ const orderedPrivilegeLevels = [
PrivilegeLevels.NONE, PrivilegeLevels.NONE,
@ -333,27 +334,43 @@ async function grantTokenAccessReadAndWrite(req, res, next) {
}) })
} }
const linkSharingEnforcement =
await SplitTestHandler.promises.getAssignmentForUser(
project.owner_ref,
'link-sharing-enforcement'
)
const pendingEditor =
linkSharingEnforcement?.variant === 'active' &&
!(await LimitationsManager.promises.canAcceptEditCollaboratorInvite(
project._id
))
await ProjectAuditLogHandler.promises.addEntry( await ProjectAuditLogHandler.promises.addEntry(
project._id, project._id,
'accept-via-link-sharing', 'accept-via-link-sharing',
userId, userId,
req.ip, req.ip,
{ privileges: 'readAndWrite' } {
privileges: pendingEditor ? 'readOnly' : 'readAndWrite',
...(pendingEditor && { pendingEditor: true }),
}
) )
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForUserInBackground(
userId, userId,
'project-joined', 'project-joined',
{ {
mode: 'read-write', mode: pendingEditor ? 'read-only' : 'read-write',
projectId: project._id.toString(), projectId: project._id.toString(),
...(pendingEditor && { pendingEditor: true }),
} }
) )
// Currently does not enforce the collaborator limit (warning phase)
await CollaboratorsHandler.promises.addUserIdToProject( await CollaboratorsHandler.promises.addUserIdToProject(
project._id, project._id,
undefined, undefined,
userId, userId,
PrivilegeLevels.READ_AND_WRITE pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
{ pendingEditor }
) )
// Does not remove any pending invite or the invite notification // Does not remove any pending invite or the invite notification
// Should be a noop if the user is already a member, // Should be a noop if the user is already a member,
@ -536,20 +553,36 @@ async function sharingUpdatesConsent(req, res, next) {
async function moveReadWriteToCollaborators(req, res, next) { async function moveReadWriteToCollaborators(req, res, next) {
const { Project_id: projectId } = req.params const { Project_id: projectId } = req.params
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const project = await ProjectGetter.promises.getProject(projectId, {
owner_ref: 1,
})
const isInvitedMember = const isInvitedMember =
await CollaboratorsGetter.promises.isUserInvitedMemberOfProject( await CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
userId, userId,
projectId projectId
) )
const linkSharingEnforcement =
await SplitTestHandler.promises.getAssignmentForUser(
project.owner_ref,
'link-sharing-enforcement'
)
const pendingEditor =
linkSharingEnforcement?.variant === 'active' &&
!(await LimitationsManager.promises.canAcceptEditCollaboratorInvite(
project._id
))
await ProjectAuditLogHandler.promises.addEntry( await ProjectAuditLogHandler.promises.addEntry(
projectId, projectId,
'accept-via-link-sharing', 'accept-via-link-sharing',
userId, userId,
req.ip, req.ip,
{ {
privileges: 'readAndWrite', privileges: pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
tokenMember: true, tokenMember: true,
invitedMember: isInvitedMember, invitedMember: isInvitedMember,
...(pendingEditor && { pendingEditor: true }),
} }
) )
if (isInvitedMember) { if (isInvitedMember) {
@ -561,7 +594,10 @@ async function moveReadWriteToCollaborators(req, res, next) {
await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel( await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
projectId, projectId,
userId, userId,
PrivilegeLevels.READ_AND_WRITE pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
{ pendingEditor }
) )
} else { } else {
// Normal case, not invited, joining via link sharing // Normal case, not invited, joining via link sharing
@ -573,7 +609,10 @@ async function moveReadWriteToCollaborators(req, res, next) {
projectId, projectId,
undefined, undefined,
userId, userId,
PrivilegeLevels.READ_AND_WRITE pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
{ pendingEditor }
) )
} }
EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {

View file

@ -592,6 +592,36 @@ describe('CollaboratorsHandler', function () {
) )
}) })
it('sets a collaborator to read-only as a pendingEditor', async function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.projectId,
$or: [
{ collaberator_refs: this.userId },
{ readOnly_refs: this.userId },
],
},
{
$addToSet: {
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
},
$pull: {
collaberator_refs: this.userId,
},
}
)
.chain('exec')
.resolves({ matchedCount: 1 })
await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
this.projectId,
this.userId,
'readOnly',
{ pendingEditor: true }
)
})
it('throws a NotFoundError if the project or collaborator does not exist', async function () { it('throws a NotFoundError if the project or collaborator does not exist', async function () {
this.ProjectMock.expects('updateOne') this.ProjectMock.expects('updateOne')
.chain('exec') .chain('exec')

View file

@ -123,6 +123,12 @@ describe('TokenAccessController', function () {
}, },
} }
this.LimitationsManager = {
promises: {
canAcceptEditCollaboratorInvite: sinon.stub().resolves(),
},
}
this.TokenAccessController = SandboxedModule.require(MODULE_PATH, { this.TokenAccessController = SandboxedModule.require(MODULE_PATH, {
requires: { requires: {
'@overleaf/settings': this.Settings, '@overleaf/settings': this.Settings,
@ -145,6 +151,7 @@ describe('TokenAccessController', function () {
}), }),
'../Analytics/AnalyticsManager': this.AnalyticsManager, '../Analytics/AnalyticsManager': this.AnalyticsManager,
'../User/UserGetter': this.UserGetter, '../User/UserGetter': this.UserGetter,
'../Subscription/LimitationsManager': this.LimitationsManager,
}, },
}) })
}) })
@ -195,9 +202,13 @@ describe('TokenAccessController', function () {
describe('when project owner in link-sharing-warning split test', function () { describe('when project owner in link-sharing-warning split test', function () {
beforeEach(function () { beforeEach(function () {
this.SplitTestHandler.promises.getAssignmentForUser.resolves({ this.SplitTestHandler.promises.getAssignmentForUser.callsFake(
variant: 'active', async (userId, test) => {
}) if (test === 'link-sharing-warning') {
return { variant: 'active' }
}
}
)
}) })
it('tells the ui to show the link-sharing-warning variant', async function () { it('tells the ui to show the link-sharing-warning variant', async function () {
@ -284,6 +295,168 @@ describe('TokenAccessController', function () {
) )
}) })
}) })
describe('when the project owner is in the link-sharing-enforcement split test', function () {
beforeEach(function () {
this.SplitTestHandler.promises.getAssignmentForUser.callsFake(
async (userId, test) => {
if (test === 'link-sharing-warning') {
return { variant: 'active' }
} else if (test === 'link-sharing-enforcement') {
return { variant: 'active' }
}
}
)
})
describe('normal case (edit slot available)', function () {
beforeEach(function (done) {
this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
true
)
this.req.params = { token: this.token }
this.req.body = {
confirmedByUser: true,
tokenHashPrefix: '#prefix',
}
this.res.callback = done
this.TokenAccessController.grantTokenAccessReadAndWrite(
this.req,
this.res,
done
)
})
it('adds the user as a read and write invited member', function () {
expect(
this.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
this.project._id,
undefined,
this.user._id,
PrivilegeLevels.READ_AND_WRITE
)
})
it('writes a project audit log', function () {
expect(
this.ProjectAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
this.project._id,
'accept-via-link-sharing',
this.user._id,
this.req.ip,
{ privileges: 'readAndWrite' }
)
})
it('records a project-joined event for the user', function () {
expect(
this.AnalyticsManager.recordEventForUserInBackground
).to.have.been.calledWith(this.user._id, 'project-joined', {
mode: 'read-write',
projectId: this.project._id.toString(),
})
})
it('emits a project membership changed event', function () {
expect(
this.EditorRealTimeController.emitToRoom
).to.have.been.calledWith(
this.project._id,
'project:membership:changed',
{ members: true }
)
})
it('checks token hash', function () {
expect(
this.TokenAccessHandler.checkTokenHashPrefix
).to.have.been.calledWith(
this.token,
'#prefix',
'readAndWrite',
this.user._id,
{ projectId: this.project._id, action: 'continue' }
)
})
})
describe('when there are no edit collaborator slots available', function () {
beforeEach(function (done) {
this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
false
)
this.req.params = { token: this.token }
this.req.body = {
confirmedByUser: true,
tokenHashPrefix: '#prefix',
}
this.res.callback = done
this.TokenAccessController.grantTokenAccessReadAndWrite(
this.req,
this.res,
done
)
})
it('adds the user as a read only invited member instead (pendingEditor)', function () {
expect(
this.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
this.project._id,
undefined,
this.user._id,
PrivilegeLevels.READ_ONLY,
{ pendingEditor: true }
)
})
it('writes a project audit log', function () {
expect(
this.ProjectAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
this.project._id,
'accept-via-link-sharing',
this.user._id,
this.req.ip,
{ privileges: 'readOnly', pendingEditor: true }
)
})
it('records a project-joined event for the user', function () {
expect(
this.AnalyticsManager.recordEventForUserInBackground
).to.have.been.calledWith(this.user._id, 'project-joined', {
mode: 'read-only',
projectId: this.project._id.toString(),
pendingEditor: true,
})
})
it('emits a project membership changed event', function () {
expect(
this.EditorRealTimeController.emitToRoom
).to.have.been.calledWith(
this.project._id,
'project:membership:changed',
{ members: true }
)
})
it('checks token hash', function () {
expect(
this.TokenAccessHandler.checkTokenHashPrefix
).to.have.been.calledWith(
this.token,
'#prefix',
'readAndWrite',
this.user._id,
{ projectId: this.project._id, action: 'continue' }
)
})
})
})
}) })
describe('when the access was already granted', function () { describe('when the access was already granted', function () {
@ -994,6 +1167,89 @@ describe('TokenAccessController', function () {
expect(this.res.sendStatus).to.have.been.calledWith(204) expect(this.res.sendStatus).to.have.been.calledWith(204)
}) })
}) })
describe('when link-sharing-enforcement test is active', function () {
beforeEach(function () {
this.SplitTestHandler.promises.getAssignmentForUser.resolves({
variant: 'active',
})
})
describe('when there are collaborator slots available', function () {
beforeEach(function () {
this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
true
)
})
describe('previously joined token access user moving to named collaborator', function () {
beforeEach(function (done) {
this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
false
)
this.res.callback = done
this.TokenAccessController.moveReadWriteToCollaborators(
this.req,
this.res,
done
)
})
it('sets the privilege level to read and write for the invited viewer', function () {
expect(
this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
).to.have.been.calledWith(this.user._id, this.project._id)
expect(
this.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
this.project._id,
undefined,
this.user._id,
PrivilegeLevels.READ_AND_WRITE
)
expect(this.res.sendStatus).to.have.been.calledWith(204)
})
})
})
describe('when there are no edit collaborator slots available', function () {
beforeEach(function () {
this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
false
)
})
describe('previously joined token access user moving to named collaborator', function () {
beforeEach(function (done) {
this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
false
)
this.res.callback = done
this.TokenAccessController.moveReadWriteToCollaborators(
this.req,
this.res,
done
)
})
it('sets the privilege level to read only for the invited viewer (pendingEditor)', function () {
expect(
this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
).to.have.been.calledWith(this.user._id, this.project._id)
expect(
this.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
this.project._id,
undefined,
this.user._id,
PrivilegeLevels.READ_ONLY,
{ pendingEditor: true }
)
expect(this.res.sendStatus).to.have.been.calledWith(204)
})
})
})
})
}) })
describe('moveReadWriteToReadOnly', function () { describe('moveReadWriteToReadOnly', function () {