4a2d85d68c
* feat(shared): rename user workspaces loader * feat(gatekeeper): intoduce the enterprise plan * chore(server): remove more "magic strings" * refactor(shared): extract user is workspace admin to an auth fragment * feat(shared): add can createWorkspacePolicy * feat(workspaces): WIP block workspace creation * feat(server): add can create workspace checks * feat(workspaces): enforce canCreateWorkspace policy on the workspace creation mutation * feat(shared): allow workspace admins and guests to create workspaces even if they are part of an exclusive workspace * test(shared): use test fake properly * fix(server): eligble workspace typing fixes * test(shared): fix more workspace fakes * fix(workspacesCore): add missing loader * fix(shared): use proper exhaustive switch cases, they stop bugs from happening * feat(shared): introduce workspacePlanHasAccessToFeature function with tests * chore(workspaces): fix more PR comments * fix(workspaces): naming * fix(workspaces): some more
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
/* eslint-disable camelcase */
|
|
|
|
import { UserEmail } from '@/modules/core/domain/userEmails/types'
|
|
import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic'
|
|
import {
|
|
OidcProvider,
|
|
UserSsoSessionRecord,
|
|
WorkspaceSsoProvider,
|
|
WorkspaceSsoProviderRecord
|
|
} from '@/modules/workspaces/domain/sso/types'
|
|
import {
|
|
OidcProviderMissingGrantTypeError,
|
|
SsoProviderExistsError,
|
|
SsoUserInviteRequiredError
|
|
} from '@/modules/workspaces/errors/sso'
|
|
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
|
|
import {
|
|
createWorkspaceUserFromSsoProfileFactory,
|
|
linkUserWithSsoProviderFactory,
|
|
listUserExpiredSsoSessionsFactory,
|
|
listWorkspaceSsoMembershipsByUserEmailFactory,
|
|
saveSsoProviderRegistrationFactory,
|
|
startOidcSsoProviderValidationFactory
|
|
} from '@/modules/workspaces/services/sso'
|
|
import { Workspace } from '@/modules/workspacesCore/domain/types'
|
|
import { expectToThrow } from '@/test/assertionHelper'
|
|
import { wait } from '@speckle/shared'
|
|
import { assert, expect } from 'chai'
|
|
import cryptoRandomString from 'crypto-random-string'
|
|
|
|
describe('Workspace SSO services', () => {
|
|
describe('startOidcSsoProviderValidationFactory creates a function, that', () => {
|
|
it('throws if given provider has invalid attributes', async () => {
|
|
const startOidcSsoProviderValidation = startOidcSsoProviderValidationFactory({
|
|
getOidcProviderAttributes: async () => ({
|
|
issuer: {
|
|
claimsSupported: [],
|
|
grantTypesSupported: [],
|
|
responseTypesSupported: []
|
|
},
|
|
client: {
|
|
grantTypes: []
|
|
}
|
|
}),
|
|
storeOidcProviderValidationRequest: async () => {
|
|
assert.fail()
|
|
},
|
|
generateCodeVerifier: () => ''
|
|
})
|
|
|
|
const err = await expectToThrow(() =>
|
|
startOidcSsoProviderValidation({ provider: {} as OidcProvider })
|
|
)
|
|
expect(err.message).to.equal(OidcProviderMissingGrantTypeError.defaultMessage)
|
|
})
|
|
})
|
|
describe('saveSsoProviderRegistrationFactory creates a function, that', () => {
|
|
it('throws if a provider is already configured for the workspace', async () => {
|
|
const saveSsoProviderRegistration = saveSsoProviderRegistrationFactory({
|
|
getWorkspaceSsoProvider: async () => ({} as WorkspaceSsoProvider),
|
|
storeProviderRecord: async () => {
|
|
assert.fail()
|
|
},
|
|
associateSsoProviderWithWorkspace: async () => {
|
|
assert.fail()
|
|
}
|
|
})
|
|
|
|
const err = await expectToThrow(() =>
|
|
saveSsoProviderRegistration({
|
|
provider: {} as OidcProvider,
|
|
workspaceId: cryptoRandomString({ length: 9 })
|
|
})
|
|
)
|
|
expect(err.message).to.equal(SsoProviderExistsError.defaultMessage)
|
|
})
|
|
})
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
describe('createWorkspaceUserFromSsoProfileFactory creates a function, that', () => {
|
|
it('throws if target email does not have a valid invite to the given workspace', async () => {
|
|
const createWorkspaceUserFromSsoProfile =
|
|
createWorkspaceUserFromSsoProfileFactory({
|
|
createUser: async () => '',
|
|
addOrUpdateWorkspaceRole: async () => {},
|
|
findInvite: async () => null,
|
|
deleteInvite: async () => true
|
|
})
|
|
|
|
const err = await expectToThrow(() =>
|
|
createWorkspaceUserFromSsoProfile({
|
|
ssoProfile: {
|
|
sub: '',
|
|
email: ''
|
|
},
|
|
workspaceId: cryptoRandomString({ length: 9 })
|
|
})
|
|
)
|
|
expect(err.message).to.include(SsoUserInviteRequiredError.defaultMessage)
|
|
})
|
|
it('throws if SSO provider user profile does not have a name configured', async () => {
|
|
const createWorkspaceUserFromSsoProfile =
|
|
createWorkspaceUserFromSsoProfileFactory({
|
|
createUser: async () => '',
|
|
addOrUpdateWorkspaceRole: async () => {},
|
|
findInvite: async () => ({} as unknown as any),
|
|
deleteInvite: async () => true
|
|
})
|
|
|
|
const err = await expectToThrow(() =>
|
|
createWorkspaceUserFromSsoProfile({
|
|
ssoProfile: {
|
|
sub: '',
|
|
email: ''
|
|
},
|
|
workspaceId: cryptoRandomString({ length: 9 })
|
|
})
|
|
)
|
|
expect(err.message).to.include('requires a name')
|
|
})
|
|
it('throws if workspace role on invite is not a valid workspace role', async () => {
|
|
const createWorkspaceUserFromSsoProfile =
|
|
createWorkspaceUserFromSsoProfileFactory({
|
|
createUser: async () => '',
|
|
addOrUpdateWorkspaceRole: async () => {},
|
|
findInvite: async () =>
|
|
({
|
|
resource: {
|
|
role: 'not-a-role'
|
|
}
|
|
} as unknown as any),
|
|
deleteInvite: async () => true
|
|
})
|
|
|
|
const err = await expectToThrow(() =>
|
|
createWorkspaceUserFromSsoProfile({
|
|
ssoProfile: {
|
|
name: 'John Speckle',
|
|
sub: '',
|
|
email: '',
|
|
email_verified: true
|
|
},
|
|
workspaceId: cryptoRandomString({ length: 9 })
|
|
})
|
|
)
|
|
expect(err.message).to.equal(WorkspaceInvalidRoleError.defaultMessage)
|
|
})
|
|
it('correctly sets both the workspace role and the server role on the given invite', async () => {
|
|
let serverRole: string | undefined = undefined
|
|
let workspaceRole: string | undefined = undefined
|
|
|
|
const createWorkspaceUserFromSsoProfile =
|
|
createWorkspaceUserFromSsoProfileFactory({
|
|
createUser: async ({ role }) => {
|
|
serverRole = role
|
|
return ''
|
|
},
|
|
addOrUpdateWorkspaceRole: async ({ role }) => {
|
|
workspaceRole = role
|
|
},
|
|
findInvite: async () =>
|
|
({
|
|
resource: {
|
|
role: 'workspace:admin',
|
|
secondaryResourceRoles: {
|
|
server: 'server:admin'
|
|
}
|
|
}
|
|
} as unknown as any),
|
|
deleteInvite: async () => true
|
|
})
|
|
|
|
await createWorkspaceUserFromSsoProfile({
|
|
ssoProfile: {
|
|
name: 'John Speckle',
|
|
sub: '',
|
|
email: '',
|
|
email_verified: true
|
|
},
|
|
workspaceId: cryptoRandomString({ length: 9 })
|
|
})
|
|
|
|
expect(serverRole).to.equal('server:admin')
|
|
expect(workspaceRole).to.equal('workspace:admin')
|
|
})
|
|
it('deletes the workspace invite after creating the user and assigning all roles', async () => {
|
|
let isDeleteCalled = false
|
|
|
|
const createWorkspaceUserFromSsoProfile =
|
|
createWorkspaceUserFromSsoProfileFactory({
|
|
createUser: async () => '',
|
|
addOrUpdateWorkspaceRole: async () => {},
|
|
findInvite: async () =>
|
|
({
|
|
resource: {
|
|
role: 'workspace:admin',
|
|
secondaryResourceRoles: {
|
|
server: 'server:admin'
|
|
}
|
|
}
|
|
} as unknown as any),
|
|
deleteInvite: async () => {
|
|
isDeleteCalled = true
|
|
return true
|
|
}
|
|
})
|
|
|
|
await createWorkspaceUserFromSsoProfile({
|
|
ssoProfile: {
|
|
name: 'John Speckle',
|
|
sub: '',
|
|
email: '',
|
|
email_verified: true
|
|
},
|
|
workspaceId: cryptoRandomString({ length: 9 })
|
|
})
|
|
|
|
expect(isDeleteCalled).to.be.true
|
|
})
|
|
})
|
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
/* eslint-enable @typescript-eslint/no-unsafe-return */
|
|
describe('linkUserWithSsoProviderFactory creates a function, that', () => {
|
|
it('does no work if user is already associated with provider', async () => {
|
|
const userId = cryptoRandomString({ length: 9 })
|
|
const email = 'test@example.org'
|
|
|
|
const linkUserWithSsoProvider = linkUserWithSsoProviderFactory({
|
|
findEmailsByUserId: async () => [
|
|
{
|
|
id: cryptoRandomString({ length: 9 }),
|
|
userId,
|
|
email,
|
|
verified: true,
|
|
primary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
}
|
|
],
|
|
createUserEmail: async () => {
|
|
assert.fail()
|
|
},
|
|
updateUserEmail: async () => {
|
|
assert.fail()
|
|
}
|
|
})
|
|
|
|
await linkUserWithSsoProvider({
|
|
userId,
|
|
ssoProfile: {
|
|
sub: cryptoRandomString({ length: 9 }),
|
|
email
|
|
}
|
|
})
|
|
})
|
|
it('verifies user email if sso email is already associated with the user', async () => {
|
|
const userId = cryptoRandomString({ length: 9 })
|
|
const email = 'test@example.org'
|
|
|
|
let isVerified = false
|
|
|
|
const linkUserWithSsoProvider = linkUserWithSsoProviderFactory({
|
|
findEmailsByUserId: async () => [
|
|
{
|
|
id: cryptoRandomString({ length: 9 }),
|
|
userId,
|
|
email,
|
|
verified: false,
|
|
primary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
}
|
|
],
|
|
createUserEmail: async () => {
|
|
assert.fail()
|
|
},
|
|
updateUserEmail: async () => {
|
|
isVerified = true
|
|
return {} as UserEmail
|
|
}
|
|
})
|
|
|
|
await linkUserWithSsoProvider({
|
|
userId,
|
|
ssoProfile: {
|
|
sub: cryptoRandomString({ length: 9 }),
|
|
email
|
|
}
|
|
})
|
|
|
|
expect(isVerified).to.be.true
|
|
})
|
|
it('adds sso email to user emails if not already present', async () => {
|
|
const userId = cryptoRandomString({ length: 9 })
|
|
const email = 'test@example.org'
|
|
|
|
const userEmails: UserEmail[] = []
|
|
|
|
const linkUserWithSsoProvider = linkUserWithSsoProviderFactory({
|
|
findEmailsByUserId: async () => [],
|
|
createUserEmail: async ({ userEmail }) => {
|
|
const email: UserEmail = {
|
|
id: cryptoRandomString({ length: 9 }),
|
|
userId,
|
|
email: userEmail.email,
|
|
verified: true,
|
|
primary: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
}
|
|
userEmails.push(email)
|
|
return email
|
|
},
|
|
updateUserEmail: async () => {
|
|
assert.fail()
|
|
}
|
|
})
|
|
|
|
await linkUserWithSsoProvider({
|
|
userId,
|
|
ssoProfile: {
|
|
sub: cryptoRandomString({ length: 9 }),
|
|
email
|
|
}
|
|
})
|
|
|
|
expect(userEmails.length).to.equal(1)
|
|
expect(userEmails[0].email).to.equal(email)
|
|
expect(userEmails[0].verified).to.be.true
|
|
})
|
|
})
|
|
describe('listWorkspaceSsoMembershipsByUserEmailFactory creates a function, that', () => {
|
|
it('returns an empty array if the user does not exist', async () => {
|
|
const listWorkspaceSsoMemberships = listWorkspaceSsoMembershipsByUserEmailFactory(
|
|
{
|
|
findEmail: async () => undefined,
|
|
listWorkspaceSsoMemberships: async () => {
|
|
assert.fail()
|
|
}
|
|
}
|
|
)
|
|
|
|
const workspaces = await listWorkspaceSsoMemberships({
|
|
userEmail: 'fake@example.org '
|
|
})
|
|
|
|
expect(workspaces.length).to.equal(0)
|
|
})
|
|
it('returns an empty array if the email exists but is not verified', async () => {
|
|
const listWorkspaceSsoMemberships = listWorkspaceSsoMembershipsByUserEmailFactory(
|
|
{
|
|
findEmail: async () =>
|
|
({
|
|
userId: cryptoRandomString({ length: 9 }),
|
|
verified: false
|
|
} as UserEmail),
|
|
listWorkspaceSsoMemberships: async () => {
|
|
assert.fail()
|
|
}
|
|
}
|
|
)
|
|
|
|
const workspaces = await listWorkspaceSsoMemberships({
|
|
userEmail: 'anything@example.org'
|
|
})
|
|
|
|
expect(workspaces.length).to.equal(0)
|
|
})
|
|
it('returns sanitized results if any matches are found', async () => {
|
|
const listWorkspaceSsoMemberships = listWorkspaceSsoMembershipsByUserEmailFactory(
|
|
{
|
|
findEmail: async () =>
|
|
({
|
|
userId: cryptoRandomString({ length: 9 }),
|
|
verified: true
|
|
} as UserEmail),
|
|
listWorkspaceSsoMemberships: async () => [
|
|
{
|
|
id: '',
|
|
slug: '',
|
|
name: '',
|
|
description: '',
|
|
logo: null,
|
|
isExclusive: false,
|
|
domainBasedMembershipProtectionEnabled: false,
|
|
discoverabilityEnabled: false,
|
|
discoverabilityAutoJoinEnabled: false,
|
|
defaultSeatType: null,
|
|
isEmbedSpeckleBrandingHidden: false,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
const workspaces = await listWorkspaceSsoMemberships({
|
|
userEmail: 'anything@example.org'
|
|
})
|
|
|
|
expect(Object.keys(workspaces[0]).includes('defaultProjectRole')).to.be.false
|
|
})
|
|
})
|
|
describe('listUserExpiredSsoSessionsFactory creates a function, that', () => {
|
|
it('returns an empty array if the user has valid sessions for all of their SSO-enabled workspaces', async () => {
|
|
const listUserExpiredSsoSessions = listUserExpiredSsoSessionsFactory({
|
|
listWorkspaceSsoMemberships: async () => [
|
|
{
|
|
id: 'workspace-a'
|
|
} as Workspace,
|
|
{
|
|
id: 'workspace-b'
|
|
} as Workspace
|
|
],
|
|
listUserSsoSessions: async () => [
|
|
{
|
|
workspaceId: 'workspace-a',
|
|
validUntil: getDefaultSsoSessionExpirationDate()
|
|
} as UserSsoSessionRecord & WorkspaceSsoProviderRecord,
|
|
{
|
|
workspaceId: 'workspace-b',
|
|
validUntil: getDefaultSsoSessionExpirationDate()
|
|
} as UserSsoSessionRecord & WorkspaceSsoProviderRecord
|
|
]
|
|
})
|
|
|
|
const expiredSessions = await listUserExpiredSsoSessions({ userId: '' })
|
|
|
|
expect(expiredSessions.length).to.equal(0)
|
|
})
|
|
it("returns workspaces where the user's SSO session does not exist", async () => {
|
|
const listUserExpiredSsoSessions = listUserExpiredSsoSessionsFactory({
|
|
listWorkspaceSsoMemberships: async () => [
|
|
{
|
|
id: 'workspace-a'
|
|
} as Workspace,
|
|
{
|
|
id: 'workspace-b'
|
|
} as Workspace
|
|
],
|
|
listUserSsoSessions: async () => [
|
|
{
|
|
workspaceId: 'workspace-a',
|
|
validUntil: getDefaultSsoSessionExpirationDate()
|
|
} as UserSsoSessionRecord & WorkspaceSsoProviderRecord
|
|
]
|
|
})
|
|
|
|
const expiredSessions = await listUserExpiredSsoSessions({ userId: '' })
|
|
|
|
expect(expiredSessions.length).to.equal(1)
|
|
expect(expiredSessions[0].id).to.equal('workspace-b')
|
|
})
|
|
it("returns workspaces where the user's SSO session exists but has expired", async () => {
|
|
const listUserExpiredSsoSessions = listUserExpiredSsoSessionsFactory({
|
|
listWorkspaceSsoMemberships: async () => [
|
|
{
|
|
id: 'workspace-a'
|
|
} as Workspace,
|
|
{
|
|
id: 'workspace-b'
|
|
} as Workspace
|
|
],
|
|
listUserSsoSessions: async () => [
|
|
{
|
|
workspaceId: 'workspace-a',
|
|
validUntil: getDefaultSsoSessionExpirationDate()
|
|
} as UserSsoSessionRecord & WorkspaceSsoProviderRecord,
|
|
{
|
|
workspaceId: 'workspace-b',
|
|
validUntil: new Date()
|
|
} as UserSsoSessionRecord & WorkspaceSsoProviderRecord
|
|
]
|
|
})
|
|
|
|
await wait(50)
|
|
|
|
const expiredSessions = await listUserExpiredSsoSessions({ userId: '' })
|
|
|
|
expect(expiredSessions.length).to.equal(1)
|
|
expect(expiredSessions[0].id).to.equal('workspace-b')
|
|
})
|
|
it('returns an empty array if the user belongs to no SSO-enabled workspaces', async () => {
|
|
const listUserExpiredSsoSessions = listUserExpiredSsoSessionsFactory({
|
|
listWorkspaceSsoMemberships: async () => [],
|
|
listUserSsoSessions: async () => []
|
|
})
|
|
|
|
const expiredSessions = await listUserExpiredSsoSessions({ userId: '' })
|
|
|
|
expect(expiredSessions.length).to.equal(0)
|
|
})
|
|
})
|
|
})
|