feat(twenty-server): add trusted domain - backend crud (#10290)

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
Antoine Moreaux
2025-02-21 17:02:48 +01:00
committed by GitHub
parent 22203bfd3c
commit bf92860d19
49 changed files with 1812 additions and 147 deletions

View File

@ -17,7 +17,7 @@ import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/micr
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
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 { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@ -114,7 +114,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ResetPasswordService,
TransientTokenService,
ApiKeyService,
SocialSsoService,
AuthSsoService,
// reenable when working on: https://github.com/twentyhq/twenty/issues/9143
// OAuthService,
],

View File

@ -8,7 +8,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
@Injectable()
export class SocialSsoService {
export class AuthSsoService {
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@ -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'],
});
}
}

View File

@ -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<Workspace>;
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>(SocialSsoService);
authSsoService = module.get<AuthSsoService>(AuthSsoService);
workspaceRepository = module.get<Repository<Workspace>>(
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',
}),

View File

@ -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<Workspace>;
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>(AuthService);
userService = module.get<UserService>(UserService);
socialSsoService = module.get<SocialSsoService>(SocialSsoService);
authSsoService = module.get<AuthSsoService>(AuthSsoService);
workspaceRepository = module.get<Repository<Workspace>>(
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);
});
});

View File

@ -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 &&