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

@ -50,6 +50,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IsApprovedAccessDomainsEnabled,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsBillingPlansEnabled,
workspaceId: workspaceId,

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddApprovedAccessDomain1740048555744
implements MigrationInterface
{
name = 'AddApprovedAccessDomain1740048555744';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."approvedAccessDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "IndexOnDomainAndWorkspaceId" UNIQUE ("domain", "workspaceId"), CONSTRAINT "PK_523281ce57c84e1a039f4538c19" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."approvedAccessDomain" ADD CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."approvedAccessDomain" DROP CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc"`,
);
await queryRunner.query(`DROP TABLE "core"."approvedAccessDomain"`);
}
}

View File

@ -22,6 +22,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
@Injectable()
export class TypeORMService implements OnModuleInit, OnModuleDestroy {
private mainDataSource: DataSource;
@ -50,6 +51,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
BillingEntitlement,
PostgresCredentials,
WorkspaceSSOIdentityProvider,
ApprovedAccessDomain,
TwoFactorMethod,
],
metadataTableName: '_typeorm_generated_columns_and_materialized_views',

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;