From 794bd7c7e9fed2dbed3eff384f9f17b3892765a7 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 12:43:27 +0200 Subject: [PATCH] gergo/web 3616 add auth policy for turning on the exclusive workspace (#4956) * 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 * feat(shared): generalize workspace feature access policy * feat(workspaces): allow toggling the isExclusive option for workspace update --- .../lib/common/generated/gql/graphql.ts | 6 ++ .../typedefs/permissions.graphql | 1 + .../typedefs/workspaces.graphql | 5 ++ .../modules/core/graph/generated/graphql.ts | 6 ++ .../graph/generated/graphql.ts | 4 ++ .../workspaces/graph/resolvers/permissions.ts | 16 +++++- .../workspaces/graph/resolvers/workspaces.ts | 21 +++++-- .../server/test/graphql/generated/graphql.ts | 4 ++ packages/shared/src/authz/policies/index.ts | 4 +- ....ts => canUseWorkspacePlanFeature.spec.ts} | 56 ++++++++++--------- ...tions.ts => canUseWorkspacePlanFeature.ts} | 39 ++++--------- .../shared/src/workspaces/helpers/features.ts | 14 ++++- 12 files changed, 111 insertions(+), 65 deletions(-) rename packages/shared/src/authz/policies/workspace/{canUpdateEmbedOptions.spec.ts => canUseWorkspacePlanFeature.spec.ts} (66%) rename packages/shared/src/authz/policies/workspace/{canUpdateEmbedOptions.ts => canUseWorkspacePlanFeature.ts} (62%) diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 72afe43e3..5af9d2f8a 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -4575,6 +4575,8 @@ export type Workspace = { id: Scalars['ID']['output']; /** Only available to workspace owners/members */ invitedTeam?: Maybe>; + /** Exclusive workspaces do not allow their workspace members to create or join other workspaces as members. */ + isExclusive: Scalars['Boolean']['output']; /** Logo image as base64-encoded string */ logo?: Maybe; name: Scalars['String']['output']; @@ -4964,6 +4966,7 @@ export type WorkspacePermissionChecks = { canCreateProject: PermissionCheckResult; canEditEmbedOptions: PermissionCheckResult; canInvite: PermissionCheckResult; + canMakeWorkspaceExclusive: PermissionCheckResult; canMoveProjectToWorkspace: PermissionCheckResult; canReadMemberEmail: PermissionCheckResult; }; @@ -5219,6 +5222,7 @@ export type WorkspaceUpdateInput = { discoverabilityEnabled?: InputMaybe; domainBasedMembershipProtectionEnabled?: InputMaybe; id: Scalars['String']['input']; + isExclusive?: InputMaybe; /** Logo image as base64-encoded string */ logo?: InputMaybe; name?: InputMaybe; @@ -9242,6 +9246,7 @@ export type WorkspaceFieldArgs = { hasAccessToFeature: WorkspaceHasAccessToFeatureArgs, id: {}, invitedTeam: WorkspaceInvitedTeamArgs, + isExclusive: {}, logo: {}, name: {}, permissions: {}, @@ -9347,6 +9352,7 @@ export type WorkspacePermissionChecksFieldArgs = { canCreateProject: {}, canEditEmbedOptions: {}, canInvite: {}, + canMakeWorkspaceExclusive: {}, canMoveProjectToWorkspace: WorkspacePermissionChecksCanMoveProjectToWorkspaceArgs, canReadMemberEmail: {}, } diff --git a/packages/server/assets/workspacesCore/typedefs/permissions.graphql b/packages/server/assets/workspacesCore/typedefs/permissions.graphql index 6906113e1..5b4192ebc 100644 --- a/packages/server/assets/workspacesCore/typedefs/permissions.graphql +++ b/packages/server/assets/workspacesCore/typedefs/permissions.graphql @@ -7,5 +7,6 @@ type WorkspacePermissionChecks { canInvite: PermissionCheckResult! canMoveProjectToWorkspace(projectId: String): PermissionCheckResult! canEditEmbedOptions: PermissionCheckResult! + canMakeWorkspaceExclusive: PermissionCheckResult! canReadMemberEmail: PermissionCheckResult! } diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 3a1c19b29..213597255 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -66,6 +66,7 @@ input WorkspaceUpdateInput { discoverabilityEnabled: Boolean discoverabilityAutoJoinEnabled: Boolean defaultSeatType: WorkspaceSeatType + isExclusive: Boolean } input WorkspaceRoleUpdateInput { @@ -331,6 +332,10 @@ type Workspace { Workspace-level configuration for models in embedded viewer """ embedOptions: WorkspaceEmbedOptions! + """ + Exclusive workspaces do not allow their workspace members to create or join other workspaces as members. + """ + isExclusive: Boolean! } type WorkspaceEmbedOptions { diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 2bd89c4ac..71aa4c8ce 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4598,6 +4598,8 @@ export type Workspace = { id: Scalars['ID']['output']; /** Only available to workspace owners/members */ invitedTeam?: Maybe>; + /** Exclusive workspaces do not allow their workspace members to create or join other workspaces as members. */ + isExclusive: Scalars['Boolean']['output']; /** Logo image as base64-encoded string */ logo?: Maybe; name: Scalars['String']['output']; @@ -4987,6 +4989,7 @@ export type WorkspacePermissionChecks = { canCreateProject: PermissionCheckResult; canEditEmbedOptions: PermissionCheckResult; canInvite: PermissionCheckResult; + canMakeWorkspaceExclusive: PermissionCheckResult; canMoveProjectToWorkspace: PermissionCheckResult; canReadMemberEmail: PermissionCheckResult; }; @@ -5244,6 +5247,7 @@ export type WorkspaceUpdateInput = { discoverabilityEnabled?: InputMaybe; domainBasedMembershipProtectionEnabled?: InputMaybe; id: Scalars['String']['input']; + isExclusive?: InputMaybe; /** Logo image as base64-encoded string */ logo?: InputMaybe; name?: InputMaybe; @@ -7608,6 +7612,7 @@ export type WorkspaceResolvers>; id?: Resolver; invitedTeam?: Resolver>, ParentType, ContextType, Partial>; + isExclusive?: Resolver; logo?: Resolver, ParentType, ContextType>; name?: Resolver; permissions?: Resolver; @@ -7741,6 +7746,7 @@ export type WorkspacePermissionChecksResolvers; canEditEmbedOptions?: Resolver; canInvite?: Resolver; + canMakeWorkspaceExclusive?: Resolver; canMoveProjectToWorkspace?: Resolver>; canReadMemberEmail?: Resolver; __isTypeOf?: IsTypeOfResolverFn; 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 615309864..bbafe5eea 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4578,6 +4578,8 @@ export type Workspace = { id: Scalars['ID']['output']; /** Only available to workspace owners/members */ invitedTeam?: Maybe>; + /** Exclusive workspaces do not allow their workspace members to create or join other workspaces as members. */ + isExclusive: Scalars['Boolean']['output']; /** Logo image as base64-encoded string */ logo?: Maybe; name: Scalars['String']['output']; @@ -4967,6 +4969,7 @@ export type WorkspacePermissionChecks = { canCreateProject: PermissionCheckResult; canEditEmbedOptions: PermissionCheckResult; canInvite: PermissionCheckResult; + canMakeWorkspaceExclusive: PermissionCheckResult; canMoveProjectToWorkspace: PermissionCheckResult; canReadMemberEmail: PermissionCheckResult; }; @@ -5224,6 +5227,7 @@ export type WorkspaceUpdateInput = { discoverabilityEnabled?: InputMaybe; domainBasedMembershipProtectionEnabled?: InputMaybe; id: Scalars['String']['input']; + isExclusive?: InputMaybe; /** Logo image as base64-encoded string */ logo?: InputMaybe; name?: InputMaybe; diff --git a/packages/server/modules/workspaces/graph/resolvers/permissions.ts b/packages/server/modules/workspaces/graph/resolvers/permissions.ts index 86a7adfe9..f5ba990f5 100644 --- a/packages/server/modules/workspaces/graph/resolvers/permissions.ts +++ b/packages/server/modules/workspaces/graph/resolvers/permissions.ts @@ -1,5 +1,5 @@ import { Resolvers } from '@/modules/core/graph/generated/graphql' -import { Authz } from '@speckle/shared' +import { Authz, WorkspacePlanFeatures } from '@speckle/shared' export default { Workspace: { @@ -33,9 +33,19 @@ export default { }, canEditEmbedOptions: async (parent, _args, ctx) => { const canEditEmbedOptions = - await ctx.authPolicies.workspace.canUpdateEmbedOptions({ + await ctx.authPolicies.workspace.canUseWorkspacePlanFeature({ userId: ctx.userId, - workspaceId: parent.workspaceId + workspaceId: parent.workspaceId, + feature: WorkspacePlanFeatures.HideSpeckleBranding + }) + return Authz.toGraphqlResult(canEditEmbedOptions) + }, + canMakeWorkspaceExclusive: async (parent, _args, ctx) => { + const canEditEmbedOptions = + await ctx.authPolicies.workspace.canUseWorkspacePlanFeature({ + userId: ctx.userId, + workspaceId: parent.workspaceId, + feature: WorkspacePlanFeatures.ExclusiveMembership }) return Authz.toGraphqlResult(canEditEmbedOptions) }, diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index af161db79..c37854b18 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -116,6 +116,7 @@ import { } from '@/modules/workspaces/services/retrieval' import { Roles, + WorkspacePlanFeatures, WorkspacePlans, WorkspaceRoles, removeNullOrUndefinedKeys, @@ -797,9 +798,9 @@ export = FF_WORKSPACES_MODULE_ENABLED }, update: async (_parent, args, context) => { const { id: workspaceId, ...workspaceInput } = args.input - + const userId = context.userId! await authorizeResolver( - context.userId!, + userId, workspaceId, Roles.Workspace.Admin, context.resourceAccessRules @@ -822,11 +823,23 @@ export = FF_WORKSPACES_MODULE_ENABLED emitWorkspaceEvent: getEventBus().emit }) + if (workspaceInput.isExclusive) { + const canMakeWorkspaceExclusive = + await context.authPolicies.workspace.canUseWorkspacePlanFeature({ + feature: WorkspacePlanFeatures.ExclusiveMembership, + workspaceId, + userId + }) + throwIfAuthNotOk(canMakeWorkspaceExclusive) + } + const workspace = await withOperationLogging( async () => await updateWorkspace({ workspaceId, - workspaceInput: omit(workspaceInput, ['defaultProjectRole']) + workspaceInput: { + ...omit(workspaceInput, ['defaultProjectRole']) + } }), { logger, @@ -967,7 +980,7 @@ export = FF_WORKSPACES_MODULE_ENABLED userId: context.userId }) }, - async deleteDomain(_parent, args, context) { + deleteDomain: async (_parent, args, context) => { const workspaceId = args.input.workspaceId await authorizeResolver( context.userId!, diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 9d7ae70d3..60aa83c8f 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4579,6 +4579,8 @@ export type Workspace = { id: Scalars['ID']['output']; /** Only available to workspace owners/members */ invitedTeam?: Maybe>; + /** Exclusive workspaces do not allow their workspace members to create or join other workspaces as members. */ + isExclusive: Scalars['Boolean']['output']; /** Logo image as base64-encoded string */ logo?: Maybe; name: Scalars['String']['output']; @@ -4968,6 +4970,7 @@ export type WorkspacePermissionChecks = { canCreateProject: PermissionCheckResult; canEditEmbedOptions: PermissionCheckResult; canInvite: PermissionCheckResult; + canMakeWorkspaceExclusive: PermissionCheckResult; canMoveProjectToWorkspace: PermissionCheckResult; canReadMemberEmail: PermissionCheckResult; }; @@ -5225,6 +5228,7 @@ export type WorkspaceUpdateInput = { discoverabilityEnabled?: InputMaybe; domainBasedMembershipProtectionEnabled?: InputMaybe; id: Scalars['String']['input']; + isExclusive?: InputMaybe; /** Logo image as base64-encoded string */ logo?: InputMaybe; name?: InputMaybe; diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index e6f0ec355..71ade434c 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -28,9 +28,9 @@ import { canDeleteProjectPolicy } from './project/canDelete.js' import { canDeleteAutomationPolicy } from './project/automation/canDelete.js' 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' +import { canUseWorkspacePlanFeature } from './workspace/canUseWorkspacePlanFeature.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ project: { @@ -75,7 +75,7 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ canInvite: canInviteToWorkspacePolicy(loaders), canReceiveProjectsUpdatedMessage: canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders), - canUpdateEmbedOptions: canUpdateEmbedOptionsPolicy(loaders), + canUseWorkspacePlanFeature: canUseWorkspacePlanFeature(loaders), canReadMemberEmail: canReadMemberEmailPolicy(loaders), canCreateWorkspace: canCreateWorkspacePolicy(loaders) } diff --git a/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.spec.ts b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts similarity index 66% rename from packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.spec.ts rename to packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts index 00105c118..26d82e8a1 100644 --- a/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.spec.ts +++ b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.spec.ts @@ -2,8 +2,8 @@ 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 { canUpdateEmbedOptionsPolicy } from './canUpdateEmbedOptions.js' -import { WorkspacePlan } from '../../../workspaces/index.js' +import { canUseWorkspacePlanFeature } from './canUseWorkspacePlanFeature.js' +import { WorkspacePlanFeatures } from '../../../workspaces/index.js' import { describe, expect, it } from 'vitest' import { ServerNoAccessError, @@ -15,12 +15,12 @@ import { WorkspaceReadOnlyError } from '../../domain/authErrors.js' -const buildCanUpdateEmbedOptionsPolicy = ( - overrides?: Partial[0]> +const buildSUT = ( + overrides?: Partial[0]> ) => { const workspaceId = cryptoRandomString({ length: 9 }) - return canUpdateEmbedOptionsPolicy({ + return canUseWorkspacePlanFeature({ getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' @@ -42,7 +42,7 @@ const buildCanUpdateEmbedOptionsPolicy = ( status: 'valid', createdAt: new Date(), updatedAt: new Date() - } as WorkspacePlan + } }, ...overrides }) @@ -50,14 +50,15 @@ const buildCanUpdateEmbedOptionsPolicy = ( const getPolicyArgs = () => ({ userId: cryptoRandomString({ length: 9 }), - workspaceId: cryptoRandomString({ length: 9 }) + workspaceId: cryptoRandomString({ length: 9 }), + feature: WorkspacePlanFeatures.HideSpeckleBranding }) -describe('canUpdateEmbedOptions', () => { +describe('canUseFeature', () => { it('returns error if user is not logged in', async () => { - const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy() + const canUseFeature = buildSUT() - const result = await canUpdateEmbedOptions({ + const result = await canUseFeature({ ...getPolicyArgs(), userId: undefined }) @@ -68,11 +69,11 @@ describe('canUpdateEmbedOptions', () => { }) it('returns error if user is not found', async () => { - const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + const canUseFeature = buildSUT({ getServerRole: async () => null }) - const result = await canUpdateEmbedOptions(getPolicyArgs()) + const result = await canUseFeature(getPolicyArgs()) expect(result).toBeAuthErrorResult({ code: ServerNoAccessError.code @@ -80,11 +81,11 @@ describe('canUpdateEmbedOptions', () => { }) it('returns error if user is a server guest', async () => { - const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + const canUseFeature = buildSUT({ getServerRole: async () => Roles.Server.Guest }) - const result = await canUpdateEmbedOptions(getPolicyArgs()) + const result = await canUseFeature(getPolicyArgs()) expect(result).toBeAuthErrorResult({ code: ServerNotEnoughPermissionsError.code @@ -92,11 +93,11 @@ describe('canUpdateEmbedOptions', () => { }) it('returns error if workspace does not exist', async () => { - const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + const canUseFeature = buildSUT({ getWorkspace: async () => null }) - const result = await canUpdateEmbedOptions(getPolicyArgs()) + const result = await canUseFeature(getPolicyArgs()) expect(result).toBeAuthErrorResult({ code: WorkspaceNoAccessError.code @@ -104,11 +105,11 @@ describe('canUpdateEmbedOptions', () => { }) it('returns error if user is not workspace admin', async () => { - const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + const canUseFeature = buildSUT({ getWorkspaceRole: async () => Roles.Workspace.Member }) - const result = await canUpdateEmbedOptions(getPolicyArgs()) + const result = await canUseFeature(getPolicyArgs()) expect(result).toBeAuthErrorResult({ code: WorkspaceNotEnoughPermissionsError.code @@ -116,7 +117,7 @@ describe('canUpdateEmbedOptions', () => { }) it('returns error if workspace is read only', async () => { - const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + const canUseFeature = buildSUT({ getWorkspacePlan: async () => ({ workspaceId: cryptoRandomString({ length: 9 }), name: 'proUnlimited', @@ -126,15 +127,15 @@ describe('canUpdateEmbedOptions', () => { }) }) - const result = await canUpdateEmbedOptions(getPolicyArgs()) + const result = await canUseFeature(getPolicyArgs()) expect(result).toBeAuthErrorResult({ code: WorkspaceReadOnlyError.code }) }) - it('returns error if workspace has invalid plan', async () => { - const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy({ + it('returns error if workspace plan does not have access to the feature', async () => { + const canUseFeature = buildSUT({ getWorkspacePlan: async () => ({ workspaceId: cryptoRandomString({ length: 9 }), name: 'free', @@ -144,17 +145,20 @@ describe('canUpdateEmbedOptions', () => { }) }) - const result = await canUpdateEmbedOptions(getPolicyArgs()) + const result = await canUseFeature({ + ...getPolicyArgs(), + feature: WorkspacePlanFeatures.CustomDataRegion + }) expect(result).toBeAuthErrorResult({ code: WorkspaceNoFeatureAccessError.code }) }) - it('returns ok if workspace has valid plan', async () => { - const canUpdateEmbedOptions = buildCanUpdateEmbedOptionsPolicy() + it('returns ok if workspace plan has access to the feature', async () => { + const canUseFeature = buildSUT() - const result = await canUpdateEmbedOptions(getPolicyArgs()) + const result = await canUseFeature(getPolicyArgs()) expect(result).toBeAuthOKResult() }) diff --git a/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.ts similarity index 62% rename from packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts rename to packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.ts index 6d953b948..230a164f5 100644 --- a/packages/shared/src/authz/policies/workspace/canUpdateEmbedOptions.ts +++ b/packages/shared/src/authz/policies/workspace/canUseWorkspacePlanFeature.ts @@ -14,12 +14,9 @@ import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js' import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js' import { AuthPolicy } from '../../domain/policies.js' import { - ensureWorkspaceNotReadOnlyFragment, - ensureWorkspaceRoleAndSessionFragment, - ensureWorkspacesEnabledFragment + ensureUserIsWorkspaceAdminFragment, + ensureWorkspaceNotReadOnlyFragment } from '../../fragments/workspaces.js' -import { ensureMinimumServerRoleFragment } from '../../fragments/server.js' -import { Roles } from '../../../core/constants.js' import { WorkspacePlanFeatures, workspacePlanHasAccessToFeature @@ -34,7 +31,8 @@ type PolicyLoaderKeys = | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession | typeof AuthCheckContextLoaderKeys.getWorkspacePlan -type PolicyArgs = MaybeUserContext & WorkspaceContext +type PolicyArgs = MaybeUserContext & + WorkspaceContext & { feature: WorkspacePlanFeatures } type PolicyErrors = | InstanceType @@ -47,31 +45,18 @@ type PolicyErrors = | InstanceType | InstanceType -export const canUpdateEmbedOptionsPolicy: AuthPolicy< +export const canUseWorkspacePlanFeature: AuthPolicy< PolicyLoaderKeys, PolicyArgs, PolicyErrors > = (loaders) => - async ({ userId, workspaceId }) => { - const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({}) - if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error) - - const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({ + async ({ userId, workspaceId, feature }) => { + const isWorkspaceAdmin = await ensureUserIsWorkspaceAdminFragment(loaders)({ userId, - role: Roles.Server.User + workspaceId }) - 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) - + if (isWorkspaceAdmin.isErr) return err(isWorkspaceAdmin.error) const ensuredNotReadOnly = await ensureWorkspaceNotReadOnlyFragment(loaders)({ workspaceId }) @@ -79,9 +64,9 @@ export const canUpdateEmbedOptionsPolicy: AuthPolicy< const workspacePlan = await loaders.getWorkspacePlan({ workspaceId }) if (!workspacePlan) return err(new WorkspaceNoFeatureAccessError()) - const canUpdateEmbedOptions = workspacePlanHasAccessToFeature({ + const canUseFeature = workspacePlanHasAccessToFeature({ plan: workspacePlan.name, - feature: WorkspacePlanFeatures.HideSpeckleBranding + feature }) - return canUpdateEmbedOptions ? ok() : err(new WorkspaceNoFeatureAccessError()) + return canUseFeature ? ok() : err(new WorkspaceNoFeatureAccessError()) } diff --git a/packages/shared/src/workspaces/helpers/features.ts b/packages/shared/src/workspaces/helpers/features.ts index 9f66618d8..49b8ab633 100644 --- a/packages/shared/src/workspaces/helpers/features.ts +++ b/packages/shared/src/workspaces/helpers/features.ts @@ -20,7 +20,8 @@ export const WorkspacePlanFeatures = { DomainSecurity: 'domainBasedSecurityPolicies', SSO: 'oidcSso', CustomDataRegion: 'workspaceDataRegionSpecificity', - HideSpeckleBranding: 'hideSpeckleBranding' + HideSpeckleBranding: 'hideSpeckleBranding', + ExclusiveMembership: 'exclusiveMembership' } export type WorkspacePlanFeatures = @@ -51,6 +52,11 @@ export const WorkspacePlanFeaturesMetadata = ({ [WorkspacePlanFeatures.HideSpeckleBranding]: { displayName: 'Customised viewer', description: 'Hide the Speckle branding in embedded viewer' + }, + [WorkspacePlanFeatures.ExclusiveMembership]: { + displayName: 'Exclusive workspace membership', + description: + 'Members of exclusive workspaces cannot join or create other workspaces' } }) satisfies Record< WorkspacePlanFeatures, @@ -155,7 +161,8 @@ export const WorkspaceUnpaidPlanConfigs: { WorkspacePlanFeatures.DomainSecurity, WorkspacePlanFeatures.SSO, WorkspacePlanFeatures.CustomDataRegion, - WorkspacePlanFeatures.HideSpeckleBranding + WorkspacePlanFeatures.HideSpeckleBranding, + WorkspacePlanFeatures.ExclusiveMembership ], limits: unlimited }, @@ -166,7 +173,8 @@ export const WorkspaceUnpaidPlanConfigs: { WorkspacePlanFeatures.DomainSecurity, WorkspacePlanFeatures.SSO, WorkspacePlanFeatures.CustomDataRegion, - WorkspacePlanFeatures.HideSpeckleBranding + WorkspacePlanFeatures.HideSpeckleBranding, + WorkspacePlanFeatures.ExclusiveMembership ], limits: unlimited },