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
@@ -34,7 +34,15 @@ export type LegacyGetStreams = (params: {
workspaceIdWhitelist?: string[] | null | undefined
offset?: MaybeNullOrUndefined<number>
publicOnly?: MaybeNullOrUndefined<boolean>
}) => Promise<{ streams: Stream[]; totalCount: number; cursorDate: Nullable<Date> }>
/**
* For filling in stream.role for the specified user
*/
userId?: string
}) => Promise<{
streams: StreamWithOptionalRole[]
totalCount: number
cursorDate: Nullable<Date>
}>
export type GetStreams = (
streamIds: string[],
@@ -76,6 +84,17 @@ export type GetStreamsCollaborators = (params: { streamIds: string[] }) => Promi
[streamId: string]: Array<LimitedUserWithStreamRole>
}>
export type GetStreamsCollaboratorCounts = (params: {
streamIds: string[]
type?: StreamRoles
}) => Promise<{
[streamId: string]:
| {
[role in StreamRoles]?: number
}
| undefined
}>
export type GetUserDeletableStreams = (userId: string) => Promise<Array<string>>
export type StoreStream = (
@@ -234,10 +253,18 @@ export type GetFavoritedStreamsCount = (
streamIdWhitelist?: Optional<string[]>
) => Promise<number>
export type RevokeStreamPermissions = (params: {
streamId: string
userId: string
}) => Promise<Optional<Stream>>
export type RevokeStreamPermissions = (
params: {
streamId: string
userId: string
},
options?: Partial<{
/**
* Whether to mark project record as updated
*/
trackProjectUpdate: boolean
}>
) => Promise<Optional<Stream>>
export type GrantStreamPermissions = (
params: {
@@ -311,6 +338,14 @@ export type AddOrUpdateStreamCollaborator = (
adderResourceAccessRules?: MaybeNullOrUndefined<TokenResourceIdentifier[]>,
options?: Partial<{
fromInvite: ServerInviteRecord
/**
* Whether to mark project record as updated
*/
trackProjectUpdate: boolean
/**
* Whether to skipp checking if setByUserId has access to the stream
*/
skipAuthorization: boolean
}>
) => Promise<Stream>
@@ -318,7 +353,40 @@ export type RemoveStreamCollaborator = (
streamId: string,
userId: string,
removedById: string,
removerResourceAccessRules?: MaybeNullOrUndefined<TokenResourceIdentifier[]>
removerResourceAccessRules?: MaybeNullOrUndefined<TokenResourceIdentifier[]>,
options?: Partial<{
/**
* Whether to mark project record as updated
*/
trackProjectUpdate: boolean
/**
* Whether to skipp checking if setByUserId has access to the stream
*/
skipAuthorization: boolean
}>
) => Promise<Stream>
export type SetStreamCollaborator = (
params: {
streamId: string
userId: string
/**
* Null/undefined means - remove collaborator
*/
role: MaybeNullOrUndefined<StreamRoles>
setByUserId: string
setterResourceAccessRules?: MaybeNullOrUndefined<TokenResourceIdentifier[]>
},
options?: Partial<{
/**
* Whether to mark project record as updated
*/
trackProjectUpdate: boolean
/**
* Whether to skipp checking if setByUserId has access to the stream
*/
skipAuthorization: boolean
}>
) => Promise<Stream>
export type CloneStream = (userId: string, sourceStreamId: string) => Promise<Stream>
@@ -96,7 +96,8 @@ import {
MarkCommitStreamUpdated,
MarkOnboardingBaseStream,
GetUserDeletableStreams,
GetStreamsCollaborators
GetStreamsCollaborators,
GetStreamsCollaboratorCounts
} from '@/modules/core/domain/streams/operations'
import { generateProjectName } from '@/modules/core/domain/projects/logic'
export type { StreamWithOptionalRole, StreamWithCommitId }
@@ -585,6 +586,33 @@ export const getDiscoverableStreamsPageFactory =
return await q
}
export const getStreamsCollaboratorCountsFactory =
(deps: { db: Knex }): GetStreamsCollaboratorCounts =>
async ({ streamIds, type }) => {
if (!streamIds.length) return {}
const q = tables
.streamAcl(deps.db)
.whereIn(StreamAcl.col.resourceId, streamIds)
.groupBy(StreamAcl.col.resourceId, StreamAcl.col.role)
.select<Array<{ streamId: string; role: StreamRoles; count: string }>>([
StreamAcl.colAs('resourceId', 'streamId'),
StreamAcl.col.role,
knex.raw('COUNT(*) as count')
])
if (type) {
q.andWhere(StreamAcl.col.role, type)
}
const res = await q
return res.reduce((acc, { streamId, role, count }) => {
acc[streamId] = acc[streamId] || {}
acc[streamId][role] = parseInt(count)
return acc
}, {} as Awaited<ReturnType<GetStreamsCollaboratorCounts>>)
}
/**
* Get stream collaborators for multiple streams at a time
*/
@@ -1087,8 +1115,9 @@ export const deleteProjectRoleFactory =
export const revokeStreamPermissionsFactory =
(deps: { db: Knex }): RevokeStreamPermissions =>
async (params: { streamId: string; userId: string }) => {
async (params, options) => {
const { streamId, userId } = params
const { trackProjectUpdate = true } = options || {}
const existingPermission = await tables
.streamAcl(deps.db)
@@ -1147,12 +1176,14 @@ export const revokeStreamPermissionsFactory =
})
}
// update stream updated at
const [stream] = await tables
.streams(deps.db)
.where({ id: streamId })
.update({ updatedAt: knex.fn.now() }, '*')
// update stream updated at, if enabled
const streamQ = tables.streams(deps.db).where({ id: streamId })
if (trackProjectUpdate) {
streamQ.update({ updatedAt: knex.fn.now() }, '*')
}
const [stream] = await streamQ
return stream
}
@@ -1219,10 +1250,10 @@ export const legacyGetStreamsFactory =
streamIdWhitelist,
workspaceIdWhitelist,
offset,
publicOnly
publicOnly,
userId
}) => {
const query = tables.streams(deps.db)
const countQuery = tables.streams(deps.db)
if (searchQuery) {
const whereFunc: Knex.QueryCallback = function () {
@@ -1233,7 +1264,6 @@ export const legacyGetStreamsFactory =
)
}
query.where(whereFunc)
countQuery.where(whereFunc)
}
if (publicOnly) {
@@ -1250,20 +1280,33 @@ export const legacyGetStreamsFactory =
this.where({ isPublic })
}
query.andWhere(publicFunc)
countQuery.andWhere(publicFunc)
}
if (streamIdWhitelist?.length) {
query.whereIn('id', streamIdWhitelist)
countQuery.whereIn('id', streamIdWhitelist)
}
if (workspaceIdWhitelist?.length) {
query.whereIn('workspaceId', workspaceIdWhitelist)
countQuery.whereIn('workspaceId', workspaceIdWhitelist)
}
const [res] = await countQuery.count()
if (userId) {
query.select<StreamWithOptionalRole[]>([
...Object.values(Streams.col),
// Getting first role from grouped results
knex.raw(`(array_agg("stream_acl"."role"))[1] as role`)
])
query.leftJoin(StreamAcl.name, function () {
this.on(StreamAcl.col.resourceId, Streams.col.id).andOnVal(
StreamAcl.col.userId,
userId
)
})
query.groupBy(Streams.col.id)
}
const countQ = deps.db.from(query.clone().as('t1')).count()
const [res] = await countQ
const count = parseInt(res.count + '')
if (!count) return { streams: [], totalCount: 0, cursorDate: null }
@@ -6,6 +6,7 @@ import {
IsStreamCollaborator,
RemoveStreamCollaborator,
RevokeStreamPermissions,
SetStreamCollaborator,
ValidateStreamAccess
} from '@/modules/core/domain/streams/operations'
import { GetUser } from '@/modules/core/domain/users/operations'
@@ -17,6 +18,7 @@ import { StreamRecord } from '@/modules/core/helpers/types'
import { ServerInvitesEvents } from '@/modules/serverinvites/domain/events'
import { AuthorizeResolver } from '@/modules/shared/domain/operations'
import { BadRequestError, ForbiddenError, LogicError } from '@/modules/shared/errors'
import { DependenciesOf } from '@/modules/shared/helpers/factory'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { ensureError, Roles, StreamRoles } from '@speckle/shared'
@@ -93,24 +95,26 @@ export const removeStreamCollaboratorFactory =
revokeStreamPermissions: RevokeStreamPermissions
emitEvent: EventBusEmit
}): RemoveStreamCollaborator =>
async (streamId, userId, removedById, removerResourceAccessRules) => {
if (userId !== removedById) {
// User must be a stream owner to remove others
await deps.validateStreamAccess(
removedById,
streamId,
Roles.Stream.Owner,
removerResourceAccessRules
)
} else {
// User must have any kind of role to remove himself
const isCollaborator = await deps.isStreamCollaborator(userId, streamId)
if (!isCollaborator) {
throw new StreamAccessUpdateError('User is not a stream collaborator')
async (streamId, userId, removedById, removerResourceAccessRules, options) => {
if (!options?.skipAuthorization) {
if (userId !== removedById) {
// User must be a stream owner to remove others
await deps.validateStreamAccess(
removedById,
streamId,
Roles.Stream.Owner,
removerResourceAccessRules
)
} else {
// User must have any kind of role to remove himself
const isCollaborator = await deps.isStreamCollaborator(userId, streamId)
if (!isCollaborator) {
throw new StreamAccessUpdateError('User is not a stream collaborator')
}
}
}
const stream = await deps.revokeStreamPermissions({ streamId, userId })
const stream = await deps.revokeStreamPermissions({ streamId, userId }, options)
if (!stream) {
throw new LogicError('Stream not found')
}
@@ -150,26 +154,28 @@ export const addOrUpdateStreamCollaboratorFactory =
role,
addedById,
adderResourceAccessRules,
{ fromInvite } = {}
{ fromInvite, trackProjectUpdate, skipAuthorization } = {}
) => {
const validRoles = Object.values(Roles.Stream) as string[]
if (!validRoles.includes(role)) {
throw new LogicError('Unexpected stream role')
}
if (userId === addedById) {
throw new StreamInvalidAccessError(
'User cannot change their own stream access level'
if (!skipAuthorization) {
if (userId === addedById) {
throw new StreamInvalidAccessError(
'User cannot change their own stream access level'
)
}
await deps.validateStreamAccess(
addedById,
streamId,
Roles.Stream.Owner,
adderResourceAccessRules
)
}
await deps.validateStreamAccess(
addedById,
streamId,
Roles.Stream.Owner,
adderResourceAccessRules
)
// make sure server guests cannot be stream owners
if (role === Roles.Stream.Owner) {
const user = await deps.getUser(userId, { withRole: true })
@@ -188,11 +194,14 @@ export const addOrUpdateStreamCollaboratorFactory =
}
})
const stream = (await deps.grantStreamPermissions({
streamId,
userId,
role: role as StreamRoles
})) as StreamRecord // validateStreamAccess already checked that it exists
const stream = (await deps.grantStreamPermissions(
{
streamId,
userId,
role: role as StreamRoles
},
{ trackProjectUpdate }
)) as StreamRecord // validateStreamAccess already checked that it exists
if (fromInvite) {
await deps.emitEvent({
@@ -217,3 +226,33 @@ export const addOrUpdateStreamCollaboratorFactory =
return stream
}
export const setStreamCollaboratorFactory =
(
deps: DependenciesOf<typeof addOrUpdateStreamCollaboratorFactory> &
DependenciesOf<typeof removeStreamCollaboratorFactory>
): SetStreamCollaborator =>
async (params, options) => {
const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory(deps)
const removeStreamCollaborator = removeStreamCollaboratorFactory(deps)
const { streamId, userId, role, setterResourceAccessRules, setByUserId } = params
if (role) {
return await addOrUpdateStreamCollaborator(
streamId,
userId,
role,
setByUserId,
setterResourceAccessRules,
options
)
} else {
return await removeStreamCollaborator(
streamId,
userId,
setByUserId,
setterResourceAccessRules,
options
)
}
}
@@ -6,7 +6,13 @@ import {
WorkspacePlanProductPrices,
WorkspacePricingProducts
} from '@/modules/gatekeeperCore/domain/billing'
import { PaidWorkspacePlans, WorkspacePlanBillingIntervals } from '@speckle/shared'
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import {
Nullable,
Optional,
PaidWorkspacePlans,
WorkspacePlanBillingIntervals
} from '@speckle/shared'
import { OverrideProperties } from 'type-fest'
import { z } from 'zod'
@@ -14,6 +20,10 @@ export type GetWorkspacePlan = (args: {
workspaceId: string
}) => Promise<WorkspacePlan | null>
export type GetWorkspaceWithPlan = (args: {
workspaceId: string
}) => Promise<Optional<Workspace & { plan: Nullable<WorkspacePlan> }>>
export type UpsertTrialWorkspacePlan = (args: {
workspacePlan: TrialWorkspacePlan
}) => Promise<void>
@@ -214,3 +224,11 @@ export type GetRecurringPrices = () => Promise<
>
export type GetWorkspacePlanProductPrices = () => Promise<WorkspacePlanProductPrices>
export type GetWorkspaceRolesAndSeats = (params: { workspaceId: string }) => Promise<{
[userId: string]: {
role: WorkspaceAcl
seat: Nullable<WorkspaceSeat>
userId: string
}
}>
@@ -201,7 +201,12 @@ export = FF_GATEKEEPER_MODULE_ENABLED
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }),
emit: getEventBus().emit
})
await assignSeat({ workspaceId, userId, type: seatType })
await assignSeat({
workspaceId,
userId,
type: seatType,
assignedByUserId: ctx.userId!
})
return ctx.loaders.workspaces!.getWorkspace.load(workspaceId)
}
@@ -1,4 +1,4 @@
import { Streams } from '@/modules/core/dbSchema'
import { buildTableHelper, Streams } from '@/modules/core/dbSchema'
import {
CheckoutSession,
GetCheckoutSession,
@@ -15,7 +15,8 @@ import {
GetWorkspaceSubscriptionBySubscriptionId,
GetWorkspaceSubscriptions,
UpsertTrialWorkspacePlan,
UpsertUnpaidWorkspacePlan
UpsertUnpaidWorkspacePlan,
GetWorkspaceWithPlan
} from '@/modules/gatekeeper/domain/billing'
import {
ChangeExpiredTrialWorkspacePlanStatuses,
@@ -23,19 +24,52 @@ import {
GetWorkspacePlanByProjectId
} from '@/modules/gatekeeper/domain/operations'
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { Workspaces } from '@/modules/workspacesCore/helpers/db'
import { Knex } from 'knex'
import { omit } from 'lodash'
const WorkspacePlans = buildTableHelper('workspace_plans', [
'workspaceId',
'name',
'status',
'createdAt',
'updatedAt'
])
const tables = {
workspaces: (db: Knex) => db<Workspace>('workspaces'),
workspacePlans: (db: Knex) => db<WorkspacePlan>('workspace_plans'),
workspacePlans: (db: Knex) => db<WorkspacePlan>(WorkspacePlans.name),
workspaceCheckoutSessions: (db: Knex) =>
db<CheckoutSession>('workspace_checkout_sessions'),
workspaceSubscriptions: (db: Knex) =>
db<WorkspaceSubscription>('workspace_subscriptions')
}
export const getWorkspaceWithPlanFactory =
(deps: { db: Knex }): GetWorkspaceWithPlan =>
async ({ workspaceId }) => {
const q = tables
.workspaces(deps.db)
.select<Workspace & { plans: WorkspacePlan[] }>([
...Workspaces.cols,
WorkspacePlans.groupArray('plans')
])
.leftJoin(WorkspacePlans.name, WorkspacePlans.col.workspaceId, Workspaces.col.id)
.where(Workspaces.col.id, workspaceId)
.groupBy(Workspaces.col.id)
.first()
const workspace = await q
if (!workspace) return undefined
return {
...omit(workspace, 'plans'),
plan: formatJsonArrayRecords(workspace.plans || [])[0] || null
}
}
export const getWorkspacePlanFactory =
({ db }: { db: Knex }): GetWorkspacePlan =>
async ({ workspaceId }) => {
@@ -1,5 +1,8 @@
import { buildTableHelper } from '@/modules/core/dbSchema'
import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing'
import {
GetWorkspaceRolesAndSeats,
WorkspaceSeat
} from '@/modules/gatekeeper/domain/billing'
import {
CountSeatsByTypeInWorkspace,
CreateWorkspaceSeat,
@@ -7,6 +10,9 @@ import {
GetWorkspaceUserSeat,
GetWorkspaceUserSeats
} from '@/modules/gatekeeper/domain/operations'
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
import { WorkspaceAcl as WorkspaceAclRecord } from '@/modules/workspacesCore/domain/types'
import { WorkspaceAcl } from '@/modules/workspacesCore/helpers/db'
import { Knex } from 'knex'
const WorkspaceSeats = buildTableHelper('workspace_seats', [
@@ -18,7 +24,8 @@ const WorkspaceSeats = buildTableHelper('workspace_seats', [
])
const tables = {
workspaceSeats: (db: Knex) => db<WorkspaceSeat>(WorkspaceSeats.name)
workspaceSeats: (db: Knex) => db<WorkspaceSeat>(WorkspaceSeats.name),
workspaceAcl: (db: Knex) => db<WorkspaceAclRecord>(WorkspaceAcl.name)
}
export const countSeatsByTypeInWorkspaceFactory =
@@ -79,3 +86,37 @@ export const getWorkspaceUserSeatFactory =
})
return seats[userId]
}
export const getWorkspaceRolesAndSeatsFactory =
(deps: { db: Knex }): GetWorkspaceRolesAndSeats =>
async ({ workspaceId }) => {
const q = tables
.workspaceAcl(deps.db)
.select<Array<{ seats: WorkspaceSeat[]; roles: WorkspaceAclRecord[] }>>([
// There's only ever gonna be 1 role and seat per user, but this way we can avoid having to group
// by many columns and we can get everything in 1 query
WorkspaceAcl.groupArray('roles'),
WorkspaceSeats.groupArray('seats')
])
.leftJoin(WorkspaceSeats.name, (j1) => {
j1.on(WorkspaceSeats.col.userId, WorkspaceAcl.col.userId).andOnVal(
WorkspaceSeats.col.workspaceId,
workspaceId
)
})
.where(WorkspaceAcl.col.workspaceId, workspaceId)
.groupBy(WorkspaceAcl.col.userId)
const res = await q
return res.reduce((acc, row) => {
const role = formatJsonArrayRecords(row.roles)[0]
if (!role) return acc
acc[role.userId] = {
role,
seat: formatJsonArrayRecords(row.seats || [])[0] || null,
userId: role.userId
}
return acc
}, {} as Awaited<ReturnType<GetWorkspaceRolesAndSeats>>)
}
@@ -2,6 +2,9 @@ import { UserRoleData } from '@/modules/shared/domain/rolesAndScopes/types'
import { AvailableRoles } from '@speckle/shared'
import { isUndefined } from 'lodash'
/**
* Order roles by weight in descending order (meaning - highest permission roles come first)
*/
export const orderByWeight = <T extends AvailableRoles>(
roles: T[],
definitions: UserRoleData<T>[]
@@ -1,5 +1,4 @@
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { StreamRecord } from '@/modules/core/helpers/types'
import {
Workspace,
WorkspaceAcl,
@@ -20,12 +19,9 @@ import {
StreamRoles,
WorkspaceRoles
} from '@speckle/shared'
import {
WorkspaceCreationState,
WorkspaceRoleToDefaultProjectRoleMapping
} from '@/modules/workspaces/domain/types'
import { WorkspaceCreationState } from '@/modules/workspaces/domain/types'
import { WorkspaceTeam } from '@/modules/workspaces/domain/types'
import { Stream } from '@/modules/core/domain/streams/types'
import { Stream, StreamWithOptionalRole } from '@/modules/core/domain/streams/types'
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import { ServerRegion } from '@/modules/multiregion/domain/types'
import { SetOptional } from 'type-fest'
@@ -199,22 +195,37 @@ export type UpdateWorkspaceRole = (
* Only add or upgrade role, prevent downgrades
*/
preventRoleDowngrade?: boolean
updatedByUserId: string
}
) => Promise<void>
export type GetWorkspaceRoleToDefaultProjectRoleMapping = (args: {
export type GetWorkspaceRolesAllowedProjectRolesFactory = (params: {
workspaceId: string
}) => Promise<WorkspaceRoleToDefaultProjectRoleMapping>
}) => Promise<{
defaultProjectRole: (args: {
workspaceRole: WorkspaceRoles
seatType: MaybeNullOrUndefined<WorkspaceSeatType>
}) => StreamRoles | null
allowedProjectRoles: (args: {
workspaceRole: WorkspaceRoles
seatType: MaybeNullOrUndefined<WorkspaceSeatType>
}) => StreamRoles[]
}>
/** Workspace Projects */
type QueryAllWorkspaceProjectsArgs = {
workspaceId: string
/**
* Optionally get project roles for a specific user
*/
userId?: string
}
export type QueryAllWorkspaceProjects = (
args: QueryAllWorkspaceProjectsArgs
) => AsyncGenerator<StreamRecord[], void, unknown>
) => AsyncGenerator<StreamWithOptionalRole[], void, unknown>
/** Workspace Project Roles */
@@ -337,7 +348,9 @@ export type GetWorkspaceJoinRequest = (
) => Promise<WorkspaceJoinRequest | undefined>
export type ApproveWorkspaceJoinRequest = (
params: Pick<WorkspaceJoinRequest, 'workspaceId' | 'userId'>
params: Pick<WorkspaceJoinRequest, 'workspaceId' | 'userId'> & {
approvedByUserId: string
}
) => Promise<boolean>
export type DenyWorkspaceJoinRequest = (
@@ -388,7 +401,10 @@ export type CopyProjectAutomations = (params: {
}) => Promise<Record<string, number>>
export type AssignWorkspaceSeat = (
params: Pick<WorkspaceSeat, 'userId' | 'workspaceId'> & { type: WorkspaceSeatType }
params: Pick<WorkspaceSeat, 'userId' | 'workspaceId'> & {
type: WorkspaceSeatType
assignedByUserId: string
}
) => Promise<WorkspaceSeat>
export type EnsureValidWorkspaceRoleSeat = (params: {
@@ -1,7 +1,7 @@
export { WorkspaceInviteResourceTarget } from '@/modules/workspacesCore/domain/types'
import { LimitedUserRecord, UserWithRole } from '@/modules/core/helpers/types'
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
import { StreamRoles, WorkspaceRoles } from '@speckle/shared'
import { WorkspaceRoles } from '@speckle/shared'
declare module '@/modules/serverinvites/domain/types' {
interface InviteResourceTargetTypeMap {
@@ -22,10 +22,6 @@ export type WorkspaceTeamMember = UserWithRole<LimitedUserRecord> & {
export type WorkspaceTeam = WorkspaceTeamMember[]
export type WorkspaceRoleToDefaultProjectRoleMapping = {
[key in WorkspaceRoles]: StreamRoles | null
}
export type WorkspaceCreationState = {
workspaceId: string
completed: boolean
@@ -1,7 +1,10 @@
import {
deleteProjectRoleFactory,
getStreamFactory,
getStreamsCollaboratorCountsFactory,
grantStreamPermissionsFactory,
legacyGetStreamsFactory,
revokeStreamPermissionsFactory,
upsertProjectRoleFactory
} from '@/modules/core/repositories/streams'
import {
@@ -9,8 +12,7 @@ import {
GetDefaultRegion,
GetWorkspace,
GetWorkspaceRoleForUser,
GetWorkspaceRoles,
GetWorkspaceRoleToDefaultProjectRoleMapping,
GetWorkspaceRolesAllowedProjectRolesFactory,
QueryAllWorkspaceProjects
} from '@/modules/workspaces/domain/operations'
import {
@@ -25,12 +27,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 {
PaidWorkspacePlansNew,
Roles,
throwUncoveredError,
WorkspaceRoles
} from '@speckle/shared'
import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared'
import {
DeleteProjectRole,
UpsertProjectRole
@@ -47,14 +44,18 @@ import {
} from '@/modules/workspaces/repositories/workspaces'
import {
queryAllWorkspaceProjectsFactory,
getWorkspaceRoleToDefaultProjectRoleMappingFactory
getWorkspaceRolesAllowedProjectRolesFactory
} from '@/modules/workspaces/services/projects'
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
import {
findEmailsByUserIdFactory,
findVerifiedEmailsByUserIdFactory
} from '@/modules/core/repositories/userEmails'
import { GetStream } from '@/modules/core/domain/streams/operations'
import {
GetStream,
GetStreamsCollaboratorCounts,
SetStreamCollaborator
} from '@/modules/core/domain/streams/operations'
import {
GetUserSsoSession,
GetWorkspaceSsoProviderRecord
@@ -66,7 +67,6 @@ import {
getWorkspaceSsoProviderRecordFactory
} from '@/modules/workspaces/repositories/sso'
import {
WorkspaceAdminError,
WorkspaceInvalidRoleError,
WorkspacesNotAuthorizedError
} from '@/modules/workspaces/errors/workspace'
@@ -80,6 +80,7 @@ import { getBaseTrackingProperties, getClient } from '@/modules/shared/utils/mix
import {
calculateSubscriptionSeats,
GetWorkspacePlan,
GetWorkspaceRolesAndSeats,
GetWorkspaceSubscription,
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
@@ -89,28 +90,37 @@ import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions'
import {
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory
getWorkspaceSubscriptionFactory,
getWorkspaceWithPlanFactory
} from '@/modules/gatekeeper/repositories/billing'
import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
import {
createWorkspaceSeatFactory,
deleteWorkspaceSeatFactory,
getWorkspaceRolesAndSeatsFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
import {
DeleteWorkspaceSeat,
GetWorkspaceUserSeat
} from '@/modules/gatekeeper/domain/operations'
import {
isStreamCollaboratorFactory,
setStreamCollaboratorFactory,
validateStreamAccessFactory
} from '@/modules/core/services/streams/access'
import { getUserFactory } from '@/modules/core/repositories/users'
import { authorizeResolver } from '@/modules/shared'
export const onProjectCreatedFactory =
({
getWorkspaceRoles,
getWorkspaceRolesAndSeats,
upsertProjectRole,
getWorkspaceRoleToDefaultProjectRoleMapping
getWorkspaceRolesAllowedProjectRoles
}: {
getWorkspaceRoles: GetWorkspaceRoles
getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats
upsertProjectRole: UpsertProjectRole
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory
}) =>
async (payload: ProjectEventsPayloads[typeof ProjectEvents.Created]) => {
const { id: projectId, workspaceId } = payload.project
@@ -119,20 +129,21 @@ export const onProjectCreatedFactory =
return
}
const workspaceMembers = await getWorkspaceRoles({ workspaceId })
const workspaceMembers = Object.values(
await getWorkspaceRolesAndSeats({ workspaceId })
)
const defaultRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping({
const { defaultProjectRole } = await getWorkspaceRolesAllowedProjectRoles({
workspaceId
})
await Promise.all(
workspaceMembers.map(({ userId, role: workspaceRole }) => {
const projectRole = defaultRoleMapping[workspaceRole]
workspaceMembers.map(({ userId, role: { role: workspaceRole }, seat }) => {
const projectRole = defaultProjectRole({ workspaceRole, seatType: seat?.type })
if (!projectRole) return
// we do not need to assign new roles to the project owner
if (userId === payload.ownerId) return
// Guests do not get roles on project create
if (!projectRole || workspaceRole === Roles.Workspace.Guest) return
return upsertProjectRole({
projectId,
@@ -182,7 +193,8 @@ export const onInviteFinalizedFactory =
role: workspaceRole,
userId: targetUserId,
workspaceId: project.workspaceId,
skipProjectRoleUpdatesFor: [project.id]
skipProjectRoleUpdatesFor: [project.id],
updatedByUserId: invite.inviterId
})
}
@@ -244,56 +256,81 @@ export const onWorkspaceRoleDeletedFactory =
export const onWorkspaceRoleUpdatedFactory =
({
getWorkspaceRoleToDefaultProjectRoleMapping,
getWorkspaceRolesAllowedProjectRoles,
queryAllWorkspaceProjects,
deleteProjectRole,
upsertProjectRole
setStreamCollaborator,
getStreamsCollaboratorCounts
}: {
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
deleteProjectRole: DeleteProjectRole
upsertProjectRole: UpsertProjectRole
setStreamCollaborator: SetStreamCollaborator
getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts
}) =>
async ({
acl,
flags
flags,
seatType,
updatedByUserId
}: {
acl: { userId: string; role: WorkspaceRoles; workspaceId: string }
seatType: WorkspaceSeatType
flags?: {
skipProjectRoleUpdatesFor: string[]
}
updatedByUserId: string
}) => {
const { userId, role, workspaceId } = acl
const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping(
{
workspaceId
}
)
const { defaultProjectRole } = await getWorkspaceRolesAllowedProjectRoles({
workspaceId
})
const nextProjectRole = defaultProjectRoleMapping[role]
const nextUserRole = defaultProjectRole({ workspaceRole: role, seatType })
// Keep user's project roles in sync with their workspace role & seat type
for await (const projectsPage of queryAllWorkspaceProjects({
workspaceId,
userId
})) {
const projectsOldOwnerCounts = await getStreamsCollaboratorCounts({
streamIds: projectsPage.map((p) => p.id),
type: Roles.Stream.Owner
})
for await (const projectsPage of queryAllWorkspaceProjects({ workspaceId })) {
await Promise.all(
projectsPage.map(async ({ id: projectId }) => {
projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => {
if (flags?.skipProjectRoleUpdatesFor.includes(projectId)) {
// Skip assignment (used during invite flow)
// TODO: Can we refactor this special case away?
return
}
if (!nextProjectRole) {
// User is being demoted to a workspace role without project access
await deleteProjectRole({ projectId, userId })
return
// If downgraded from owner & last owner, transfer ownership to admin causing the role update (updatedByUserId)
const isNoLongerOwner =
originalProjectRole === Roles.Stream.Owner &&
(!nextUserRole || nextUserRole !== Roles.Stream.Owner)
const wasLastOwner =
projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1
if (isNoLongerOwner && wasLastOwner) {
await setStreamCollaborator(
{
streamId: projectId,
userId: updatedByUserId,
role: Roles.Stream.Owner,
setByUserId: updatedByUserId
},
{ trackProjectUpdate: false, skipAuthorization: true }
)
}
await upsertProjectRole(
// Finally change target role
await setStreamCollaborator(
{
projectId,
streamId: projectId,
userId,
role: nextProjectRole
role: nextUserRole,
setByUserId: updatedByUserId
},
{ trackProjectUpdate: false }
{ trackProjectUpdate: false, skipAuthorization: true }
)
})
)
@@ -481,7 +518,7 @@ const blockInvalidWorkspaceProjectRoleUpdatesFactory =
getStream: GetStream
getWorkspaceRoleForUser: GetWorkspaceRoleForUser
getWorkspaceUserSeat: GetWorkspaceUserSeat
getWorkspacePlan: GetWorkspacePlan
getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory
}) =>
async ({ payload }: EventPayload<typeof ProjectEvents.PermissionsBeingAdded>) => {
const project = await deps.getStream({ streamId: payload.projectId })
@@ -491,41 +528,23 @@ const blockInvalidWorkspaceProjectRoleUpdatesFactory =
workspaceId: project.workspaceId,
userId: payload.targetUserId
}
const [currentWorkspaceRole, seat, plan] = await Promise.all([
const [currentWorkspaceRole, seat, { allowedProjectRoles }] = await Promise.all([
deps.getWorkspaceRoleForUser(roleSeatParams),
deps.getWorkspaceUserSeat(roleSeatParams),
deps.getWorkspacePlan({ workspaceId: project.workspaceId })
deps.getWorkspaceRolesAllowedProjectRoles({ workspaceId: project.workspaceId })
])
// Workspace role checks
if (currentWorkspaceRole?.role === Roles.Workspace.Admin) {
// User is workspace admin and cannot have their project roles changed
throw new WorkspaceAdminError()
}
if (!currentWorkspaceRole) return
if (
currentWorkspaceRole?.role === Roles.Workspace.Guest &&
payload.role === Roles.Stream.Owner
) {
// Workspace guests cannot be project owners
throw new WorkspaceInvalidRoleError('Workspace guests cannot be project owners.')
}
const allowedRoles = allowedProjectRoles({
workspaceRole: currentWorkspaceRole.role,
seatType: seat?.type
})
// Workspace seat checks
if (
!plan ||
!seat ||
!(Object.values(PaidWorkspacePlansNew) as string[]).includes(plan.name)
) {
return // Doesn't apply
}
if (
seat.type === WorkspaceSeatType.Viewer &&
payload.role !== Roles.Stream.Reviewer
) {
if (!allowedRoles.includes(payload.role)) {
// User's workspace role does not allow the requested project role
throw new WorkspaceInvalidRoleError(
'Workspace viewers can only be project reviewers.'
`User's workspace role '${currentWorkspaceRole.role}' and seat type '${seat?.type}' does not allow project role '${payload.role}'.`
)
}
}
@@ -548,7 +567,10 @@ export const initializeEventListenersFactory =
getStream,
getWorkspaceRoleForUser,
getWorkspaceUserSeat,
getWorkspacePlan
getWorkspaceRolesAllowedProjectRoles:
getWorkspaceRolesAllowedProjectRolesFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
})
})
const createWorkspaceSeat = createWorkspaceSeatFactory({ db })
const ensureValidWorkspaceRoleSeat = ensureValidWorkspaceRoleSeatFactory({
@@ -559,12 +581,12 @@ export const initializeEventListenersFactory =
const quitCbs = [
eventBus.listen(ProjectEvents.Created, async ({ payload }) => {
const onProjectCreated = onProjectCreatedFactory({
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspace
getWorkspaceRolesAllowedProjectRoles:
getWorkspaceRolesAllowedProjectRolesFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
upsertProjectRole: upsertProjectRoleFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db })
getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db })
})
await onProjectCreated(payload)
}),
@@ -624,13 +646,22 @@ export const initializeEventListenersFactory =
eventBus.listen(WorkspaceEvents.RoleUpdated, async ({ payload }) => {
const trx = await db.transaction()
const onWorkspaceRoleUpdated = onWorkspaceRoleUpdatedFactory({
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspace
getWorkspaceRolesAllowedProjectRoles:
getWorkspaceRolesAllowedProjectRolesFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
upsertProjectRole: upsertProjectRoleFactory({ db: trx })
setStreamCollaborator: setStreamCollaboratorFactory({
getUser: getUserFactory({ db }),
validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }),
emitEvent: eventBus.emit,
grantStreamPermissions: grantStreamPermissionsFactory({ db: trx }),
isStreamCollaborator: isStreamCollaboratorFactory({
getStream: getStreamFactory({ db })
}),
revokeStreamPermissions: revokeStreamPermissionsFactory({ db: trx })
}),
getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db })
})
await withTransaction(onWorkspaceRoleUpdated(payload), trx)
}),
@@ -127,7 +127,7 @@ export default FF_WORKSPACES_MODULE_ENABLED
workspaceJoinRequestMutations: () => ({})
},
WorkspaceJoinRequestMutations: {
approve: async (_parent, args) => {
approve: async (_parent, args, ctx) => {
const approveWorkspaceJoinRequest =
commandFactory<ApproveWorkspaceJoinRequest>({
db,
@@ -163,7 +163,8 @@ export default FF_WORKSPACES_MODULE_ENABLED
})
return await approveWorkspaceJoinRequest({
userId: args.input.userId,
workspaceId: args.input.workspaceId
workspaceId: args.input.workspaceId,
approvedByUserId: ctx.userId!
})
},
deny: async (_parent, args) => {
@@ -98,7 +98,7 @@ import {
import {
createWorkspaceProjectFactory,
getWorkspaceProjectsFactory,
getWorkspaceRoleToDefaultProjectRoleMappingFactory,
getWorkspaceRolesAllowedProjectRolesFactory,
moveProjectToWorkspaceFactory,
queryAllWorkspaceProjectsFactory
} from '@/modules/workspaces/services/projects'
@@ -177,6 +177,7 @@ import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regio
import { convertFunctionToGraphQLReturn } from '@/modules/automate/services/functionManagement'
import {
getWorkspacePlanFactory,
getWorkspaceWithPlanFactory,
upsertWorkspacePlanFactory
} from '@/modules/gatekeeper/repositories/billing'
import { Knex } from 'knex'
@@ -206,6 +207,7 @@ import {
import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
import {
createWorkspaceSeatFactory,
getWorkspaceRolesAndSeatsFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
@@ -606,7 +608,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
})
})
})
await updateWorkspaceRole({ userId, workspaceId, role })
await updateWorkspaceRole({
userId,
workspaceId,
role,
updatedByUserId: context.userId!
})
}
return await getWorkspaceFactory({ db })({
@@ -1004,10 +1011,10 @@ export = FF_WORKSPACES_MODULE_ENABLED
updateProject: updateProjectFactory({ db }),
upsertProjectRole: upsertProjectRoleFactory({ db }),
getProjectCollaborators: getProjectCollaboratorsFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
getWorkspaceRoleToDefaultProjectRoleMapping:
getWorkspaceRoleToDefaultProjectRoleMappingFactory({
getWorkspace: getWorkspaceFactory({ db })
getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db }),
getWorkspaceRolesAllowedProjectRoles:
getWorkspaceRolesAllowedProjectRolesFactory({
getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db })
}),
updateWorkspaceRole: updateWorkspaceRoleFactory({
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
@@ -1025,7 +1032,11 @@ export = FF_WORKSPACES_MODULE_ENABLED
})
})
return await moveProjectToWorkspace({ projectId, workspaceId })
return await moveProjectToWorkspace({
projectId,
workspaceId,
movedByUserId: context.userId!
})
}
},
Workspace: {
@@ -575,7 +575,8 @@ export const processFinalizedWorkspaceInviteFactory =
userId: finalizerUserId,
workspaceId: workspace.id,
role: invite.resource.role || Roles.Workspace.Member,
preventRoleDowngrade: true
preventRoleDowngrade: true,
updatedByUserId: invite.inviterId
})
}
}
@@ -56,6 +56,10 @@ export const joinWorkspaceFactory =
})
await emitWorkspaceEvent({
eventName: WorkspaceEvents.RoleUpdated,
payload: { acl: { userId, workspaceId, role }, seatType: type }
payload: {
acl: { userId, workspaceId, role },
seatType: type,
updatedByUserId: userId
}
})
}
@@ -421,7 +421,8 @@ export const updateWorkspaceRoleFactory =
userId,
role: nextWorkspaceRole,
skipProjectRoleUpdatesFor,
preventRoleDowngrade
preventRoleDowngrade,
updatedByUserId
}): Promise<void> => {
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
@@ -494,7 +495,8 @@ export const updateWorkspaceRoleFactory =
seatType: type,
flags: {
skipProjectRoleUpdatesFor: skipProjectRoleUpdatesFor ?? []
}
},
updatedByUserId
}
})
}
@@ -1,9 +1,7 @@
import { StreamRecord } from '@/modules/core/helpers/types'
import {
GetDefaultRegion,
GetWorkspace,
GetWorkspaceRoles,
GetWorkspaceRoleToDefaultProjectRoleMapping,
GetWorkspaceRolesAllowedProjectRolesFactory,
QueryAllWorkspaceProjects,
UpdateWorkspaceRole
} from '@/modules/workspaces/domain/operations'
@@ -18,8 +16,14 @@ import {
UpdateProject,
UpsertProjectRole
} from '@/modules/core/domain/projects/operations'
import { chunk } from 'lodash'
import { Roles, StreamRoles } from '@speckle/shared'
import { chunk, intersection } from 'lodash'
import {
MaybeNullOrUndefined,
Roles,
StreamRoles,
throwUncoveredError,
WorkspaceRoles
} from '@speckle/shared'
import { orderByWeight } from '@/modules/shared/domain/rolesAndScopes/logic'
import coreUserRoles from '@/modules/core/roles'
import {
@@ -46,6 +50,13 @@ import {
getWorkspaceFactory,
upsertWorkspaceFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
GetWorkspaceRolesAndSeats,
GetWorkspaceWithPlan,
WorkspaceSeatType
} from '@/modules/gatekeeper/domain/billing'
import { isNewPaidPlanType } from '@/modules/gatekeeper/helpers/plans'
import { LogicError } from '@/modules/shared/errors'
export const queryAllWorkspaceProjectsFactory = ({
getStreams
@@ -53,7 +64,8 @@ export const queryAllWorkspaceProjectsFactory = ({
getStreams: LegacyGetStreams
}): QueryAllWorkspaceProjects =>
async function* queryAllWorkspaceProjects({
workspaceId
workspaceId,
userId
}): AsyncGenerator<StreamRecord[], void, unknown> {
let cursor: Date | null = null
let iterationCount = 0
@@ -64,11 +76,12 @@ export const queryAllWorkspaceProjectsFactory = ({
const { streams, cursorDate } = await getStreams({
cursor,
orderBy: null,
limit: 1000,
limit: 100,
visibility: null,
searchQuery: null,
streamIdWhitelist: null,
workspaceIdWhitelist: [workspaceId]
workspaceIdWhitelist: [workspaceId],
userId
})
yield streams
@@ -119,6 +132,7 @@ export const getWorkspaceProjectsFactory =
type MoveProjectToWorkspaceArgs = {
projectId: string
workspaceId: string
movedByUserId: string
}
export const moveProjectToWorkspaceFactory =
@@ -127,21 +141,22 @@ export const moveProjectToWorkspaceFactory =
updateProject,
upsertProjectRole,
getProjectCollaborators,
getWorkspaceRoles,
getWorkspaceRoleToDefaultProjectRoleMapping,
getWorkspaceRolesAndSeats,
getWorkspaceRolesAllowedProjectRoles,
updateWorkspaceRole
}: {
getProject: GetProject
updateProject: UpdateProject
upsertProjectRole: UpsertProjectRole
getProjectCollaborators: GetProjectCollaborators
getWorkspaceRoles: GetWorkspaceRoles
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats
getWorkspaceRolesAllowedProjectRoles: GetWorkspaceRolesAllowedProjectRolesFactory
updateWorkspaceRole: UpdateWorkspaceRole
}) =>
async ({
projectId,
workspaceId
workspaceId,
movedByUserId
}: MoveProjectToWorkspaceArgs): Promise<StreamRecord> => {
const project = await getProject({ projectId })
@@ -155,37 +170,56 @@ export const moveProjectToWorkspaceFactory =
// Update roles for current project members
const projectTeam = await getProjectCollaborators({ projectId })
const workspaceTeam = await getWorkspaceRoles({ workspaceId })
const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping(
{ workspaceId }
)
const workspaceTeam = await getWorkspaceRolesAndSeats({ workspaceId })
const {
defaultProjectRole: getDefaultProjectRole,
allowedProjectRoles: getAllowedProjectRoles
} = await getWorkspaceRolesAllowedProjectRoles({
workspaceId
})
for (const projectMembers of chunk(projectTeam, 5)) {
await Promise.all(
projectMembers.map(
async ({ id: userId, role: serverRole, streamRole: currentProjectRole }) => {
// Update workspace role. Prefer existing workspace role if there is one.
const currentWorkspaceRole = workspaceTeam.find(
(role) => role.userId === userId
)
const nextWorkspaceRole = currentWorkspaceRole ?? {
userId,
workspaceId,
role:
serverRole === Roles.Server.Guest
? Roles.Workspace.Guest
: Roles.Workspace.Member,
createdAt: new Date()
}
await updateWorkspaceRole(nextWorkspaceRole)
const currentWorkspaceRole = workspaceTeam[userId]?.role
const currentWorkspaceSeat = workspaceTeam[userId]?.seat
const nextWorkspaceRole = currentWorkspaceRole
? currentWorkspaceRole
: {
userId,
workspaceId,
role:
serverRole === Roles.Server.Guest
? Roles.Workspace.Guest
: Roles.Workspace.Member,
createdAt: new Date()
}
await updateWorkspaceRole({
...nextWorkspaceRole,
updatedByUserId: movedByUserId
})
// Update project role. Prefer default workspace project role if more permissive.
const defaultProjectRole =
defaultProjectRoleMapping[nextWorkspaceRole.role] ?? Roles.Stream.Reviewer
const nextProjectRole = orderByWeight(
getDefaultProjectRole({
workspaceRole: nextWorkspaceRole.role,
seatType: currentWorkspaceSeat?.type
}) ?? Roles.Stream.Reviewer
const allowedProjectRoles = getAllowedProjectRoles({
workspaceRole: nextWorkspaceRole.role,
seatType: currentWorkspaceSeat?.type
})
const rolePicks = intersection(
[currentProjectRole, defaultProjectRole],
coreUserRoles
)[0]
allowedProjectRoles
)
const nextProjectRole = orderByWeight(rolePicks, coreUserRoles)[0]
// TODO: Shouldn't this be the service call that also fires events?
await upsertProjectRole({
userId,
projectId,
@@ -200,23 +234,76 @@ export const moveProjectToWorkspaceFactory =
return await updateProject({ projectUpdate: { id: projectId, workspaceId } })
}
export const getWorkspaceRoleToDefaultProjectRoleMappingFactory =
({
getWorkspace
}: {
getWorkspace: GetWorkspace
}): GetWorkspaceRoleToDefaultProjectRoleMapping =>
export const getWorkspaceRolesAllowedProjectRolesFactory =
(deps: {
getWorkspaceWithPlan: GetWorkspaceWithPlan
}): GetWorkspaceRolesAllowedProjectRolesFactory =>
async ({ workspaceId }) => {
const workspace = await getWorkspace({ workspaceId })
const workspace = await deps.getWorkspaceWithPlan({ workspaceId })
if (!workspace) {
throw new WorkspaceNotFoundError()
}
const isNewPlan = workspace.plan && isNewPaidPlanType(workspace.plan.name)
const allowedProjectRoles = (args: {
workspaceRole: WorkspaceRoles
seatType: MaybeNullOrUndefined<WorkspaceSeatType>
}) => {
const { workspaceRole, seatType = WorkspaceSeatType.Viewer } = args
switch (workspaceRole) {
case Roles.Workspace.Guest:
if (isNewPlan && seatType === WorkspaceSeatType.Viewer) {
return [Roles.Stream.Reviewer]
} else {
return [Roles.Stream.Reviewer, Roles.Stream.Contributor]
}
case Roles.Workspace.Member:
if (isNewPlan && seatType === WorkspaceSeatType.Viewer) {
return [Roles.Stream.Reviewer]
} else {
return [Roles.Stream.Reviewer, Roles.Stream.Contributor, Roles.Stream.Owner]
}
case Roles.Workspace.Admin:
return [Roles.Stream.Owner]
default:
throwUncoveredError(workspaceRole)
}
}
const defaultProjectRole = (args: {
workspaceRole: WorkspaceRoles
seatType: MaybeNullOrUndefined<WorkspaceSeatType>
}) => {
const { workspaceRole, seatType = WorkspaceSeatType.Viewer } = args
const allowedRoles = allowedProjectRoles({ workspaceRole, seatType })
const role = (() => {
switch (workspaceRole) {
case Roles.Workspace.Guest:
return null // No default role
case Roles.Workspace.Member:
if (isNewPlan && seatType === WorkspaceSeatType.Viewer)
return Roles.Stream.Reviewer
return workspace.defaultProjectRole
case Roles.Workspace.Admin:
return Roles.Stream.Owner
default:
throwUncoveredError(workspaceRole)
}
})()
if (role && !allowedRoles.includes(role)) {
throw new LogicError('Invalid default project role')
}
return role
}
return {
[Roles.Workspace.Guest]: null,
[Roles.Workspace.Member]: workspace.defaultProjectRole,
[Roles.Workspace.Admin]: Roles.Stream.Owner
defaultProjectRole,
allowedProjectRoles
}
}
@@ -7,6 +7,7 @@ import {
import { GetUser } from '@/modules/core/domain/users/operations'
import { NotFoundError } from '@/modules/shared/errors'
import {
ApproveWorkspaceJoinRequest,
CreateWorkspaceJoinRequest,
DenyWorkspaceJoinRequest,
EnsureValidWorkspaceRoleSeat,
@@ -119,8 +120,8 @@ export const approveWorkspaceJoinRequestFactory =
upsertWorkspaceRole: UpsertWorkspaceRole
emit: EventBus['emit']
ensureValidWorkspaceRoleSeat: EnsureValidWorkspaceRoleSeat
}) =>
async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => {
}): ApproveWorkspaceJoinRequest =>
async ({ userId, workspaceId, approvedByUserId }) => {
const requester = await getUserById(userId)
if (!requester) {
throw new NotFoundError('User not found')
@@ -153,7 +154,11 @@ export const approveWorkspaceJoinRequestFactory =
await emit({ eventName: WorkspaceEvents.Updated, payload: { workspace } })
await emit({
eventName: WorkspaceEvents.RoleUpdated,
payload: { acl: { workspaceId, userId, role }, seatType: type }
payload: {
acl: { workspaceId, userId, role },
seatType: type,
updatedByUserId: approvedByUserId
}
})
await sendWorkspaceJoinRequestApprovedEmail({
@@ -96,7 +96,7 @@ export const assignWorkspaceSeatFactory =
getWorkspaceRoleForUser: GetWorkspaceRoleForUser
emit: EventBusEmit
}): AssignWorkspaceSeat =>
async ({ workspaceId, userId, type }) => {
async ({ workspaceId, userId, type, assignedByUserId }) => {
const workspaceAcl = await getWorkspaceRoleForUser({ workspaceId, userId })
if (!workspaceAcl) {
throw new NotFoundError('User does not have a role in the workspace')
@@ -127,7 +127,11 @@ export const assignWorkspaceSeatFactory =
await emit({
eventName: WorkspaceEvents.RoleUpdated,
payload: { acl: workspaceAcl, seatType: seat.type }
payload: {
acl: workspaceAcl,
seatType: seat.type,
updatedByUserId: assignedByUserId
}
})
return seat
@@ -308,14 +308,16 @@ export const assignToWorkspace = async (
await updateWorkspaceRole({
userId: user.id,
workspaceId: workspace.id,
role
role,
updatedByUserId: workspace.ownerId
})
if (seatType) {
await assignWorkspaceSeat({
userId: user.id,
workspaceId: workspace.id,
type: seatType
type: seatType,
assignedByUserId: workspace.ownerId
})
}
}
@@ -12,6 +12,7 @@ import {
createWebhookConfigFactory,
createWebhookEventFactory
} from '@/modules/webhooks/repositories/webhooks'
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
import {
assignToWorkspace,
BasicTestWorkspace,
@@ -281,9 +282,7 @@ describe('Workspace project GQL CRUD', () => {
})
const newRole = await getUserStreamRole(workspaceGuest.id, roleProject.id)
expect(res).to.haveGraphQLErrors(
'Workspace guests cannot be project owners'
)
expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code })
expect(newRole).to.eq(Roles.Stream.Reviewer)
})
@@ -310,12 +309,12 @@ describe('Workspace project GQL CRUD', () => {
expect(resB).to.not.haveGraphQLErrors()
expect(newRole).to.eq(Roles.Stream.Owner)
} else {
expect(resA).to.haveGraphQLErrors(
'Workspace viewers can only be project reviewers.'
)
expect(resB).to.haveGraphQLErrors(
'Workspace viewers can only be project reviewers.'
)
expect(resA).to.haveGraphQLErrors({
code: WorkspaceInvalidRoleError.code
})
expect(resB).to.haveGraphQLErrors({
code: WorkspaceInvalidRoleError.code
})
expect(newRole).to.eq(Roles.Stream.Reviewer)
}
})
@@ -250,7 +250,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
ensureValidWorkspaceRoleSeat: async () => {
throw new Error('Should not happen')
}
})({ workspaceId: createRandomString(), userId: createRandomString() })
})({
workspaceId: createRandomString(),
userId: createRandomString(),
approvedByUserId: createRandomString()
})
)
expect(err.message).to.equal('User not found')
@@ -270,7 +274,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
ensureValidWorkspaceRoleSeat: async () => {
throw new Error('Should not happen')
}
})({ workspaceId: createRandomString(), userId: createRandomString() })
})({
workspaceId: createRandomString(),
userId: createRandomString(),
approvedByUserId: createRandomString()
})
)
expect(err.message).to.equal(WorkspaceNotFoundError.defaultMessage)
@@ -298,7 +306,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
ensureValidWorkspaceRoleSeat: async () => {
throw new Error('Should not happen')
}
})({ workspaceId: createRandomString(), userId: createRandomString() })
})({
workspaceId: createRandomString(),
userId: createRandomString(),
approvedByUserId: createRandomString()
})
)
expect(err.message).to.equal('Workspace join request not found')
@@ -364,7 +376,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
createdAt: new Date(),
updatedAt: new Date()
})
})({ workspaceId: workspace.id, userId: user.id })
})({ workspaceId: workspace.id, userId: user.id, approvedByUserId: user.id })
).to.equal(true)
expect(
@@ -3,6 +3,7 @@ import {
createRandomEmail,
createRandomString
} from '@/modules/core/helpers/testHelpers'
import { deleteProjectRoleFactory } from '@/modules/core/repositories/streams'
import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
import { getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
import {
@@ -12,12 +13,14 @@ import {
} from '@/modules/workspaces/tests/helpers/creation'
import { BasicTestUser, createTestUser } from '@/test/authHelper'
import {
GetProjectCollaboratorsDocument,
UpdateWorkspaceSeatTypeDocument,
WorkspaceUpdateSeatTypeInput
} from '@/test/graphql/generated/graphql'
import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper'
import { beforeEachContext } from '@/test/hooks'
import { StripeClientMock } from '@/test/mocks/global'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
@@ -112,7 +115,7 @@ describe('Workspace Seats @graphql', () => {
expect(res.data?.workspaceMutations.updateSeatType).to.not.be.ok
})
it('should assign a workspace seat with the provided type and reconcile subscription', async () => {
it('should upgrade a workspace seat and reconcile subscription', async () => {
const user: BasicTestUser = {
id: createRandomString(),
name: createRandomString(),
@@ -151,5 +154,107 @@ describe('Workspace Seats @graphql', () => {
expect(reconcileArgs.prorationBehavior).to.eq('always_invoice') // new plan
expect(reconcileArgs.subscriptionData.products.length).to.be.ok
})
it('should downgrade a workspace seat', async () => {
const user: BasicTestUser = {
id: createRandomString(),
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
}
await createTestUser(user)
await assignToWorkspace(
testWorkspace1,
user,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
)
const res = await updateSeatType({
workspaceId: testWorkspace1.id,
userId: user.id,
seatType: WorkspaceSeatType.Viewer
})
expect(res).to.not.haveGraphQLErrors()
expect(
res.data?.workspaceMutations.updateSeatType.team.items.find(
(i) => i.id === user.id
)?.seatType
).to.eq(WorkspaceSeatType.Viewer)
})
it('should assign away project ownership on downgrade to viewer seat', async () => {
const testWorkspace2: BasicTestWorkspace = {
id: '',
slug: '',
ownerId: '',
name: 'Test Workspace 2'
}
await createTestWorkspace(testWorkspace2, workspaceAdmin, {
addPlan: { name: 'pro', status: 'valid' }
})
const user: BasicTestUser = {
id: createRandomString(),
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
}
await createTestUser(user)
await assignToWorkspace(
testWorkspace2,
user,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
)
const userOwnedProject: BasicTestStream = {
name: 'User Owned Project',
isPublic: false,
id: '',
ownerId: '',
workspaceId: testWorkspace2.id
}
await createTestStream(userOwnedProject, user)
// Manually remove admin stream role, to test that it's being added
await deleteProjectRoleFactory({ db })({
projectId: userOwnedProject.id,
userId: workspaceAdmin.id
})
const res1 = await updateSeatType({
workspaceId: testWorkspace2.id,
userId: user.id,
seatType: WorkspaceSeatType.Viewer
})
expect(res1).to.not.haveGraphQLErrors()
expect(
res1.data?.workspaceMutations.updateSeatType.team.items.find(
(i) => i.id === user.id
)?.seatType
).to.eq(WorkspaceSeatType.Viewer)
// Check project ownership
const res2 = await apollo.execute(
GetProjectCollaboratorsDocument,
{
projectId: userOwnedProject.id
},
{ assertNoErrors: true }
)
expect(res2.data?.project.id).to.eq(userOwnedProject.id)
expect(res2.data?.project.team.length).to.greaterThanOrEqual(2)
const adminRes = res2.data?.project.team.find((t) => t.id === workspaceAdmin.id)
const userRes = res2.data?.project.team.find((t) => t.id === user.id)
expect(adminRes?.role).to.eq(Roles.Stream.Owner)
expect(userRes?.role).to.eq(Roles.Stream.Reviewer)
})
})
})
@@ -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)
@@ -34,6 +34,7 @@ type WorkspaceRoleUpdatedPayload = {
acl: Pick<WorkspaceAcl, 'userId' | 'workspaceId' | 'role'>
seatType: WorkspaceSeatType
flags?: { skipProjectRoleUpdatesFor: string[] }
updatedByUserId: string
}
type WorkspaceJoinedFromDiscoveryPayload = {
userId: string
@@ -5577,6 +5577,13 @@ export type UpdateProjectRoleMutationVariables = Exact<{
export type UpdateProjectRoleMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', updateRole: { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: SimpleProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } } };
export type GetProjectCollaboratorsQueryVariables = Exact<{
projectId: Scalars['String']['input'];
}>;
export type GetProjectCollaboratorsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, team: Array<{ __typename?: 'ProjectCollaborator', id: string, role: string }> } };
export type CreateServerInviteMutationVariables = Exact<{
input: ServerInviteCreateInput;
}>;
@@ -6058,6 +6065,7 @@ export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"Ope
export const CreateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<CreateProjectMutation, CreateProjectMutationVariables>;
export const BatchDeleteProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchDeleteProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}]}]}}]}}]} as unknown as DocumentNode<BatchDeleteProjectsMutation, BatchDeleteProjectsMutationVariables>;
export const UpdateProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProjectRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateProjectRoleMutation, UpdateProjectRoleMutationVariables>;
export const GetProjectCollaboratorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectCollaborators"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]} as unknown as DocumentNode<GetProjectCollaboratorsQuery, GetProjectCollaboratorsQueryVariables>;
export const CreateServerInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServerInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<CreateServerInviteMutation, CreateServerInviteMutationVariables>;
export const CreateStreamInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"StreamInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamInviteCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<CreateStreamInviteMutation, CreateStreamInviteMutationVariables>;
export const ResendInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResendInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteResend"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}]}]}}]} as unknown as DocumentNode<ResendInviteMutation, ResendInviteMutationVariables>;
+12
View File
@@ -93,3 +93,15 @@ export const updateProjectRoleMutation = gql`
${basicProjectFieldsFragment}
`
export const getProjectCollaboratorsQuery = gql`
query GetProjectCollaborators($projectId: String!) {
project(id: $projectId) {
id
team {
id
role
}
}
}
`
+10 -3
View File
@@ -23,6 +23,7 @@ import {
MaybeNullOrUndefined,
Nullable,
Optional,
retry,
wait
} from '@speckle/shared'
import * as mocha from 'mocha'
@@ -233,8 +234,11 @@ const truncateTablesFactory = (deps: { db: Knex }) => async (tableNames?: string
if (!tableNames.length) return // Nothing to truncate
// We're deleting everything, so lets turn off triggers to avoid deadlocks/slowdowns
await deps.db.transaction(async (trx) => {
await trx.raw(`
// This still seems to randomly cause deadlocks, so adding a retry
await retry(
async () =>
await deps.db.transaction(async (trx) => {
await trx.raw(`
-- Disable triggers and foreign key constraints for this session
SET session_replication_role = replica;
@@ -243,7 +247,10 @@ const truncateTablesFactory = (deps: { db: Knex }) => async (tableNames?: string
-- Re-enable triggers and foreign key constraints
SET session_replication_role = DEFAULT;
`)
})
}),
3,
200
)
} else {
await deps.db.raw(`truncate table ${tableNames.join(',')} cascade`)
}