Merge remote-tracking branch 'origin' into chuck/web-2435-move-comments-and-webhooks-without-attachments
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
export { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
|
||||
export const WorkspaceEarlyAdopterDiscount = {
|
||||
name: '50% Early Adopter Discount',
|
||||
amount: 0.5
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export type GetWorkspaceBySlugOrId = (args: {
|
||||
}) => Promise<Workspace | null>
|
||||
|
||||
export type GetWorkspaces = (args: {
|
||||
workspaceIds: string[]
|
||||
workspaceIds?: string[]
|
||||
userId?: string
|
||||
}) => Promise<WorkspaceWithOptionalRole[]>
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { UserSsoSessionRecord } from '@/modules/workspaces/domain/sso/types'
|
||||
import {
|
||||
OidcProfile,
|
||||
SpeckleOidcProfile,
|
||||
UserSsoSessionRecord
|
||||
} from '@/modules/workspaces/domain/sso/types'
|
||||
import { UnknownObject, UserinfoResponse } from 'openid-client'
|
||||
|
||||
/**
|
||||
* Get the default expiration time for an SSO session based on the current time.
|
||||
@@ -13,3 +18,22 @@ export const getDefaultSsoSessionExpirationDate = (): Date => {
|
||||
export const isValidSsoSession = (session: UserSsoSessionRecord): boolean => {
|
||||
return session.validUntil.getTime() > new Date().getTime()
|
||||
}
|
||||
|
||||
export const isValidOidcProfile = (
|
||||
profile: UserinfoResponse<UnknownObject, UnknownObject>
|
||||
): profile is OidcProfile => {
|
||||
return !!profile.email || !!profile.upn
|
||||
}
|
||||
|
||||
const isSpeckleOidcProfile = (
|
||||
profile: OidcProfile
|
||||
): profile is OidcProfile<SpeckleOidcProfile> => {
|
||||
return Object.hasOwn(profile, 'email')
|
||||
}
|
||||
|
||||
/**
|
||||
* Special handling required in case we encounter Entra ID with a particular configuration.
|
||||
*/
|
||||
export const getEmailFromOidcProfile = (profile: OidcProfile): string => {
|
||||
return isSpeckleOidcProfile(profile) ? profile.email : profile.upn
|
||||
}
|
||||
|
||||
@@ -53,3 +53,23 @@ export type OidcProviderAttributes = {
|
||||
export type SsoSessionState = {
|
||||
isValidationFlow: boolean
|
||||
}
|
||||
|
||||
export type OidcProfile<
|
||||
OidcProviderProperties = SpeckleOidcProfile | MicrosoftEntraIdProfile
|
||||
> = {
|
||||
sub: string
|
||||
} & OidcProviderProperties
|
||||
|
||||
/**
|
||||
* OIDC profile properties required by Speckle SSO logic.
|
||||
*/
|
||||
export type SpeckleOidcProfile = {
|
||||
email: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Because of course.
|
||||
*/
|
||||
export type MicrosoftEntraIdProfile = {
|
||||
upn: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { WorkspaceInviteResourceTarget } from '@/modules/workspacesCore/domain/types'
|
||||
import { LimitedUserRecord, UserWithRole } from '@/modules/core/helpers/types'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import { StreamRoles, WorkspaceRoles } from '@speckle/shared'
|
||||
|
||||
declare module '@/modules/serverinvites/domain/types' {
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
upsertProjectRoleFactory
|
||||
} from '@/modules/core/repositories/streams'
|
||||
import {
|
||||
CountWorkspaceRoleWithOptionalProjectRole,
|
||||
GetDefaultRegion,
|
||||
GetWorkspace,
|
||||
GetWorkspaceRoleForUser,
|
||||
GetWorkspaceRoles,
|
||||
@@ -22,8 +24,8 @@ import {
|
||||
import { logger, moduleLogger } from '@/logging/logging'
|
||||
import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
|
||||
import { EventPayload, getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
|
||||
import { Roles, WorkspaceRoles } from '@speckle/shared'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared'
|
||||
import {
|
||||
DeleteProjectRole,
|
||||
UpsertProjectRole
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
|
||||
import { Knex } from 'knex'
|
||||
import {
|
||||
countWorkspaceRoleWithOptionalProjectRoleFactory,
|
||||
getWorkspaceFactory,
|
||||
getWorkspaceRoleForUserFactory,
|
||||
getWorkspaceRolesFactory,
|
||||
@@ -42,7 +45,10 @@ import {
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory
|
||||
} from '@/modules/workspaces/services/projects'
|
||||
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
|
||||
import { findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
|
||||
import {
|
||||
findEmailsByUserIdFactory,
|
||||
findVerifiedEmailsByUserIdFactory
|
||||
} from '@/modules/core/repositories/userEmails'
|
||||
import { GetStream } from '@/modules/core/domain/streams/operations'
|
||||
import {
|
||||
GetUserSsoSession,
|
||||
@@ -61,6 +67,20 @@ import {
|
||||
ProjectEvents,
|
||||
ProjectEventsPayloads
|
||||
} from '@/modules/core/domain/projects/events'
|
||||
import { getBaseTrackingProperties, getClient } from '@/modules/shared/utils/mixpanel'
|
||||
import {
|
||||
calculateSubscriptionSeats,
|
||||
GetWorkspacePlan,
|
||||
GetWorkspaceSubscription
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import { getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe'
|
||||
import { Workspace } from '@/modules/workspacesCore/domain/types'
|
||||
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
|
||||
import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions'
|
||||
import {
|
||||
getWorkspacePlanFactory,
|
||||
getWorkspaceSubscriptionFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
|
||||
export const onProjectCreatedFactory =
|
||||
({
|
||||
@@ -258,6 +278,134 @@ export const onWorkspaceRoleUpdatedFactory =
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceTrackingFactory =
|
||||
({
|
||||
getWorkspace,
|
||||
countWorkspaceRole,
|
||||
getDefaultRegion,
|
||||
getWorkspacePlan,
|
||||
getWorkspaceSubscription,
|
||||
getUserEmails
|
||||
}: {
|
||||
getWorkspace: GetWorkspace
|
||||
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
|
||||
getDefaultRegion: GetDefaultRegion
|
||||
getWorkspacePlan: GetWorkspacePlan
|
||||
getWorkspaceSubscription: GetWorkspaceSubscription
|
||||
getUserEmails: FindEmailsByUserId
|
||||
}) =>
|
||||
async (params: EventPayload<'workspace.*'> | EventPayload<'gatekeeper.*'>) => {
|
||||
const { eventName, payload } = params
|
||||
const mixpanel = getClient()
|
||||
if (!mixpanel) return
|
||||
const calculateProperties = async (workspace: Workspace) => {
|
||||
const workspaceId = workspace.id
|
||||
const [adminCount, memberCount, guestCount, defaultRegion, plan, subscription] =
|
||||
await Promise.all([
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: Roles.Workspace.Admin }),
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: Roles.Workspace.Member }),
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: Roles.Workspace.Guest }),
|
||||
getDefaultRegion({ workspaceId }),
|
||||
getWorkspacePlan({ workspaceId }),
|
||||
getWorkspaceSubscription({ workspaceId })
|
||||
])
|
||||
const seats = subscription?.subscriptionData
|
||||
? calculateSubscriptionSeats({
|
||||
subscriptionData: subscription?.subscriptionData,
|
||||
guestSeatProductId: getWorkspacePlanProductId({ workspacePlan: 'guest' })
|
||||
})
|
||||
: { plan: 0, guest: 0 }
|
||||
return {
|
||||
name: workspace.name,
|
||||
description: workspace.description,
|
||||
domainBasedMembershipProtectionEnabled:
|
||||
workspace.domainBasedMembershipProtectionEnabled,
|
||||
discoverabilityEnabled: workspace.discoverabilityEnabled,
|
||||
defaultRegionKey: defaultRegion?.key,
|
||||
teamTotalCount: adminCount + memberCount + guestCount,
|
||||
teamAdminCount: adminCount,
|
||||
teamMemberCount: memberCount,
|
||||
teamGuestCount: guestCount,
|
||||
planName: plan?.name || '',
|
||||
planStatus: plan?.status || '',
|
||||
planCreatedAt: plan?.createdAt,
|
||||
subscriptionBillingInterval: subscription?.billingInterval,
|
||||
subscriptionCurrentBillingCycleEnd: subscription?.currentBillingCycleEnd,
|
||||
seats: seats.plan,
|
||||
seatsGuest: seats.guest,
|
||||
...getBaseTrackingProperties()
|
||||
}
|
||||
}
|
||||
const checkForSpeckleMembers = async ({
|
||||
userId
|
||||
}: {
|
||||
userId: string
|
||||
}): Promise<{ hasSpeckleMembers: boolean }> => {
|
||||
const userEmails = await getUserEmails({ userId })
|
||||
return {
|
||||
hasSpeckleMembers: userEmails.some((e) => e.email.endsWith('@speckle.systems'))
|
||||
}
|
||||
}
|
||||
switch (eventName) {
|
||||
case 'gatekeeper.workspace-plan-updated':
|
||||
const updatedPlanWorkspace = await getWorkspace({
|
||||
workspaceId: payload.workspacePlan.workspaceId
|
||||
})
|
||||
if (!updatedPlanWorkspace) break
|
||||
mixpanel.groups.set(
|
||||
'workspace_id',
|
||||
payload.workspacePlan.workspaceId,
|
||||
await calculateProperties(updatedPlanWorkspace)
|
||||
)
|
||||
break
|
||||
case 'gatekeeper.workspace-trial-expired':
|
||||
break
|
||||
case 'workspace.authorized':
|
||||
break
|
||||
case 'workspace.created':
|
||||
payload.createdByUserId
|
||||
|
||||
// we're setting workspace props and attributing to speckle users
|
||||
mixpanel.groups.set('workspace_id', payload.workspace.id, {
|
||||
...(await calculateProperties(payload.workspace)),
|
||||
...(await checkForSpeckleMembers({ userId: payload.createdByUserId }))
|
||||
})
|
||||
break
|
||||
case 'workspace.updated':
|
||||
// just updating workspace props
|
||||
mixpanel.groups.set(
|
||||
'workspace_id',
|
||||
payload.workspace.id,
|
||||
await calculateProperties(payload.workspace)
|
||||
)
|
||||
break
|
||||
case 'workspace.deleted':
|
||||
// just marking workspace deleted
|
||||
mixpanel.groups.set('workspace_id', payload.workspaceId, {
|
||||
isDeleted: true,
|
||||
...getBaseTrackingProperties()
|
||||
})
|
||||
break
|
||||
case 'workspace.role-deleted':
|
||||
case 'workspace.role-updated':
|
||||
const speckleMembers = await checkForSpeckleMembers({ userId: payload.userId })
|
||||
const workspace = await getWorkspace({ workspaceId: payload.workspaceId })
|
||||
if (!workspace) break
|
||||
mixpanel.groups.set('workspace_id', payload.workspaceId, {
|
||||
...(await calculateProperties(workspace)),
|
||||
// only marking has speckle members to true
|
||||
// calculating this for speckle member removal would require getting all users
|
||||
// that is too costly in here imho
|
||||
...(speckleMembers.hasSpeckleMembers ? speckleMembers : {})
|
||||
})
|
||||
break
|
||||
case 'workspace.joined-from-discovery':
|
||||
break
|
||||
default:
|
||||
throwUncoveredError(eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const emitWorkspaceGraphqlSubscriptionsFactory =
|
||||
(deps: { getWorkspace: GetWorkspace }) => async (params: EventPayload<'**'>) => {
|
||||
const { eventName, payload } = params
|
||||
@@ -342,6 +490,26 @@ export const initializeEventListenersFactory =
|
||||
})
|
||||
await onInviteFinalized(payload)
|
||||
}),
|
||||
eventBus.listen('workspace.*', async (payload) => {
|
||||
await workspaceTrackingFactory({
|
||||
countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }),
|
||||
getDefaultRegion: getDefaultRegionFactory({ db }),
|
||||
getUserEmails: findEmailsByUserIdFactory({ db }),
|
||||
getWorkspace: getWorkspaceFactory({ db }),
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db })
|
||||
})(payload)
|
||||
}),
|
||||
eventBus.listen('gatekeeper.*', async (payload) => {
|
||||
await workspaceTrackingFactory({
|
||||
countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }),
|
||||
getDefaultRegion: getDefaultRegionFactory({ db }),
|
||||
getUserEmails: findEmailsByUserIdFactory({ db }),
|
||||
getWorkspace: getWorkspaceFactory({ db }),
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db })
|
||||
})(payload)
|
||||
}),
|
||||
eventBus.listen(WorkspaceEvents.Authorized, async ({ payload }) => {
|
||||
const onWorkspaceAuthorized = onWorkspaceAuthorizedFactory({
|
||||
getWorkspace,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { db } from '@/db/knex'
|
||||
import {
|
||||
Resolvers,
|
||||
WorkspacePlans,
|
||||
WorkspacePlanStatuses
|
||||
} from '@/modules/core/graph/generated/graphql'
|
||||
import { Resolvers } from '@/modules/core/graph/generated/graphql'
|
||||
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
|
||||
import {
|
||||
getProjectCollaboratorsFactory,
|
||||
@@ -47,7 +43,7 @@ import { getInvitationTargetUsersFactory } from '@/modules/serverinvites/service
|
||||
import { authorizeResolver } from '@/modules/shared'
|
||||
import { getFeatureFlags, getServerOrigin } from '@/modules/shared/helpers/envHelper'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import {
|
||||
WorkspaceInvalidRoleError,
|
||||
WorkspaceJoinNotAllowedError,
|
||||
@@ -147,7 +143,6 @@ import {
|
||||
validateStreamAccessFactory
|
||||
} from '@/modules/core/services/streams/access'
|
||||
import {
|
||||
addStreamInviteAcceptedActivityFactory,
|
||||
addStreamPermissionsAddedActivityFactory,
|
||||
addStreamPermissionsRevokedActivityFactory
|
||||
} from '@/modules/activitystream/services/streamActivity'
|
||||
@@ -194,13 +189,10 @@ import { getGenericRedis } from '@/modules/shared/redis/redis'
|
||||
import { convertFunctionToGraphQLReturn } from '@/modules/automate/services/functionManagement'
|
||||
import {
|
||||
getWorkspacePlanFactory,
|
||||
upsertPaidWorkspacePlanFactory,
|
||||
upsertTrialWorkspacePlanFactory,
|
||||
upsertUnpaidWorkspacePlanFactory
|
||||
upsertWorkspacePlanFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import { Knex } from 'knex'
|
||||
import { getPaginatedItemsFactory } from '@/modules/shared/services/paginatedItems'
|
||||
import { InvalidWorkspacePlanStatus } from '@/modules/gatekeeper/errors/billing'
|
||||
import { BadRequestError } from '@/modules/shared/errors'
|
||||
import {
|
||||
dismissWorkspaceJoinRequestFactory,
|
||||
@@ -212,6 +204,8 @@ import {
|
||||
} from '@/modules/workspaces/repositories/workspaceJoinRequests'
|
||||
import { sendWorkspaceJoinRequestReceivedEmailFactory } from '@/modules/workspaces/services/workspaceJoinRequestEmails/received'
|
||||
import { getProjectFactory } from '@/modules/core/repositories/projects'
|
||||
import { OperationTypeNode } from 'graphql'
|
||||
import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans'
|
||||
|
||||
const eventBus = getEventBus()
|
||||
const getServerInfo = getServerInfoFactory({ db })
|
||||
@@ -289,10 +283,7 @@ const updateStreamRoleAndNotify = updateStreamRoleAndNotifyFactory({
|
||||
validateStreamAccess,
|
||||
getUser,
|
||||
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
|
||||
addStreamInviteAcceptedActivity: addStreamInviteAcceptedActivityFactory({
|
||||
saveActivity,
|
||||
publish
|
||||
}),
|
||||
emitEvent: getEventBus().emit,
|
||||
addStreamPermissionsAddedActivity: addStreamPermissionsAddedActivityFactory({
|
||||
saveActivity,
|
||||
publish
|
||||
@@ -439,73 +430,13 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
AdminMutations: {
|
||||
updateWorkspacePlan: async (_parent, { input }) => {
|
||||
const { workspaceId, plan: name, status } = input
|
||||
const workspace = await getWorkspaceFactory({ db })({
|
||||
workspaceId
|
||||
})
|
||||
const createdAt = new Date()
|
||||
if (!workspace) throw new WorkspaceNotFoundError()
|
||||
switch (name) {
|
||||
case WorkspacePlans.Starter:
|
||||
switch (status) {
|
||||
case WorkspacePlanStatuses.Trial:
|
||||
case WorkspacePlanStatuses.Expired:
|
||||
await upsertTrialWorkspacePlanFactory({ db })({
|
||||
workspacePlan: { workspaceId, status, name, createdAt }
|
||||
})
|
||||
return true
|
||||
case WorkspacePlanStatuses.Valid:
|
||||
case WorkspacePlanStatuses.CancelationScheduled:
|
||||
case WorkspacePlanStatuses.Canceled:
|
||||
case WorkspacePlanStatuses.PaymentFailed:
|
||||
await upsertPaidWorkspacePlanFactory({ db })({
|
||||
workspacePlan: { workspaceId, status, name, createdAt }
|
||||
})
|
||||
return true
|
||||
default:
|
||||
throwUncoveredError(status)
|
||||
}
|
||||
case WorkspacePlans.Business:
|
||||
case WorkspacePlans.Plus:
|
||||
switch (status) {
|
||||
case WorkspacePlanStatuses.Trial:
|
||||
case WorkspacePlanStatuses.Expired:
|
||||
throw new InvalidWorkspacePlanStatus()
|
||||
case WorkspacePlanStatuses.Valid:
|
||||
case WorkspacePlanStatuses.CancelationScheduled:
|
||||
case WorkspacePlanStatuses.Canceled:
|
||||
case WorkspacePlanStatuses.PaymentFailed:
|
||||
await upsertPaidWorkspacePlanFactory({ db })({
|
||||
workspacePlan: { workspaceId, status, name, createdAt }
|
||||
})
|
||||
return true
|
||||
default:
|
||||
throwUncoveredError(status)
|
||||
}
|
||||
|
||||
case WorkspacePlans.Academia:
|
||||
case WorkspacePlans.Unlimited:
|
||||
case WorkspacePlans.StarterInvoiced:
|
||||
case WorkspacePlans.PlusInvoiced:
|
||||
case WorkspacePlans.BusinessInvoiced:
|
||||
switch (status) {
|
||||
case WorkspacePlanStatuses.Valid:
|
||||
await upsertUnpaidWorkspacePlanFactory({ db })({
|
||||
workspacePlan: { workspaceId, status, name, createdAt }
|
||||
})
|
||||
|
||||
return true
|
||||
case WorkspacePlanStatuses.CancelationScheduled:
|
||||
case WorkspacePlanStatuses.Canceled:
|
||||
case WorkspacePlanStatuses.Expired:
|
||||
case WorkspacePlanStatuses.PaymentFailed:
|
||||
case WorkspacePlanStatuses.Trial:
|
||||
throw new InvalidWorkspacePlanStatus()
|
||||
default:
|
||||
throwUncoveredError(status)
|
||||
}
|
||||
default:
|
||||
throwUncoveredError(name)
|
||||
}
|
||||
await updateWorkspacePlanFactory({
|
||||
getWorkspace: getWorkspaceFactory({ db }),
|
||||
upsertWorkspacePlan: upsertWorkspacePlanFactory({ db }),
|
||||
emitEvent: getEventBus().emit
|
||||
})({ workspaceId, name, status })
|
||||
return true
|
||||
}
|
||||
},
|
||||
WorkspaceMutations: {
|
||||
@@ -584,7 +515,8 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({
|
||||
getStreams: legacyGetStreamsFactory({ db })
|
||||
}),
|
||||
deleteSsoProvider: deleteSsoProviderFactory({ db })
|
||||
deleteSsoProvider: deleteSsoProviderFactory({ db }),
|
||||
emitWorkspaceEvent: getEventBus().emit
|
||||
})
|
||||
|
||||
// this should be turned into a get all regions and map over the regions...
|
||||
@@ -1005,7 +937,8 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
context.userId!,
|
||||
args.input.workspaceId,
|
||||
Roles.Workspace.Member,
|
||||
context.resourceAccessRules
|
||||
context.resourceAccessRules,
|
||||
OperationTypeNode.MUTATION
|
||||
)
|
||||
|
||||
const createWorkspaceProject = createWorkspaceProjectFactory({
|
||||
@@ -1040,13 +973,15 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
context.userId,
|
||||
projectId,
|
||||
Roles.Stream.Owner,
|
||||
context.resourceAccessRules
|
||||
context.resourceAccessRules,
|
||||
OperationTypeNode.MUTATION
|
||||
)
|
||||
await authorizeResolver(
|
||||
context.userId,
|
||||
workspaceId,
|
||||
Roles.Workspace.Admin,
|
||||
context.resourceAccessRules
|
||||
context.resourceAccessRules,
|
||||
OperationTypeNode.MUTATION
|
||||
)
|
||||
|
||||
const moveProjectToWorkspace = commandFactory({
|
||||
|
||||
@@ -57,7 +57,10 @@ export const buildValidationErrorRedirectUrl = (
|
||||
error: string,
|
||||
oidcProvider?: OidcProvider
|
||||
) => {
|
||||
const url = new URL(`/workspaces/${workspaceSlug}`, getFrontendOrigin())
|
||||
const url = new URL(
|
||||
`/settings/workspaces/${workspaceSlug}/security`,
|
||||
getFrontendOrigin()
|
||||
)
|
||||
|
||||
url.searchParams.set('ssoValidationSuccess', 'false')
|
||||
url.searchParams.set('ssoError', error)
|
||||
|
||||
@@ -114,7 +114,7 @@ export const copyWorkspaceFactory =
|
||||
.workspaces(deps.targetDb)
|
||||
.insert(workspace)
|
||||
.onConflict(Workspaces.withoutTablePrefix.col.id)
|
||||
.merge(Workspaces.withoutTablePrefix.cols as (keyof Workspace)[])
|
||||
.ignore()
|
||||
|
||||
return workspaceId
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
filterByResource,
|
||||
InvitesRetrievalValidityFilter
|
||||
} from '@/modules/serverinvites/repositories/serverInvites'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import { clamp } from 'lodash'
|
||||
import {
|
||||
WorkspaceCreationState,
|
||||
@@ -136,17 +136,10 @@ const workspaceWithRoleBaseQuery = ({
|
||||
|
||||
export const getWorkspacesFactory =
|
||||
({ db }: { db: Knex }): GetWorkspaces =>
|
||||
async (params: {
|
||||
workspaceIds: string[]
|
||||
/**
|
||||
* Optionally - for each workspace, return the user's role in that workspace
|
||||
*/
|
||||
userId?: string
|
||||
}) => {
|
||||
const { workspaceIds, userId } = params
|
||||
|
||||
async ({ workspaceIds, userId }) => {
|
||||
const q = workspaceWithRoleBaseQuery({ db, userId })
|
||||
const results = await q.whereIn(Workspaces.col.id, workspaceIds)
|
||||
if (workspaceIds !== undefined) q.whereIn(Workspaces.col.id, workspaceIds)
|
||||
const results = await q
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getGenericRedis } from '@/modules/shared/redis/redis'
|
||||
import { generators, UserinfoResponse } from 'openid-client'
|
||||
import { oidcProvider } from '@/modules/workspaces/domain/sso/models'
|
||||
import {
|
||||
OidcProfile,
|
||||
OidcProvider,
|
||||
SsoSessionState,
|
||||
WorkspaceSsoProvider
|
||||
@@ -79,7 +80,11 @@ import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repos
|
||||
import { sendEmail } from '@/modules/emails/services/sending'
|
||||
import { renderEmail } from '@/modules/emails/services/emailRendering'
|
||||
import { createAuthorizationCodeFactory } from '@/modules/auth/repositories/apps'
|
||||
import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic'
|
||||
import {
|
||||
getDefaultSsoSessionExpirationDate,
|
||||
isValidOidcProfile,
|
||||
getEmailFromOidcProfile
|
||||
} from '@/modules/workspaces/domain/sso/logic'
|
||||
import {
|
||||
GetWorkspaceBySlug,
|
||||
GetWorkspaceRoles
|
||||
@@ -321,7 +326,8 @@ export const getSsoRouter = (): Router => {
|
||||
linkUserWithSsoProvider: linkUserWithSsoProviderFactory({
|
||||
findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }),
|
||||
createUserEmail: createUserEmailFactory({ db: trx }),
|
||||
updateUserEmail: updateUserEmailFactory({ db: trx })
|
||||
updateUserEmail: updateUserEmailFactory({ db: trx }),
|
||||
logger: req.log
|
||||
}),
|
||||
upsertUserSsoSession: upsertUserSsoSessionFactory({ db: trx })
|
||||
})
|
||||
@@ -642,7 +648,7 @@ const getOidcProviderUserDataFactory =
|
||||
WorkspaceSsoOidcCallbackRequestQuery
|
||||
>,
|
||||
provider: OidcProvider
|
||||
): Promise<UserinfoResponse<{ email: string }>> => {
|
||||
): Promise<UserinfoResponse<OidcProfile>> => {
|
||||
if (!req.session.ssoNonce) throw new OidcStateInvalidError()
|
||||
|
||||
const codeVerifier = await parseCodeVerifier(req)
|
||||
@@ -658,24 +664,29 @@ const getOidcProviderUserDataFactory =
|
||||
if (!oidcProviderUserData) {
|
||||
throw new SsoProviderProfileMissingError()
|
||||
}
|
||||
if (!oidcProviderUserData.email) {
|
||||
if (!isValidOidcProfile(oidcProviderUserData)) {
|
||||
req.log.error(
|
||||
{ providedClaims: Object.keys(oidcProviderUserData) },
|
||||
'Missing required properties on OIDC provider.'
|
||||
)
|
||||
throw new SsoProviderProfileMissingPropertiesError(['email'])
|
||||
}
|
||||
|
||||
return oidcProviderUserData as UserinfoResponse<{ email: string }>
|
||||
return oidcProviderUserData as UserinfoResponse<OidcProfile>
|
||||
}
|
||||
|
||||
const tryGetSpeckleUserDataFactory =
|
||||
({ findEmail, getUser }: { findEmail: FindEmail; getUser: GetUser }) =>
|
||||
async (
|
||||
req: Request<WorkspaceSsoAuthRequestParams>,
|
||||
oidcProviderUserData: UserinfoResponse<{ email: string }>
|
||||
oidcProviderUserData: UserinfoResponse<OidcProfile>
|
||||
): Promise<UserWithOptionalRole | null> => {
|
||||
// Get currently signed-in user, if available
|
||||
const currentSessionUser = await getUser(req.context.userId ?? '')
|
||||
|
||||
// Get user with email that matches OIDC provider user email, if match exists
|
||||
const userEmail = await findEmail({ email: oidcProviderUserData.email })
|
||||
const providerEmail = getEmailFromOidcProfile(oidcProviderUserData)
|
||||
const userEmail = await findEmail({ email: providerEmail })
|
||||
if (!!userEmail && !userEmail.verified) throw new SsoUserEmailUnverifiedError()
|
||||
const existingSpeckleUser = await getUser(userEmail?.userId ?? '')
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
} from '@/modules/serverinvites/services/operations'
|
||||
import { authorizeResolver } from '@/modules/shared'
|
||||
import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import {
|
||||
GetWorkspace,
|
||||
GetWorkspaceBySlug,
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
FindVerifiedEmailsByUserId
|
||||
} from '@/modules/core/domain/userEmails/operations'
|
||||
import { DeleteAllResourceInvites } from '@/modules/serverinvites/domain/operations'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import { ProjectInviteResourceType } from '@/modules/serverinvites/domain/constants'
|
||||
import { chunk, isEmpty, omit } from 'lodash'
|
||||
import { userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/domain/logic'
|
||||
@@ -291,13 +291,15 @@ export const deleteWorkspaceFactory =
|
||||
deleteProject,
|
||||
queryAllWorkspaceProjects,
|
||||
deleteAllResourceInvites,
|
||||
deleteSsoProvider
|
||||
deleteSsoProvider,
|
||||
emitWorkspaceEvent
|
||||
}: {
|
||||
deleteWorkspace: DeleteWorkspace
|
||||
deleteProject: DeleteStreamRecord
|
||||
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
|
||||
deleteAllResourceInvites: DeleteAllResourceInvites
|
||||
deleteSsoProvider: DeleteSsoProvider
|
||||
emitWorkspaceEvent: EventBus['emit']
|
||||
}) =>
|
||||
async ({ workspaceId }: WorkspaceDeleteArgs): Promise<void> => {
|
||||
// Delete workspace SSO provider, if present
|
||||
@@ -328,6 +330,10 @@ export const deleteWorkspaceFactory =
|
||||
for (const projectIdsChunk of chunk(projectIds, 25)) {
|
||||
await Promise.all(projectIdsChunk.map((projectId) => deleteProject(projectId)))
|
||||
}
|
||||
await emitWorkspaceEvent({
|
||||
eventName: WorkspaceEvents.Deleted,
|
||||
payload: { workspaceId }
|
||||
})
|
||||
}
|
||||
|
||||
type WorkspaceRoleDeleteArgs = {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import {
|
||||
OidcProvider,
|
||||
OidcProviderRecord,
|
||||
OidcProviderAttributes
|
||||
OidcProviderAttributes,
|
||||
OidcProfile
|
||||
} from '@/modules/workspaces/domain/sso/types'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { UserinfoResponse } from 'openid-client'
|
||||
@@ -33,7 +34,11 @@ import {
|
||||
} from '@/modules/workspaces/errors/sso'
|
||||
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
|
||||
import { LimitedWorkspace } from '@/modules/workspacesCore/domain/types'
|
||||
import { isValidSsoSession } from '@/modules/workspaces/domain/sso/logic'
|
||||
import {
|
||||
getEmailFromOidcProfile,
|
||||
isValidSsoSession
|
||||
} from '@/modules/workspaces/domain/sso/logic'
|
||||
import { Logger } from '@/logging/logging'
|
||||
|
||||
// this probably should go a lean validation endpoint too
|
||||
const validateOidcProviderAttributes = ({
|
||||
@@ -129,7 +134,7 @@ export const createWorkspaceUserFromSsoProfileFactory =
|
||||
deleteInvite: DeleteInvite
|
||||
}) =>
|
||||
async (args: {
|
||||
ssoProfile: UserinfoResponse<{ email: string }>
|
||||
ssoProfile: UserinfoResponse<OidcProfile>
|
||||
workspaceId: string
|
||||
}): Promise<Pick<UserWithOptionalRole, 'id' | 'email'>> => {
|
||||
// Check if user has email-based invite to given workspace
|
||||
@@ -146,7 +151,8 @@ export const createWorkspaceUserFromSsoProfileFactory =
|
||||
}
|
||||
|
||||
// Create Speckle user
|
||||
const { name, email } = args.ssoProfile
|
||||
const { name } = args.ssoProfile
|
||||
const email = getEmailFromOidcProfile(args.ssoProfile)
|
||||
|
||||
if (!name) {
|
||||
throw new SsoProviderProfileInvalidError('SSO provider user requires a name')
|
||||
@@ -185,42 +191,56 @@ export const linkUserWithSsoProviderFactory =
|
||||
({
|
||||
findEmailsByUserId,
|
||||
createUserEmail,
|
||||
updateUserEmail
|
||||
updateUserEmail,
|
||||
logger
|
||||
}: {
|
||||
findEmailsByUserId: FindEmailsByUserId
|
||||
createUserEmail: CreateUserEmail
|
||||
updateUserEmail: UpdateUserEmail
|
||||
logger?: Logger
|
||||
}) =>
|
||||
async (args: {
|
||||
userId: string
|
||||
ssoProfile: UserinfoResponse<{ email: string }>
|
||||
ssoProfile: UserinfoResponse<OidcProfile>
|
||||
}): Promise<void> => {
|
||||
// TODO: Chuck's soapbox -
|
||||
//
|
||||
// Assert link between req.user.id & { providerId: decryptedOidcProvider.id, email: oidcProviderUserData.email }
|
||||
// Create link implicitly if req.context.userId exists (user performed SSO flow while signed in)
|
||||
// If req.context.userId does not exist, and link does not exist, throw and require user to sign in before SSO
|
||||
//
|
||||
// In addition, investigate using oidcProviderUserData.sub as source of truth here. Some providers appear to allow
|
||||
// `email` fields to change, or do not guarantee they will exist (Entra ID)
|
||||
|
||||
// Add oidcProviderUserData.email to req.user.id verified emails, if not already present
|
||||
// Add SSO provider email to req.user.id verified emails, if not already present
|
||||
const userEmails = await findEmailsByUserId({ userId: args.userId })
|
||||
const maybeSsoEmail = userEmails.find(
|
||||
(entry) => entry.email === args.ssoProfile.email
|
||||
const providerEmail = getEmailFromOidcProfile(args.ssoProfile)
|
||||
const maybeExistingEmail = userEmails.find(
|
||||
(entry) => entry.email === providerEmail.toLowerCase()
|
||||
)
|
||||
|
||||
if (!maybeSsoEmail) {
|
||||
logger?.info(
|
||||
{
|
||||
userEmails: userEmails.map((entry) => entry.email),
|
||||
providerEmail
|
||||
},
|
||||
'Comparing existing user emails against SSO provider email:'
|
||||
)
|
||||
|
||||
if (!maybeExistingEmail) {
|
||||
await createUserEmail({
|
||||
userEmail: {
|
||||
userId: args.userId,
|
||||
email: args.ssoProfile.email,
|
||||
email: getEmailFromOidcProfile(args.ssoProfile),
|
||||
verified: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!!maybeSsoEmail && !maybeSsoEmail.verified) {
|
||||
if (!!maybeExistingEmail && !maybeExistingEmail.verified) {
|
||||
await updateUserEmail({
|
||||
query: {
|
||||
id: maybeSsoEmail.id,
|
||||
id: maybeExistingEmail.id,
|
||||
userId: args.userId
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
upsertRegionAssignmentFactory
|
||||
} from '@/modules/workspaces/repositories/regions'
|
||||
import { getDb } from '@/modules/multiregion/utils/dbSelector'
|
||||
import { WorkspacePlan } from '@/modules/gatekeeper/domain/billing'
|
||||
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
|
||||
|
||||
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
|
||||
|
||||
|
||||
@@ -58,16 +58,14 @@ import {
|
||||
validateStreamAccessFactory
|
||||
} from '@/modules/core/services/streams/access'
|
||||
import { authorizeResolver } from '@/modules/shared'
|
||||
import {
|
||||
addStreamInviteAcceptedActivityFactory,
|
||||
addStreamPermissionsAddedActivityFactory
|
||||
} from '@/modules/activitystream/services/streamActivity'
|
||||
import { addStreamPermissionsAddedActivityFactory } from '@/modules/activitystream/services/streamActivity'
|
||||
import { publish } from '@/modules/shared/utils/subscriptions'
|
||||
import { getUserFactory } from '@/modules/core/repositories/users'
|
||||
import {
|
||||
TestInvitesGraphQLOperations,
|
||||
buildInvitesGraphqlOperations
|
||||
} from '@/modules/workspaces/tests/helpers/invites'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
|
||||
enum InviteByTarget {
|
||||
Email = 'email',
|
||||
@@ -82,10 +80,7 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
|
||||
validateStreamAccess,
|
||||
getUser,
|
||||
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
|
||||
addStreamInviteAcceptedActivity: addStreamInviteAcceptedActivityFactory({
|
||||
saveActivity,
|
||||
publish
|
||||
}),
|
||||
emitEvent: getEventBus().emit,
|
||||
addStreamPermissionsAddedActivity: addStreamPermissionsAddedActivityFactory({
|
||||
saveActivity,
|
||||
publish
|
||||
|
||||
@@ -582,6 +582,7 @@ isMultiRegionTestMode()
|
||||
|
||||
expect(res).to.not.haveGraphQLErrors()
|
||||
|
||||
// TODO: Replace with gql query when possible
|
||||
const automation = await tables
|
||||
.automations(targetRegionDb)
|
||||
.select('*')
|
||||
@@ -623,6 +624,7 @@ isMultiRegionTestMode()
|
||||
|
||||
expect(res).to.not.haveGraphQLErrors()
|
||||
|
||||
// TODO: Replace with gql query when possible
|
||||
const automationRun = await tables
|
||||
.automationRuns(targetRegionDb)
|
||||
.select('*')
|
||||
@@ -658,6 +660,7 @@ isMultiRegionTestMode()
|
||||
|
||||
expect(res).to.not.haveGraphQLErrors()
|
||||
|
||||
// TODO: Replace with gql query when possible
|
||||
const comment = await tables
|
||||
.comments(targetRegionDb)
|
||||
.select('*')
|
||||
|
||||
Reference in New Issue
Block a user