feat(server): support editor -> viewer seat downgrades (#4181)

* new seat based project role checks implemented

* everything done

* minor bugfix
This commit is contained in:
Kristaps Fabians Geikins
2025-03-14 14:21:25 +02:00
committed by GitHub
parent 50fd05afe8
commit d903e8ffc4
30 changed files with 975 additions and 337 deletions
@@ -8,6 +8,10 @@ import {
} from '@/modules/workspaces/events/eventListener'
import { expect } from 'chai'
import { chunk } from 'lodash'
import {
GetWorkspaceRolesAndSeats,
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
describe('Event handlers', () => {
describe('onProjectCreatedFactory creates a function, that', () => {
@@ -38,13 +42,28 @@ describe('Event handlers', () => {
const projectRoles: StreamAclRecord[] = []
// TODO: New plan support
const onProjectCreated = onProjectCreatedFactory({
getWorkspaceRoles: async () => workspaceRoles,
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Guest]: null
}),
getWorkspaceRolesAndSeats: async () =>
workspaceRoles.reduce((acc, role) => {
acc[role.userId] = { role, seat: null, userId: role.userId }
return acc
}, {} as Awaited<ReturnType<GetWorkspaceRolesAndSeats>>),
getWorkspaceRolesAllowedProjectRoles: async () => {
const mapping = {
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Guest]: null
}
return {
defaultProjectRole: ({ workspaceRole }) => {
return mapping[workspaceRole]
},
allowedProjectRoles: ({ workspaceRole }) => {
return [mapping[workspaceRole] || Roles.Stream.Reviewer]
}
}
},
upsertProjectRole: async ({ projectId, userId, role }) => {
projectRoles.push({
resourceId: projectId,
@@ -68,29 +87,47 @@ describe('Event handlers', () => {
describe('onWorkspaceRoleUpdatedFactory creates a function, that', () => {
it('assigns no project roles if the role mapping returns null', async () => {
let isDeleteCalled = false
const fakeProject = { id: 'test' } as StreamRecord
await onWorkspaceRoleUpdatedFactory({
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Guest]: null
}),
getWorkspaceRolesAllowedProjectRoles: async () => {
const mapping = {
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Guest]: null
}
return {
defaultProjectRole: ({ workspaceRole }) => {
return mapping[workspaceRole]
},
allowedProjectRoles: ({ workspaceRole }) => {
return [mapping[workspaceRole] || Roles.Stream.Reviewer]
}
}
},
async *queryAllWorkspaceProjects() {
yield [{ id: 'test' } as StreamRecord]
yield [fakeProject as StreamRecord]
},
deleteProjectRole: async () => {
isDeleteCalled = true
return undefined
getStreamsCollaboratorCounts: async () => {
return {}
},
upsertProjectRole: async () => {
expect.fail()
setStreamCollaborator: async ({ role }) => {
if (!role) {
isDeleteCalled = true
} else {
expect.fail()
}
return fakeProject
}
})({
acl: {
role: Roles.Workspace.Guest,
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
}
},
seatType: WorkspaceSeatType.Editor,
updatedByUserId: cryptoRandomString({ length: 10 })
})
expect(isDeleteCalled).to.be.true
@@ -108,30 +145,50 @@ describe('Event handlers', () => {
const storedRoles: { userId: string; role: StreamRoles; projectId: string }[] = []
let trackProjectUpdate: boolean | undefined = false
await onWorkspaceRoleUpdatedFactory({
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: projectRole,
[Roles.Workspace.Guest]: null
}),
getWorkspaceRolesAllowedProjectRoles: async () => {
const mapping = {
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: projectRole,
[Roles.Workspace.Guest]: null
}
return {
defaultProjectRole: ({ workspaceRole }) => {
return mapping[workspaceRole]
},
allowedProjectRoles: ({ workspaceRole }) => {
return [mapping[workspaceRole] || Roles.Stream.Reviewer]
}
}
},
async *queryAllWorkspaceProjects() {
for (const projIds of chunk(projectIds, 3)) {
yield projIds.map((projId) => ({ id: projId } as unknown as StreamRecord))
}
},
deleteProjectRole: async () => {
expect.fail()
getStreamsCollaboratorCounts: async () => {
return {}
},
upsertProjectRole: async (args, options) => {
storedRoles.push(args)
trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate
return {} as StreamRecord
setStreamCollaborator: async (params, options) => {
if (!params.role) {
return expect.fail()
} else {
storedRoles.push({
userId: params.userId,
role: params.role,
projectId: params.streamId
})
trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate
return {} as StreamRecord
}
}
})({
acl: {
role: Roles.Workspace.Member,
userId,
workspaceId: cryptoRandomString({ length: 10 })
}
},
seatType: WorkspaceSeatType.Editor,
updatedByUserId: cryptoRandomString({ length: 10 })
})
expect(storedRoles).deep.equals(
projectIds.map((projectId) => ({ projectId, role: projectRole, userId }))
@@ -770,10 +770,12 @@ describe('Workspace role services', () => {
it('sets the workspace role', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceOwnerId = cryptoRandomString({ length: 10 })
const role = {
userId,
workspaceId,
role: Roles.Workspace.Member
role: Roles.Workspace.Member,
updatedByUserId: workspaceOwnerId
}
const { updateWorkspaceRole, context } = buildUpdateWorkspaceRoleAndTestContext({
@@ -791,6 +793,7 @@ describe('Workspace role services', () => {
it('emits a role-updated event', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceOwnerId = cryptoRandomString({ length: 10 })
const role: Pick<WorkspaceAcl, 'userId' | 'workspaceId' | 'role'> = {
userId,
workspaceId,
@@ -801,7 +804,7 @@ describe('Workspace role services', () => {
workspaceId
})
await updateWorkspaceRole(role)
await updateWorkspaceRole({ ...role, updatedByUserId: workspaceOwnerId })
const payload = {
...(context.eventData
@@ -811,11 +814,16 @@ describe('Workspace role services', () => {
expect(context.eventData.isCalled).to.be.true
expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated)
expect(payload).to.deep.equal({ acl: role, seatType: WorkspaceSeatType.Editor })
expect(payload).to.deep.equal({
acl: role,
seatType: WorkspaceSeatType.Editor,
updatedByUserId: workspaceOwnerId
})
})
it('throws if attempting to remove the last admin in a workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceOwnerId = cryptoRandomString({ length: 10 })
const role: WorkspaceAcl = {
userId,
workspaceId,
@@ -829,7 +837,11 @@ describe('Workspace role services', () => {
})
await expectToThrow(() =>
updateWorkspaceRole({ ...role, role: Roles.Workspace.Member })
updateWorkspaceRole({
...role,
role: Roles.Workspace.Member,
updatedByUserId: workspaceOwnerId
})
)
})
it('throws if attempting to set user role to more than GUEST and workspace domain protection is enabled and user has not an email matching a workspace domain', async () => {
@@ -880,7 +892,8 @@ describe('Workspace role services', () => {
updateWorkspaceRole({
workspaceId,
userId: guestId,
role: Roles.Workspace.Member
role: Roles.Workspace.Member,
updatedByUserId: adminId
})
)
expect(err.message).to.eq(new WorkspaceProtectedError().message)
@@ -888,6 +901,7 @@ describe('Workspace role services', () => {
it('sets roles on workspace projects when user added to workspace as admin', async () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceOwnerId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const workspaceRole: WorkspaceAcl = {
@@ -902,7 +916,7 @@ describe('Workspace role services', () => {
workspaceProjects: [{ id: projectId } as StreamRecord]
})
await updateWorkspaceRole(workspaceRole)
await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId })
expect(context.workspaceProjectRoles.length).to.equal(1)
expect(context.workspaceProjectRoles[0].userId).to.equal(userId)
@@ -912,6 +926,7 @@ describe('Workspace role services', () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const workspaceOwnerId = cryptoRandomString({ length: 10 })
const workspaceRole: WorkspaceAcl = {
userId,
@@ -925,7 +940,7 @@ describe('Workspace role services', () => {
workspaceProjects: [{ id: projectId } as StreamRecord]
})
await updateWorkspaceRole(workspaceRole)
await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId })
expect(context.workspaceProjectRoles.length).to.equal(1)
expect(context.workspaceProjectRoles[0].userId).to.equal(userId)
@@ -935,6 +950,7 @@ describe('Workspace role services', () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const workspaceOwnerId = cryptoRandomString({ length: 10 })
const workspaceRole: WorkspaceAcl = {
userId,
@@ -948,7 +964,7 @@ describe('Workspace role services', () => {
workspaceProjects: [{ id: projectId } as StreamRecord]
})
await updateWorkspaceRole(workspaceRole)
await updateWorkspaceRole({ ...workspaceRole, updatedByUserId: workspaceOwnerId })
expect(context.workspaceProjectRoles.find((role) => role.userId === userId)).to
.not.exist
@@ -1,6 +1,7 @@
import { ProjectTeamMember } from '@/modules/core/domain/projects/types'
import { ProjectNotFoundError } from '@/modules/core/errors/projects'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { GetWorkspaceRolesAllowedProjectRolesFactory } from '@/modules/workspaces/domain/operations'
import { WorkspaceInvalidProjectError } from '@/modules/workspaces/errors/workspace'
import {
moveProjectToWorkspaceFactory,
@@ -12,11 +13,22 @@ import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
const getWorkspaceRoleToDefaultProjectRoleMapping = async () => ({
'workspace:admin': Roles.Stream.Owner,
'workspace:guest': null,
'workspace:member': Roles.Stream.Contributor
})
const getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory =
async () => {
const mapping = {
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Guest]: null
}
return {
defaultProjectRole: ({ workspaceRole }) => {
return mapping[workspaceRole]
},
allowedProjectRoles: () => {
return Object.values(Roles.Stream)
}
}
}
describe('Project retrieval services', () => {
describe('queryAllWorkspaceProjectFactory returns a generator, that', () => {
@@ -105,10 +117,10 @@ describe('Project management services', () => {
getProjectCollaborators: async () => {
expect.fail()
},
getWorkspaceRoles: async () => {
getWorkspaceRolesAndSeats: async () => {
expect.fail()
},
getWorkspaceRoleToDefaultProjectRoleMapping: async () => {
getWorkspaceRolesAllowedProjectRoles: async () => {
expect.fail()
},
updateWorkspaceRole: async () => {
@@ -119,7 +131,8 @@ describe('Project management services', () => {
const err = await expectToThrow(() =>
moveProjectToWorkspace({
projectId: cryptoRandomString({ length: 6 }),
workspaceId: cryptoRandomString({ length: 6 })
workspaceId: cryptoRandomString({ length: 6 }),
movedByUserId: cryptoRandomString({ length: 10 })
})
)
expect(err.message).to.equal(new ProjectNotFoundError().message)
@@ -140,10 +153,10 @@ describe('Project management services', () => {
getProjectCollaborators: async () => {
expect.fail()
},
getWorkspaceRoles: async () => {
getWorkspaceRolesAndSeats: async () => {
expect.fail()
},
getWorkspaceRoleToDefaultProjectRoleMapping: async () => {
getWorkspaceRolesAllowedProjectRoles: async () => {
expect.fail()
},
updateWorkspaceRole: async () => {
@@ -154,7 +167,8 @@ describe('Project management services', () => {
const err = await expectToThrow(() =>
moveProjectToWorkspace({
projectId: cryptoRandomString({ length: 6 }),
workspaceId: cryptoRandomString({ length: 6 })
workspaceId: cryptoRandomString({ length: 6 }),
movedByUserId: cryptoRandomString({ length: 10 })
})
)
expect(err instanceof WorkspaceInvalidProjectError).to.be.true
@@ -185,27 +199,27 @@ describe('Project management services', () => {
} as unknown as ProjectTeamMember
]
},
getWorkspaceRoles: async () => {
return [
{
userId,
role: Roles.Workspace.Admin,
workspaceId,
createdAt: new Date()
getWorkspaceRolesAndSeats: async () => {
return {
[userId]: {
role: {
userId,
role: Roles.Workspace.Admin,
workspaceId,
createdAt: new Date()
},
seat: null,
userId
}
]
}
},
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
'workspace:admin': Roles.Stream.Owner,
'workspace:guest': null,
'workspace:member': Roles.Stream.Contributor
}),
getWorkspaceRolesAllowedProjectRoles,
updateWorkspaceRole: async (role) => {
updatedRoles.push(role)
}
})
await moveProjectToWorkspace({ projectId, workspaceId })
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
expect(updatedRoles.length).to.equal(1)
expect(updatedRoles[0].role).to.equal(Roles.Workspace.Admin)
@@ -236,16 +250,16 @@ describe('Project management services', () => {
} as unknown as ProjectTeamMember
]
},
getWorkspaceRoles: async () => {
return []
getWorkspaceRolesAndSeats: async () => {
return {}
},
getWorkspaceRoleToDefaultProjectRoleMapping,
getWorkspaceRolesAllowedProjectRoles,
updateWorkspaceRole: async (role) => {
updatedRoles.push(role)
}
})
await moveProjectToWorkspace({ projectId, workspaceId })
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
expect(updatedRoles.length).to.equal(1)
expect(updatedRoles[0].role).to.equal(Roles.Workspace.Member)
@@ -277,16 +291,16 @@ describe('Project management services', () => {
} as unknown as ProjectTeamMember
]
},
getWorkspaceRoles: async () => {
return []
getWorkspaceRolesAndSeats: async () => {
return {}
},
getWorkspaceRoleToDefaultProjectRoleMapping,
getWorkspaceRolesAllowedProjectRoles,
updateWorkspaceRole: async (role) => {
updatedRoles.push(role)
}
})
await moveProjectToWorkspace({ projectId, workspaceId })
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
expect(updatedRoles.length).to.equal(1)
expect(updatedRoles[0].role).to.equal(Roles.Workspace.Guest)
@@ -319,14 +333,14 @@ describe('Project management services', () => {
} as unknown as ProjectTeamMember
]
},
getWorkspaceRoles: async () => {
return []
getWorkspaceRolesAndSeats: async () => {
return {}
},
getWorkspaceRoleToDefaultProjectRoleMapping,
getWorkspaceRolesAllowedProjectRoles,
updateWorkspaceRole: async () => {}
})
await moveProjectToWorkspace({ projectId, workspaceId })
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
expect(updatedRoles.length).to.equal(1)
expect(updatedRoles[0].role).to.equal(Roles.Stream.Owner)
@@ -359,18 +373,14 @@ describe('Project management services', () => {
} as unknown as ProjectTeamMember
]
},
getWorkspaceRoles: async () => {
return []
getWorkspaceRolesAndSeats: async () => {
return {}
},
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
[Roles.Workspace.Guest]: null,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Admin]: Roles.Stream.Owner
}),
getWorkspaceRolesAllowedProjectRoles,
updateWorkspaceRole: async () => {}
})
await moveProjectToWorkspace({ projectId, workspaceId })
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
expect(updatedRoles.length).to.equal(1)
expect(updatedRoles[0].role).to.equal(Roles.Stream.Contributor)
@@ -403,25 +413,25 @@ describe('Project management services', () => {
} as unknown as ProjectTeamMember
]
},
getWorkspaceRoles: async () => {
return [
{
userId,
workspaceId,
role: Roles.Workspace.Admin,
createdAt: new Date()
getWorkspaceRolesAndSeats: async () => {
return {
[userId]: {
role: {
userId,
workspaceId,
role: Roles.Workspace.Admin,
createdAt: new Date()
},
seat: null,
userId
}
]
}
},
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
[Roles.Workspace.Guest]: null,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Admin]: Roles.Stream.Owner
}),
getWorkspaceRolesAllowedProjectRoles,
updateWorkspaceRole: async () => {}
})
await moveProjectToWorkspace({ projectId, workspaceId })
await moveProjectToWorkspace({ projectId, workspaceId, movedByUserId: userId })
expect(updatedRoles.length).to.equal(1)
expect(updatedRoles[0].role).to.equal(Roles.Stream.Owner)