fix(server): prevent creating project contributor invite to viewer se… (#4462)

* fix(server): prevent creating project contributor invite to viewer seat member

* undo regionConfig change

* moar cleanup
This commit is contained in:
Kristaps Fabians Geikins
2025-04-17 07:00:33 +03:00
committed by GitHub
parent 385157ac81
commit 93bc55630b
7 changed files with 227 additions and 103 deletions
@@ -221,6 +221,12 @@ export type GetWorkspaceSeatTypeToProjectRoleMapping = (args: {
}
}>
export type ValidateWorkspaceMemberProjectRole = (params: {
workspaceId: string
userId: string
projectRole: StreamRoles
}) => Promise<void>
/** Workspace Projects */
type QueryAllWorkspaceProjectsArgs = {
@@ -14,7 +14,8 @@ import {
GetWorkspaceRoleForUser,
GetWorkspaceRoleToDefaultProjectRoleMapping,
GetWorkspaceSeatTypeToProjectRoleMapping,
QueryAllWorkspaceProjects
QueryAllWorkspaceProjects,
ValidateWorkspaceMemberProjectRole
} from '@/modules/workspaces/domain/operations'
import {
ServerInvitesEvents,
@@ -28,12 +29,7 @@ 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,
StreamRoles,
throwUncoveredError,
WorkspaceRoles
} from '@speckle/shared'
import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared'
import {
DeleteProjectRole,
UpsertProjectRole
@@ -51,7 +47,8 @@ import {
import {
queryAllWorkspaceProjectsFactory,
getWorkspaceRoleToDefaultProjectRoleMappingFactory,
getWorkspaceSeatTypeToProjectRoleMappingFactory
getWorkspaceSeatTypeToProjectRoleMappingFactory,
validateWorkspaceMemberProjectRoleFactory
} from '@/modules/workspaces/services/projects'
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
import {
@@ -73,10 +70,7 @@ import {
getUserSsoSessionFactory,
getWorkspaceSsoProviderRecordFactory
} from '@/modules/workspaces/repositories/sso'
import {
WorkspaceInvalidRoleError,
WorkspacesNotAuthorizedError
} from '@/modules/workspaces/errors/workspace'
import { WorkspacesNotAuthorizedError } from '@/modules/workspaces/errors/workspace'
import { publish, WorkspaceSubscriptions } from '@/modules/shared/utils/subscriptions'
import { isWorkspaceResourceTarget } from '@/modules/workspaces/services/invites'
import {
@@ -87,11 +81,9 @@ import { getBaseTrackingProperties, getClient } from '@/modules/shared/utils/mix
import {
calculateSubscriptionSeats,
GetWorkspacePlan,
GetWorkspaceRoleAndSeat,
GetWorkspaceRolesAndSeats,
GetWorkspaceSubscription,
GetWorkspaceWithPlan,
WorkspaceSeatType
GetWorkspaceWithPlan
} from '@/modules/gatekeeper/domain/billing'
import { getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe'
import { Workspace } from '@/modules/workspacesCore/domain/types'
@@ -601,63 +593,17 @@ const emitWorkspaceGraphqlSubscriptionsFactory =
const blockInvalidWorkspaceProjectRoleUpdatesFactory =
(deps: {
getStream: GetStream
getWorkspaceRoleAndSeat: GetWorkspaceRoleAndSeat
getWorkspaceWithPlan: GetWorkspaceWithPlan
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
getWorkspaceSeatTypeToProjectRoleMapping: GetWorkspaceSeatTypeToProjectRoleMapping
validateWorkspaceMemberProjectRole: ValidateWorkspaceMemberProjectRole
}) =>
async ({ payload }: EventPayload<typeof ProjectEvents.PermissionsBeingAdded>) => {
const project = await deps.getStream({ streamId: payload.projectId })
if (!project?.workspaceId) return // No extra validation necessary
const roleSeatParams = {
workspaceId: project.workspaceId,
userId: payload.targetUserId
}
const [currentWorkspaceRoleAndSeat, workspace] = await Promise.all([
deps.getWorkspaceRoleAndSeat(roleSeatParams),
deps.getWorkspaceWithPlan({ workspaceId: project.workspaceId })
])
if (!workspace || !currentWorkspaceRoleAndSeat?.role) return
const {
role: { role: workspaceRole },
seat
} = currentWorkspaceRoleAndSeat
const seatType = seat?.type || WorkspaceSeatType.Viewer
let allowedRoles: StreamRoles[]
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (isNewPlan) {
const workspaceAllowedRoles = (
await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId: project.workspaceId
})
).allowed[workspaceRole]
const seatAllowedRoles = (
await deps.getWorkspaceSeatTypeToProjectRoleMapping({
workspaceId: project.workspaceId
})
).allowed[seatType]
allowedRoles = Array.from(
new Set(workspaceAllowedRoles).intersection(new Set(seatAllowedRoles))
)
} else {
const roleMapping = await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId: project.workspaceId
})
allowedRoles = roleMapping.allowed[workspaceRole]
}
if (!allowedRoles.includes(payload.role)) {
// 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 '${payload.role}'.`
: `User's workspace role '${workspaceRole}' does not allow project role '${payload.role}'.`
)
}
await deps.validateWorkspaceMemberProjectRole({
userId: payload.targetUserId,
projectRole: payload.role,
workspaceId: project.workspaceId
})
}
export const initializeEventListenersFactory =
@@ -677,16 +623,18 @@ export const initializeEventListenersFactory =
const blockInvalidWorkspaceProjectRoleUpdates =
blockInvalidWorkspaceProjectRoleUpdatesFactory({
getStream,
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan
}),
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan
}),
getWorkspaceWithPlan
validateWorkspaceMemberProjectRole: validateWorkspaceMemberProjectRoleFactory({
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan
}),
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan
}),
getWorkspaceWithPlan
})
})
const createWorkspaceSeat = createWorkspaceSeatFactory({ db })
const ensureValidWorkspaceRoleSeat = ensureValidWorkspaceRoleSeatFactory({
@@ -99,8 +99,11 @@ import {
} from '@/modules/workspaces/services/management'
import {
createWorkspaceProjectFactory,
getWorkspaceRoleToDefaultProjectRoleMappingFactory,
getWorkspaceSeatTypeToProjectRoleMappingFactory,
moveProjectToWorkspaceFactory,
queryAllWorkspaceProjectsFactory
queryAllWorkspaceProjectsFactory,
validateWorkspaceMemberProjectRoleFactory
} from '@/modules/workspaces/services/projects'
import {
getDiscoverableWorkspacesForUserFactory,
@@ -205,6 +208,7 @@ import {
import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
import {
createWorkspaceSeatFactory,
getWorkspaceRoleAndSeatFactory,
getWorkspaceRolesAndSeatsFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
@@ -234,7 +238,21 @@ const buildCollectAndValidateResourceTargets = () =>
getStream,
getWorkspace: getWorkspaceFactory({ db }),
getWorkspaceDomains: getWorkspaceDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db })
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
validateWorkspaceMemberProjectRoleFactory:
validateWorkspaceMemberProjectRoleFactory({
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
})
})
const buildCreateAndSendServerOrProjectInvite = () =>
@@ -57,7 +57,8 @@ import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/con
import {
GetWorkspace,
GetWorkspaceBySlug,
GetWorkspaceDomains
GetWorkspaceDomains,
ValidateWorkspaceMemberProjectRole
} from '@/modules/workspaces/domain/operations'
import { WorkspaceInviteResourceTarget } from '@/modules/workspaces/domain/types'
import { mapGqlWorkspaceRoleToMainRole } from '@/modules/workspaces/helpers/roles'
@@ -72,6 +73,7 @@ import {
} from '@/modules/workspaces/domain/logic'
import { GetStream } from '@/modules/core/domain/streams/operations'
import { GetUser } from '@/modules/core/domain/users/operations'
import { GetWorkspaceRoleAndSeat } from '@/modules/workspacesCore/domain/operations'
export const isWorkspaceResourceTarget = (
target: InviteResourceTarget
@@ -122,6 +124,8 @@ export const createWorkspaceInviteFactory =
type CollectAndValidateWorkspaceTargetsFactoryDeps =
CollectAndValidateCoreTargetsFactoryDeps & {
getWorkspace: GetWorkspace
getWorkspaceRoleAndSeat: GetWorkspaceRoleAndSeat
validateWorkspaceMemberProjectRoleFactory: ValidateWorkspaceMemberProjectRole
getWorkspaceDomains: GetWorkspaceDomains
findVerifiedEmailsByUserId: FindVerifiedEmailsByUserId
getStream: GetStream
@@ -191,31 +195,42 @@ export const collectAndValidateWorkspaceTargetsFactory =
return [...baseTargets]
}
const workspace = await deps.getWorkspace({
workspaceId,
userId: targetUser?.id
})
const [workspace, workspaceRoleAndSeat] = await Promise.all([
deps.getWorkspace({
workspaceId
}),
...(targetUser?.id
? [
deps.getWorkspaceRoleAndSeat({
workspaceId,
userId: targetUser.id
})
]
: [])
])
if (!workspace) {
throw new InviteCreateValidationError(
'Attempting to invite into a non-existant workspace'
)
}
// If inviting to workspace project, disallow workspace guests to become project owners
const workspaceRole = workspaceRoleAndSeat?.role.role
// If inviting to workspace project, validate target role
const projectTarget = baseTargets.find(isProjectResourceTarget)
if (
workspace?.role === Roles.Workspace.Guest &&
projectTarget?.role === Roles.Stream.Owner
) {
throw new InviteCreateValidationError(
'Workspace guests cannot be owners of workspace projects'
)
const projectRole = projectTarget?.role
if (projectRole && targetUser) {
await deps.validateWorkspaceMemberProjectRoleFactory({
workspaceId,
userId: targetUser.id,
projectRole
})
}
// Do further validation only if we're actually planning to invite to a workspace
// (maybe the invitation is implicitly there, but user already is a member of the workspace)
const isInvitingToWorkspace =
primaryWorkspaceResourceTarget || (workspace && !workspace.role)
primaryWorkspaceResourceTarget || (workspace && !workspaceRole)
if (!isInvitingToWorkspace) {
return [...baseTargets]
}
@@ -236,7 +251,7 @@ export const collectAndValidateWorkspaceTargetsFactory =
}
// Only check this on creation, on finalization its fine if the user's already a member
if (workspace.role && !finalizingInvite) {
if (workspaceRole && !finalizingInvite) {
throw new InviteCreateValidationError(
'The target user is already a member of the specified workspace'
)
@@ -6,16 +6,18 @@ import {
GetWorkspaceSeatTypeToProjectRoleMapping,
IntersectProjectCollaboratorsAndWorkspaceCollaborators,
QueryAllWorkspaceProjects,
UpdateWorkspaceRole
UpdateWorkspaceRole,
ValidateWorkspaceMemberProjectRole
} from '@/modules/workspaces/domain/operations'
import {
WorkspaceInvalidProjectError,
WorkspaceInvalidRoleError,
WorkspaceNotFoundError,
WorkspaceQueryError
} from '@/modules/workspaces/errors/workspace'
import { GetProject, UpdateProject } from '@/modules/core/domain/projects/operations'
import { chunk } from 'lodash'
import { Roles } from '@speckle/shared'
import { Roles, StreamRoles } from '@speckle/shared'
import {
GetStreamCollaborators,
LegacyGetStreams,
@@ -42,6 +44,7 @@ import {
upsertWorkspaceFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
GetWorkspaceRoleAndSeat,
GetWorkspaceRolesAndSeats,
GetWorkspaceWithPlan,
WorkspaceSeatType
@@ -296,6 +299,69 @@ export const getWorkspaceSeatTypeToProjectRoleMappingFactory =
}
}
/**
* Validate that the specified workspace member can have the specified project role
*/
export const validateWorkspaceMemberProjectRoleFactory =
(deps: {
getWorkspaceRoleAndSeat: GetWorkspaceRoleAndSeat
getWorkspaceWithPlan: GetWorkspaceWithPlan
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
getWorkspaceSeatTypeToProjectRoleMapping: GetWorkspaceSeatTypeToProjectRoleMapping
}): ValidateWorkspaceMemberProjectRole =>
async (params) => {
const { workspaceId, userId, projectRole } = params
const roleSeatParams = {
workspaceId,
userId
}
const [currentWorkspaceRoleAndSeat, workspace] = await Promise.all([
deps.getWorkspaceRoleAndSeat(roleSeatParams),
deps.getWorkspaceWithPlan({ workspaceId })
])
if (!workspace || !currentWorkspaceRoleAndSeat?.role) return
const {
role: { role: workspaceRole },
seat
} = currentWorkspaceRoleAndSeat
const seatType = seat?.type || WorkspaceSeatType.Viewer
let allowedRoles: StreamRoles[]
const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name)
if (isNewPlan) {
const workspaceAllowedRoles = (
await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId
})
).allowed[workspaceRole]
const seatAllowedRoles = (
await deps.getWorkspaceSeatTypeToProjectRoleMapping({
workspaceId
})
).allowed[seatType]
allowedRoles = Array.from(
new Set(workspaceAllowedRoles).intersection(new Set(seatAllowedRoles))
)
} else {
const roleMapping = await deps.getWorkspaceRoleToDefaultProjectRoleMapping({
workspaceId
})
allowedRoles = roleMapping.allowed[workspaceRole]
}
if (!allowedRoles.includes(projectRole)) {
// 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 role '${workspaceRole}' does not allow project role '${projectRole}'.`
)
}
}
export const createWorkspaceProjectFactory =
(deps: { getDefaultRegion: GetDefaultRegion }) =>
async (params: { input: WorkspaceProjectCreateInput; ownerId: string }) => {
@@ -59,6 +59,7 @@ import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/env
import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic'
import {
getWorkspacePlanFactory,
getWorkspaceWithPlanFactory,
upsertPaidWorkspacePlanFactory,
upsertWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
@@ -82,9 +83,15 @@ import {
} from '@/modules/workspaces/services/workspaceSeat'
import {
createWorkspaceSeatFactory,
getWorkspaceRoleAndSeatFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
import dayjs from 'dayjs'
import {
getWorkspaceRoleToDefaultProjectRoleMappingFactory,
getWorkspaceSeatTypeToProjectRoleMappingFactory,
validateWorkspaceMemberProjectRoleFactory
} from '@/modules/workspaces/services/projects'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
@@ -377,7 +384,21 @@ export const createWorkspaceInviteDirectly = async (
getStream,
getWorkspace: getWorkspaceFactory({ db }),
getWorkspaceDomains: getWorkspaceDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db })
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
validateWorkspaceMemberProjectRoleFactory:
validateWorkspaceMemberProjectRoleFactory({
getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }),
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
getWorkspaceSeatTypeToProjectRoleMapping:
getWorkspaceSeatTypeToProjectRoleMappingFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
})
}),
buildInviteEmailContents: buildWorkspaceInviteEmailContentsFactory({
getStream,
@@ -49,7 +49,10 @@ import {
} from '@/modules/core/repositories/userEmails'
import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification'
import { createRandomPassword } from '@/modules/core/helpers/testHelpers'
import { WorkspaceProtectedError } from '@/modules/workspaces/errors/workspace'
import {
WorkspaceInvalidRoleError,
WorkspaceProtectedError
} from '@/modules/workspaces/errors/workspace'
import cryptoRandomString from 'crypto-random-string'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import {
@@ -469,6 +472,13 @@ describe('Workspaces Invites GQL', () => {
ownerId: ''
}
const myProjectInviteTargetWorkspaceWithNewPlan: BasicTestWorkspace = {
name: 'My Project Invite Target Workspace w/ New Plan #1',
id: '',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
const myProjectInviteTargetBasicProject: BasicTestStream = {
name: 'My Project Invite Target Basic Project #1',
id: '',
@@ -483,6 +493,13 @@ describe('Workspaces Invites GQL', () => {
isPublic: false
}
const myProjectInviteTargetWorkspaceNewPlanProject: BasicTestStream = {
name: 'My Project Invite Target Workspace New Plan Project #1',
id: '',
ownerId: '',
isPublic: false
}
const workspaceMemberWithNoProjectAccess: BasicTestUser = {
name: 'Workspace Member With No Project Access #1',
email: 'workspaceMemberWithNoProjectAccess1@example.org',
@@ -497,7 +514,19 @@ describe('Workspaces Invites GQL', () => {
before(async () => {
await createTestUsers([workspaceMemberWithNoProjectAccess, workspaceGuest])
await createTestWorkspaces([[myProjectInviteTargetWorkspace, me]])
await createTestWorkspaces([
[myProjectInviteTargetWorkspace, me],
[
myProjectInviteTargetWorkspaceWithNewPlan,
me,
{
addPlan: {
name: 'teamUnlimited',
status: 'valid'
}
}
]
])
await assignToWorkspaces([
[myProjectInviteTargetWorkspace, myWorkspaceFriend, Roles.Workspace.Member],
[
@@ -505,13 +534,21 @@ describe('Workspaces Invites GQL', () => {
workspaceMemberWithNoProjectAccess,
Roles.Workspace.Member
],
[myProjectInviteTargetWorkspace, workspaceGuest, Roles.Workspace.Guest]
[myProjectInviteTargetWorkspace, workspaceGuest, Roles.Workspace.Guest],
[
myProjectInviteTargetWorkspaceWithNewPlan,
workspaceGuest,
Roles.Workspace.Guest
]
])
myProjectInviteTargetWorkspaceNewPlanProject.workspaceId =
myProjectInviteTargetWorkspaceWithNewPlan.id
myProjectInviteTargetWorkspaceProject.workspaceId =
myProjectInviteTargetWorkspace.id
await createTestStreams([
[myProjectInviteTargetWorkspaceProject, me],
[myProjectInviteTargetWorkspaceNewPlanProject, me],
[myProjectInviteTargetBasicProject, me]
])
@@ -626,9 +663,22 @@ describe('Workspaces Invites GQL', () => {
]
})
expect(res).to.haveGraphQLErrors(
'Workspace guests cannot be owners of workspace projects'
)
expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code })
expect(res.data?.projectMutations.invites.createForWorkspace.id).to.not.be.ok
})
it("can't invite someone with a viewer seat to be a contributor", async () => {
const res = await gqlHelpers.createWorkspaceProjectInvite({
projectId: myProjectInviteTargetWorkspaceNewPlanProject.id,
inputs: [
{
userId: workspaceGuest.id,
role: Roles.Stream.Contributor
}
]
})
expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code })
expect(res.data?.projectMutations.invites.createForWorkspace.id).to.not.be.ok
})