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:
@ -0,0 +1,45 @@
|
||||
import { ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Entity({ name: 'approvedAccessDomain', schema: 'core' })
|
||||
@ObjectType()
|
||||
@Unique('IndexOnDomainAndWorkspaceId', ['domain', 'workspaceId'])
|
||||
export class ApprovedAccessDomain {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ type: 'varchar', nullable: false })
|
||||
domain: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false, nullable: false })
|
||||
isValidated: boolean;
|
||||
|
||||
@Column()
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.approvedAccessDomains, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ApprovedAccessDomainException extends CustomException {
|
||||
constructor(message: string, code: ApprovedAccessDomainExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export enum ApprovedAccessDomainExceptionCode {
|
||||
APPROVED_ACCESS_DOMAIN_NOT_FOUND = 'APPROVED_ACCESS_DOMAIN_NOT_FOUND',
|
||||
APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED = 'APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED',
|
||||
APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL = 'APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL',
|
||||
APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID = 'APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID',
|
||||
APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED = 'APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED',
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { ApprovedAccessDomainResolver } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.resolver';
|
||||
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
|
||||
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DomainManagerModule,
|
||||
NestjsQueryTypeOrmModule.forFeature([ApprovedAccessDomain], 'core'),
|
||||
],
|
||||
exports: [ApprovedAccessDomainService],
|
||||
providers: [ApprovedAccessDomainService, ApprovedAccessDomainResolver],
|
||||
})
|
||||
export class ApprovedAccessDomainModule {}
|
||||
@ -0,0 +1,71 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver, Query } from '@nestjs/graphql';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
|
||||
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto';
|
||||
import { CreateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input';
|
||||
import { DeleteApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input';
|
||||
import { ValidateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver()
|
||||
export class ApprovedAccessDomainResolver {
|
||||
constructor(
|
||||
private readonly approvedAccessDomainService: ApprovedAccessDomainService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => ApprovedAccessDomain)
|
||||
async createApprovedAccessDomain(
|
||||
@Args('input') { domain, email }: CreateApprovedAccessDomainInput,
|
||||
@AuthWorkspace() currentWorkspace: Workspace,
|
||||
@AuthUser() currentUser: User,
|
||||
): Promise<ApprovedAccessDomain> {
|
||||
return this.approvedAccessDomainService.createApprovedAccessDomain(
|
||||
domain,
|
||||
currentWorkspace,
|
||||
currentUser,
|
||||
email,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApprovedAccessDomain(
|
||||
@Args('input') { id }: DeleteApprovedAccessDomainInput,
|
||||
@AuthWorkspace() currentWorkspace: Workspace,
|
||||
): Promise<boolean> {
|
||||
await this.approvedAccessDomainService.deleteApprovedAccessDomain(
|
||||
currentWorkspace,
|
||||
id,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => ApprovedAccessDomain)
|
||||
async validateApprovedAccessDomain(
|
||||
@Args('input')
|
||||
{
|
||||
validationToken,
|
||||
approvedAccessDomainId,
|
||||
}: ValidateApprovedAccessDomainInput,
|
||||
): Promise<ApprovedAccessDomain> {
|
||||
return await this.approvedAccessDomainService.validateApprovedAccessDomain({
|
||||
validationToken,
|
||||
approvedAccessDomainId,
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [ApprovedAccessDomain])
|
||||
async getApprovedAccessDomains(
|
||||
@AuthWorkspace() currentWorkspace: Workspace,
|
||||
): Promise<Array<ApprovedAccessDomain>> {
|
||||
return await this.approvedAccessDomainService.getApprovedAccessDomains(
|
||||
currentWorkspace,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
|
||||
import {
|
||||
ApprovedAccessDomainException,
|
||||
ApprovedAccessDomainExceptionCode,
|
||||
} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception';
|
||||
|
||||
const assertIsDefinedOrThrow = (
|
||||
approvedAccessDomain: ApprovedAccessDomain | undefined | null,
|
||||
exceptionToThrow: CustomException = new ApprovedAccessDomainException(
|
||||
'Approved access domain not found',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_NOT_FOUND,
|
||||
),
|
||||
): asserts approvedAccessDomain is ApprovedAccessDomain => {
|
||||
if (!isDefined(approvedAccessDomain)) {
|
||||
throw exceptionToThrow;
|
||||
}
|
||||
};
|
||||
|
||||
export const approvedAccessDomainValidator: {
|
||||
assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow;
|
||||
} = {
|
||||
assertIsDefinedOrThrow,
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('ApprovedAccessDomain')
|
||||
export class ApprovedAccessDomain {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: false })
|
||||
domain: string;
|
||||
|
||||
@Field({ nullable: false })
|
||||
isValidated: boolean;
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { InputType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsString, IsEmail, IsNotEmpty } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateApprovedAccessDomainInput {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
domain: string;
|
||||
|
||||
@Field(() => String)
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class DeleteApprovedAccessDomainInput {
|
||||
@Field()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class ValidateApprovedAccessDomainInput {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
validationToken: string;
|
||||
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
approvedAccessDomainId: string;
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { render } from '@react-email/render';
|
||||
import { Repository } from 'typeorm';
|
||||
import { APP_LOCALES } from 'twenty-shared';
|
||||
import { SendApprovedAccessDomainValidation } from 'twenty-emails';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { ApprovedAccessDomain as ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
|
||||
import { approvedAccessDomainValidator } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.validate';
|
||||
import {
|
||||
ApprovedAccessDomainException,
|
||||
ApprovedAccessDomainExceptionCode,
|
||||
} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class ApprovedAccessDomainService {
|
||||
constructor(
|
||||
@InjectRepository(ApprovedAccessDomainEntity, 'core')
|
||||
private readonly approvedAccessDomainRepository: Repository<ApprovedAccessDomainEntity>,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {}
|
||||
|
||||
async sendApprovedAccessDomainValidationEmail(
|
||||
sender: User,
|
||||
to: string,
|
||||
workspace: Workspace,
|
||||
approvedAccessDomain: ApprovedAccessDomainEntity,
|
||||
) {
|
||||
if (approvedAccessDomain.isValidated) {
|
||||
throw new ApprovedAccessDomainException(
|
||||
'Approved access domain has already been validated',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED,
|
||||
);
|
||||
}
|
||||
|
||||
if (to.split('@')[1] !== approvedAccessDomain.domain) {
|
||||
throw new ApprovedAccessDomainException(
|
||||
'Approved access domain does not match email domain',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL,
|
||||
);
|
||||
}
|
||||
|
||||
const link = this.domainManagerService.buildWorkspaceURL({
|
||||
workspace,
|
||||
pathname: `settings/security`,
|
||||
searchParams: {
|
||||
wtdId: approvedAccessDomain.id,
|
||||
validationToken: this.generateUniqueHash(approvedAccessDomain),
|
||||
},
|
||||
});
|
||||
|
||||
const emailTemplate = SendApprovedAccessDomainValidation({
|
||||
link: link.toString(),
|
||||
workspace: { name: workspace.displayName, logo: workspace.logo },
|
||||
domain: approvedAccessDomain.domain,
|
||||
sender: {
|
||||
email: sender.email,
|
||||
firstName: sender.firstName,
|
||||
lastName: sender.lastName,
|
||||
},
|
||||
serverUrl: this.environmentService.get('SERVER_URL'),
|
||||
locale: 'en' as keyof typeof APP_LOCALES,
|
||||
});
|
||||
const html = render(emailTemplate);
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
await this.emailService.send({
|
||||
from: `${sender.firstName} ${sender.lastName} (via Twenty) <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to,
|
||||
subject: 'Approve your access domain',
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
private generateUniqueHash(approvedAccessDomain: ApprovedAccessDomainEntity) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
id: approvedAccessDomain.id,
|
||||
domain: approvedAccessDomain.domain,
|
||||
key: this.environmentService.get('APP_SECRET'),
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
async validateApprovedAccessDomain({
|
||||
validationToken,
|
||||
approvedAccessDomainId,
|
||||
}: {
|
||||
validationToken: string;
|
||||
approvedAccessDomainId: string;
|
||||
}) {
|
||||
const approvedAccessDomain =
|
||||
await this.approvedAccessDomainRepository.findOneBy({
|
||||
id: approvedAccessDomainId,
|
||||
});
|
||||
|
||||
approvedAccessDomainValidator.assertIsDefinedOrThrow(approvedAccessDomain);
|
||||
|
||||
if (approvedAccessDomain.isValidated) {
|
||||
throw new ApprovedAccessDomainException(
|
||||
'Approved access domain has already been validated',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED,
|
||||
);
|
||||
}
|
||||
|
||||
const isHashValid =
|
||||
this.generateUniqueHash(approvedAccessDomain) === validationToken;
|
||||
|
||||
if (!isHashValid) {
|
||||
throw new ApprovedAccessDomainException(
|
||||
'Invalid approved access domain validation token',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.approvedAccessDomainRepository.save({
|
||||
...approvedAccessDomain,
|
||||
isValidated: true,
|
||||
});
|
||||
}
|
||||
|
||||
async createApprovedAccessDomain(
|
||||
domain: string,
|
||||
inWorkspace: Workspace,
|
||||
fromUser: User,
|
||||
emailToValidateDomain: string,
|
||||
): Promise<ApprovedAccessDomainEntity> {
|
||||
const approvedAccessDomain = await this.approvedAccessDomainRepository.save(
|
||||
{
|
||||
workspaceId: inWorkspace.id,
|
||||
domain,
|
||||
},
|
||||
);
|
||||
|
||||
await this.sendApprovedAccessDomainValidationEmail(
|
||||
fromUser,
|
||||
emailToValidateDomain,
|
||||
inWorkspace,
|
||||
approvedAccessDomain,
|
||||
);
|
||||
|
||||
return approvedAccessDomain;
|
||||
}
|
||||
|
||||
async deleteApprovedAccessDomain(
|
||||
workspace: Workspace,
|
||||
approvedAccessDomainId: string,
|
||||
) {
|
||||
const approvedAccessDomain =
|
||||
await this.approvedAccessDomainRepository.findOneBy({
|
||||
id: approvedAccessDomainId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
approvedAccessDomainValidator.assertIsDefinedOrThrow(approvedAccessDomain);
|
||||
|
||||
await this.approvedAccessDomainRepository.delete(approvedAccessDomain);
|
||||
}
|
||||
|
||||
async getApprovedAccessDomains(workspace: Workspace) {
|
||||
return await this.approvedAccessDomainRepository.find({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,390 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { DeleteResult, Repository } from 'typeorm';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
|
||||
import {
|
||||
ApprovedAccessDomainException,
|
||||
ApprovedAccessDomainExceptionCode,
|
||||
} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception';
|
||||
|
||||
import { ApprovedAccessDomainService } from './approved-access-domain.service';
|
||||
|
||||
describe('ApprovedAccessDomainService', () => {
|
||||
let service: ApprovedAccessDomainService;
|
||||
let approvedAccessDomainRepository: Repository<ApprovedAccessDomain>;
|
||||
let emailService: EmailService;
|
||||
let environmentService: EnvironmentService;
|
||||
let domainManagerService: DomainManagerService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApprovedAccessDomainService,
|
||||
{
|
||||
provide: getRepositoryToken(ApprovedAccessDomain, 'core'),
|
||||
useValue: {
|
||||
delete: jest.fn(),
|
||||
findOneBy: jest.fn(),
|
||||
find: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {
|
||||
send: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
buildWorkspaceURL: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ApprovedAccessDomainService>(
|
||||
ApprovedAccessDomainService,
|
||||
);
|
||||
approvedAccessDomainRepository = module.get(
|
||||
getRepositoryToken(ApprovedAccessDomain, 'core'),
|
||||
);
|
||||
emailService = module.get<EmailService>(EmailService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
});
|
||||
|
||||
describe('createApprovedAccessDomain', () => {
|
||||
it('should successfully create an approved access domain', async () => {
|
||||
const domain = 'custom-domain.com';
|
||||
const inWorkspace = {
|
||||
id: 'workspace-id',
|
||||
customDomain: null,
|
||||
isCustomDomainEnabled: false,
|
||||
} as Workspace;
|
||||
const fromUser = {
|
||||
email: 'user@custom-domain.com',
|
||||
isEmailVerified: true,
|
||||
} as User;
|
||||
|
||||
const expectedApprovedAccessDomain = {
|
||||
workspaceId: 'workspace-id',
|
||||
domain,
|
||||
isValidated: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'save')
|
||||
.mockResolvedValue(
|
||||
expectedApprovedAccessDomain as unknown as ApprovedAccessDomain,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, 'sendApprovedAccessDomainValidationEmail')
|
||||
.mockResolvedValue();
|
||||
|
||||
const result = await service.createApprovedAccessDomain(
|
||||
domain,
|
||||
inWorkspace,
|
||||
fromUser,
|
||||
'validator@custom-domain.com',
|
||||
);
|
||||
|
||||
expect(approvedAccessDomainRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-id',
|
||||
domain,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(expectedApprovedAccessDomain);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteApprovedAccessDomain', () => {
|
||||
it('should delete an approved access domain successfully', async () => {
|
||||
const workspace: Workspace = { id: 'workspace-id' } as Workspace;
|
||||
const approvedAccessDomainId = 'approved-access-domain-id';
|
||||
const approvedAccessDomainEntity = {
|
||||
id: approvedAccessDomainId,
|
||||
workspaceId: workspace.id,
|
||||
} as ApprovedAccessDomain;
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(approvedAccessDomainEntity);
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'delete')
|
||||
.mockResolvedValue({} as unknown as DeleteResult);
|
||||
|
||||
await service.deleteApprovedAccessDomain(
|
||||
workspace,
|
||||
approvedAccessDomainId,
|
||||
);
|
||||
|
||||
expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({
|
||||
id: approvedAccessDomainId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
expect(approvedAccessDomainRepository.delete).toHaveBeenCalledWith(
|
||||
approvedAccessDomainEntity,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the approved access domain does not exist', async () => {
|
||||
const workspace: Workspace = { id: 'workspace-id' } as Workspace;
|
||||
const approvedAccessDomainId = 'approved-access-domain-id';
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.deleteApprovedAccessDomain(workspace, approvedAccessDomainId),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({
|
||||
id: approvedAccessDomainId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
expect(approvedAccessDomainRepository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendApprovedAccessDomainValidationEmail', () => {
|
||||
it('should throw an exception if the approved access domain is already validated', async () => {
|
||||
const approvedAccessDomainId = 'approved-access-domain-id';
|
||||
const sender = {} as User;
|
||||
const workspace = {} as Workspace;
|
||||
const email = 'validator@example.com';
|
||||
|
||||
const approvedAccessDomain = {
|
||||
id: approvedAccessDomainId,
|
||||
isValidated: true,
|
||||
} as ApprovedAccessDomain;
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(approvedAccessDomain);
|
||||
|
||||
await expect(
|
||||
service.sendApprovedAccessDomainValidationEmail(
|
||||
sender,
|
||||
email,
|
||||
workspace,
|
||||
approvedAccessDomain,
|
||||
),
|
||||
).rejects.toThrowError(
|
||||
new ApprovedAccessDomainException(
|
||||
'Approved access domain has already been validated',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an exception if the email does not match the approved access domain', async () => {
|
||||
const approvedAccessDomainId = 'approved-access-domain-id';
|
||||
const sender = {} as User;
|
||||
const workspace = {} as Workspace;
|
||||
const email = 'validator@different.com';
|
||||
const approvedAccessDomain = {
|
||||
id: approvedAccessDomainId,
|
||||
isValidated: false,
|
||||
domain: 'example.com',
|
||||
} as ApprovedAccessDomain;
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(approvedAccessDomain);
|
||||
|
||||
await expect(
|
||||
service.sendApprovedAccessDomainValidationEmail(
|
||||
sender,
|
||||
email,
|
||||
workspace,
|
||||
approvedAccessDomain,
|
||||
),
|
||||
).rejects.toThrowError(
|
||||
new ApprovedAccessDomainException(
|
||||
'Approved access domain does not match email domain',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should send a validation email if all conditions are met', async () => {
|
||||
const sender = {
|
||||
email: 'sender@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
} as User;
|
||||
const workspace = {
|
||||
displayName: 'Test Workspace',
|
||||
logo: '/logo.png',
|
||||
} as Workspace;
|
||||
const email = 'validator@custom-domain.com';
|
||||
const approvedAccessDomain = {
|
||||
isValidated: false,
|
||||
domain: 'custom-domain.com',
|
||||
} as ApprovedAccessDomain;
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(approvedAccessDomain);
|
||||
|
||||
jest
|
||||
.spyOn(domainManagerService, 'buildWorkspaceURL')
|
||||
.mockReturnValue(new URL('https://sub.twenty.com'));
|
||||
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === 'EMAIL_FROM_ADDRESS') return 'no-reply@example.com';
|
||||
if (key === 'SERVER_URL') return 'https://api.example.com';
|
||||
});
|
||||
|
||||
await service.sendApprovedAccessDomainValidationEmail(
|
||||
sender,
|
||||
email,
|
||||
workspace,
|
||||
approvedAccessDomain,
|
||||
);
|
||||
|
||||
expect(domainManagerService.buildWorkspaceURL).toHaveBeenCalledWith({
|
||||
workspace: workspace,
|
||||
pathname: 'settings/security',
|
||||
searchParams: { validationToken: expect.any(String) },
|
||||
});
|
||||
|
||||
expect(emailService.send).toHaveBeenCalledWith({
|
||||
from: 'John Doe (via Twenty) <no-reply@example.com>',
|
||||
to: email,
|
||||
subject: 'Approve your access domain',
|
||||
text: expect.any(String),
|
||||
html: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApprovedAccessDomain', () => {
|
||||
it('should validate the approved access domain successfully with a correct token', async () => {
|
||||
const approvedAccessDomainId = 'domain-id';
|
||||
const validationToken = 'valid-token';
|
||||
const approvedAccessDomain = {
|
||||
id: approvedAccessDomainId,
|
||||
domain: 'example.com',
|
||||
isValidated: false,
|
||||
} as ApprovedAccessDomain;
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(approvedAccessDomain);
|
||||
jest
|
||||
.spyOn(service as any, 'generateUniqueHash')
|
||||
.mockReturnValue(validationToken);
|
||||
const saveSpy = jest.spyOn(approvedAccessDomainRepository, 'save');
|
||||
|
||||
await service.validateApprovedAccessDomain({
|
||||
validationToken,
|
||||
approvedAccessDomainId: approvedAccessDomainId,
|
||||
});
|
||||
|
||||
expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({
|
||||
id: approvedAccessDomainId,
|
||||
});
|
||||
expect(saveSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isValidated: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the approved access domain does not exist', async () => {
|
||||
const approvedAccessDomainId = 'invalid-domain-id';
|
||||
const validationToken = 'valid-token';
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.validateApprovedAccessDomain({
|
||||
validationToken,
|
||||
approvedAccessDomainId: approvedAccessDomainId,
|
||||
}),
|
||||
).rejects.toThrowError(
|
||||
new ApprovedAccessDomainException(
|
||||
'Approved access domain not found',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the validation token is invalid', async () => {
|
||||
const approvedAccessDomainId = 'domain-id';
|
||||
const validationToken = 'invalid-token';
|
||||
const approvedAccessDomain = {
|
||||
id: approvedAccessDomainId,
|
||||
domain: 'example.com',
|
||||
isValidated: false,
|
||||
} as ApprovedAccessDomain;
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(approvedAccessDomain);
|
||||
jest
|
||||
.spyOn(service as any, 'generateUniqueHash')
|
||||
.mockReturnValue('valid-token');
|
||||
|
||||
await expect(
|
||||
service.validateApprovedAccessDomain({
|
||||
validationToken,
|
||||
approvedAccessDomainId: approvedAccessDomainId,
|
||||
}),
|
||||
).rejects.toThrowError(
|
||||
new ApprovedAccessDomainException(
|
||||
'Invalid approved access domain validation token',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the approved access domain is already validated', async () => {
|
||||
const approvedAccessDomainId = 'domain-id';
|
||||
const validationToken = 'valid-token';
|
||||
const approvedAccessDomain = {
|
||||
id: approvedAccessDomainId,
|
||||
domain: 'example.com',
|
||||
isValidated: true,
|
||||
} as ApprovedAccessDomain;
|
||||
|
||||
jest
|
||||
.spyOn(approvedAccessDomainRepository, 'findOneBy')
|
||||
.mockResolvedValue(approvedAccessDomain);
|
||||
|
||||
await expect(
|
||||
service.validateApprovedAccessDomain({
|
||||
validationToken,
|
||||
approvedAccessDomainId: approvedAccessDomainId,
|
||||
}),
|
||||
).rejects.toThrowError(
|
||||
new ApprovedAccessDomainException(
|
||||
'Approved access domain has already been validated',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
}),
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -46,6 +46,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
|
||||
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { ClientConfigModule } from './client-config/client-config.module';
|
||||
@ -68,6 +69,7 @@ import { FileModule } from './file/file.module';
|
||||
WorkspaceModule,
|
||||
WorkspaceInvitationModule,
|
||||
WorkspaceSSOModule,
|
||||
ApprovedAccessDomainModule,
|
||||
PostgresCredentialsModule,
|
||||
WorkflowApiModule,
|
||||
WorkspaceEventEmitterModule,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class FindAvailableSSOIDPInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
@ -20,6 +20,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
|
||||
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
|
||||
|
||||
registerEnumType(WorkspaceActivationStatus, {
|
||||
name: 'WorkspaceActivationStatus',
|
||||
@ -28,6 +29,7 @@ registerEnumType(WorkspaceActivationStatus, {
|
||||
@Entity({ name: 'workspace', schema: 'core' })
|
||||
@ObjectType()
|
||||
export class Workspace {
|
||||
// Fields
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
@ -56,6 +58,15 @@ export class Workspace {
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
allowImpersonation: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
isPublicInviteLinkEnabled: boolean;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => AppToken, (appToken) => appToken.workspace, {
|
||||
cascade: true,
|
||||
})
|
||||
@ -71,17 +82,15 @@ export class Workspace {
|
||||
})
|
||||
workspaceUsers: Relation<UserWorkspace[]>;
|
||||
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
allowImpersonation: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
isPublicInviteLinkEnabled: boolean;
|
||||
|
||||
@OneToMany(() => FeatureFlag, (featureFlag) => featureFlag.workspace)
|
||||
featureFlags: Relation<FeatureFlag[]>;
|
||||
|
||||
@OneToMany(
|
||||
() => ApprovedAccessDomain,
|
||||
(approvedAccessDomain) => approvedAccessDomain.workspace,
|
||||
)
|
||||
approvedAccessDomains: Relation<ApprovedAccessDomain[]>;
|
||||
|
||||
@Field({ nullable: true })
|
||||
workspaceMembersCount: number;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user