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
@@ -124,6 +124,10 @@ export type GetWorkspaceCollaboratorsArgs = {
*/
search?: string
seatType?: WorkspaceSeatType
/**
* Optionally filter by user id
*/
excludeUserIds?: string[]
}
}
@@ -1,5 +1,4 @@
import {
deleteProjectRoleFactory,
getStreamFactory,
getStreamsCollaboratorCountsFactory,
grantStreamPermissionsFactory,
@@ -11,6 +10,7 @@ import {
CountWorkspaceRoleWithOptionalProjectRole,
GetDefaultRegion,
GetWorkspace,
GetWorkspaceCollaborators,
GetWorkspaceRoleForUser,
GetWorkspaceRoleToDefaultProjectRoleMapping,
GetWorkspaceSeatTypeToProjectRoleMapping,
@@ -29,15 +29,18 @@ import { logger, moduleLogger } from '@/observability/logging'
import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
import { EventPayload, getEventBus } from '@/modules/shared/services/eventBus'
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared'
import {
DeleteProjectRole,
UpsertProjectRole
} from '@/modules/core/domain/projects/operations'
Roles,
StreamRoles,
throwUncoveredError,
WorkspaceRoles
} from '@speckle/shared'
import { UpsertProjectRole } from '@/modules/core/domain/projects/operations'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { Knex } from 'knex'
import {
countWorkspaceRoleWithOptionalProjectRoleFactory,
getWorkspaceCollaboratorsFactory,
getWorkspaceFactory,
getWorkspaceRoleForUserFactory,
getWorkspaceRolesFactory,
@@ -86,7 +89,7 @@ import {
GetWorkspaceWithPlan
} from '@/modules/gatekeeper/domain/billing'
import { getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { Workspace, WorkspaceSeatType } from '@/modules/workspacesCore/domain/types'
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions'
import {
@@ -103,7 +106,10 @@ import {
getWorkspaceRolesAndSeatsFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
import { DeleteWorkspaceSeat } from '@/modules/gatekeeper/domain/operations'
import {
DeleteWorkspaceSeat,
GetWorkspaceUserSeat
} from '@/modules/gatekeeper/domain/operations'
import {
isStreamCollaboratorFactory,
setStreamCollaboratorFactory,
@@ -202,9 +208,9 @@ export const onInviteFinalizedFactory =
role: workspaceRole,
userId: targetUserId,
workspaceId: project.workspaceId,
skipProjectRoleUpdatesFor: [project.id],
preventRoleDowngrade: true,
updatedByUserId: invite.inviterId
updatedByUserId: invite.inviterId,
skipProjectRoleUpdatesFor: [project.id]
})
// Automatically promote user to project owner if workspace admin
@@ -256,29 +262,73 @@ export const onWorkspaceAuthorizedFactory =
}
export const onWorkspaceRoleDeletedFactory =
({
queryAllWorkspaceProjects,
deleteProjectRole,
deleteWorkspaceSeat
}: {
(deps: {
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
deleteProjectRole: DeleteProjectRole
deleteWorkspaceSeat: DeleteWorkspaceSeat
getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts
getWorkspaceCollaborators: GetWorkspaceCollaborators
setStreamCollaborator: SetStreamCollaborator
}) =>
async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => {
async ({
acl: { userId, workspaceId },
updatedByUserId
}: {
acl: { userId: string; workspaceId: string }
updatedByUserId: string
}) => {
// Resolve a fallback admin
const [admin] = await deps.getWorkspaceCollaborators({
workspaceId,
limit: 1,
filter: {
roles: [Roles.Workspace.Admin],
excludeUserIds: [userId]
}
})
// Delete roles for all workspace projects
for await (const projectsPage of queryAllWorkspaceProjects({
workspaceId
for await (const projectsPage of deps.queryAllWorkspaceProjects({
workspaceId,
userId
})) {
const projectsOldOwnerCounts = await deps.getStreamsCollaboratorCounts({
streamIds: projectsPage.map((p) => p.id),
type: Roles.Stream.Owner
})
await Promise.all(
projectsPage.map(({ id: projectId }) =>
deleteProjectRole({ projectId, userId })
)
projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => {
// If downgraded from owner & last owner, transfer ownership to a workspace admin
const isNoLongerOwner = originalProjectRole === Roles.Stream.Owner
const wasLastOwner =
projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1
if (isNoLongerOwner && wasLastOwner) {
await deps.setStreamCollaborator(
{
streamId: projectId,
userId: admin.id,
role: Roles.Stream.Owner,
setByUserId: updatedByUserId
},
{ trackProjectUpdate: false, skipAuthorization: true }
)
}
// Do actual role change for changed user
await deps.setStreamCollaborator(
{
streamId: projectId,
userId,
role: null,
setByUserId: updatedByUserId
},
{ trackProjectUpdate: false, skipAuthorization: true }
)
})
)
}
// Delete seat
await deleteWorkspaceSeat({ userId, workspaceId })
await deps.deleteWorkspaceSeat({ userId, workspaceId })
}
export const onWorkspaceSeatUpdatedFactory =
@@ -288,6 +338,8 @@ export const onWorkspaceSeatUpdatedFactory =
setStreamCollaborator: SetStreamCollaborator
getWorkspaceWithPlan: GetWorkspaceWithPlan
getWorkspaceRoleForUser: GetWorkspaceRoleForUser
getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts
getWorkspaceCollaborators: GetWorkspaceCollaborators
}) =>
async (params: EventPayload<typeof WorkspaceEvents.SeatUpdated>) => {
const { seat, updatedByUserId } = params.payload
@@ -310,11 +362,25 @@ export const onWorkspaceSeatUpdatedFactory =
workspaceId
})
// Resolve a fallback admin
const [admin] = await deps.getWorkspaceCollaborators({
workspaceId,
limit: 1,
filter: {
roles: [Roles.Workspace.Admin],
excludeUserIds: [userId]
}
})
// Ensure project roles are valid on seat type switch
for await (const projectsPage of deps.queryAllWorkspaceProjects({
workspaceId,
userId
})) {
const projectsOldOwnerCounts = await deps.getStreamsCollaboratorCounts({
streamIds: projectsPage.map((p) => p.id),
type: Roles.Stream.Owner
})
await Promise.all(
projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => {
const disallowedProjectRole =
@@ -322,12 +388,32 @@ export const onWorkspaceSeatUpdatedFactory =
!allowedProjectRoles[seatType].includes(originalProjectRole)
if (!disallowedProjectRole) return
const newRole = defaultProjectRoles[seatType]
const nextUserRole = defaultProjectRoles[seatType]
// If downgraded from owner & last owner, transfer ownership to a workspace admin
const isNoLongerOwner =
originalProjectRole === Roles.Stream.Owner &&
nextUserRole !== Roles.Stream.Owner
const wasLastOwner =
projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1
if (isNoLongerOwner && wasLastOwner) {
await deps.setStreamCollaborator(
{
streamId: projectId,
userId: admin.id,
role: Roles.Stream.Owner,
setByUserId: updatedByUserId
},
{ trackProjectUpdate: false, skipAuthorization: true }
)
}
// Do actual role change for changed user
await deps.setStreamCollaborator(
{
streamId: projectId,
userId,
role: newRole,
role: nextUserRole,
setByUserId: updatedByUserId
},
{ trackProjectUpdate: false, skipAuthorization: true }
@@ -342,13 +428,15 @@ export const onWorkspaceRoleUpdatedFactory =
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
setStreamCollaborator: SetStreamCollaborator
getWorkspaceUserSeat: GetWorkspaceUserSeat
getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts
getWorkspaceCollaborators: GetWorkspaceCollaborators
getWorkspaceWithPlan: GetWorkspaceWithPlan
}) =>
async ({
acl,
flags,
updatedByUserId
updatedByUserId,
flags
}: {
acl: { userId: string; role: WorkspaceRoles; workspaceId: string }
flags?: {
@@ -357,23 +445,31 @@ export const onWorkspaceRoleUpdatedFactory =
updatedByUserId: string
}) => {
const { userId, role, workspaceId } = acl
const workspace = await deps.getWorkspaceWithPlan({ workspaceId })
if (!workspace) return
// New plans don't do automatic project role assignment
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (isNewPlan) {
return
}
// Until we kill old plan code, we need to do full project role assignment for them
const isOldPlan = !workspace.plan || !isNewPlanType(workspace.plan.name)
const { default: defaultProjectRoles } =
await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId
})
const nextUserRole = defaultProjectRoles[role]
const seatType = await deps.getWorkspaceUserSeat({ workspaceId, userId })
if (!seatType) return
// Keep user's project roles in sync with their workspace role
// Resolve a fallback admin
const [admin] = await deps.getWorkspaceCollaborators({
workspaceId,
limit: 1,
filter: {
roles: [Roles.Workspace.Admin],
excludeUserIds: [userId]
}
})
// Enforce project roles based on workspace role and seat type, if project role exists
for await (const projectsPage of deps.queryAllWorkspaceProjects({
workspaceId,
userId
@@ -382,26 +478,64 @@ export const onWorkspaceRoleUpdatedFactory =
streamIds: projectsPage.map((p) => p.id),
type: Roles.Stream.Owner
})
await Promise.all(
projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => {
if (flags?.skipProjectRoleUpdatesFor.includes(projectId)) {
if (isOldPlan && flags?.skipProjectRoleUpdatesFor.includes(projectId)) {
// Skip assignment (used during invite flow)
// TODO: Can we refactor this special case away?
return
}
// If downgraded from owner & last owner, transfer ownership to admin causing the role update (updatedByUserId)
if (!originalProjectRole && !isOldPlan) {
return
}
/**
* We cant really throw here, because by this point the workspace role has already
* been written to DB. So we must ensure the updates we make here are valid
*/
let nextUserRole: StreamRoles | null
if (isOldPlan) {
nextUserRole = defaultProjectRoles[role]
} else {
switch (role) {
case Roles.Workspace.Admin: {
// Set workspace owner as project owner
nextUserRole = Roles.Stream.Owner
break
}
case Roles.Workspace.Guest: {
// If workspace guest is project owner
if (originalProjectRole !== Roles.Stream.Owner) {
return
}
// If workspace guest has an editor seat
if (seatType.type !== WorkspaceSeatType.Editor) {
return
}
// Demote to contributor
nextUserRole = Roles.Stream.Contributor
break
}
default:
return
}
}
// If downgraded from owner & last owner, transfer ownership to a workspace admin
const isNoLongerOwner =
originalProjectRole === Roles.Stream.Owner &&
(!nextUserRole || nextUserRole !== Roles.Stream.Owner)
nextUserRole !== Roles.Stream.Owner
const wasLastOwner =
projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1
if (isNoLongerOwner && wasLastOwner) {
await deps.setStreamCollaborator(
{
streamId: projectId,
userId: updatedByUserId,
userId: admin.id,
role: Roles.Stream.Owner,
setByUserId: updatedByUserId
},
@@ -409,7 +543,7 @@ export const onWorkspaceRoleUpdatedFactory =
)
}
// Finally change target role
// Do actual role change for changed user
await deps.setStreamCollaborator(
{
streamId: projectId,
@@ -735,11 +869,28 @@ export const initializeEventListenersFactory =
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({
getStreams
}),
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
deleteWorkspaceSeat: deleteWorkspaceSeatFactory({ db: trx })
deleteWorkspaceSeat: deleteWorkspaceSeatFactory({ db: trx }),
getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }),
getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db }),
setStreamCollaborator: setStreamCollaboratorFactory({
getUser: getUserFactory({ db }),
validateStreamAccess: validateStreamAccessFactory({
authorizeResolver
}),
emitEvent: eventBus.emit,
grantStreamPermissions: grantStreamPermissionsFactory({
db: trx
}),
isStreamCollaborator: isStreamCollaboratorFactory({
getStream: getStreamFactory({ db })
}),
revokeStreamPermissions: revokeStreamPermissionsFactory({
db: trx
})
})
})
return await onWorkspaceRoleDeleted(payload.acl)
return await onWorkspaceRoleDeleted(payload)
},
{ db }
)
@@ -748,11 +899,6 @@ export const initializeEventListenersFactory =
await withTransaction(
async ({ db: trx }) => {
const onWorkspaceRoleUpdated = onWorkspaceRoleUpdatedFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({
getStreams
}),
@@ -772,7 +918,14 @@ export const initializeEventListenersFactory =
db: trx
})
}),
getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db })
getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }),
getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }),
getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db }),
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
})
return await onWorkspaceRoleUpdated(payload)
},
@@ -803,7 +956,9 @@ export const initializeEventListenersFactory =
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
}),
getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }),
getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db })
})
return await onWorkspaceSeatUpdated(payload)
@@ -148,7 +148,6 @@ import { updateStreamRoleAndNotifyFactory } from '@/modules/core/services/stream
import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { asOperation, commandFactory } from '@/modules/shared/command'
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
import {
getRateLimitResult,
isRateLimitBreached
@@ -728,37 +727,35 @@ export = FF_WORKSPACES_MODULE_ENABLED
})
if (!role) {
// this is currently not working with the command factory
// TODO: include the onWorkspaceRoleDeletedFactory listener service
await withOperationLogging(
async () =>
await withTransaction(
async ({ db: trx }) => {
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db: trx }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }),
emitWorkspaceEvent: getEventBus().emit
})
await asOperation(
async ({ db, emit }) => {
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
emitWorkspaceEvent: emit
})
return await deleteWorkspaceRole({ workspaceId, userId })
},
{ db }
),
return await deleteWorkspaceRole({
workspaceId,
userId,
deletedByUserId: context.userId!
})
},
{
logger,
operationName: 'deleteWorkspaceRole',
operationDescription: 'Delete workspace role'
name: 'deleteWorkspaceRole',
description: 'Delete workspace role',
transaction: true
}
)
} else {
if (!isWorkspaceRole(role)) {
throw new WorkspaceInvalidRoleError()
}
const updateWorkspaceRole = commandFactory({
db,
eventBus,
operationFactory: ({ trx, emit }) =>
updateWorkspaceRoleFactory({
await asOperation(
async ({ db: trx, emit }) => {
const updateWorkspaceRole = updateWorkspaceRoleFactory({
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: trx }),
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db: trx }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({
@@ -772,23 +769,25 @@ export = FF_WORKSPACES_MODULE_ENABLED
eventEmit: emit
})
})
})
await withOperationLogging(
async () =>
await updateWorkspaceRole({
return await updateWorkspaceRole({
userId,
workspaceId,
role,
updatedByUserId: context.userId!
}),
})
},
{
logger,
operationName: 'updateWorkspaceRole',
operationDescription: 'Update workspace role'
name: 'updateWorkspaceRole',
description: 'Update workspace role',
transaction: true
}
)
}
context.clearCache()
return await getWorkspaceFactory({ db })({
workspaceId: args.input.workspaceId,
userId: context.userId
@@ -943,31 +942,30 @@ export = FF_WORKSPACES_MODULE_ENABLED
const logger = context.log.child({
workspaceId
})
// this is currently not working with the command factory
// TODO: include the onWorkspaceRoleDeletedFactory listener service
await withOperationLogging(
async () =>
await withTransaction(
async ({ db: trx }) => {
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db: trx }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }),
emitWorkspaceEvent: getEventBus().emit
})
await asOperation(
async ({ db, emit }) => {
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
emitWorkspaceEvent: emit
})
return await deleteWorkspaceRole({
workspaceId,
userId: context.userId!
})
},
{ db }
),
return await deleteWorkspaceRole({
workspaceId,
userId: context.userId!,
deletedByUserId: context.userId!
})
},
{
logger,
operationName: 'leaveWorkspace',
operationDescription: 'Leave workspace'
name: 'leaveWorkspace',
description: 'Leave workspace',
transaction: true
}
)
context.clearCache()
return true
},
updateCreationState: async (_parent, args, context) => {
@@ -1394,7 +1392,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
projectId,
streamId: projectId //legacy
})
return await withOperationLogging(
const ret = await withOperationLogging(
async () =>
await updateStreamRoleAndNotify(
args.input,
@@ -1407,6 +1405,10 @@ export = FF_WORKSPACES_MODULE_ENABLED
operationDescription: 'Update workspace project role'
}
)
context.clearCache()
return ret
},
moveToWorkspace: async (_parent, args, context) => {
const { projectId, workspaceId } = args
@@ -364,7 +364,7 @@ export const getWorkspaceCollaboratorsFactory =
.where(DbWorkspaceAcl.col.workspaceId, workspaceId)
.orderBy('workspaceRoleCreatedAt', 'desc')
const { search, roles, seatType } = filter || {}
const { search, roles, seatType, excludeUserIds } = filter || {}
if (seatType) {
query
@@ -387,6 +387,12 @@ export const getWorkspaceCollaboratorsFactory =
})
}
if (excludeUserIds?.length) {
query.andWhere((w) => {
w.whereNotIn(Users.col.id, excludeUserIds)
})
}
if (cursor) {
query.andWhere(DbWorkspaceAcl.col.createdAt, '<', cursor)
}
@@ -349,6 +349,7 @@ export const deleteWorkspaceFactory =
type WorkspaceRoleDeleteArgs = {
userId: string
workspaceId: string
deletedByUserId: string
}
export const deleteWorkspaceRoleFactory =
@@ -363,7 +364,8 @@ export const deleteWorkspaceRoleFactory =
}) =>
async ({
workspaceId,
userId
userId,
deletedByUserId
}: WorkspaceRoleDeleteArgs): Promise<WorkspaceAcl | null> => {
// Protect against removing last admin
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
@@ -380,7 +382,7 @@ export const deleteWorkspaceRoleFactory =
// Emit deleted role
await emitWorkspaceEvent({
eventName: WorkspaceEvents.RoleDeleted,
payload: { acl: deletedRole }
payload: { acl: deletedRole, updatedByUserId: deletedByUserId }
})
return deletedRole
@@ -420,9 +422,9 @@ export const updateWorkspaceRoleFactory =
workspaceId,
userId,
role: nextWorkspaceRole,
skipProjectRoleUpdatesFor,
preventRoleDowngrade,
updatedByUserId
updatedByUserId,
skipProjectRoleUpdatesFor
}): Promise<void> => {
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
@@ -493,10 +495,10 @@ export const updateWorkspaceRoleFactory =
workspaceId,
role: nextWorkspaceRole
},
updatedByUserId,
flags: {
skipProjectRoleUpdatesFor: skipProjectRoleUpdatesFor ?? []
},
updatedByUserId
}
}
})
}
@@ -356,7 +356,7 @@ export const validateWorkspaceMemberProjectRoleFactory =
// User's workspace role does not allow the requested project role
throw new WorkspaceInvalidRoleError(
isNewPlan
? `User's workspace seat type '${seatType}' does not allow project role '${projectRole}'.`
? `User's workspace seat type '${seatType}' and workspace role '${workspaceRole}' does not allow project role '${projectRole}'.`
: `User's workspace role '${workspaceRole}' does not allow project role '${projectRole}'.`
)
}
@@ -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 () => {