From 4a2d85d68c3b0f7b41823835beacb081360eb428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= <57442769+gjedlicska@users.noreply.github.com> Date: Wed, 18 Jun 2025 08:58:26 +0100 Subject: [PATCH] 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 --- .zed/debug.json | 28 ++ .../lib/common/generated/gql/graphql.ts | 2 + .../assets/core/typedefs/permissions.graphql | 1 + .../modules/core/graph/generated/graphql.ts | 2 + .../core/graph/resolvers/permissions.ts | 6 + .../graph/generated/graphql.ts | 1 + .../modules/shared/helpers/errorHelper.ts | 1 + .../modules/workspaces/authz/loaders/index.ts | 13 +- .../server/modules/workspaces/domain/logic.ts | 3 +- .../modules/workspaces/domain/operations.ts | 8 + .../workspaces/graph/resolvers/workspaces.ts | 6 + .../workspaces/repositories/workspaces.ts | 40 +- .../modules/workspaces/services/management.ts | 3 +- .../modules/workspaces/services/retrieval.ts | 30 +- .../tests/integration/repositories.spec.ts | 225 ++++++++++- .../tests/unit/services/management.spec.ts | 3 + .../tests/unit/services/sso.spec.ts | 1 + .../workspacesCore/authz/loaders/index.ts | 3 + .../modules/workspacesCore/domain/types.ts | 9 +- .../modules/workspacesCore/helpers/db.ts | 3 +- ...06043717_add_exclusive_workspace_option.ts | 13 + .../server/test/graphql/generated/graphql.ts | 1 + .../server/test/speckle-helpers/workspaces.ts | 1 + .../shared/src/authz/domain/authErrors.ts | 9 + packages/shared/src/authz/domain/loaders.ts | 4 + .../src/authz/domain/workspaces/operations.ts | 2 + .../src/authz/domain/workspaces/types.ts | 4 + .../src/authz/fragments/projects.spec.ts | 10 +- .../src/authz/fragments/workspaces.spec.ts | 141 ++++++- .../shared/src/authz/fragments/workspaces.ts | 46 +++ packages/shared/src/authz/policies/index.ts | 4 +- .../project/automation/canCreate.spec.ts | 3 +- .../project/automation/canDelete.spec.ts | 3 +- .../project/automation/canUpdate.spec.ts | 3 +- .../project/canBroadcastActivity.spec.ts | 4 +- .../authz/policies/project/canDelete.spec.ts | 4 +- .../authz/policies/project/canLeave.spec.ts | 4 +- .../authz/policies/project/canLoad.spec.ts | 3 +- .../authz/policies/project/canPublish.spec.ts | 3 +- .../authz/policies/project/canRead.spec.ts | 5 +- .../policies/project/canReadSettings.spec.ts | 4 +- .../policies/project/canReadWebhooks.spec.ts | 4 +- .../authz/policies/project/canUpdate.spec.ts | 7 +- .../project/comment/canArchive.spec.ts | 11 +- .../project/comment/canCreate.spec.ts | 4 +- .../policies/project/comment/canEdit.spec.ts | 11 +- .../policies/project/model/canDelete.spec.ts | 8 +- .../policies/project/model/canUpdate.spec.ts | 7 +- .../project/version/canUpdate.spec.ts | 11 +- .../workspace/canCreateWorkspace.spec.ts | 380 ++++++++++++++++++ .../policies/workspace/canCreateWorkspace.ts | 77 ++++ .../workspace/canReadMemberEmail.spec.ts | 77 ++-- .../policies/workspace/canReadMemberEmail.ts | 45 +-- .../canReceiveProjectsUpdatedMessage.spec.ts | 7 +- .../workspace/canUpdateEmbedOptions.ts | 22 +- packages/shared/src/tests/fakes.ts | 3 +- .../src/workspaces/helpers/features.spec.ts | 26 ++ .../shared/src/workspaces/helpers/features.ts | 12 + 58 files changed, 1207 insertions(+), 164 deletions(-) create mode 100644 packages/server/modules/workspacesCore/migrations/20250606043717_add_exclusive_workspace_option.ts create mode 100644 packages/shared/src/authz/policies/workspace/canCreateWorkspace.spec.ts create mode 100644 packages/shared/src/authz/policies/workspace/canCreateWorkspace.ts create mode 100644 packages/shared/src/workspaces/helpers/features.spec.ts diff --git a/.zed/debug.json b/.zed/debug.json index e6fcae251..bfd9ee505 100644 --- a/.zed/debug.json +++ b/.zed/debug.json @@ -12,5 +12,33 @@ "skipFiles": ["/**"], "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": ["/**"], + "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": ["/**"], + "env": {}, + "stop_on_entry": false } ] diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index f8ca9b394..72afe43e3 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -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: {}, diff --git a/packages/server/assets/core/typedefs/permissions.graphql b/packages/server/assets/core/typedefs/permissions.graphql index 853a2bb97..9861eea99 100644 --- a/packages/server/assets/core/typedefs/permissions.graphql +++ b/packages/server/assets/core/typedefs/permissions.graphql @@ -20,6 +20,7 @@ type ProjectPermissionChecks { type RootPermissionChecks { canCreatePersonalProject: PermissionCheckResult! + canCreateWorkspace: PermissionCheckResult! } extend type User { diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index fddceae88..2bd89c4ac 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -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 = { canCreatePersonalProject?: Resolver; + canCreateWorkspace?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/server/modules/core/graph/resolvers/permissions.ts b/packages/server/modules/core/graph/resolvers/permissions.ts index 497457800..0691408fc 100644 --- a/packages/server/modules/core/graph/resolvers/permissions.ts +++ b/packages/server/modules/core/graph/resolvers/permissions.ts @@ -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 diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 67c8b55b3..615309864 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -3106,6 +3106,7 @@ export type Role = { export type RootPermissionChecks = { __typename?: 'RootPermissionChecks'; canCreatePersonalProject: PermissionCheckResult; + canCreateWorkspace: PermissionCheckResult; }; /** Available scopes. */ diff --git a/packages/server/modules/shared/helpers/errorHelper.ts b/packages/server/modules/shared/helpers/errorHelper.ts index d1bafaa9a..5fe0836d3 100644 --- a/packages/server/modules/shared/helpers/errorHelper.ts +++ b/packages/server/modules/shared/helpers/errorHelper.ts @@ -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, { diff --git a/packages/server/modules/workspaces/authz/loaders/index.ts b/packages/server/modules/workspaces/authz/loaders/index.ts index 48a9b9d64..7bb6af505 100644 --- a/packages/server/modules/workspaces/authz/loaders/index.ts +++ b/packages/server/modules/workspaces/authz/loaders/index.ts @@ -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) } diff --git a/packages/server/modules/workspaces/domain/logic.ts b/packages/server/modules/workspaces/domain/logic.ts index ca1b7b311..2193f7a51 100644 --- a/packages/server/modules/workspaces/domain/logic.ts +++ b/packages/server/modules/workspaces/domain/logic.ts @@ -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 } } diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index 865c6e141..c2a7e7eba 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -48,6 +48,14 @@ export type GetUserDiscoverableWorkspaces = (args: { userId: string }) => Promise +// adding optional role to each workspace +export type EligibleWorkspace = LimitedWorkspace & { role?: WorkspaceRoles }[] + +export type GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces = (args: { + domains: string[] + userId: string +}) => Promise + export type GetWorkspace = (args: { workspaceId: string userId?: string diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index a7012e919..af161db79 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -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( diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index d77820f8d..3edf1ee94 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -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('workspace_join_requests') } +export const getUserEligibleWorkspacesFactory = + ({ db }: { db: Knex }): GetUsersCurrentAndEligibleToBecomeAMemberWorkspaces => + async ({ userId, domains }) => { + const q = tables + .workspaces(db) + .distinctOn(Workspaces.col.id) + .select([...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' ]) } diff --git a/packages/server/modules/workspaces/services/management.ts b/packages/server/modules/workspaces/services/management.ts index 636c24f71..450045d57 100644 --- a/packages/server/modules/workspaces/services/management.ts +++ b/packages/server/modules/workspaces/services/management.ts @@ -167,7 +167,8 @@ export const createWorkspaceFactory = discoverabilityEnabled: false, discoverabilityAutoJoinEnabled: false, isEmbedSpeckleBrandingHidden: false, - defaultSeatType: null + defaultSeatType: null, + isExclusive: false } satisfies Workspace await upsertWorkspace({ workspace }) diff --git a/packages/server/modules/workspaces/services/retrieval.ts b/packages/server/modules/workspaces/services/retrieval.ts index 69b521649..8dc84fec3 100644 --- a/packages/server/modules/workspaces/services/retrieval.ts +++ b/packages/server/modules/workspaces/services/retrieval.ts @@ -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 => { 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 => { + const userEmails = await findEmailsByUserId({ userId }) + const userVerifiedDomains = getUserVerifiedDomains(userEmails) + const workspaces = await getUserEligibleWorkspaces({ + domains: userVerifiedDomains, + userId + }) + + return workspaces + } + type GetWorkspacesForUserArgs = { userId: string completed?: boolean diff --git a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts index 7fad8175e..f1fbe2e27 100644 --- a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts @@ -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]) + }) + }) }) diff --git a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts index e4943f148..22b3b8742 100644 --- a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -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, diff --git a/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts index cea51db83..9ce3ef3ad 100644 --- a/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts @@ -381,6 +381,7 @@ describe('Workspace SSO services', () => { name: '', description: '', logo: null, + isExclusive: false, domainBasedMembershipProtectionEnabled: false, discoverabilityEnabled: false, discoverabilityAutoJoinEnabled: false, diff --git a/packages/server/modules/workspacesCore/authz/loaders/index.ts b/packages/server/modules/workspacesCore/authz/loaders/index.ts index 8514991c8..40e28c95f 100644 --- a/packages/server/modules/workspacesCore/authz/loaders/index.ts +++ b/packages/server/modules/workspacesCore/authz/loaders/index.ts @@ -14,6 +14,9 @@ export default defineModuleLoaders(() => ({ getWorkspaceSsoProvider: async () => { throw new LoaderUnsupportedError() }, + getUsersCurrentAndEligibleToBecomeAMemberWorkspaces: async () => { + throw new LoaderUnsupportedError() + }, getWorkspaceSeat: async () => { throw new LoaderUnsupportedError() }, diff --git a/packages/server/modules/workspacesCore/domain/types.ts b/packages/server/modules/workspacesCore/domain/types.ts index 8aaf84476..cda4800f7 100644 --- a/packages/server/modules/workspacesCore/domain/types.ts +++ b/packages/server/modules/workspacesCore/domain/types.ts @@ -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[] } diff --git a/packages/server/modules/workspacesCore/helpers/db.ts b/packages/server/modules/workspacesCore/helpers/db.ts index 7a9d7c031..20b75f2fb 100644 --- a/packages/server/modules/workspacesCore/helpers/db.ts +++ b/packages/server/modules/workspacesCore/helpers/db.ts @@ -12,7 +12,8 @@ export const Workspaces = buildTableHelper('workspaces', [ 'discoverabilityEnabled', 'discoverabilityAutoJoinEnabled', 'defaultSeatType', - 'isEmbedSpeckleBrandingHidden' + 'isEmbedSpeckleBrandingHidden', + 'isExclusive' ]) export const WorkspaceAcl = buildTableHelper('workspace_acl', [ diff --git a/packages/server/modules/workspacesCore/migrations/20250606043717_add_exclusive_workspace_option.ts b/packages/server/modules/workspacesCore/migrations/20250606043717_add_exclusive_workspace_option.ts new file mode 100644 index 000000000..6e78485f5 --- /dev/null +++ b/packages/server/modules/workspacesCore/migrations/20250606043717_add_exclusive_workspace_option.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('workspaces', (table) => { + table.boolean('isExclusive').notNullable().defaultTo(false) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('workspaces', (table) => { + table.dropColumn('isExclusive') + }) +} diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 35c78d9a9..9d7ae70d3 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -3107,6 +3107,7 @@ export type Role = { export type RootPermissionChecks = { __typename?: 'RootPermissionChecks'; canCreatePersonalProject: PermissionCheckResult; + canCreateWorkspace: PermissionCheckResult; }; /** Available scopes. */ diff --git a/packages/server/test/speckle-helpers/workspaces.ts b/packages/server/test/speckle-helpers/workspaces.ts index ea747fa13..3fc32cb0c 100644 --- a/packages/server/test/speckle-helpers/workspaces.ts +++ b/packages/server/test/speckle-helpers/workspaces.ts @@ -13,6 +13,7 @@ export const createAndStoreTestWorkspaceFactory = updatedAt: new Date(), description: null, logo: null, + isExclusive: false, domainBasedMembershipProtectionEnabled: false, discoverabilityEnabled: false, discoverabilityAutoJoinEnabled: false, diff --git a/packages/shared/src/authz/domain/authErrors.ts b/packages/shared/src/authz/domain/authErrors.ts index 427dff14f..ff6eecc9b 100644 --- a/packages/shared/src/authz/domain/authErrors.ts +++ b/packages/shared/src/authz/domain/authErrors.ts @@ -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' diff --git a/packages/shared/src/authz/domain/loaders.ts b/packages/shared/src/authz/domain/loaders.ts index abf3522ae..50f29c751 100644 --- a/packages/shared/src/authz/domain/loaders.ts +++ b/packages/shared/src/authz/domain/loaders.ts @@ -10,6 +10,7 @@ import type { import type { GetAdminOverrideEnabled, GetEnv, + GetUserWorkspaces, GetWorkspace, GetWorkspaceLimits, GetWorkspaceModelCount, @@ -60,6 +61,8 @@ export const AuthCheckContextLoaderKeys = { 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 diff --git a/packages/shared/src/authz/domain/workspaces/operations.ts b/packages/shared/src/authz/domain/workspaces/operations.ts index b7b4feed1..67b56c442 100644 --- a/packages/shared/src/authz/domain/workspaces/operations.ts +++ b/packages/shared/src/authz/domain/workspaces/operations.ts @@ -7,6 +7,8 @@ import { Workspace, WorkspaceSsoProvider, WorkspaceSsoSession } from './types.js export type GetWorkspace = (args: WorkspaceContext) => Promise +export type GetUserWorkspaces = (args: UserContext) => Promise + export type GetWorkspaceRole = ( args: UserContext & WorkspaceContext ) => Promise diff --git a/packages/shared/src/authz/domain/workspaces/types.ts b/packages/shared/src/authz/domain/workspaces/types.ts index 4eb48e271..4db38499b 100644 --- a/packages/shared/src/authz/domain/workspaces/types.ts +++ b/packages/shared/src/authz/domain/workspaces/types.ts @@ -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 = { diff --git a/packages/shared/src/authz/fragments/projects.spec.ts b/packages/shared/src/authz/fragments/projects.spec.ts index 0c5457cfa..8344544c7 100644 --- a/packages/shared/src/authz/fragments/projects.spec.ts +++ b/packages/shared/src/authz/fragments/projects.spec.ts @@ -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' }), diff --git a/packages/shared/src/authz/fragments/workspaces.spec.ts b/packages/shared/src/authz/fragments/workspaces.spec.ts index e51bd74a0..1a6212a2a 100644 --- a/packages/shared/src/authz/fragments/workspaces.spec.ts +++ b/packages/shared/src/authz/fragments/workspaces.spec.ts @@ -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[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() + }) +}) diff --git a/packages/shared/src/authz/fragments/workspaces.ts b/packages/shared/src/authz/fragments/workspaces.ts index f0ad11620..4132ff16b 100644 --- a/packages/shared/src/authz/fragments/workspaces.ts +++ b/packages/shared/src/authz/fragments/workspaces.ts @@ -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() + } diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index 2cdd01a67..e6f0ec355 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -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) } }) diff --git a/packages/shared/src/authz/policies/project/automation/canCreate.spec.ts b/packages/shared/src/authz/policies/project/automation/canCreate.spec.ts index e2820821e..4fbb646f5 100644 --- a/packages/shared/src/authz/policies/project/automation/canCreate.spec.ts +++ b/packages/shared/src/authz/policies/project/automation/canCreate.spec.ts @@ -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[0]> @@ -134,7 +135,7 @@ describe('canCreateAutomation', () => { visibility: ProjectVisibility.Private, allowPublicComments: false }), - getWorkspace: async () => ({ + getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }), diff --git a/packages/shared/src/authz/policies/project/automation/canDelete.spec.ts b/packages/shared/src/authz/policies/project/automation/canDelete.spec.ts index 704136317..e23edf78a 100644 --- a/packages/shared/src/authz/policies/project/automation/canDelete.spec.ts +++ b/packages/shared/src/authz/policies/project/automation/canDelete.spec.ts @@ -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[0]> @@ -134,7 +135,7 @@ describe('canDeleteAutomation', () => { visibility: ProjectVisibility.Private, allowPublicComments: false }), - getWorkspace: async () => ({ + getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }), diff --git a/packages/shared/src/authz/policies/project/automation/canUpdate.spec.ts b/packages/shared/src/authz/policies/project/automation/canUpdate.spec.ts index 366256e21..0fb700ee5 100644 --- a/packages/shared/src/authz/policies/project/automation/canUpdate.spec.ts +++ b/packages/shared/src/authz/policies/project/automation/canUpdate.spec.ts @@ -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[0]> @@ -134,7 +135,7 @@ describe('canUpdateAutomation', () => { visibility: ProjectVisibility.Private, allowPublicComments: false }), - getWorkspace: async () => ({ + getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }), diff --git a/packages/shared/src/authz/policies/project/canBroadcastActivity.spec.ts b/packages/shared/src/authz/policies/project/canBroadcastActivity.spec.ts index 3926da424..84bda1635 100644 --- a/packages/shared/src/authz/policies/project/canBroadcastActivity.spec.ts +++ b/packages/shared/src/authz/policies/project/canBroadcastActivity.spec.ts @@ -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' }), diff --git a/packages/shared/src/authz/policies/project/canDelete.spec.ts b/packages/shared/src/authz/policies/project/canDelete.spec.ts index 262ada0c2..8f086cea2 100644 --- a/packages/shared/src/authz/policies/project/canDelete.spec.ts +++ b/packages/shared/src/authz/policies/project/canDelete.spec.ts @@ -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' }), diff --git a/packages/shared/src/authz/policies/project/canLeave.spec.ts b/packages/shared/src/authz/policies/project/canLeave.spec.ts index 228fd3373..ca88e6a3e 100644 --- a/packages/shared/src/authz/policies/project/canLeave.spec.ts +++ b/packages/shared/src/authz/policies/project/canLeave.spec.ts @@ -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' }), diff --git a/packages/shared/src/authz/policies/project/canLoad.spec.ts b/packages/shared/src/authz/policies/project/canLoad.spec.ts index 45af540b1..c5b016d51 100644 --- a/packages/shared/src/authz/policies/project/canLoad.spec.ts +++ b/packages/shared/src/authz/policies/project/canLoad.spec.ts @@ -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[0]>) => canLoadPolicy({ @@ -164,7 +165,7 @@ describe('canLoad', () => { visibility: ProjectVisibility.Workspace, allowPublicComments: false }), - getWorkspace: async () => ({ + getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }), diff --git a/packages/shared/src/authz/policies/project/canPublish.spec.ts b/packages/shared/src/authz/policies/project/canPublish.spec.ts index e62a8b009..109d5d10c 100644 --- a/packages/shared/src/authz/policies/project/canPublish.spec.ts +++ b/packages/shared/src/authz/policies/project/canPublish.spec.ts @@ -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[0]> @@ -122,7 +123,7 @@ describe('canPublish', () => { visibility: ProjectVisibility.Workspace, allowPublicComments: false }), - getWorkspace: async () => ({ + getWorkspace: getWorkspaceFake({ id: 'workspace-id', slug: 'workspace-slug' }), diff --git a/packages/shared/src/authz/policies/project/canRead.spec.ts b/packages/shared/src/authz/policies/project/canRead.spec.ts index 49e5c700f..901874a65 100644 --- a/packages/shared/src/authz/policies/project/canRead.spec.ts +++ b/packages/shared/src/authz/policies/project/canRead.spec.ts @@ -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' }) diff --git a/packages/shared/src/authz/policies/project/canReadSettings.spec.ts b/packages/shared/src/authz/policies/project/canReadSettings.spec.ts index c844fd74e..3473e36d2 100644 --- a/packages/shared/src/authz/policies/project/canReadSettings.spec.ts +++ b/packages/shared/src/authz/policies/project/canReadSettings.spec.ts @@ -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' }), diff --git a/packages/shared/src/authz/policies/project/canReadWebhooks.spec.ts b/packages/shared/src/authz/policies/project/canReadWebhooks.spec.ts index a380bf529..0b47959f5 100644 --- a/packages/shared/src/authz/policies/project/canReadWebhooks.spec.ts +++ b/packages/shared/src/authz/policies/project/canReadWebhooks.spec.ts @@ -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' }), diff --git a/packages/shared/src/authz/policies/project/canUpdate.spec.ts b/packages/shared/src/authz/policies/project/canUpdate.spec.ts index cffaf43c8..93c3d3ff3 100644 --- a/packages/shared/src/authz/policies/project/canUpdate.spec.ts +++ b/packages/shared/src/authz/policies/project/canUpdate.spec.ts @@ -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' diff --git a/packages/shared/src/authz/policies/project/comment/canArchive.spec.ts b/packages/shared/src/authz/policies/project/comment/canArchive.spec.ts index eae023d85..312294444 100644 --- a/packages/shared/src/authz/policies/project/comment/canArchive.spec.ts +++ b/packages/shared/src/authz/policies/project/comment/canArchive.spec.ts @@ -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' diff --git a/packages/shared/src/authz/policies/project/comment/canCreate.spec.ts b/packages/shared/src/authz/policies/project/comment/canCreate.spec.ts index c79fa3da4..6622696fe 100644 --- a/packages/shared/src/authz/policies/project/comment/canCreate.spec.ts +++ b/packages/shared/src/authz/policies/project/comment/canCreate.spec.ts @@ -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' }), diff --git a/packages/shared/src/authz/policies/project/comment/canEdit.spec.ts b/packages/shared/src/authz/policies/project/comment/canEdit.spec.ts index 4697c3886..4a1e83671 100644 --- a/packages/shared/src/authz/policies/project/comment/canEdit.spec.ts +++ b/packages/shared/src/authz/policies/project/comment/canEdit.spec.ts @@ -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' diff --git a/packages/shared/src/authz/policies/project/model/canDelete.spec.ts b/packages/shared/src/authz/policies/project/model/canDelete.spec.ts index 21e714774..18b5439df 100644 --- a/packages/shared/src/authz/policies/project/model/canDelete.spec.ts +++ b/packages/shared/src/authz/policies/project/model/canDelete.spec.ts @@ -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' }), diff --git a/packages/shared/src/authz/policies/project/model/canUpdate.spec.ts b/packages/shared/src/authz/policies/project/model/canUpdate.spec.ts index ac7b02c4d..8054526b3 100644 --- a/packages/shared/src/authz/policies/project/model/canUpdate.spec.ts +++ b/packages/shared/src/authz/policies/project/model/canUpdate.spec.ts @@ -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' diff --git a/packages/shared/src/authz/policies/project/version/canUpdate.spec.ts b/packages/shared/src/authz/policies/project/version/canUpdate.spec.ts index ede2ee9dd..88d4f8e24 100644 --- a/packages/shared/src/authz/policies/project/version/canUpdate.spec.ts +++ b/packages/shared/src/authz/policies/project/version/canUpdate.spec.ts @@ -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' diff --git a/packages/shared/src/authz/policies/workspace/canCreateWorkspace.spec.ts b/packages/shared/src/authz/policies/workspace/canCreateWorkspace.spec.ts new file mode 100644 index 000000000..cfaf5408b --- /dev/null +++ b/packages/shared/src/authz/policies/workspace/canCreateWorkspace.spec.ts @@ -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() + }) + }) +}) diff --git a/packages/shared/src/authz/policies/workspace/canCreateWorkspace.ts b/packages/shared/src/authz/policies/workspace/canCreateWorkspace.ts new file mode 100644 index 000000000..df087408e --- /dev/null +++ b/packages/shared/src/authz/policies/workspace/canCreateWorkspace.ts @@ -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() + } diff --git a/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts index 8fb7d58f7..c1ee79496 100644 --- a/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts +++ b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.spec.ts @@ -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[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[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' }) diff --git a/packages/shared/src/authz/policies/workspace/canReadMemberEmail.ts b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.ts index 4186f96d6..541f521f9 100644 --- a/packages/shared/src/authz/policies/workspace/canReadMemberEmail.ts +++ b/packages/shared/src/authz/policies/workspace/canReadMemberEmail.ts @@ -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 - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType +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 }) } diff --git a/packages/shared/src/authz/policies/workspace/canReceiveProjectsUpdatedMessage.spec.ts b/packages/shared/src/authz/policies/workspace/canReceiveProjectsUpdatedMessage.spec.ts index c9388dfe5..f0f205390 100644 --- a/packages/shared/src/authz/policies/workspace/canReceiveProjectsUpdatedMessage.spec.ts +++ b/packages/shared/src/authz/policies/workspace/canReceiveProjectsUpdatedMessage.spec.ts @@ -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' diff --git a/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts b/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts index c1a5d78fc..6d953b948 100644 --- a/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts +++ b/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts @@ -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()) } diff --git a/packages/shared/src/tests/fakes.ts b/packages/shared/src/tests/fakes.ts index 97ffae802..7dca11ca2 100644 --- a/packages/shared/src/tests/fakes.ts +++ b/packages/shared/src/tests/fakes.ts @@ -28,7 +28,8 @@ export const getProjectFake = fakeGetFactory(() => ({ export const getWorkspaceFake = fakeGetFactory(() => ({ id: nanoid(10), - slug: nanoid(10) + slug: nanoid(10), + isExclusive: false })) export const getCommentFake = fakeGetFactory(() => ({ diff --git a/packages/shared/src/workspaces/helpers/features.spec.ts b/packages/shared/src/workspaces/helpers/features.spec.ts new file mode 100644 index 000000000..676da1a6f --- /dev/null +++ b/packages/shared/src/workspaces/helpers/features.spec.ts @@ -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) + }) + }) + }) +}) diff --git a/packages/shared/src/workspaces/helpers/features.ts b/packages/shared/src/workspaces/helpers/features.ts index 87bb14911..9f66618d8 100644 --- a/packages/shared/src/workspaces/helpers/features.ts +++ b/packages/shared/src/workspaces/helpers/features.ts @@ -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 +}