diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index c18fffa572b0..cce851177752 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -567,6 +567,7 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled', IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 02ae5f27d546..1a745e664d44 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -499,6 +499,7 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled', IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx index cbce19f83674..a37761aca7ab 100644 --- a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx @@ -24,11 +24,13 @@ export const SettingsSecurityApprovedAccessDomainValidationEffect = () => { }, onCompleted: () => { enqueueSnackBar('Approved access domain validated', { + dedupeKey: 'approved-access-domain-validation-dedupe-key', variant: SnackBarVariant.Success, }); }, onError: () => { enqueueSnackBar('Error validating approved access domain', { + dedupeKey: 'approved-access-domain-validation-error-dedupe-key', variant: SnackBarVariant.Error, }); }, diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 9787a41329d6..8a2becdc39ba 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -10,6 +10,8 @@ import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; const StyledContainer = styled.div` width: 100%; @@ -29,6 +31,10 @@ const StyledSection = styled(Section)` export const SettingsSecurity = () => { const { t } = useLingui(); + const IsApprovedAccessDomainsEnabled = useIsFeatureEnabled( + FeatureFlagKey.IsApprovedAccessDomainsEnabled, + ); + return ( { /> - - - - + {IsApprovedAccessDomainsEnabled && ( + + + + + )}
, @@ -55,14 +55,21 @@ export class SocialSsoService { }, }, }, - relations: ['workspaceUsers', 'workspaceUsers.user'], + relations: [ + 'workspaceUsers', + 'workspaceUsers.user', + 'approvedAccessDomains', + ], }); return workspace ?? undefined; } - return await this.workspaceRepository.findOneBy({ - id: workspaceId, + return await this.workspaceRepository.findOne({ + where: { + id: workspaceId, + }, + relations: ['approvedAccessDomains'], }); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts similarity index 75% rename from packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts rename to packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts index b03074726494..7f2ff2ecaf8b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts @@ -3,22 +3,24 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -describe('SocialSsoService', () => { - let socialSsoService: SocialSsoService; +describe('AuthSsoService', () => { + let authSsoService: AuthSsoService; let workspaceRepository: Repository; let environmentService: EnvironmentService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - SocialSsoService, + AuthSsoService, { provide: getRepositoryToken(Workspace, 'core'), - useClass: Repository, + useValue: { + findOne: jest.fn(), + }, }, { provide: EnvironmentService, @@ -29,7 +31,7 @@ describe('SocialSsoService', () => { ], }).compile(); - socialSsoService = module.get(SocialSsoService); + authSsoService = module.get(AuthSsoService); workspaceRepository = module.get>( getRepositoryToken(Workspace, 'core'), ); @@ -42,18 +44,21 @@ describe('SocialSsoService', () => { const mockWorkspace = { id: workspaceId } as Workspace; jest - .spyOn(workspaceRepository, 'findOneBy') + .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { authProvider: 'google', email: 'test@example.com' }, workspaceId, ); expect(result).toEqual(mockWorkspace); - expect(workspaceRepository.findOneBy).toHaveBeenCalledWith({ - id: workspaceId, + expect(workspaceRepository.findOne).toHaveBeenCalledWith({ + where: { + id: workspaceId, + }, + relations: ['approvedAccessDomains'], }); }); @@ -68,7 +73,7 @@ describe('SocialSsoService', () => { .mockResolvedValue(mockWorkspace); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider, email, }); @@ -83,7 +88,11 @@ describe('SocialSsoService', () => { }, }, }, - relations: ['workspaceUsers', 'workspaceUsers.user'], + relations: [ + 'workspaceUsers', + 'workspaceUsers.user', + 'approvedAccessDomains', + ], }); }); @@ -92,7 +101,7 @@ describe('SocialSsoService', () => { jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider: 'google', email: 'notfound@example.com', }); @@ -104,7 +113,7 @@ describe('SocialSsoService', () => { jest.spyOn(environmentService, 'get').mockReturnValue(true); await expect( - socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider: 'invalid-provider' as any, email: 'test@example.com', }), diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index 6812aa94ace1..563b0ec3d27d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -10,7 +10,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type'; @@ -28,7 +28,7 @@ import { AuthService } from './auth.service'; jest.mock('bcrypt'); const UserFindOneMock = jest.fn(); -const UserWorkspaceFindOneByMock = jest.fn(); +const UserWorkspacefindOneMock = jest.fn(); const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn(); const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn(); @@ -41,7 +41,7 @@ describe('AuthService', () => { let service: AuthService; let userService: UserService; let workspaceRepository: Repository; - let socialSsoService: SocialSsoService; + let authSsoService: AuthSsoService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -50,7 +50,7 @@ describe('AuthService', () => { { provide: getRepositoryToken(Workspace, 'core'), useValue: { - findOneBy: jest.fn(), + findOne: jest.fn(), }, }, { @@ -120,7 +120,7 @@ describe('AuthService', () => { }, }, { - provide: SocialSsoService, + provide: AuthSsoService, useValue: { findWorkspaceFromWorkspaceIdOrAuthProvider: jest.fn(), }, @@ -130,7 +130,7 @@ describe('AuthService', () => { service = module.get(AuthService); userService = module.get(UserService); - socialSsoService = module.get(SocialSsoService); + authSsoService = module.get(AuthSsoService); workspaceRepository = module.get>( getRepositoryToken(Workspace, 'core'), ); @@ -160,7 +160,7 @@ describe('AuthService', () => { captchaToken: user.captchaToken, }); - UserWorkspaceFindOneByMock.mockReturnValueOnce({}); + UserWorkspacefindOneMock.mockReturnValueOnce({}); userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({}); @@ -245,7 +245,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(1); @@ -269,7 +270,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }), ).rejects.toThrow(new Error('Access denied')); @@ -292,7 +294,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: false, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }), ).rejects.toThrow( new AuthException( @@ -356,7 +359,7 @@ describe('AuthService', () => { } as ExistingUserOrNewUser['userData'], invitation: {} as AppToken, workspaceInviteHash: undefined, - workspace: {} as Workspace, + workspace: { approvedAccessDomains: [] } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(0); @@ -376,99 +379,127 @@ describe('AuthService', () => { workspaceInviteHash: 'workspaceInviteHash', workspace: { isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(0); }); + + it('checkAccessForSignIn - allow signup for new user who target a workspace with valid trusted domain', async () => { + expect(async () => { + await service.checkAccessForSignIn({ + userData: { + type: 'newUser', + newUserPayload: { + email: 'email@domain.com', + }, + } as ExistingUserOrNewUser['userData'], + invitation: undefined, + workspaceInviteHash: 'workspaceInviteHash', + workspace: { + isPublicInviteLinkEnabled: true, + approvedAccessDomains: [ + { domain: 'domain.com', isValidated: true }, + ], + } as unknown as Workspace, + }); + }).not.toThrow(); + }); }); - it('findWorkspaceForSignInUp - signup password auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + describe('findWorkspaceForSignInUp', () => { + it('findWorkspaceForSignInUp - signup password auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); + + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', + expect(result).toBeUndefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => { + const spyWorkspaceRepository = jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ + approvedAccessDomains: [], + } as unknown as Workspace); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); - expect(result).toBeUndefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => { - const spyWorkspaceRepository = jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + workspaceInviteHash: 'workspaceInviteHash', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', - workspaceInviteHash: 'workspaceInviteHash', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup social sso auth with workspaceInviteHash', async () => { + const spyWorkspaceRepository = jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ + approvedAccessDomains: [], + } as unknown as Workspace); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup social sso auth with workspaceInviteHash', async () => { - const spyWorkspaceRepository = jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + workspaceInviteHash: 'workspaceInviteHash', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', - workspaceInviteHash: 'workspaceInviteHash', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup social sso auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup social sso auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); + const spyAuthSsoService = jest + .spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') + .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest - .spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') - .mockResolvedValue({} as Workspace); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'google', + workspaceId: 'workspaceId', + email: 'email', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'google', - workspaceId: 'workspaceId', - email: 'email', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); + it('findWorkspaceForSignInUp - sso auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(1); - }); - it('findWorkspaceForSignInUp - sso auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); + const spyAuthSsoService = jest + .spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') + .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest - .spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') - .mockResolvedValue({} as Workspace); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'sso', + workspaceId: 'workspaceId', + email: 'email', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'sso', - workspaceId: 'workspaceId', - email: 'email', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); - - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 202b44ed94c8..6268ca0c2b4b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -36,7 +36,7 @@ import { } from 'src/engine/core-modules/auth/dto/user-exists.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { @@ -67,7 +67,7 @@ export class AuthService { private readonly refreshTokenService: RefreshTokenService, private readonly userWorkspaceService: UserWorkspaceService, private readonly workspaceInvitationService: WorkspaceInvitationService, - private readonly socialSsoService: SocialSsoService, + private readonly authSsoService: AuthSsoService, private readonly userService: UserService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') @@ -518,15 +518,18 @@ export class AuthService { ) { if (params.workspaceInviteHash) { return ( - (await this.workspaceRepository.findOneBy({ - inviteHash: params.workspaceInviteHash, + (await this.workspaceRepository.findOne({ + where: { + inviteHash: params.workspaceInviteHash, + }, + relations: ['approvedAccessDomains'], })) ?? undefined ); } if (params.authProvider !== 'password') { return ( - (await this.socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + (await this.authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { email: params.email, authProvider: params.authProvider, @@ -568,6 +571,20 @@ export class AuthService { const isTargetAnExistingWorkspace = !!workspace; const isAnExistingUser = userData.type === 'existingUser'; + const email = + userData.type === 'newUser' + ? userData.newUserPayload.email + : userData.existingUser.email; + + if ( + workspace?.approvedAccessDomains.some( + (trustDomain) => + trustDomain.isValidated && trustDomain.domain === email.split('@')[1], + ) + ) { + return; + } + if ( hasPublicInviteLink && !hasPersonalInvitation && diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 105d4da170fd..82e2a7e3e004 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -11,6 +11,7 @@ export enum FeatureFlagKey { IsCommandMenuV2Enabled = 'IS_COMMAND_MENU_V2_ENABLED', IsJsonFilterEnabled = 'IS_JSON_FILTER_ENABLED', IsCustomDomainEnabled = 'IS_CUSTOM_DOMAIN_ENABLED', + IsApprovedAccessDomainsEnabled = 'IS_APPROVED_ACCESS_DOMAINS_ENABLED', IsBillingPlansEnabled = 'IS_BILLING_PLANS_ENABLED', IsRichTextV2Enabled = 'IS_RICH_TEXT_V2_ENABLED', IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',