feat(server): web 3485 prevent accounts from creating new workspaces (#4913)
* 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
This commit is contained in:
@@ -12,5 +12,33 @@
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"env": {},
|
||||
"stop_on_entry": false
|
||||
},
|
||||
{
|
||||
"adapter": "JavaScript",
|
||||
"label": "ZED yarn test (server)",
|
||||
"request": "launch",
|
||||
"console": "integratedTerminal",
|
||||
"program": "test",
|
||||
"runtimeExecutable": "yarn",
|
||||
"args": [],
|
||||
"type": "pwa-node",
|
||||
"cwd": "$ZED_WORKTREE_ROOT/packages/server",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"env": {},
|
||||
"stop_on_entry": false
|
||||
},
|
||||
{
|
||||
"adapter": "JavaScript",
|
||||
"label": "ZED yarn test (shared)",
|
||||
"request": "launch",
|
||||
"console": "integratedTerminal",
|
||||
"program": "test",
|
||||
"runtimeExecutable": "yarn",
|
||||
"args": ["-t", "forbids creation for users eligible"],
|
||||
"type": "pwa-node",
|
||||
"cwd": "$ZED_WORKTREE_ROOT/packages/shared",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"env": {},
|
||||
"stop_on_entry": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3103,6 +3103,7 @@ export type Role = {
|
||||
export type RootPermissionChecks = {
|
||||
__typename?: 'RootPermissionChecks';
|
||||
canCreatePersonalProject: PermissionCheckResult;
|
||||
canCreateWorkspace: PermissionCheckResult;
|
||||
};
|
||||
|
||||
/** Available scopes. */
|
||||
@@ -8817,6 +8818,7 @@ export type RoleFieldArgs = {
|
||||
}
|
||||
export type RootPermissionChecksFieldArgs = {
|
||||
canCreatePersonalProject: {},
|
||||
canCreateWorkspace: {},
|
||||
}
|
||||
export type ScopeFieldArgs = {
|
||||
description: {},
|
||||
|
||||
@@ -20,6 +20,7 @@ type ProjectPermissionChecks {
|
||||
|
||||
type RootPermissionChecks {
|
||||
canCreatePersonalProject: PermissionCheckResult!
|
||||
canCreateWorkspace: PermissionCheckResult!
|
||||
}
|
||||
|
||||
extend type User {
|
||||
|
||||
@@ -3126,6 +3126,7 @@ export type Role = {
|
||||
export type RootPermissionChecks = {
|
||||
__typename?: 'RootPermissionChecks';
|
||||
canCreatePersonalProject: PermissionCheckResult;
|
||||
canCreateWorkspace: PermissionCheckResult;
|
||||
};
|
||||
|
||||
/** Available scopes. */
|
||||
@@ -7080,6 +7081,7 @@ export type RoleResolvers<ContextType = GraphQLContext, ParentType extends Resol
|
||||
|
||||
export type RootPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['RootPermissionChecks'] = ResolversParentTypes['RootPermissionChecks']> = {
|
||||
canCreatePersonalProject?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
canCreateWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
|
||||
@@ -164,6 +164,12 @@ export default {
|
||||
}
|
||||
)
|
||||
return Authz.toGraphqlResult(canCreatePersonalProject)
|
||||
},
|
||||
canCreateWorkspace: async (_parent, _args, ctx) => {
|
||||
const policyResult = await ctx.authPolicies.workspace.canCreateWorkspace({
|
||||
userId: ctx.userId
|
||||
})
|
||||
return Authz.toGraphqlResult(policyResult)
|
||||
}
|
||||
}
|
||||
} as Resolvers
|
||||
|
||||
@@ -3106,6 +3106,7 @@ export type Role = {
|
||||
export type RootPermissionChecks = {
|
||||
__typename?: 'RootPermissionChecks';
|
||||
canCreatePersonalProject: PermissionCheckResult;
|
||||
canCreateWorkspace: PermissionCheckResult;
|
||||
};
|
||||
|
||||
/** Available scopes. */
|
||||
|
||||
@@ -42,6 +42,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
|
||||
case Authz.CommentNoAccessError.code:
|
||||
case Authz.ProjectNotEnoughPermissionsError.code:
|
||||
case Authz.WorkspaceNoFeatureAccessError.code:
|
||||
case Authz.EligibleForExclusiveWorkspaceError.code:
|
||||
return new ForbiddenError(e.message)
|
||||
case Authz.WorkspaceSsoSessionNoAccessError.code:
|
||||
throw new SsoSessionMissingOrExpiredError(e.message, {
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
getUserSsoSessionFactory,
|
||||
getWorkspaceSsoProviderRecordFactory
|
||||
} from '@/modules/workspaces/repositories/sso'
|
||||
import { getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces'
|
||||
import {
|
||||
getUserEligibleWorkspacesFactory,
|
||||
getWorkspaceRoleForUserFactory
|
||||
} from '@/modules/workspaces/repositories/workspaces'
|
||||
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
|
||||
import { getWorkspaceModelCountFactory } from '@/modules/workspaces/services/workspaceLimits'
|
||||
import { getUsersCurrentAndEligibleToBecomeAMemberWorkspaces } from '@/modules/workspaces/services/retrieval'
|
||||
import { findEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
|
||||
|
||||
// TODO: Move everything to use dataLoaders
|
||||
export default defineModuleLoaders(async () => {
|
||||
@@ -71,6 +76,12 @@ export default defineModuleLoaders(async () => {
|
||||
getWorkspacePlan: async ({ workspaceId }) => {
|
||||
return await getWorkspacePlan({ workspaceId })
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({ userId }) => {
|
||||
return await getUsersCurrentAndEligibleToBecomeAMemberWorkspaces({
|
||||
findEmailsByUserId: findEmailsByUserIdFactory({ db }),
|
||||
getUserEligibleWorkspaces: getUserEligibleWorkspacesFactory({ db })
|
||||
})({ userId })
|
||||
},
|
||||
getWorkspaceLimits: async ({ workspaceId }, { dataLoaders }) => {
|
||||
return await dataLoaders.gatekeeper!.getWorkspaceLimits.load(workspaceId)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export const toLimitedWorkspace = (workspace: Workspace): LimitedWorkspace => {
|
||||
name: workspace.name,
|
||||
description: workspace.description,
|
||||
logo: workspace.logo,
|
||||
discoverabilityAutoJoinEnabled: workspace.discoverabilityAutoJoinEnabled
|
||||
discoverabilityAutoJoinEnabled: workspace.discoverabilityAutoJoinEnabled,
|
||||
isExclusive: workspace.isExclusive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,14 @@ export type GetUserDiscoverableWorkspaces = (args: {
|
||||
userId: string
|
||||
}) => Promise<LimitedWorkspace[]>
|
||||
|
||||
// adding optional role to each workspace
|
||||
export type EligibleWorkspace = LimitedWorkspace & { role?: WorkspaceRoles }[]
|
||||
|
||||
export type GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces = (args: {
|
||||
domains: string[]
|
||||
userId: string
|
||||
}) => Promise<EligibleWorkspace[]>
|
||||
|
||||
export type GetWorkspace = (args: {
|
||||
workspaceId: string
|
||||
userId?: string
|
||||
|
||||
@@ -611,6 +611,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
enableDomainDiscoverabilityForDomain
|
||||
} = args.input
|
||||
|
||||
// Check if user can create workspace
|
||||
const canCreate = await context.authPolicies.workspace.canCreateWorkspace({
|
||||
userId: context.userId
|
||||
})
|
||||
throwIfAuthNotOk(canCreate)
|
||||
|
||||
const logger = context.log
|
||||
|
||||
return await asOperation(
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
DeleteWorkspace,
|
||||
DeleteWorkspaceDomain,
|
||||
DeleteWorkspaceRole,
|
||||
EligibleWorkspace,
|
||||
GetAllWorkspaces,
|
||||
GetPaginatedWorkspaceProjects,
|
||||
GetPaginatedWorkspaceProjectsArgs,
|
||||
GetPaginatedWorkspaceProjectsItems,
|
||||
GetPaginatedWorkspaceProjectsTotalCount,
|
||||
GetUserDiscoverableWorkspaces,
|
||||
GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces,
|
||||
GetUserIdsWithRoleInWorkspace,
|
||||
GetWorkspace,
|
||||
GetWorkspaceBySlug,
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
import {
|
||||
knex,
|
||||
ServerAcl,
|
||||
ServerInvites,
|
||||
StreamAcl,
|
||||
Streams,
|
||||
UserEmails,
|
||||
@@ -96,6 +99,39 @@ const tables = {
|
||||
db<WorkspaceJoinRequest>('workspace_join_requests')
|
||||
}
|
||||
|
||||
export const getUserEligibleWorkspacesFactory =
|
||||
({ db }: { db: Knex }): GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces =>
|
||||
async ({ userId, domains }) => {
|
||||
const q = tables
|
||||
.workspaces(db)
|
||||
.distinctOn(Workspaces.col.id)
|
||||
.select<EligibleWorkspace[]>([...Workspaces.cols, DbWorkspaceAcl.col.role])
|
||||
.joinRaw(
|
||||
`left join ${DbWorkspaceAcl.name}
|
||||
on ${Workspaces.col.id} = ${DbWorkspaceAcl.name}."${DbWorkspaceAcl.withoutTablePrefix.col.workspaceId}"
|
||||
and ${DbWorkspaceAcl.name}."${DbWorkspaceAcl.withoutTablePrefix.col.userId}" = '${userId}'`
|
||||
)
|
||||
.joinRaw(
|
||||
`left join ${ServerInvites.name}
|
||||
on ${Workspaces.col.id} = ${ServerInvites.col.resource} ->> 'resourceId'
|
||||
and ${ServerInvites.col.target} = '@${userId}'`
|
||||
)
|
||||
.leftJoin(
|
||||
WorkspaceDomains.name,
|
||||
WorkspaceDomains.col.workspaceId,
|
||||
Workspaces.col.id
|
||||
)
|
||||
.whereNotNull(DbWorkspaceAcl.col.userId)
|
||||
.orWhereNotNull(ServerInvites.col.target)
|
||||
if (domains.length)
|
||||
q.orWhere(function () {
|
||||
this.where(Workspaces.col.discoverabilityEnabled, true)
|
||||
this.whereIn(WorkspaceDomains.col.domain, domains)
|
||||
})
|
||||
const items = await q
|
||||
return items
|
||||
}
|
||||
|
||||
export const getUserDiscoverableWorkspacesFactory =
|
||||
({ db }: { db: Knex }): GetUserDiscoverableWorkspaces =>
|
||||
async ({ domains, userId }) => {
|
||||
@@ -112,6 +148,7 @@ export const getUserDiscoverableWorkspacesFactory =
|
||||
'description',
|
||||
'logo',
|
||||
'discoverabilityAutoJoinEnabled',
|
||||
'isExclusive',
|
||||
tables
|
||||
.workspacesAcl(db)
|
||||
.select(knex.raw('count(*)::integer'))
|
||||
@@ -312,7 +349,8 @@ export const upsertWorkspaceFactory =
|
||||
'discoverabilityEnabled',
|
||||
'discoverabilityAutoJoinEnabled',
|
||||
'defaultSeatType',
|
||||
'isEmbedSpeckleBrandingHidden'
|
||||
'isEmbedSpeckleBrandingHidden',
|
||||
'isExclusive'
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,8 @@ export const createWorkspaceFactory =
|
||||
discoverabilityEnabled: false,
|
||||
discoverabilityAutoJoinEnabled: false,
|
||||
isEmbedSpeckleBrandingHidden: false,
|
||||
defaultSeatType: null
|
||||
defaultSeatType: null,
|
||||
isExclusive: false
|
||||
} satisfies Workspace
|
||||
await upsertWorkspace({ workspace })
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
|
||||
import { UserEmail } from '@/modules/core/domain/userEmails/types'
|
||||
import {
|
||||
GetUserDiscoverableWorkspaces,
|
||||
GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces,
|
||||
GetWorkspaceRolesForUser,
|
||||
GetWorkspaces
|
||||
} from '@/modules/workspaces/domain/operations'
|
||||
@@ -10,6 +12,9 @@ type GetDiscoverableWorkspaceForUserArgs = {
|
||||
userId: string
|
||||
}
|
||||
|
||||
const getUserVerifiedDomains = (userEmails: UserEmail[]): string[] =>
|
||||
userEmails.filter((email) => email.verified).map((email) => email.email.split('@')[1])
|
||||
|
||||
export const getDiscoverableWorkspacesForUserFactory =
|
||||
({
|
||||
findEmailsByUserId,
|
||||
@@ -22,9 +27,7 @@ export const getDiscoverableWorkspacesForUserFactory =
|
||||
userId
|
||||
}: GetDiscoverableWorkspaceForUserArgs): Promise<LimitedWorkspace[]> => {
|
||||
const userEmails = await findEmailsByUserId({ userId })
|
||||
const userVerifiedDomains = userEmails
|
||||
.filter((email) => email.verified)
|
||||
.map((email) => email.email.split('@')[1])
|
||||
const userVerifiedDomains = getUserVerifiedDomains(userEmails)
|
||||
const workspaces = await getDiscoverableWorkspaces({
|
||||
domains: userVerifiedDomains,
|
||||
userId
|
||||
@@ -33,6 +36,27 @@ export const getDiscoverableWorkspacesForUserFactory =
|
||||
return workspaces
|
||||
}
|
||||
|
||||
export const getUsersCurrentAndEligibleToBecomeAMemberWorkspaces =
|
||||
({
|
||||
findEmailsByUserId,
|
||||
getUserEligibleWorkspaces
|
||||
}: {
|
||||
findEmailsByUserId: FindEmailsByUserId
|
||||
getUserEligibleWorkspaces: GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces
|
||||
}) =>
|
||||
async ({
|
||||
userId
|
||||
}: GetDiscoverableWorkspaceForUserArgs): Promise<LimitedWorkspace[]> => {
|
||||
const userEmails = await findEmailsByUserId({ userId })
|
||||
const userVerifiedDomains = getUserVerifiedDomains(userEmails)
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
domains: userVerifiedDomains,
|
||||
userId
|
||||
})
|
||||
|
||||
return workspaces
|
||||
}
|
||||
|
||||
type GetWorkspacesForUserArgs = {
|
||||
userId: string
|
||||
completed?: boolean
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
getWorkspaceRolesFactory,
|
||||
getWorkspaceRolesForUserFactory,
|
||||
deleteWorkspaceFactory,
|
||||
storeWorkspaceDomainFactory,
|
||||
getUserDiscoverableWorkspacesFactory,
|
||||
getUserEligibleWorkspacesFactory,
|
||||
getWorkspaceWithDomainsFactory,
|
||||
countWorkspaceRoleWithOptionalProjectRoleFactory,
|
||||
getWorkspaceCollaboratorsFactory,
|
||||
getWorkspaceBySlugFactory,
|
||||
getWorkspacesFactory
|
||||
getWorkspacesFactory,
|
||||
storeWorkspaceDomainFactory
|
||||
} from '@/modules/workspaces/repositories/workspaces'
|
||||
import db from '@/db/knex'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
@@ -52,7 +53,7 @@ import {
|
||||
import { omit } from 'lodash'
|
||||
import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces'
|
||||
import { WorkspaceJoinRequests } from '@/modules/workspacesCore/helpers/db'
|
||||
|
||||
import { insertInviteAndDeleteOldFactory } from '@/modules/serverinvites/repositories/serverInvites'
|
||||
const getWorkspace = getWorkspaceFactory({ db })
|
||||
const getWorkspaces = getWorkspacesFactory({ db })
|
||||
const getWorkspaceBySlug = getWorkspaceBySlugFactory({ db })
|
||||
@@ -67,9 +68,11 @@ const storeWorkspaceDomain = storeWorkspaceDomainFactory({ db })
|
||||
const createUserEmail = createUserEmailFactory({ db })
|
||||
const updateUserEmail = updateUserEmailFactory({ db })
|
||||
const getUserDiscoverableWorkspaces = getUserDiscoverableWorkspacesFactory({ db })
|
||||
const getUserEligibleWorkspaces = getUserEligibleWorkspacesFactory({ db })
|
||||
const upsertProjectRole = upsertProjectRoleFactory({ db })
|
||||
const grantStreamPermissions = grantStreamPermissionsFactory({ db })
|
||||
const upsertWorkspace = upsertWorkspaceFactory({ db })
|
||||
const insertInviteAndDeleteOld = insertInviteAndDeleteOldFactory({ db })
|
||||
|
||||
const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({
|
||||
upsertWorkspace
|
||||
@@ -1317,4 +1320,220 @@ describe('Workspace repositories', () => {
|
||||
expect(count).to.equal(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserEligibleWorkspacesFactory creates a function, that', () => {
|
||||
it('returns workspaces where user is a member', async () => {
|
||||
const testUser1 = buildBasicTestUser()
|
||||
await createTestUsers([testUser1])
|
||||
|
||||
const workspace1 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 1',
|
||||
description: 'User is member'
|
||||
})
|
||||
await createTestWorkspace(workspace1, testUser1)
|
||||
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
userId: testUser1.id,
|
||||
domains: []
|
||||
})
|
||||
|
||||
const workspaceIds = workspaces.map((w) => w.id)
|
||||
expect(workspaceIds).deep.equalInAnyOrder([workspace1.id])
|
||||
})
|
||||
|
||||
it('returns workspaces where user has an invite', async () => {
|
||||
const testUser1 = buildBasicTestUser()
|
||||
const testUser2 = buildBasicTestUser()
|
||||
await createTestUsers([testUser1, testUser2])
|
||||
|
||||
const workspace1 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 1',
|
||||
description: 'User is member'
|
||||
})
|
||||
|
||||
await createTestWorkspace(workspace1, testUser1)
|
||||
|
||||
await insertInviteAndDeleteOld({
|
||||
id: createRandomString(),
|
||||
inviterId: testUser1.id,
|
||||
message: '',
|
||||
target: `@${testUser2.id}`,
|
||||
token: createRandomString(),
|
||||
resource: {
|
||||
role: 'workspace:member',
|
||||
primary: true,
|
||||
resourceId: workspace1.id,
|
||||
resourceType: 'workspace',
|
||||
secondaryResourceRoles: {}
|
||||
}
|
||||
})
|
||||
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
userId: testUser2.id,
|
||||
domains: []
|
||||
})
|
||||
|
||||
const workspaceIds = workspaces.map((w) => w.id)
|
||||
expect(workspaceIds).deep.equalInAnyOrder([workspace1.id])
|
||||
})
|
||||
|
||||
it('returns empty if user not eligible', async () => {
|
||||
const testUser1 = buildBasicTestUser()
|
||||
const testUser2 = buildBasicTestUser()
|
||||
await createTestUsers([testUser1, testUser2])
|
||||
|
||||
const workspace1 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 1',
|
||||
description: 'User is member'
|
||||
})
|
||||
|
||||
await createTestWorkspace(workspace1, testUser1)
|
||||
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
userId: testUser2.id,
|
||||
domains: []
|
||||
})
|
||||
|
||||
const workspaceIds = workspaces.map((w) => w.id)
|
||||
expect(workspaceIds).to.have.length(0)
|
||||
})
|
||||
|
||||
it('returns discoverable workspaces with matching verified domains', async () => {
|
||||
const domain = `${createRandomString()}.com`
|
||||
const testUser1 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain}`,
|
||||
verified: true
|
||||
})
|
||||
const testUser2 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain}`,
|
||||
verified: true
|
||||
})
|
||||
await createTestUsers([testUser1, testUser2])
|
||||
const workspace1 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 1',
|
||||
description: 'User is member',
|
||||
discoverabilityEnabled: true
|
||||
})
|
||||
const workspace2 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 2',
|
||||
description: 'User is member'
|
||||
})
|
||||
await createTestWorkspace(workspace1, testUser1, { domain })
|
||||
await createTestWorkspace(workspace2, testUser2)
|
||||
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
userId: testUser2.id,
|
||||
domains: [domain]
|
||||
})
|
||||
|
||||
const workspaceIds = workspaces.map((w) => w.id)
|
||||
expect(workspaceIds).deep.equalInAnyOrder([workspace1.id, workspace2.id])
|
||||
})
|
||||
|
||||
it('does not return discoverable workspaces with matching unverified domains', async () => {
|
||||
const domain = `${createRandomString()}.com`
|
||||
const testUser1 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain}`,
|
||||
verified: true
|
||||
})
|
||||
const testUser2 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain}`,
|
||||
verified: false
|
||||
})
|
||||
await createTestUsers([testUser1, testUser2])
|
||||
const workspace1 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 2',
|
||||
description: 'User is member',
|
||||
discoverabilityEnabled: false
|
||||
})
|
||||
await createTestWorkspace(workspace1, testUser1, { domain })
|
||||
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
userId: testUser2.id,
|
||||
domains: [domain]
|
||||
})
|
||||
|
||||
const workspaceIds = workspaces.map((w) => w.id)
|
||||
expect(workspaceIds).deep.equalInAnyOrder([])
|
||||
})
|
||||
|
||||
it('does not return non discoverable workspaces with matching verified domains', async () => {
|
||||
const domain = `${createRandomString()}.com`
|
||||
const testUser1 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain}`,
|
||||
verified: true
|
||||
})
|
||||
const testUser2 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain}`,
|
||||
verified: true
|
||||
})
|
||||
await createTestUsers([testUser1, testUser2])
|
||||
const workspace1 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 2',
|
||||
description: 'User is member',
|
||||
discoverabilityEnabled: false
|
||||
})
|
||||
await createTestWorkspace(workspace1, testUser1, { domain })
|
||||
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
userId: testUser2.id,
|
||||
domains: [domain]
|
||||
})
|
||||
|
||||
const workspaceIds = workspaces.map((w) => w.id)
|
||||
expect(workspaceIds).deep.equalInAnyOrder([])
|
||||
})
|
||||
|
||||
it('does not return workspaces without matching domains when not member/invited', async () => {
|
||||
const domain1 = `${createRandomString()}.com`
|
||||
const testUser1 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain1}`,
|
||||
verified: true
|
||||
})
|
||||
const domain2 = `${createRandomString()}.com`
|
||||
const testUser2 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain2}.com`,
|
||||
verified: true
|
||||
})
|
||||
await createTestUsers([testUser1, testUser2])
|
||||
const workspace1 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 2',
|
||||
description: 'User is member',
|
||||
discoverabilityEnabled: true
|
||||
})
|
||||
await createTestWorkspace(workspace1, testUser1, { domain: domain1 })
|
||||
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
userId: testUser2.id,
|
||||
domains: [domain2]
|
||||
})
|
||||
|
||||
const workspaceIds = workspaces.map((w) => w.id)
|
||||
expect(workspaceIds).deep.equalInAnyOrder([])
|
||||
})
|
||||
|
||||
it('returns unique workspaces when user has multiple access paths', async () => {
|
||||
const domain1 = `${createRandomString()}.com`
|
||||
const testUser1 = buildBasicTestUser({
|
||||
email: `${createRandomString()}@${domain1}`,
|
||||
verified: true
|
||||
})
|
||||
await createTestUsers([testUser1])
|
||||
|
||||
const workspace1 = buildBasicTestWorkspace({
|
||||
name: 'Workspace 2',
|
||||
description: 'User is member',
|
||||
discoverabilityEnabled: true
|
||||
})
|
||||
await createTestWorkspace(workspace1, testUser1, { domain: domain1 })
|
||||
|
||||
const workspaces = await getUserEligibleWorkspaces({
|
||||
userId: testUser1.id,
|
||||
domains: ['example.com']
|
||||
})
|
||||
|
||||
const workspaceIds = workspaces.map((w) => w.id)
|
||||
expect(workspaceIds).deep.equalInAnyOrder([workspace1.id])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -258,6 +258,7 @@ describe('Workspace services', () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
logo: null,
|
||||
isExclusive: false,
|
||||
discoverabilityEnabled: false,
|
||||
discoverabilityAutoJoinEnabled: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
@@ -1147,6 +1148,7 @@ describe('Workspace role services', () => {
|
||||
name: cryptoRandomString({ length: 10 }),
|
||||
slug: cryptoRandomString({ length: 10 }),
|
||||
logo: null,
|
||||
isExclusive: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
description: null,
|
||||
@@ -1191,6 +1193,7 @@ describe('Workspace role services', () => {
|
||||
logo: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isExclusive: false,
|
||||
description: null,
|
||||
discoverabilityEnabled: false,
|
||||
discoverabilityAutoJoinEnabled: false,
|
||||
|
||||
@@ -381,6 +381,7 @@ describe('Workspace SSO services', () => {
|
||||
name: '',
|
||||
description: '',
|
||||
logo: null,
|
||||
isExclusive: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
discoverabilityEnabled: false,
|
||||
discoverabilityAutoJoinEnabled: false,
|
||||
|
||||
@@ -14,6 +14,9 @@ export default defineModuleLoaders(() => ({
|
||||
getWorkspaceSsoProvider: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
getWorkspaceSeat: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
|
||||
@@ -33,11 +33,18 @@ export type Workspace = {
|
||||
defaultSeatType: WorkspaceSeatType | null
|
||||
// TODO: Create new table/structure if embeds get more workspace-level configuration
|
||||
isEmbedSpeckleBrandingHidden: boolean
|
||||
isExclusive: boolean
|
||||
}
|
||||
|
||||
export type LimitedWorkspace = Pick<
|
||||
Workspace,
|
||||
'id' | 'slug' | 'name' | 'description' | 'logo' | 'discoverabilityAutoJoinEnabled'
|
||||
| 'id'
|
||||
| 'slug'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'logo'
|
||||
| 'discoverabilityAutoJoinEnabled'
|
||||
| 'isExclusive'
|
||||
>
|
||||
|
||||
export type WorkspaceWithDomains = Workspace & { domains: WorkspaceDomain[] }
|
||||
|
||||
@@ -12,7 +12,8 @@ export const Workspaces = buildTableHelper('workspaces', [
|
||||
'discoverabilityEnabled',
|
||||
'discoverabilityAutoJoinEnabled',
|
||||
'defaultSeatType',
|
||||
'isEmbedSpeckleBrandingHidden'
|
||||
'isEmbedSpeckleBrandingHidden',
|
||||
'isExclusive'
|
||||
])
|
||||
|
||||
export const WorkspaceAcl = buildTableHelper('workspace_acl', [
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Knex } from 'knex'
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('workspaces', (table) => {
|
||||
table.boolean('isExclusive').notNullable().defaultTo(false)
|
||||
})
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('workspaces', (table) => {
|
||||
table.dropColumn('isExclusive')
|
||||
})
|
||||
}
|
||||
@@ -3107,6 +3107,7 @@ export type Role = {
|
||||
export type RootPermissionChecks = {
|
||||
__typename?: 'RootPermissionChecks';
|
||||
canCreatePersonalProject: PermissionCheckResult;
|
||||
canCreateWorkspace: PermissionCheckResult;
|
||||
};
|
||||
|
||||
/** Available scopes. */
|
||||
|
||||
@@ -13,6 +13,7 @@ export const createAndStoreTestWorkspaceFactory =
|
||||
updatedAt: new Date(),
|
||||
description: null,
|
||||
logo: null,
|
||||
isExclusive: false,
|
||||
domainBasedMembershipProtectionEnabled: false,
|
||||
discoverabilityEnabled: false,
|
||||
discoverabilityAutoJoinEnabled: false,
|
||||
|
||||
@@ -94,6 +94,15 @@ export const WorkspaceNotEnoughPermissionsError = defineAuthError({
|
||||
message: 'You do not have enough permissions in the workspace to perform this action'
|
||||
})
|
||||
|
||||
export const EligibleForExclusiveWorkspaceError = defineAuthError({
|
||||
code: 'UserEligibleForExclusiveWorkspace',
|
||||
message:
|
||||
'Cannot create workspace: ' +
|
||||
'You are a member or eligible to become a member of an exclusive workspace. ' +
|
||||
'This is due to you having received an invite to the workspace ' +
|
||||
'or having a matching verified email.'
|
||||
})
|
||||
|
||||
export const WorkspaceReadOnlyError = defineAuthError({
|
||||
code: 'WorkspaceReadOnly',
|
||||
message: 'The workspace is in a read only mode, upgrade your plan to unlock it'
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import type {
|
||||
GetAdminOverrideEnabled,
|
||||
GetEnv,
|
||||
GetUserWorkspaces,
|
||||
GetWorkspace,
|
||||
GetWorkspaceLimits,
|
||||
GetWorkspaceModelCount,
|
||||
@@ -60,6 +61,8 @@ export const AuthCheckContextLoaderKeys = <const>{
|
||||
getProjectModelCount: 'getProjectModelCount',
|
||||
getServerRole: 'getServerRole',
|
||||
getWorkspace: 'getWorkspace',
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces:
|
||||
'getUsersCurrentAndEligibleToBecomeAMemberWorkspaces',
|
||||
getWorkspaceRole: 'getWorkspaceRole',
|
||||
getWorkspaceSeat: 'getWorkspaceSeat',
|
||||
getWorkspaceModelCount: 'getWorkspaceModelCount',
|
||||
@@ -88,6 +91,7 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
|
||||
getProjectModelCount: GetProjectModelCount
|
||||
getServerRole: GetServerRole
|
||||
getWorkspace: GetWorkspace
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: GetUserWorkspaces
|
||||
getWorkspaceRole: GetWorkspaceRole
|
||||
getWorkspaceLimits: GetWorkspaceLimits
|
||||
getWorkspacePlan: GetWorkspacePlan
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Workspace, WorkspaceSsoProvider, WorkspaceSsoSession } from './types.js
|
||||
|
||||
export type GetWorkspace = (args: WorkspaceContext) => Promise<Workspace | null>
|
||||
|
||||
export type GetUserWorkspaces = (args: UserContext) => Promise<Workspace[]>
|
||||
|
||||
export type GetWorkspaceRole = (
|
||||
args: UserContext & WorkspaceContext
|
||||
) => Promise<WorkspaceRoles | null>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { WorkspaceRoles } from '../../../core/constants.js'
|
||||
|
||||
export type Workspace = {
|
||||
id: string
|
||||
slug: string
|
||||
isExclusive: boolean
|
||||
role?: WorkspaceRoles | null
|
||||
}
|
||||
|
||||
export type WorkspaceSsoProvider = {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../domain/authErrors.js'
|
||||
import { OverridesOf } from '../../tests/helpers/types.js'
|
||||
import { getProjectFake } from '../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../tests/fakes.js'
|
||||
import { TIME_MS } from '../../core/index.js'
|
||||
import { ProjectVisibility } from '../domain/projects/types.js'
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('ensureMinimumProjectRoleFragment', () => {
|
||||
workspaceId: 'workspaceId',
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspaceId',
|
||||
slug: 'workspaceSlug'
|
||||
}),
|
||||
@@ -290,7 +290,7 @@ describe('ensureProjectWorkspaceAccessFragment', () => {
|
||||
id: 'projectId',
|
||||
workspaceId: 'workspaceId'
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspaceId',
|
||||
slug: 'workspaceSlug'
|
||||
}),
|
||||
@@ -450,7 +450,7 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspaceId',
|
||||
slug: 'workspaceSlug'
|
||||
}),
|
||||
@@ -688,7 +688,7 @@ describe('ensureImplicitProjectMemberWithWriteAccessFragment', () => {
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspaceId',
|
||||
slug: 'workspaceSlug'
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ensureWorkspaceRoleAndSessionFragment,
|
||||
ensureWorkspacesEnabledFragment
|
||||
ensureWorkspacesEnabledFragment,
|
||||
ensureUserIsWorkspaceAdminFragment
|
||||
} from './workspaces.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import {
|
||||
ServerNoAccessError,
|
||||
ServerNoSessionError,
|
||||
ServerNotEnoughPermissionsError,
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceNotEnoughPermissionsError,
|
||||
WorkspacesNotEnabledError,
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../domain/authErrors.js'
|
||||
import { OverridesOf } from '../../tests/helpers/types.js'
|
||||
import { parseFeatureFlags } from '../../environment/index.js'
|
||||
import { Roles } from '../../core/constants.js'
|
||||
import { getWorkspaceFake } from '../../tests/fakes.js'
|
||||
|
||||
describe('ensureWorkspaceRoleAndSessionFragment', () => {
|
||||
it('hides non existing workspaces behind a WorkspaceNoAccessError', async () => {
|
||||
@@ -35,7 +42,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
|
||||
})
|
||||
it('returns WorkspaceNoAccessError if the user does not have a workspace role', async () => {
|
||||
const result = await ensureWorkspaceRoleAndSessionFragment({
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'aaa',
|
||||
slug: 'bbb'
|
||||
}),
|
||||
@@ -56,7 +63,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
|
||||
})
|
||||
it('returns ok w/o checking session if user is a workspace guest', async () => {
|
||||
const result = await ensureWorkspaceRoleAndSessionFragment({
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'aaa',
|
||||
slug: 'bbb'
|
||||
}),
|
||||
@@ -75,7 +82,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
|
||||
})
|
||||
it('returns just(ok()) if user is a member and workspace has no SSO provider', async () => {
|
||||
const result = await ensureWorkspaceRoleAndSessionFragment({
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'aaa',
|
||||
slug: 'bbb'
|
||||
}),
|
||||
@@ -92,7 +99,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
|
||||
})
|
||||
it('returns WorkspaceSsoSessionInvalidError if user does not have an SSO session', async () => {
|
||||
const result = ensureWorkspaceRoleAndSessionFragment({
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'aaa',
|
||||
slug: 'bbb'
|
||||
}),
|
||||
@@ -120,7 +127,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
|
||||
validUntil.setDate(validUntil.getDate() - 1)
|
||||
|
||||
const result = await ensureWorkspaceRoleAndSessionFragment({
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'aaa',
|
||||
slug: 'bbb'
|
||||
}),
|
||||
@@ -148,7 +155,7 @@ describe('ensureWorkspaceRoleAndSessionFragment', () => {
|
||||
validUntil.setDate(validUntil.getDate() + 100)
|
||||
|
||||
const result = await ensureWorkspaceRoleAndSessionFragment({
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'aaa',
|
||||
slug: 'bbb'
|
||||
}),
|
||||
@@ -194,3 +201,123 @@ describe('ensureWorkspacesEnabledFragment', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureUserIsWorkspaceAdminFragment', () => {
|
||||
const buildEnsureUserIsWorkspaAdminFragment = (
|
||||
overrides?: Partial<Parameters<typeof ensureUserIsWorkspaceAdminFragment>[0]>
|
||||
) => {
|
||||
const workspaceId = cryptoRandomString({ length: 9 })
|
||||
|
||||
return ensureUserIsWorkspaceAdminFragment({
|
||||
getEnv: async () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getServerRole: async () => Roles.Server.Admin,
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: workspaceId,
|
||||
slug: cryptoRandomString({ length: 9 })
|
||||
}),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Admin,
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
workspaceId,
|
||||
name: 'unlimited' as const,
|
||||
status: 'valid' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
const getPolicyArgs = () => ({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
workspaceId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
it('returns error if workspaces is not enabled', async () => {
|
||||
const policy = buildEnsureUserIsWorkspaAdminFragment({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' })
|
||||
})
|
||||
|
||||
const result = await policy({
|
||||
...getPolicyArgs(),
|
||||
userId: undefined
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspacesNotEnabledError.code
|
||||
})
|
||||
})
|
||||
it('returns error if user is not logged in', async () => {
|
||||
const policy = buildEnsureUserIsWorkspaAdminFragment()
|
||||
|
||||
const result = await policy({
|
||||
...getPolicyArgs(),
|
||||
userId: undefined
|
||||
})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ServerNoSessionError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error if user is not found', async () => {
|
||||
const policy = buildEnsureUserIsWorkspaAdminFragment({
|
||||
getServerRole: async () => null
|
||||
})
|
||||
|
||||
const result = await policy(getPolicyArgs())
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ServerNoAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error if user is a server guest', async () => {
|
||||
const policy = buildEnsureUserIsWorkspaAdminFragment({
|
||||
getServerRole: async () => Roles.Server.Guest
|
||||
})
|
||||
|
||||
const result = await policy(getPolicyArgs())
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ServerNotEnoughPermissionsError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error if workspace does not exist', async () => {
|
||||
const policy = buildEnsureUserIsWorkspaAdminFragment({
|
||||
getWorkspace: async () => null
|
||||
})
|
||||
|
||||
const result = await policy(getPolicyArgs())
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspaceNoAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error if user is not workspace admin', async () => {
|
||||
const policy = buildEnsureUserIsWorkspaAdminFragment({
|
||||
getWorkspaceRole: async () => Roles.Workspace.Member
|
||||
})
|
||||
|
||||
const result = await policy(getPolicyArgs())
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspaceNotEnoughPermissionsError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('returns ok if user is workspace admin', async () => {
|
||||
const policy = buildEnsureUserIsWorkspaAdminFragment()
|
||||
|
||||
const result = await policy(getPolicyArgs())
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
import {
|
||||
PersonalProjectsLimitedError,
|
||||
ProjectNotFoundError,
|
||||
ServerNoAccessError,
|
||||
ServerNoSessionError,
|
||||
ServerNotEnoughPermissionsError,
|
||||
WorkspaceLimitsReachedError,
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceNoEditorSeatError,
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
} from '../domain/context.js'
|
||||
import { isWorkspacePlanStatusReadOnly } from '../../workspaces/helpers/plans.js'
|
||||
import { hasEditorSeat } from '../checks/workspaceSeat.js'
|
||||
import { ensureMinimumServerRoleFragment } from './server.js'
|
||||
|
||||
/**
|
||||
* Ensure user has a workspace role, and a valid SSO session (if SSO is configured)
|
||||
@@ -303,3 +307,45 @@ export const ensureModelCanBeCreatedFragment: AuthPolicyEnsureFragment<
|
||||
return ok()
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureUserIsWorkspaceAdminFragment: AuthPolicyEnsureFragment<
|
||||
| typeof Loaders.getEnv
|
||||
| typeof Loaders.getServerRole
|
||||
| typeof Loaders.getWorkspace
|
||||
| typeof Loaders.getWorkspaceRole
|
||||
| typeof Loaders.getWorkspaceSsoProvider
|
||||
| typeof Loaders.getWorkspaceSsoSession
|
||||
| typeof Loaders.getWorkspacePlan,
|
||||
WorkspaceContext & MaybeUserContext,
|
||||
InstanceType<
|
||||
| typeof WorkspaceNoAccessError
|
||||
| typeof WorkspaceSsoSessionNoAccessError
|
||||
| typeof WorkspacesNotEnabledError
|
||||
| typeof ServerNoSessionError
|
||||
| typeof ServerNoAccessError
|
||||
| typeof ServerNotEnoughPermissionsError
|
||||
| typeof WorkspaceNotEnoughPermissionsError
|
||||
>
|
||||
> =
|
||||
(loaders) =>
|
||||
async ({ userId, workspaceId }) => {
|
||||
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
|
||||
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
|
||||
|
||||
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
|
||||
userId,
|
||||
role: Roles.Server.User
|
||||
})
|
||||
|
||||
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
|
||||
|
||||
const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)(
|
||||
{
|
||||
userId: userId!,
|
||||
workspaceId,
|
||||
role: Roles.Workspace.Admin
|
||||
}
|
||||
)
|
||||
if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error)
|
||||
return ok()
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { canPublishPolicy } from './project/canPublish.js'
|
||||
import { canLoadPolicy } from './project/canLoad.js'
|
||||
import { canUpdateEmbedOptionsPolicy } from './workspace/canUpdateEmbedOptions.js'
|
||||
import { canReadMemberEmailPolicy } from './workspace/canReadMemberEmail.js'
|
||||
import { canCreateWorkspacePolicy } from './workspace/canCreateWorkspace.js'
|
||||
|
||||
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
project: {
|
||||
@@ -75,7 +76,8 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
canReceiveProjectsUpdatedMessage:
|
||||
canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders),
|
||||
canUpdateEmbedOptions: canUpdateEmbedOptionsPolicy(loaders),
|
||||
canReadMemberEmail: canReadMemberEmailPolicy(loaders)
|
||||
canReadMemberEmail: canReadMemberEmailPolicy(loaders),
|
||||
canCreateWorkspace: canCreateWorkspacePolicy(loaders)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../../domain/authErrors.js'
|
||||
import { TIME_MS } from '../../../../core/index.js'
|
||||
import { ProjectVisibility } from '../../../domain/projects/types.js'
|
||||
import { getWorkspaceFake } from '../../../../tests/fakes.js'
|
||||
|
||||
const buildCanCreatePolicy = (
|
||||
overrides?: Partial<Parameters<typeof canCreateAutomationPolicy>[0]>
|
||||
@@ -134,7 +135,7 @@ describe('canCreateAutomation', () => {
|
||||
visibility: ProjectVisibility.Private,
|
||||
allowPublicComments: false
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../../domain/authErrors.js'
|
||||
import { TIME_MS } from '../../../../core/index.js'
|
||||
import { ProjectVisibility } from '../../../domain/projects/types.js'
|
||||
import { getWorkspaceFake } from '../../../../tests/fakes.js'
|
||||
|
||||
const buildCanDeletePolicy = (
|
||||
overrides?: Partial<Parameters<typeof canDeleteAutomationPolicy>[0]>
|
||||
@@ -134,7 +135,7 @@ describe('canDeleteAutomation', () => {
|
||||
visibility: ProjectVisibility.Private,
|
||||
allowPublicComments: false
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../../domain/authErrors.js'
|
||||
import { TIME_MS } from '../../../../core/index.js'
|
||||
import { ProjectVisibility } from '../../../domain/projects/types.js'
|
||||
import { getWorkspaceFake } from '../../../../tests/fakes.js'
|
||||
|
||||
const buildCanUpdatePolicy = (
|
||||
overrides?: Partial<Parameters<typeof canUpdateAutomationPolicy>[0]>
|
||||
@@ -134,7 +135,7 @@ describe('canUpdateAutomation', () => {
|
||||
visibility: ProjectVisibility.Private,
|
||||
allowPublicComments: false
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { OverridesOf } from '../../../tests/helpers/types.js'
|
||||
import { canBroadcastProjectActivityPolicy } from './canBroadcastActivity.js'
|
||||
import { parseFeatureFlags } from '../../../environment/index.js'
|
||||
import { getProjectFake } from '../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
import {
|
||||
ProjectNoAccessError,
|
||||
@@ -48,7 +48,7 @@ describe('canBroadcastProjectActivityPolicy', () => {
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canDeleteProjectPolicy } from './canDelete.js'
|
||||
import { parseFeatureFlags } from '../../../environment/index.js'
|
||||
import { getProjectFake } from '../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
import { TIME_MS } from '../../../core/index.js'
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('canDeleteProjectPolicy', () => {
|
||||
id: 'project-id',
|
||||
workspaceId: 'workspace-id'
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { getProjectFake } from '../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import { TIME_MS } from '../../../core/helpers/timeConstants.js'
|
||||
|
||||
describe('canLeaveProjectPolicy', () => {
|
||||
@@ -39,7 +39,7 @@ describe('canLeaveProjectPolicy', () => {
|
||||
workspaceId: 'workspace-id'
|
||||
}),
|
||||
getProjectRole: async () => Roles.Stream.Reviewer,
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../domain/authErrors.js'
|
||||
import { TIME_MS } from '../../../core/index.js'
|
||||
import { ProjectVisibility } from '../../domain/projects/types.js'
|
||||
import { getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
|
||||
const buildCanLoadPolicy = (overrides?: Partial<Parameters<typeof canLoadPolicy>[0]>) =>
|
||||
canLoadPolicy({
|
||||
@@ -164,7 +165,7 @@ describe('canLoad', () => {
|
||||
visibility: ProjectVisibility.Workspace,
|
||||
allowPublicComments: false
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../domain/authErrors.js'
|
||||
import { TIME_MS } from '../../../core/index.js'
|
||||
import { ProjectVisibility } from '../../domain/projects/types.js'
|
||||
import { getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
|
||||
const buildCanPublishPolicy = (
|
||||
overrides?: Partial<Parameters<typeof canPublishPolicy>[0]>
|
||||
@@ -122,7 +123,7 @@ describe('canPublish', () => {
|
||||
visibility: ProjectVisibility.Workspace,
|
||||
allowPublicComments: false
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { getProjectFake } from '../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { AuthCheckContextLoaders } from '../../domain/loaders.js'
|
||||
import { ProjectVisibility } from '../../domain/projects/types.js'
|
||||
|
||||
const canReadProjectArgs = () => {
|
||||
@@ -22,7 +21,7 @@ const canReadProjectArgs = () => {
|
||||
return { projectId, userId }
|
||||
}
|
||||
|
||||
const getWorkspace: AuthCheckContextLoaders['getWorkspace'] = async () => ({
|
||||
const getWorkspace = getWorkspaceFake({
|
||||
id: 'aaa',
|
||||
slug: 'bbb'
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { getProjectFake } from '../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import { TIME_MS } from '../../../core/helpers/timeConstants.js'
|
||||
import { ProjectVisibility } from '../../domain/projects/types.js'
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('canReadProjectSettingsPolicy', () => {
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { canReadProjectWebhooksPolicy } from './canReadWebhooks.js'
|
||||
import { getProjectFake } from '../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import { TIME_MS } from '../../../core/helpers/timeConstants.js'
|
||||
import { ProjectVisibility } from '../../domain/projects/types.js'
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('canReadProjectWebhooksPolicy', () => {
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { getProjectFake } from '../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import { TIME_MS } from '../../../core/index.js'
|
||||
|
||||
// Default deps allow test to succeed, this makes it so that we need to override less of them
|
||||
@@ -40,10 +40,7 @@ const buildWorkspaceSUT = (
|
||||
id: 'project-id',
|
||||
workspaceId: 'workspace-id'
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Member,
|
||||
getWorkspaceSsoProvider: async () => ({
|
||||
providerId: 'provider-id'
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import { canArchiveProjectCommentPolicy } from './canArchive.js'
|
||||
import { OverridesOf } from '../../../../tests/helpers/types.js'
|
||||
import { parseFeatureFlags } from '../../../../environment/index.js'
|
||||
import { getCommentFake, getProjectFake } from '../../../../tests/fakes.js'
|
||||
import {
|
||||
getCommentFake,
|
||||
getProjectFake,
|
||||
getWorkspaceFake
|
||||
} from '../../../../tests/fakes.js'
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import {
|
||||
CommentNotFoundError,
|
||||
@@ -48,10 +52,7 @@ describe('canArchiveProjectCommentPolicy', () => {
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspace: async () => ({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Member,
|
||||
getWorkspaceSsoProvider: async () => ({
|
||||
providerId: 'provider-id'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { OverridesOf } from '../../../../tests/helpers/types.js'
|
||||
import { canCreateProjectCommentPolicy } from './canCreate.js'
|
||||
import { parseFeatureFlags } from '../../../../environment/index.js'
|
||||
import { getProjectFake } from '../../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../../tests/fakes.js'
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import {
|
||||
ProjectNoAccessError,
|
||||
@@ -42,7 +42,7 @@ describe('canCreateProjectCommentPolicy', () => {
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OverridesOf } from '../../../../tests/helpers/types.js'
|
||||
import { parseFeatureFlags } from '../../../../environment/index.js'
|
||||
import { getCommentFake, getProjectFake } from '../../../../tests/fakes.js'
|
||||
import {
|
||||
getCommentFake,
|
||||
getProjectFake,
|
||||
getWorkspaceFake
|
||||
} from '../../../../tests/fakes.js'
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import {
|
||||
CommentNoAccessError,
|
||||
@@ -49,10 +53,7 @@ describe('canEditProjectCommentPolicy', () => {
|
||||
visibility: ProjectVisibility.Workspace
|
||||
}),
|
||||
getProjectRole: async () => null,
|
||||
getWorkspace: async () => ({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Member,
|
||||
getWorkspaceSsoProvider: async () => ({
|
||||
providerId: 'provider-id'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import { parseFeatureFlags } from '../../../../environment/index.js'
|
||||
import { getModelFake, getProjectFake } from '../../../../tests/fakes.js'
|
||||
import {
|
||||
getModelFake,
|
||||
getProjectFake,
|
||||
getWorkspaceFake
|
||||
} from '../../../../tests/fakes.js'
|
||||
import {
|
||||
ModelNotFoundError,
|
||||
ProjectNoAccessError,
|
||||
@@ -45,7 +49,7 @@ const buildWorkspaceSUT = (
|
||||
id: 'project-id',
|
||||
workspaceId: 'workspace-id'
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import { parseFeatureFlags } from '../../../../environment/index.js'
|
||||
import { getProjectFake } from '../../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../../tests/fakes.js'
|
||||
import { canUpdateModelPolicy } from './canUpdate.js'
|
||||
import {
|
||||
ProjectNoAccessError,
|
||||
@@ -37,10 +37,7 @@ const buildWorkspaceSUT = (
|
||||
id: 'project-id',
|
||||
workspaceId: 'workspace-id'
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Member,
|
||||
getWorkspaceSsoProvider: async () => ({
|
||||
providerId: 'provider-id'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Roles } from '../../../../core/constants.js'
|
||||
import { parseFeatureFlags } from '../../../../environment/index.js'
|
||||
import { getProjectFake, getVersionFake } from '../../../../tests/fakes.js'
|
||||
import {
|
||||
getProjectFake,
|
||||
getVersionFake,
|
||||
getWorkspaceFake
|
||||
} from '../../../../tests/fakes.js'
|
||||
import { OverridesOf } from '../../../../tests/helpers/types.js'
|
||||
import { canUpdateProjectVersionPolicy } from './canUpdate.js'
|
||||
import {
|
||||
@@ -46,10 +50,7 @@ const buildWorkspaceSUT = (
|
||||
id: 'project-id',
|
||||
workspaceId: 'workspace-id'
|
||||
}),
|
||||
getWorkspace: async () => ({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Member,
|
||||
getWorkspaceSsoProvider: async () => ({
|
||||
providerId: 'provider-id'
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { assert, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EligibleForExclusiveWorkspaceError,
|
||||
ServerNoAccessError,
|
||||
ServerNoSessionError,
|
||||
ServerNotEnoughPermissionsError,
|
||||
WorkspacesNotEnabledError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { canCreateWorkspacePolicy } from './canCreateWorkspace.js'
|
||||
import { parseFeatureFlags } from '../../../environment/index.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
|
||||
const createTestArgs = () => ({
|
||||
userId: cryptoRandomString({ length: 10 })
|
||||
})
|
||||
|
||||
describe('canCreateWorkspacePolicy creates a function, that handles', () => {
|
||||
describe('server environment configuration by', () => {
|
||||
it('forbids creation if the workspaces module is not enabled', async () => {
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'false'
|
||||
}),
|
||||
getServerRole: async () => {
|
||||
assert.fail()
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
|
||||
assert.fail()
|
||||
}
|
||||
})(createTestArgs())
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: WorkspacesNotEnabledError.code
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('user server roles', () => {
|
||||
it('forbids creation for users without a session', async () => {
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return null
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
|
||||
assert.fail()
|
||||
}
|
||||
})({})
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ServerNoSessionError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('forbids creation for users with no server role', async () => {
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return null
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
|
||||
assert.fail()
|
||||
}
|
||||
})({ userId: cryptoRandomString({ length: 10 }) })
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ServerNoAccessError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('forbids creation for users with insufficient server role', async () => {
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.Guest
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => {
|
||||
assert.fail()
|
||||
}
|
||||
})(createTestArgs())
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: ServerNotEnoughPermissionsError.code
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace eligibility', () => {
|
||||
it('forbids creation for users eligible for exclusive workspaces', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Regular Workspace',
|
||||
slug: 'regular-workspace',
|
||||
isExclusive: false
|
||||
},
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace',
|
||||
slug: 'exclusive-workspace',
|
||||
isExclusive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: EligibleForExclusiveWorkspaceError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('allows creation for users not eligible for any exclusive workspaces', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Regular Workspace 1',
|
||||
slug: 'regular-workspace-1',
|
||||
isExclusive: false
|
||||
},
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Regular Workspace 2',
|
||||
slug: 'regular-workspace-2',
|
||||
isExclusive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it('allows creation for users with no eligible workspaces', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return []
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it('allows creation for admin users even if eligible for exclusive workspaces', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.Admin
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace',
|
||||
slug: 'exclusive-workspace',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Admin
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it('allows creation for workspace admins of exclusive workspaces', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace',
|
||||
slug: 'exclusive-workspace',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Admin
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it('allows creation for workspace guests of exclusive workspaces', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace',
|
||||
slug: 'exclusive-workspace',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Guest
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it('forbids creation for workspace members of exclusive workspaces', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace',
|
||||
slug: 'exclusive-workspace',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Member
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: EligibleForExclusiveWorkspaceError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('forbids creation for workspace admins if they have a member role on an exclusive workspace', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace 1',
|
||||
slug: 'exclusive-workspace-1',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Admin
|
||||
},
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace 2',
|
||||
slug: 'exclusive-workspace-2',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Member
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthErrorResult({
|
||||
code: EligibleForExclusiveWorkspaceError.code
|
||||
})
|
||||
})
|
||||
|
||||
it('allows creation for workspace admins even with mixed exclusive workspace roles', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace 1',
|
||||
slug: 'exclusive-workspace-1',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Admin
|
||||
},
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace 2',
|
||||
slug: 'exclusive-workspace-2',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Guest
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
|
||||
it('allows creation when all workspace roles pass the policy', async () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const result = await canCreateWorkspacePolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
|
||||
getServerRole: async () => {
|
||||
return Roles.Server.User
|
||||
},
|
||||
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async ({
|
||||
userId: queriedUserId
|
||||
}) => {
|
||||
expect(queriedUserId).toBe(userId)
|
||||
return [
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace 1',
|
||||
slug: 'exclusive-workspace-1',
|
||||
isExclusive: false,
|
||||
role: Roles.Workspace.Member
|
||||
},
|
||||
{
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
name: 'Exclusive Workspace 2',
|
||||
slug: 'exclusive-workspace-2',
|
||||
isExclusive: true,
|
||||
role: Roles.Workspace.Guest
|
||||
}
|
||||
]
|
||||
}
|
||||
})({ userId })
|
||||
|
||||
expect(result).toBeAuthOKResult()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import {
|
||||
EligibleForExclusiveWorkspaceError,
|
||||
ServerNoAccessError,
|
||||
ServerNoSessionError,
|
||||
ServerNotEnoughPermissionsError,
|
||||
WorkspacesNotEnabledError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { MaybeUserContext } from '../../domain/context.js'
|
||||
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
|
||||
import { AuthPolicy } from '../../domain/policies.js'
|
||||
import { ensureWorkspacesEnabledFragment } from '../../fragments/workspaces.js'
|
||||
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
import { throwUncoveredError } from '../../../core/index.js'
|
||||
|
||||
type PolicyArgs = MaybeUserContext
|
||||
type PolicyLoaderKeys =
|
||||
| typeof AuthCheckContextLoaderKeys.getEnv
|
||||
| typeof AuthCheckContextLoaderKeys.getUsersCurrentAndEligibleToBecomeAMemberWorkspaces
|
||||
| typeof AuthCheckContextLoaderKeys.getServerRole
|
||||
|
||||
type PolicyErrors = InstanceType<
|
||||
| typeof WorkspacesNotEnabledError
|
||||
| typeof ServerNoSessionError
|
||||
| typeof ServerNoAccessError
|
||||
| typeof ServerNotEnoughPermissionsError
|
||||
| typeof EligibleForExclusiveWorkspaceError
|
||||
>
|
||||
|
||||
export const canCreateWorkspacePolicy: AuthPolicy<
|
||||
PolicyLoaderKeys,
|
||||
PolicyArgs,
|
||||
PolicyErrors
|
||||
> =
|
||||
(loaders) =>
|
||||
async ({ userId }) => {
|
||||
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
|
||||
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
|
||||
|
||||
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
|
||||
userId,
|
||||
role: Roles.Server.User
|
||||
})
|
||||
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
|
||||
|
||||
// userId is not null here, ensured by the serverRoleFragment
|
||||
const workspaces =
|
||||
await loaders.getUsersCurrentAndEligibleToBecomeAMemberWorkspaces({
|
||||
userId: userId!
|
||||
})
|
||||
const isUserEligibleForExclusiveWorkspaces = workspaces.some((w) => {
|
||||
if (w.isExclusive) {
|
||||
// if the user has no role in the workspace, means they are eligible
|
||||
// to join it via an invite or discovery
|
||||
if (!w.role) return true
|
||||
// for exclusive workspaces, if the user has a role, some of them are not affected by this policy
|
||||
// ie.: Workspace admins of exclusive workspaces should be able to create new ones
|
||||
// also guests should not be bound by this rule
|
||||
switch (w.role) {
|
||||
case Roles.Workspace.Admin:
|
||||
case Roles.Workspace.Guest:
|
||||
return false
|
||||
case Roles.Workspace.Member:
|
||||
return true
|
||||
default:
|
||||
throwUncoveredError(w.role)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (isUserEligibleForExclusiveWorkspaces) {
|
||||
return err(new EligibleForExclusiveWorkspaceError())
|
||||
}
|
||||
return ok()
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
import { parseFeatureFlags } from '../../../environment/index.js'
|
||||
import { Workspace } from '../../domain/workspaces/types.js'
|
||||
import { WorkspacePlan } from '../../../workspaces/index.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ServerNoAccessError,
|
||||
@@ -13,45 +11,44 @@ import {
|
||||
WorkspacesNotEnabledError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { canReadMemberEmailPolicy } from './canReadMemberEmail.js'
|
||||
|
||||
const buildCanReadMemberEmailPolicy = (
|
||||
overrides?: Partial<Parameters<typeof canReadMemberEmailPolicy>[0]>
|
||||
) => {
|
||||
const workspaceId = cryptoRandomString({ length: 9 })
|
||||
|
||||
return canReadMemberEmailPolicy({
|
||||
getEnv: async () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getServerRole: async () => Roles.Server.Admin,
|
||||
getWorkspace: async () => {
|
||||
return {
|
||||
id: workspaceId,
|
||||
slug: cryptoRandomString({ length: 9 })
|
||||
} as Workspace
|
||||
},
|
||||
getWorkspaceRole: async () => Roles.Workspace.Admin,
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
workspaceId,
|
||||
name: 'unlimited',
|
||||
status: 'valid',
|
||||
createdAt: new Date()
|
||||
} as WorkspacePlan
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
const getPolicyArgs = () => ({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
workspaceId: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
import { getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
|
||||
describe('canReadMemberEmailPolicy', () => {
|
||||
const workspaceId = cryptoRandomString({ length: 9 })
|
||||
|
||||
const buildCanReadMemberEmailPolicy = (
|
||||
overrides?: Partial<Parameters<typeof canReadMemberEmailPolicy>[0]>
|
||||
) => {
|
||||
return canReadMemberEmailPolicy({
|
||||
getEnv: async () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getServerRole: async () => Roles.Server.Admin,
|
||||
getWorkspace: getWorkspaceFake({
|
||||
id: workspaceId,
|
||||
slug: cryptoRandomString({ length: 9 })
|
||||
}),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Admin,
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
workspaceId,
|
||||
name: 'unlimited' as const,
|
||||
status: 'valid' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
const getPolicyArgs = () => ({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
workspaceId
|
||||
})
|
||||
it('returns error if workspaces is not enabled', async () => {
|
||||
const policy = buildCanReadMemberEmailPolicy({
|
||||
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' })
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
|
||||
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
|
||||
import { AuthPolicy } from '../../domain/policies.js'
|
||||
import {
|
||||
ensureWorkspaceRoleAndSessionFragment,
|
||||
ensureWorkspacesEnabledFragment
|
||||
} from '../../fragments/workspaces.js'
|
||||
import { ensureUserIsWorkspaceAdminFragment } from '../../fragments/workspaces.js'
|
||||
import {
|
||||
ServerNoAccessError,
|
||||
ServerNoSessionError,
|
||||
@@ -15,8 +11,6 @@ import {
|
||||
WorkspacesNotEnabledError,
|
||||
WorkspaceSsoSessionNoAccessError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
|
||||
type PolicyArgs = MaybeUserContext & WorkspaceContext
|
||||
|
||||
@@ -29,14 +23,15 @@ type PolicyLoaderKeys =
|
||||
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
|
||||
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
|
||||
|
||||
type PolicyErrors =
|
||||
| InstanceType<typeof WorkspaceNoAccessError>
|
||||
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
|
||||
| InstanceType<typeof WorkspacesNotEnabledError>
|
||||
| InstanceType<typeof ServerNoSessionError>
|
||||
| InstanceType<typeof ServerNoAccessError>
|
||||
| InstanceType<typeof ServerNotEnoughPermissionsError>
|
||||
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
|
||||
type PolicyErrors = InstanceType<
|
||||
| typeof WorkspaceNoAccessError
|
||||
| typeof WorkspaceSsoSessionNoAccessError
|
||||
| typeof WorkspacesNotEnabledError
|
||||
| typeof ServerNoSessionError
|
||||
| typeof ServerNoAccessError
|
||||
| typeof ServerNotEnoughPermissionsError
|
||||
| typeof WorkspaceNotEnoughPermissionsError
|
||||
>
|
||||
|
||||
export const canReadMemberEmailPolicy: AuthPolicy<
|
||||
PolicyLoaderKeys,
|
||||
@@ -45,23 +40,5 @@ export const canReadMemberEmailPolicy: AuthPolicy<
|
||||
> =
|
||||
(loaders) =>
|
||||
async ({ userId, workspaceId }) => {
|
||||
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
|
||||
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
|
||||
|
||||
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
|
||||
userId,
|
||||
role: Roles.Server.User
|
||||
})
|
||||
|
||||
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
|
||||
|
||||
const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)(
|
||||
{
|
||||
userId: userId!,
|
||||
workspaceId,
|
||||
role: Roles.Workspace.Admin
|
||||
}
|
||||
)
|
||||
if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error)
|
||||
return ok()
|
||||
return ensureUserIsWorkspaceAdminFragment(loaders)({ userId, workspaceId })
|
||||
}
|
||||
|
||||
+2
-5
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canReceiveWorkspaceProjectsUpdatedMessagePolicy } from './canReceiveProjectsUpdatedMessage.js'
|
||||
import { OverridesOf } from '../../../tests/helpers/types.js'
|
||||
import { getProjectFake } from '../../../tests/fakes.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
import { TIME_MS } from '../../../core/index.js'
|
||||
import { parseFeatureFlags } from '../../../environment/index.js'
|
||||
@@ -25,10 +25,7 @@ describe('canReceiveWorkspaceProjectsUpdatedMessagePolicy', () => {
|
||||
}),
|
||||
getProjectRole: async () => Roles.Stream.Reviewer,
|
||||
getServerRole: async () => Roles.Server.Guest,
|
||||
getWorkspace: async () => ({
|
||||
id: 'workspace-id',
|
||||
slug: 'workspace-slug'
|
||||
}),
|
||||
getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }),
|
||||
getWorkspaceRole: async () => Roles.Workspace.Guest,
|
||||
getWorkspaceSsoProvider: async () => ({
|
||||
providerId: 'provider-id'
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
} from '../../fragments/workspaces.js'
|
||||
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
import { WorkspacePlans } from '../../../workspaces/index.js'
|
||||
import {
|
||||
WorkspacePlanFeatures,
|
||||
workspacePlanHasAccessToFeature
|
||||
} from '../../../workspaces/index.js'
|
||||
|
||||
type PolicyLoaderKeys =
|
||||
| typeof AuthCheckContextLoaderKeys.getEnv
|
||||
@@ -74,16 +77,11 @@ export const canUpdateEmbedOptionsPolicy: AuthPolicy<
|
||||
})
|
||||
if (ensuredNotReadOnly.isErr) return err(ensuredNotReadOnly.error)
|
||||
|
||||
const validPlans: WorkspacePlans[] = [
|
||||
'academia',
|
||||
'unlimited',
|
||||
'pro',
|
||||
'proUnlimited',
|
||||
'proUnlimitedInvoiced'
|
||||
]
|
||||
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
|
||||
if (!workspacePlan || !validPlans.includes(workspacePlan.name))
|
||||
return err(new WorkspaceNoFeatureAccessError())
|
||||
|
||||
return ok()
|
||||
if (!workspacePlan) return err(new WorkspaceNoFeatureAccessError())
|
||||
const canUpdateEmbedOptions = workspacePlanHasAccessToFeature({
|
||||
plan: workspacePlan.name,
|
||||
feature: WorkspacePlanFeatures.HideSpeckleBranding
|
||||
})
|
||||
return canUpdateEmbedOptions ? ok() : err(new WorkspaceNoFeatureAccessError())
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ export const getProjectFake = fakeGetFactory<Project>(() => ({
|
||||
|
||||
export const getWorkspaceFake = fakeGetFactory<Workspace>(() => ({
|
||||
id: nanoid(10),
|
||||
slug: nanoid(10)
|
||||
slug: nanoid(10),
|
||||
isExclusive: false
|
||||
}))
|
||||
|
||||
export const getCommentFake = fakeGetFactory<Comment>(() => ({
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
workspacePlanHasAccessToFeature,
|
||||
WorkspacePlanFeatures,
|
||||
WorkspacePlanConfigs
|
||||
} from './features.js'
|
||||
import { WorkspacePlans } from './plans.js'
|
||||
|
||||
describe('workspacePlanHasAccessToFeature', () => {
|
||||
describe('Comprehensive feature coverage', () => {
|
||||
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
|
||||
const allFeatures = Object.values(WorkspacePlanFeatures) as WorkspacePlanFeatures[]
|
||||
|
||||
describe.each(allPlans)('should work for %s plan', (plan) => {
|
||||
it.each(allFeatures)('%s feature combination', (feature) => {
|
||||
const expectedResult = WorkspacePlanConfigs[plan].features.includes(feature)
|
||||
const actualResult = workspacePlanHasAccessToFeature({ plan, feature })
|
||||
|
||||
expect(
|
||||
actualResult,
|
||||
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
|
||||
).toBe(expectedResult)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -244,3 +244,15 @@ export const workspaceReachedPlanLimit = (
|
||||
|
||||
return projectCount === limits.projectCount || modelCount === limits.modelCount
|
||||
}
|
||||
|
||||
export const workspacePlanHasAccessToFeature = ({
|
||||
plan,
|
||||
feature
|
||||
}: {
|
||||
plan: WorkspacePlans
|
||||
feature: WorkspacePlanFeatures
|
||||
}): boolean => {
|
||||
const planConfig = WorkspacePlanConfigs[plan]
|
||||
const hasAccess = planConfig.features.includes(feature)
|
||||
return hasAccess
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user