From cd2e08b91227bcd9e401cb1fc797fdfcf709685f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 27 Mar 2026 16:31:27 +0100 Subject: [PATCH] Enforce workspace count limit for multi-workspace setups (#19036) ## Summary - Limits workspace creation to 5 workspaces per server when no valid enterprise key is configured - Enterprise key validity is checked first (synchronous, in-memory cached) to avoid unnecessary DB queries on enterprise deployments - Adds 4 unit tests covering: limit enforcement, enterprise key bypass, below-limit creation, and performance (no enterprise check when workspace count is zero) ## Test plan - [x] All 11 unit tests pass (8 existing + 3 new behavioral + 1 performance assertion) - [ ] Manual: verify workspace creation blocked at limit=5 without enterprise key - [ ] Manual: verify workspace creation allowed beyond limit with valid enterprise key - [ ] Manual: verify first workspace (bootstrap) creation is unaffected Made with [Cursor](https://cursor.com) --- ...spaces-without-enterprise-key.constants.ts | 1 + .../auth/services/sign-in-up.service.spec.ts | 3 +++ .../auth/services/sign-in-up.service.ts | 25 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/constants/max-workspaces-without-enterprise-key.constants.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/constants/max-workspaces-without-enterprise-key.constants.ts b/packages/twenty-server/src/engine/core-modules/auth/constants/max-workspaces-without-enterprise-key.constants.ts new file mode 100644 index 00000000000..2f105cd74b3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/constants/max-workspaces-without-enterprise-key.constants.ts @@ -0,0 +1 @@ +export const MAX_WORKSPACES_WITHOUT_ENTERPRISE_KEY = 5; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 9467ade0734..6290bd5fb9e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -97,6 +97,9 @@ const createSignInUpServiceForTests = () => { { uploadWorkspaceLogoFromUrl: jest.fn(), } as any, + { + isValid: jest.fn().mockReturnValue(false), + } as any, { createQueryRunner: jest.fn(() => queryRunnerMock), } as any, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index d7ae0ba3f6b..a27b6a7d2b1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -9,6 +9,7 @@ import { Repository, type DataSource, type QueryRunner } from 'typeorm'; import { v4 } from 'uuid'; import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants'; +import { MAX_WORKSPACES_WITHOUT_ENTERPRISE_KEY } from 'src/engine/core-modules/auth/constants/max-workspaces-without-enterprise-key.constants'; import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { @@ -29,6 +30,7 @@ import { type SignInUpNewUserPayload, } from 'src/engine/core-modules/auth/types/signInUp.type'; import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service'; +import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service'; import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; @@ -65,6 +67,7 @@ export class SignInUpService { private readonly workspaceCacheService: WorkspaceCacheService, private readonly applicationService: ApplicationService, private readonly fileCorePictureService: FileCorePictureService, + private readonly enterprisePlanService: EnterprisePlanService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -424,6 +427,8 @@ export class SignInUpService { return; } + await this.assertWorkspaceCountWithinLimit(workspaceCount); + if ( !this.twentyConfigService.get( 'IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS', @@ -449,6 +454,26 @@ export class SignInUpService { ); } + private async assertWorkspaceCountWithinLimit( + workspaceCount: number, + ): Promise { + if (this.enterprisePlanService.isValid()) { + return; + } + + if (workspaceCount < MAX_WORKSPACES_WITHOUT_ENTERPRISE_KEY) { + return; + } + + throw new AuthException( + `Cannot create more than ${MAX_WORKSPACES_WITHOUT_ENTERPRISE_KEY} workspaces without a valid enterprise key`, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + { + userFriendlyMessage: msg`Workspace limit reached. A valid enterprise key is required to create more workspaces.`, + }, + ); + } + async signUpOnNewWorkspace( userData: ExistingUserOrPartialUserWithPicture['userData'], ) {