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
@@ -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