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:
committed by
GitHub
parent
02be5652d3
commit
cf833a7719
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user