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)
This commit is contained in:
Félix Malfait
2026-03-27 16:31:27 +01:00
committed by GitHub
parent fb21f3ccf5
commit cd2e08b912
3 changed files with 29 additions and 0 deletions

View File

@@ -0,0 +1 @@
export const MAX_WORKSPACES_WITHOUT_ENTERPRISE_KEY = 5;

View File

@@ -97,6 +97,9 @@ const createSignInUpServiceForTests = () => {
{
uploadWorkspaceLogoFromUrl: jest.fn(),
} as any,
{
isValid: jest.fn().mockReturnValue(false),
} as any,
{
createQueryRunner: jest.fn(() => queryRunnerMock),
} as any,

View File

@@ -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<void> {
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'],
) {