Merge remote-tracking branch 'origin' into chuck/web-2435-move-comments-and-webhooks-without-attachments

This commit is contained in:
Chuck Driesler
2025-02-18 16:04:20 +00:00
609 changed files with 5668 additions and 51059 deletions
@@ -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
}
+18 -7
View File
@@ -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('*')