fix(server): project role updates after workspace role/seat changes (#4599)

* fix(workspaces): workspace role sync

* role changes fixed + validated

* seat changes validated

* fix tests

---------

Co-authored-by: Charles Driesler <chuck@speckle.systems>
This commit is contained in:
Kristaps Fabians Geikins
2025-04-29 10:49:37 +03:00
committed by GitHub
parent 02be5652d3
commit cf833a7719
18 changed files with 1328 additions and 654 deletions
@@ -339,7 +339,8 @@ export const unassignFromWorkspace = async (
await deleteWorkspaceRole({
userId: user.id,
workspaceId: workspace.id
workspaceId: workspace.id,
deletedByUserId: workspace.ownerId
})
}
@@ -0,0 +1,112 @@
import { basicWorkspaceFragment } from '@/modules/workspaces/tests/helpers/graphql'
import { ProjectImplicitRoleCheckFragment } from '@/test/graphql/generated/graphql'
import { MaybeNullOrUndefined, Roles } from '@speckle/shared'
import { gql } from 'graphql-tag'
export const fullPermissionCheckResultFragment = gql(`
fragment FullPermissionCheckResult on PermissionCheckResult {
authorized
code
message
payload
}
`)
export const projectImplicitRoleCheckFragment = gql`
fragment ProjectImplicitRoleCheck on Project {
id
role
permissions {
# general access check
canRead {
...FullPermissionCheckResult
}
# implicit reviewer check
canReadSettings {
...FullPermissionCheckResult
}
# implicit owner check
canReadWebhooks {
...FullPermissionCheckResult
}
# implicit contributor check
canCreateModel {
...FullPermissionCheckResult
}
}
}
${fullPermissionCheckResultFragment}
`
export const getUserWorkspaceAccessQuery = gql`
query GetUserWorkspaceAccess($id: String!) {
workspace(id: $id) {
id
role
seatType
}
}
`
export const getUserWorkspaceProjectsWithAccessChecksQuery = gql`
query GetUserWorkspaceProjectsWithAccessChecks(
$id: String!
$limit: Int
$cursor: String
$filter: WorkspaceProjectsFilter
) {
workspace(id: $id) {
...BasicWorkspace
role
seatType
projects(limit: $limit, cursor: $cursor, filter: $filter) {
items {
...ProjectImplicitRoleCheck
}
cursor
totalCount
}
}
}
${basicWorkspaceFragment}
${projectImplicitRoleCheckFragment}
`
export const getUserProjectsWithAccessChecksQuery = gql`
query GetUserProjectsWithAccessChecks(
$limit: Int
$cursor: String
$filter: UserProjectsFilter
) {
activeUser {
id
projects(limit: $limit, cursor: $cursor, filter: $filter) {
items {
...ProjectImplicitRoleCheck
}
cursor
totalCount
}
}
}
${projectImplicitRoleCheckFragment}
`
export const projectImplicitRoleCheck = (
project: MaybeNullOrUndefined<ProjectImplicitRoleCheckFragment>
) => {
return {
hasAccess: !!project?.permissions?.canRead.authorized,
isReviewer: !!project?.permissions?.canReadSettings.authorized,
isContributor: !!project?.permissions?.canCreateModel.authorized,
isOwner: !!project?.permissions?.canReadWebhooks.authorized,
isExplicitOwner: project?.role === Roles.Stream.Owner,
isExplicitContributor: project?.role === Roles.Stream.Contributor,
isExplicitReviewer: project?.role === Roles.Stream.Reviewer,
hasExplicitRole: !!project?.role
}
}
export type ProjectImplicitRoleCheck = ReturnType<typeof projectImplicitRoleCheck>
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,9 @@
import cryptoRandomString from 'crypto-random-string'
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import { Roles, StreamRoles } from '@speckle/shared'
import { Roles } from '@speckle/shared'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import {
onProjectCreatedFactory,
onWorkspaceRoleUpdatedFactory
} from '@/modules/workspaces/events/eventListener'
import { onProjectCreatedFactory } from '@/modules/workspaces/events/eventListener'
import { expect } from 'chai'
import { chunk } from 'lodash'
import { GetWorkspaceRolesAndSeats } from '@/modules/gatekeeper/domain/billing'
describe('Event handlers', () => {
@@ -89,132 +85,4 @@ describe('Event handlers', () => {
expect(projectRoles.length).to.equal(2)
})
})
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({
getWorkspaceWithPlan: async () =>
({
id: 'fake'
} as Workspace & { plan: null }),
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
default: {
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Guest]: null
},
allowed: {
[Roles.Workspace.Admin]: [
Roles.Stream.Owner,
Roles.Stream.Contributor,
Roles.Stream.Reviewer
],
[Roles.Workspace.Member]: [
Roles.Stream.Owner,
Roles.Stream.Contributor,
Roles.Stream.Reviewer
],
[Roles.Workspace.Guest]: [Roles.Stream.Reviewer, Roles.Stream.Contributor]
}
}),
async *queryAllWorkspaceProjects() {
yield [fakeProject as StreamRecord]
},
getStreamsCollaboratorCounts: async () => {
return {}
},
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 })
},
updatedByUserId: cryptoRandomString({ length: 10 })
})
expect(isDeleteCalled).to.be.true
})
it('assigns the mapped projects roles to all queried project', async () => {
const projectIds = [
cryptoRandomString({ length: 10 }),
cryptoRandomString({ length: 10 }),
cryptoRandomString({ length: 10 }),
cryptoRandomString({ length: 10 })
]
const userId = cryptoRandomString({ length: 10 })
const projectRole = Roles.Stream.Reviewer
const storedRoles: { userId: string; role: StreamRoles; projectId: string }[] = []
let trackProjectUpdate: boolean | undefined = false
await onWorkspaceRoleUpdatedFactory({
getWorkspaceWithPlan: async () =>
({
id: 'fake'
} as Workspace & { plan: null }),
getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({
default: {
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: projectRole,
[Roles.Workspace.Guest]: null
},
allowed: {
[Roles.Workspace.Admin]: [
Roles.Stream.Owner,
Roles.Stream.Contributor,
Roles.Stream.Reviewer
],
[Roles.Workspace.Member]: [
Roles.Stream.Owner,
Roles.Stream.Contributor,
Roles.Stream.Reviewer
],
[Roles.Workspace.Guest]: [Roles.Stream.Reviewer, Roles.Stream.Contributor]
}
}),
async *queryAllWorkspaceProjects() {
for (const projIds of chunk(projectIds, 3)) {
yield projIds.map((projId) => ({ id: projId } as unknown as StreamRecord))
}
},
getStreamsCollaboratorCounts: async () => {
return {}
},
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 })
},
updatedByUserId: cryptoRandomString({ length: 10 })
})
expect(storedRoles).deep.equals(
projectIds.map((projectId) => ({ projectId, role: projectRole, userId }))
)
expect(trackProjectUpdate).to.not.be.true
})
})
})
@@ -693,7 +693,11 @@ describe('Workspace role services', () => {
workspaceRoles: [role]
})
const deletedRole = await deleteWorkspaceRole({ userId, workspaceId })
const deletedRole = await deleteWorkspaceRole({
userId,
workspaceId,
deletedByUserId: cryptoRandomString({ length: 10 })
})
expect(context.workspaceRoles.length).to.equal(0)
expect(deletedRole).to.deep.equal(role)
@@ -713,11 +717,19 @@ describe('Workspace role services', () => {
workspaceRoles: [role]
})
await deleteWorkspaceRole({ userId, workspaceId })
const deletedByUserId = cryptoRandomString({ length: 10 })
await deleteWorkspaceRole({
userId,
workspaceId,
deletedByUserId
})
expect(context.eventData.isCalled).to.be.true
expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleDeleted)
expect(context.eventData.payload).to.deep.equal({ acl: role })
expect(context.eventData.payload).to.deep.equal({
acl: role,
updatedByUserId: deletedByUserId
})
})
it('throws if attempting to delete the last admin from a workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
@@ -734,7 +746,13 @@ describe('Workspace role services', () => {
workspaceRoles: [role]
})
await expectToThrow(() => deleteWorkspaceRole({ userId, workspaceId }))
await expectToThrow(() =>
deleteWorkspaceRole({
userId,
workspaceId,
deletedByUserId: cryptoRandomString({ length: 10 })
})
)
})
it('deletes workspace project roles', async () => {
const userId = cryptoRandomString({ length: 10 })
@@ -757,7 +775,11 @@ describe('Workspace role services', () => {
]
})
await deleteWorkspaceRole({ userId, workspaceId })
await deleteWorkspaceRole({
userId,
workspaceId,
deletedByUserId: cryptoRandomString({ length: 10 })
})
expect(context.workspaceProjectRoles.length).to.equal(0)
})
@@ -807,13 +829,15 @@ describe('Workspace role services', () => {
...(context.eventData
.payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleUpdated])
}
delete payload.flags
expect(context.eventData.isCalled).to.be.true
expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated)
expect(payload).to.deep.equal({
acl: role,
updatedByUserId: workspaceOwnerId
updatedByUserId: workspaceOwnerId,
flags: {
skipProjectRoleUpdatesFor: []
}
})
})
it('throws if attempting to remove the last admin in a workspace', async () => {