From 9e70f5b6507417cc3789594d9347ccb11f1b8252 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Mon, 25 Mar 2024 15:21:23 +0100 Subject: [PATCH] Add endpoints to create and delete remote server (#4606) * Build remote server * Add getters * Migrate to json inputs * Use extendable type * Use regex validation * Remove acronymes --------- Co-authored-by: Thomas Trompette --- packages/twenty-server/.env.test | 1 + .../1711374137222-addRemoteServerTable.ts | 15 ++ .../graphql/metadata-graphql-api.module.ts | 6 +- .../factories/factories.ts | 3 + .../foreign-data-wrapper-query.factory.ts | 48 ++++++ .../src/engine/core-modules/auth/auth.util.ts | 41 +++++ .../engine/core-modules/core-engine.module.ts | 2 + .../metadata-engine.module.ts | 3 + .../dtos/create-remote-server.input.ts | 24 +++ .../dtos/remote-server-id.input.ts | 9 ++ .../dtos/remote-server-type.input.ts | 12 ++ .../remote-server/dtos/remote-server.dto.ts | 34 +++++ .../remote-server/remote-server.entity.ts | 59 +++++++ .../remote-server/remote-server.module.ts | 22 +++ .../remote-server/remote-server.resolver.ts | 56 +++++++ .../remote-server/remote-server.service.ts | 144 ++++++++++++++++++ .../utils/validate-remote-server-input.ts | 20 +++ 17 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1711374137222-addRemoteServerTable.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-query.factory.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/create-remote-server.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server-id.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server-type.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.resolver.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/remote-server/utils/validate-remote-server-input.ts diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index 5e090b245..1c2c967fd 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -9,6 +9,7 @@ FRONT_BASE_URL=http://localhost:3001 ACCESS_TOKEN_SECRET=secret_jwt LOGIN_TOKEN_SECRET=secret_login_tokens REFRESH_TOKEN_SECRET=secret_refresh_token +FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh # ———————— Optional ———————— # DEBUG_MODE=false diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1711374137222-addRemoteServerTable.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1711374137222-addRemoteServerTable.ts new file mode 100644 index 000000000..29cc5870c --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1711374137222-addRemoteServerTable.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRemoteServerTable1711374137222 implements MigrationInterface { + name = 'AddRemoteServerTable1711374137222'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "metadata"."remoteServer" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "foreignDataWrapperId" uuid NOT NULL DEFAULT uuid_generate_v4(), "foreignDataWrapperType" character varying, "foreignDataWrapperOptions" jsonb, "userMappingOptions" jsonb, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_8e5d208498fa2c9710bb934023a" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "metadata"."remoteServer"`); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/metadata-graphql-api.module.ts b/packages/twenty-server/src/engine/api/graphql/metadata-graphql-api.module.ts index 5bfadcee7..0ef80a645 100644 --- a/packages/twenty-server/src/engine/api/graphql/metadata-graphql-api.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/metadata-graphql-api.module.ts @@ -5,12 +5,12 @@ import { YogaDriverConfig, YogaDriver } from '@graphql-yoga/nestjs'; import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module'; import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; +import { MetadataEngineModule } from 'src/engine/metadata-modules/metadata-engine.module'; +import { CreateContextFactory } from 'src/engine/api/graphql/graphql-config/factories/create-context.factory'; +import { GraphQLConfigModule } from 'src/engine/api/graphql/graphql-config/graphql-config.module'; import { metadataModuleFactory } from 'src/engine/api/graphql/metadata.module-factory'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { ExceptionHandlerService } from 'src/engine/integrations/exception-handler/exception-handler.service'; -import { GraphQLConfigModule } from 'src/engine/api/graphql/graphql-config/graphql-config.module'; -import { CreateContextFactory } from 'src/engine/api/graphql/graphql-config/factories/create-context.factory'; -import { MetadataEngineModule } from 'src/engine/metadata-modules/metadata-engine.module'; @Module({ imports: [ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/factories.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/factories.ts index dd1ec66d8..db1255540 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/factories.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/factories.ts @@ -1,3 +1,5 @@ +import { ForeignDataWrapperQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-query.factory'; + import { ArgsAliasFactory } from './args-alias.factory'; import { ArgsStringFactory } from './args-string.factory'; import { RelationFieldAliasFactory } from './relation-field-alias.factory'; @@ -28,4 +30,5 @@ export const workspaceQueryBuilderFactories = [ UpdateOneQueryFactory, UpdateManyQueryFactory, DeleteManyQueryFactory, + ForeignDataWrapperQueryFactory, ]; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-query.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-query.factory.ts new file mode 100644 index 000000000..83420c83e --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-query.factory.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; + +import { + ForeignDataWrapperOptions, + RemoteServerType, + UserMappingOptions, +} from 'src/engine/metadata-modules/remote-server/remote-server.entity'; + +@Injectable() +export class ForeignDataWrapperQueryFactory { + createForeignDataWrapper( + foreignDataWrapperId: string, + foreignDataWrapperType: RemoteServerType, + foreignDataWrapperOptions: ForeignDataWrapperOptions, + ) { + const [name, options] = this.buildNameAndOptionsFromType( + foreignDataWrapperType, + foreignDataWrapperOptions, + ); + + return `CREATE SERVER "${foreignDataWrapperId}" FOREIGN DATA WRAPPER ${name} OPTIONS (${options})`; + } + + createUserMapping( + foreignDataWrapperId: string, + userMappingOptions: UserMappingOptions, + ) { + return `CREATE USER MAPPING IF NOT EXISTS FOR ${userMappingOptions.username} SERVER "${foreignDataWrapperId}" OPTIONS (user '${userMappingOptions.username}', password '${userMappingOptions.password}')`; + } + + private buildNameAndOptionsFromType( + type: RemoteServerType, + options: ForeignDataWrapperOptions, + ) { + switch (type) { + case RemoteServerType.POSTGRES_FDW: + return ['postgres_fdw', this.buildPostgresFDWQueryOptions(options)]; + default: + throw new Error('Foreign data wrapper type not supported'); + } + } + + private buildPostgresFDWQueryOptions( + foreignDataWrapperOptions: ForeignDataWrapperOptions, + ) { + return `dbname '${foreignDataWrapperOptions.dbname}', host '${foreignDataWrapperOptions.host}', port '${foreignDataWrapperOptions.port}'`; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.util.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.util.ts index 6fe8a920b..0a3346be4 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.util.ts @@ -1,3 +1,5 @@ +import { createCipheriv, createDecipheriv, createHash } from 'crypto'; + import * as bcrypt from 'bcrypt'; export const PASSWORD_REGEX = /^.{8,}$/; @@ -13,3 +15,42 @@ export const hashPassword = async (password: string) => { export const compareHash = async (password: string, passwordHash: string) => { return bcrypt.compare(password, passwordHash); }; + +export const encryptText = async ( + textToEncrypt: string, + key: string, + iv: string, +): Promise => { + const keyHash = createHash('sha512') + .update(key) + .digest('hex') + .substring(0, 32); + + const ivHash = createHash('sha512').update(iv).digest('hex').substring(0, 16); + + const cipher = createCipheriv('aes-256-ctr', keyHash, ivHash); + + return Buffer.concat([cipher.update(textToEncrypt), cipher.final()]).toString( + 'base64', + ); +}; + +export const decryptText = async ( + textToDecrypt: string, + key: string, + iv: string, +) => { + const keyHash = createHash('sha512') + .update(key) + .digest('hex') + .substring(0, 32); + + const ivHash = createHash('sha512').update(iv).digest('hex').substring(0, 16); + + const decipher = createDecipheriv('aes-256-ctr', keyHash, ivHash); + + return Buffer.concat([ + decipher.update(Buffer.from(textToDecrypt, 'base64')), + decipher.final(), + ]).toString(); +}; diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index c61f55b2f..a74284d09 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -10,6 +10,7 @@ import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timel import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/timeline-calendar-event.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { HealthModule } from 'src/engine/core-modules/health/health.module'; +import { RemoteServerModule } from 'src/engine/metadata-modules/remote-server/remote-server.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { FileModule } from './file/file.module'; @@ -30,6 +31,7 @@ import { ClientConfigModule } from './client-config/client-config.module'; TimelineCalendarEventModule, UserModule, WorkspaceModule, + RemoteServerModule, ], exports: [ AnalyticsModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index 9eb3ea468..9e096c8e0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -4,6 +4,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { RelationMetadataModule } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.module'; +import { RemoteServerModule } from 'src/engine/metadata-modules/remote-server/remote-server.module'; import { WorkspaceCacheVersionModule } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.module'; import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; @@ -15,6 +16,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace- RelationMetadataModule, WorkspaceCacheVersionModule, WorkspaceMigrationModule, + RemoteServerModule, ], providers: [], exports: [ @@ -22,6 +24,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace- FieldMetadataModule, ObjectMetadataModule, RelationMetadataModule, + RemoteServerModule, ], }) export class MetadataEngineModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/create-remote-server.input.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/create-remote-server.input.ts new file mode 100644 index 000000000..35cacf89b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/create-remote-server.input.ts @@ -0,0 +1,24 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsOptional } from 'class-validator'; +import GraphQLJSON from 'graphql-type-json'; + +import { + ForeignDataWrapperOptions, + RemoteServerType, + UserMappingOptions, +} from 'src/engine/metadata-modules/remote-server/remote-server.entity'; + +@InputType() +export class CreateRemoteServerInput { + @Field(() => String) + foreignDataWrapperType: T; + + @IsOptional() + @Field(() => GraphQLJSON) + foreignDataWrapperOptions: ForeignDataWrapperOptions; + + @IsOptional() + @Field(() => GraphQLJSON, { nullable: true }) + userMappingOptions?: UserMappingOptions; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server-id.input.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server-id.input.ts new file mode 100644 index 000000000..958d0bae3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server-id.input.ts @@ -0,0 +1,9 @@ +import { InputType, ID } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +@InputType() +export class RemoteServerIdInput { + @IDField(() => ID, { description: 'The id of the record.' }) + id!: string; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server-type.input.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server-type.input.ts new file mode 100644 index 000000000..9c7b75e10 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server-type.input.ts @@ -0,0 +1,12 @@ +import { InputType, Field } from '@nestjs/graphql'; + +import { IsString } from 'class-validator'; + +import { RemoteServerType } from 'src/engine/metadata-modules/remote-server/remote-server.entity'; + +@InputType() +export class RemoteServerTypeInput { + @Field(() => String) + @IsString() + foreignDataWrapperType!: T; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server.dto.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server.dto.ts new file mode 100644 index 000000000..7773e948b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/dtos/remote-server.dto.ts @@ -0,0 +1,34 @@ +import { ObjectType, Field, HideField, ID } from '@nestjs/graphql'; + +import { IsOptional } from 'class-validator'; +import GraphQLJSON from 'graphql-type-json'; + +import { + ForeignDataWrapperOptions, + RemoteServerType, +} from 'src/engine/metadata-modules/remote-server/remote-server.entity'; + +@ObjectType('RemoteServer') +export class RemoteServerDTO { + @Field(() => ID) + id: string; + + @Field(() => ID) + foreignDataWrapperId: string; + + @Field(() => String) + foreignDataWrapperType: T; + + @IsOptional() + @Field(() => GraphQLJSON, { nullable: true }) + foreignDataWrapperOptions?: ForeignDataWrapperOptions; + + @HideField() + workspaceId: string; + + @Field() + createdAt: Date; + + @Field() + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.entity.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.entity.ts new file mode 100644 index 000000000..71b8d090e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.entity.ts @@ -0,0 +1,59 @@ +import { ObjectType } from '@nestjs/graphql'; + +import { + Column, + CreateDateColumn, + Entity, + Generated, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum RemoteServerType { + POSTGRES_FDW = 'postgres_fdw', +} + +type PostgresForeignDataWrapperOptions = { + host: string; + port: number; + dbname: string; +}; + +export type ForeignDataWrapperOptions = + T extends RemoteServerType.POSTGRES_FDW + ? PostgresForeignDataWrapperOptions + : never; + +export type UserMappingOptions = { + username: string; + password: string; +}; + +@Entity('remoteServer') +@ObjectType('RemoteServer') +export class RemoteServerEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Generated('uuid') + foreignDataWrapperId: string; + + @Column({ nullable: true }) + foreignDataWrapperType: T; + + @Column({ nullable: true, type: 'jsonb' }) + foreignDataWrapperOptions: ForeignDataWrapperOptions; + + @Column({ nullable: true, type: 'jsonb' }) + userMappingOptions: UserMappingOptions; + + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.module.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.module.ts new file mode 100644 index 000000000..4041b4dc8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { ForeignDataWrapperQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-query.factory'; +import { RemoteServerEntity } from 'src/engine/metadata-modules/remote-server/remote-server.entity'; +import { RemoteServerResolver } from 'src/engine/metadata-modules/remote-server/remote-server.resolver'; +import { RemoteServerService } from 'src/engine/metadata-modules/remote-server/remote-server.service'; + +@Module({ + imports: [ + TypeORMModule, + TypeOrmModule.forFeature([RemoteServerEntity], 'metadata'), + ], + providers: [ + RemoteServerService, + RemoteServerResolver, + ForeignDataWrapperQueryFactory, + ], + exports: [RemoteServerService], +}) +export class RemoteServerModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.resolver.ts new file mode 100644 index 000000000..9165aa408 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.resolver.ts @@ -0,0 +1,56 @@ +import { UseGuards } from '@nestjs/common'; +import { Resolver, Args, Mutation, 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 { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; +import { CreateRemoteServerInput } from 'src/engine/metadata-modules/remote-server/dtos/create-remote-server.input'; +import { RemoteServerIdInput } from 'src/engine/metadata-modules/remote-server/dtos/remote-server-id.input'; +import { RemoteServerTypeInput } from 'src/engine/metadata-modules/remote-server/dtos/remote-server-type.input'; +import { RemoteServerDTO } from 'src/engine/metadata-modules/remote-server/dtos/remote-server.dto'; +import { RemoteServerType } from 'src/engine/metadata-modules/remote-server/remote-server.entity'; +import { RemoteServerService } from 'src/engine/metadata-modules/remote-server/remote-server.service'; + +@UseGuards(JwtAuthGuard) +@Resolver() +export class RemoteServerResolver { + constructor( + private readonly remoteServerService: RemoteServerService, + ) {} + + @Mutation(() => RemoteServerDTO) + async createOneRemoteServer( + @Args('input') input: CreateRemoteServerInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.remoteServerService.createOneRemoteServer(input, workspaceId); + } + + @Mutation(() => RemoteServerDTO) + async deleteOneRemoteServer( + @Args('input') { id }: RemoteServerIdInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.remoteServerService.deleteOneRemoteServer(id, workspaceId); + } + + @Query(() => RemoteServerDTO) + async findOneRemoteServerById( + @Args('input') { id }: RemoteServerIdInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.remoteServerService.findOneByIdWithinWorkspace(id, workspaceId); + } + + @Query(() => [RemoteServerDTO]) + async findManyRemoteServersByType( + @Args('input') + { foreignDataWrapperType }: RemoteServerTypeInput, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.remoteServerService.findManyByTypeWithinWorkspace( + foreignDataWrapperType, + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts new file mode 100644 index 000000000..9064fc733 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts @@ -0,0 +1,144 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { v4 } from 'uuid'; +import { Repository } from 'typeorm'; + +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { CreateRemoteServerInput } from 'src/engine/metadata-modules/remote-server/dtos/create-remote-server.input'; +import { + RemoteServerEntity, + RemoteServerType, +} from 'src/engine/metadata-modules/remote-server/remote-server.entity'; +import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { encryptText } from 'src/engine/core-modules/auth/auth.util'; +import { + validateObject, + validateString, +} from 'src/engine/metadata-modules/remote-server/utils/validate-remote-server-input'; +import { ForeignDataWrapperQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-query.factory'; + +@Injectable() +export class RemoteServerService { + constructor( + @InjectRepository(RemoteServerEntity, 'metadata') + private readonly remoteServerRepository: Repository< + RemoteServerEntity + >, + private readonly typeORMService: TypeORMService, + private readonly environmentService: EnvironmentService, + private readonly foreignDataWrapperQueryFactory: ForeignDataWrapperQueryFactory, + ) {} + + async createOneRemoteServer( + remoteServerInput: CreateRemoteServerInput, + workspaceId: string, + ): Promise> { + validateObject(remoteServerInput.foreignDataWrapperOptions); + + if (remoteServerInput.userMappingOptions) { + validateObject(remoteServerInput.userMappingOptions); + } + + const mainDatasource = this.typeORMService.getMainDataSource(); + const foreignDataWrapperId = v4(); + + let remoteServerToCreate = { + ...remoteServerInput, + workspaceId, + foreignDataWrapperId, + }; + + if (remoteServerInput.userMappingOptions) { + const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const encryptedPassword = await encryptText( + remoteServerInput.userMappingOptions.password, + key, + // TODO: check if we should use a separated IV + key, + ); + + remoteServerToCreate = { + ...remoteServerToCreate, + userMappingOptions: { + ...remoteServerInput.userMappingOptions, + password: encryptedPassword, + }, + }; + } + + const createdRemoteServer = + await this.remoteServerRepository.create(remoteServerToCreate); + + const foreignDataWrapperQuery = + this.foreignDataWrapperQueryFactory.createForeignDataWrapper( + createdRemoteServer.foreignDataWrapperId, + remoteServerInput.foreignDataWrapperType, + remoteServerInput.foreignDataWrapperOptions, + ); + + await mainDatasource.query(foreignDataWrapperQuery); + + if (remoteServerInput.userMappingOptions) { + const userMappingQuery = + this.foreignDataWrapperQueryFactory.createUserMapping( + createdRemoteServer.foreignDataWrapperId, + remoteServerInput.userMappingOptions, + ); + + await mainDatasource.query(userMappingQuery); + } + + await this.remoteServerRepository.save(createdRemoteServer); + + return createdRemoteServer; + } + + async deleteOneRemoteServer( + id: string, + workspaceId: string, + ): Promise> { + validateString(id); + + const remoteServer = await this.remoteServerRepository.findOne({ + where: { + id, + workspaceId, + }, + }); + + if (!remoteServer) { + throw new NotFoundException('Object does not exist'); + } + + const mainDatasource = this.typeORMService.getMainDataSource(); + + await mainDatasource.query( + `DROP SERVER "${remoteServer.foreignDataWrapperId}" CASCADE`, + ); + await this.remoteServerRepository.delete(id); + + return remoteServer; + } + + public async findOneByIdWithinWorkspace(id: string, workspaceId: string) { + return this.remoteServerRepository.findOne({ + where: { + id, + workspaceId, + }, + }); + } + + public async findManyByTypeWithinWorkspace( + foreignDataWrapperType: T, + workspaceId: string, + ) { + return this.remoteServerRepository.find({ + where: { + foreignDataWrapperType, + workspaceId, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/utils/validate-remote-server-input.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/utils/validate-remote-server-input.ts new file mode 100644 index 000000000..4847351c3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/utils/validate-remote-server-input.ts @@ -0,0 +1,20 @@ +const INPUT_REGEX = /^([A-Za-z0-9\-\_]+)$/; + +export const validateObject = (input: object) => { + for (const [key, value] of Object.entries(input)) { + // Password are encrypted so we don't need to validate them + if (key === 'password') { + continue; + } + + if (!INPUT_REGEX.test(value.toString())) { + throw new Error('Invalid remote server input'); + } + } +}; + +export const validateString = (input: string) => { + if (!INPUT_REGEX.test(input)) { + throw new Error('Invalid remote server input'); + } +};