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

@ -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>;
}

View File

@ -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',
}

View File

@ -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 {}

View File

@ -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,
);
}
}

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,10 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsString } from 'class-validator';
@InputType()
export class DeleteApprovedAccessDomainInput {
@Field()
@IsString()
id: string;
}

View File

@ -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;
}

View File

@ -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,
},
});
}
}

View File

@ -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,
),
);
});
});
});

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

View File

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

View File

@ -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',

View File

@ -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;
}

View File

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