From e3db73ef4605f4a5b0f94b95d8a11067e9dabf3d Mon Sep 17 00:00:00 2001 From: manstfu <33990720+manstfu@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:29:59 +0100 Subject: [PATCH] Change condition for multi-workspace check (#17938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When deploying with ``` IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true IS_MULTIWORKSPACE_ENABLED=true ``` The first workspace can be created successfully. However, any attempt to create additional workspaces as admin fails with the error: `Workspace creation is restricted to admins` because `canAccessFullAdminPanel` is **false** If these flags are set to false during the initial deployment and restarting the Docker container, workspace creation works normally. Problem is caused by `canAccessFullAdminPanel` --------- Co-authored-by: ehconitin Co-authored-by: Félix Malfait Co-authored-by: Cursor Co-authored-by: Félix Malfait --- .../engine/core-modules/auth/auth.resolver.ts | 4 - .../auth/services/sign-in-up.service.spec.ts | 304 ++++++++++++++++++ .../auth/services/sign-in-up.service.ts | 130 ++++---- 3 files changed, 370 insertions(+), 68 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 13104ac74bd..7d2daf4a7c0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -506,10 +506,6 @@ export class AuthResolver { @AuthUser() currentUser: UserEntity, @AuthProvider() authProvider: AuthProviderEnum, ): Promise { - await this.signInUpService.checkWorkspaceCreationIsAllowedOrThrow( - currentUser, - ); - const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace( { type: 'existingUser', existingUser: currentUser }, ); 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 new file mode 100644 index 00000000000..2a54372344b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -0,0 +1,304 @@ +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { type SignInUpNewUserPayload } from 'src/engine/core-modules/auth/types/signInUp.type'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +import { SignInUpService } from './sign-in-up.service'; + +const mockPartialUserPayload: SignInUpNewUserPayload = { + email: 'first.user@acme.dev', + firstName: 'First', + lastName: 'User', + locale: 'en', + isEmailAlreadyVerified: true, +}; + +type MockConfigurationValues = { + IS_MULTIWORKSPACE_ENABLED: boolean; + IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS: boolean; + SERVER_URL: string; +}; + +const createSignInUpServiceForTests = () => { + const mockUserRepository = { + create: jest.fn((user) => user), + save: jest.fn(async (user) => ({ id: 'saved-user-id', ...user })), + count: jest.fn(), + }; + + const mockWorkspaceRepository = { + count: jest.fn(), + create: jest.fn(), + }; + + const mockConfigurationValues: MockConfigurationValues = { + IS_MULTIWORKSPACE_ENABLED: true, + IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS: false, + SERVER_URL: 'http://localhost:3000', + }; + + const mockTwentyConfigService = { + get: jest.fn( + (configKey: keyof MockConfigurationValues) => + mockConfigurationValues[configKey], + ), + }; + + const queryRunnerMock = { + manager: { + save: jest.fn(), + }, + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + }; + + const service = new SignInUpService( + mockUserRepository as any, + mockWorkspaceRepository as any, + { + validatePersonalInvitation: jest.fn(), + invalidateWorkspaceInvitation: jest.fn(), + } as any, + { + create: jest.fn(), + checkUserWorkspaceExists: jest.fn(), + } as any, + { + setOnboardingCreateProfilePending: jest.fn(), + setOnboardingInviteTeamPending: jest.fn(), + createOnboardingStatusForWorkspaceMember: jest.fn(), + } as any, + { + emitCustomBatchEvent: jest.fn(), + } as any, + { + getHttpClient: jest.fn(), + } as any, + mockTwentyConfigService as any, + { + generateSubdomain: jest.fn(), + } as any, + { + findUserByEmail: jest.fn(), + findByEmail: jest.fn(), + markEmailAsVerified: jest.fn(), + } as any, + { + incrementCounter: jest.fn(), + } as any, + { + invalidateAndRecompute: jest.fn(), + } as any, + { + createWorkspaceCustomApplication: jest.fn(), + } as any, + { + createQueryRunner: jest.fn(() => queryRunnerMock), + } as any, + ); + + return { + service, + mockUserRepository, + mockWorkspaceRepository, + mockConfigurationValues, + }; +}; + +describe('SignInUpService workspace-creation policy', () => { + it('grants bootstrap owner server permissions when multi-workspace is enabled and unrestricted', async () => { + const { + service, + mockUserRepository, + mockWorkspaceRepository, + mockConfigurationValues, + } = createSignInUpServiceForTests(); + + mockConfigurationValues.IS_MULTIWORKSPACE_ENABLED = true; + mockConfigurationValues.IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = + false; + mockWorkspaceRepository.count.mockResolvedValue(0); + mockUserRepository.count.mockResolvedValue(0); + jest + .spyOn((service as any).userService, 'findUserByEmail') + .mockResolvedValue(null); + + await service.signUpWithoutWorkspace(mockPartialUserPayload, { + provider: AuthProviderEnum.Google, + } as any); + + expect(mockUserRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + canImpersonate: true, + canAccessFullAdminPanel: true, + }), + ); + }); + + it('grants bootstrap owner server permissions when multi-workspace is enabled and restricted', async () => { + const { + service, + mockUserRepository, + mockWorkspaceRepository, + mockConfigurationValues, + } = createSignInUpServiceForTests(); + + mockConfigurationValues.IS_MULTIWORKSPACE_ENABLED = true; + mockConfigurationValues.IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = + true; + mockWorkspaceRepository.count.mockResolvedValue(0); + mockUserRepository.count.mockResolvedValue(0); + jest + .spyOn((service as any).userService, 'findUserByEmail') + .mockResolvedValue(null); + + await service.signUpWithoutWorkspace(mockPartialUserPayload, { + provider: AuthProviderEnum.Google, + } as any); + + expect(mockUserRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + canImpersonate: true, + canAccessFullAdminPanel: true, + }), + ); + }); + + it('assigns default non-admin permissions after bootstrap in multi-workspace mode', async () => { + const { + service, + mockUserRepository, + mockWorkspaceRepository, + mockConfigurationValues, + } = createSignInUpServiceForTests(); + + mockConfigurationValues.IS_MULTIWORKSPACE_ENABLED = true; + mockConfigurationValues.IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = + false; + mockWorkspaceRepository.count.mockResolvedValue(1); + mockUserRepository.count.mockResolvedValue(1); + jest + .spyOn((service as any).userService, 'findUserByEmail') + .mockResolvedValue(null); + + await service.signUpWithoutWorkspace(mockPartialUserPayload, { + provider: AuthProviderEnum.Google, + } as any); + + expect(mockUserRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + canImpersonate: false, + canAccessFullAdminPanel: false, + }), + ); + }); + + it('does not grant admin to second user signing up before any workspace exists', async () => { + const { + service, + mockUserRepository, + mockWorkspaceRepository, + mockConfigurationValues, + } = createSignInUpServiceForTests(); + + mockConfigurationValues.IS_MULTIWORKSPACE_ENABLED = true; + mockConfigurationValues.IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = + false; + mockWorkspaceRepository.count.mockResolvedValue(0); + mockUserRepository.count.mockResolvedValue(1); + jest + .spyOn((service as any).userService, 'findUserByEmail') + .mockResolvedValue(null); + + await service.signUpWithoutWorkspace(mockPartialUserPayload, { + provider: AuthProviderEnum.Google, + } as any); + + expect(mockUserRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + canImpersonate: false, + canAccessFullAdminPanel: false, + }), + ); + }); + + it('throws forbidden when a non-admin existing user creates workspace in restricted mode after bootstrap', async () => { + const { service, mockWorkspaceRepository, mockConfigurationValues } = + createSignInUpServiceForTests(); + + mockConfigurationValues.IS_MULTIWORKSPACE_ENABLED = true; + mockConfigurationValues.IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = + true; + mockWorkspaceRepository.count.mockResolvedValue(1); + + const nonAdminExistingUser = { + id: 'existing-user-id', + email: 'existing.user@acme.dev', + canAccessFullAdminPanel: false, + }; + + await expect( + service.signUpOnNewWorkspace({ + type: 'existingUser', + existingUser: nonAdminExistingUser as any, + }), + ).rejects.toMatchObject({ + code: AuthExceptionCode.FORBIDDEN_EXCEPTION, + }); + }); + + it('throws SIGNUP_DISABLED when creating workspace in single-workspace mode after bootstrap', async () => { + const { service, mockWorkspaceRepository, mockConfigurationValues } = + createSignInUpServiceForTests(); + + mockConfigurationValues.IS_MULTIWORKSPACE_ENABLED = false; + mockConfigurationValues.IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = + false; + mockWorkspaceRepository.count.mockResolvedValue(1); + + await expect( + service.signUpOnNewWorkspace({ + type: 'existingUser', + existingUser: { + id: 'existing-user-id', + email: 'existing.user@acme.dev', + canAccessFullAdminPanel: true, + } as any, + }), + ).rejects.toMatchObject({ + code: AuthExceptionCode.SIGNUP_DISABLED, + }); + }); + + it('keeps single-workspace SIGNUP_DISABLED behavior after first workspace exists', async () => { + const { service, mockWorkspaceRepository, mockConfigurationValues } = + createSignInUpServiceForTests(); + + mockConfigurationValues.IS_MULTIWORKSPACE_ENABLED = false; + mockConfigurationValues.IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = + false; + mockWorkspaceRepository.count.mockResolvedValue(1); + jest + .spyOn((service as any).userService, 'findUserByEmail') + .mockResolvedValue(null); + + await expect( + service.signUpWithoutWorkspace(mockPartialUserPayload, { + provider: AuthProviderEnum.Google, + } as any), + ).rejects.toBeInstanceOf(AuthException); + + await expect( + service.signUpWithoutWorkspace(mockPartialUserPayload, { + provider: AuthProviderEnum.Google, + } as any), + ).rejects.toMatchObject({ + code: AuthExceptionCode.SIGNUP_DISABLED, + }); + }); +}); 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 1b129528ad0..8212bff833c 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 @@ -4,7 +4,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; import { TWENTY_ICONS_BASE_URL } from 'twenty-shared/constants'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { type DataSource, type QueryRunner, Repository } from 'typeorm'; +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'; @@ -379,55 +379,68 @@ export class SignInUpService { return savedUser; } - private async setDefaultImpersonateAndAccessFullAdminPanel() { - if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) { - const workspacesCount = await this.workspaceRepository.count(); + private async isSignUpEnabled(): Promise { + const workspaceCount = await this.workspaceRepository.count(); - // let the creation of the first workspace - if (workspacesCount > 0) { - throw new AuthException( - 'New workspace setup is disabled', - AuthExceptionCode.SIGNUP_DISABLED, - ); - } - - return { canImpersonate: true, canAccessFullAdminPanel: true }; - } - - return { canImpersonate: false, canAccessFullAdminPanel: false }; - } - - private isWorkspaceCreationLimitedToServerAdmins(): boolean { - return this.twentyConfigService.get( - 'IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS', + return ( + this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED') || + workspaceCount === 0 ); } - private async isFirstWorkspaceInSystem(): Promise { - const count = await this.workspaceRepository.count(); - - return count === 0; - } - - async checkWorkspaceCreationIsAllowedOrThrow( - currentUser: UserEntity, - ): Promise { - if (!this.isWorkspaceCreationLimitedToServerAdmins()) return; - - // Only allow bypass during initial system bootstrap (no workspaces exist yet) - if (await this.isFirstWorkspaceInSystem()) return; - - if (!currentUser.canAccessFullAdminPanel) { + private async assertSignUpEnabled(): Promise { + if (!(await this.isSignUpEnabled())) { throw new AuthException( - 'Workspace creation is restricted to admins', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - { - userFriendlyMessage: msg`Workspace creation is restricted to admins`, - }, + 'New workspace setup is disabled', + AuthExceptionCode.SIGNUP_DISABLED, ); } } + private async hasServerAdmin(): Promise { + const adminCount = await this.userRepository.count({ + where: { canAccessFullAdminPanel: true }, + }); + + return adminCount > 0; + } + + private async assertWorkspaceCreationAllowed( + userData: ExistingUserOrPartialUserWithPicture['userData'], + ): Promise { + await this.assertSignUpEnabled(); + + const workspaceCount = await this.workspaceRepository.count(); + + if (workspaceCount === 0) { + return; + } + + if ( + !this.twentyConfigService.get( + 'IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS', + ) + ) { + return; + } + + const isExistingAdmin = + userData.type === 'existingUser' && + userData.existingUser.canAccessFullAdminPanel; + + if (isExistingAdmin) { + return; + } + + throw new AuthException( + 'Workspace creation is restricted to admins', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + { + userFriendlyMessage: msg`Workspace creation is restricted to admins`, + }, + ); + } + async signUpOnNewWorkspace( userData: ExistingUserOrPartialUserWithPicture['userData'], ) { @@ -446,27 +459,9 @@ export class SignInUpService { ); } - if ( - this.isWorkspaceCreationLimitedToServerAdmins() && - !(await this.isFirstWorkspaceInSystem()) - ) { - const isExistingAdmin = - userData.type === 'existingUser' && - userData.existingUser.canAccessFullAdminPanel; + await this.assertWorkspaceCreationAllowed(userData); - if (!isExistingAdmin) { - throw new AuthException( - 'Workspace creation is restricted to admins', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - { - userFriendlyMessage: msg`Workspace creation is restricted to admins`, - }, - ); - } - } - - const { canImpersonate, canAccessFullAdminPanel } = - await this.setDefaultImpersonateAndAccessFullAdminPanel(); + const shouldGrantServerAdmin = !(await this.hasServerAdmin()); const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`; const isLogoUrlValid = async () => { @@ -523,8 +518,8 @@ export class SignInUpService { : await this.saveNewUser( userData.newUserWithPicture, { - canImpersonate, - canAccessFullAdminPanel, + canImpersonate: shouldGrantServerAdmin, + canAccessFullAdminPanel: shouldGrantServerAdmin, }, queryRunner, ); @@ -584,9 +579,16 @@ export class SignInUpService { ); } + await this.assertSignUpEnabled(); + + const shouldGrantServerAdmin = !(await this.hasServerAdmin()); + return this.saveNewUser( await this.computePartialUserFromUserPayload(newUserParams, authParams), - await this.setDefaultImpersonateAndAccessFullAdminPanel(), + { + canImpersonate: shouldGrantServerAdmin, + canAccessFullAdminPanel: shouldGrantServerAdmin, + }, ); } }