diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 0997f9facd..14eaba8836 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2103,6 +2103,7 @@ export type Mutation = { evaluateAgentTurn: AgentTurnEvaluation; executeOneLogicFunction: LogicFunctionExecutionResult; generateApiKeyToken: ApiKeyToken; + generateApplicationToken: AuthToken; generateTransientToken: TransientTokenOutput; getAuthTokensFromLoginToken: AuthTokens; getAuthTokensFromOTP: AuthTokens; @@ -2610,6 +2611,11 @@ export type MutationGenerateApiKeyTokenArgs = { }; +export type MutationGenerateApplicationTokenArgs = { + applicationId: Scalars['UUID']; +}; + + export type MutationGetAuthTokensFromLoginTokenArgs = { loginToken: Scalars['String']; origin: Scalars['String']; diff --git a/packages/twenty-server/patches/@graphql-yoga+nestjs+2.1.0.patch b/packages/twenty-server/patches/@graphql-yoga+nestjs+2.1.0.patch index 5479588335..5ecfd4f10c 100644 --- a/packages/twenty-server/patches/@graphql-yoga+nestjs+2.1.0.patch +++ b/packages/twenty-server/patches/@graphql-yoga+nestjs+2.1.0.patch @@ -1,5 +1,5 @@ diff --git a/dist/cjs/index.js b/dist/cjs/index.js -index 1684394..dd0773d 100644 +index 1684394..8604546 100644 --- a/dist/cjs/index.js +++ b/dist/cjs/index.js @@ -3,10 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); @@ -34,8 +34,9 @@ index 1684394..dd0773d 100644 + const workspaceId = request.req.workspace?.id ?? 'anonymous' + const workspaceCacheVersion = request.req.workspaceMetadataVersion ?? '0' + const url = request.req.baseUrl ++ const applicationId = request.req.application?.id ?? 'all' + -+ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}` ++ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}-${applicationId}` + + if(this.schemaCache.has(cacheKey)) { + return this.schemaCache.get(cacheKey) @@ -54,7 +55,6 @@ index 1684394..dd0773d 100644 + } + } + -+ + const mergedSchemas = (0, schema_1.mergeSchemas)({ + schemas, + }); @@ -86,8 +86,9 @@ index 1684394..dd0773d 100644 + const workspaceId = request.req.workspace?.id ?? 'anonymous' + const workspaceCacheVersion = request.req.workspaceMetadataVersion ?? '0' + const url = request.req.baseUrl ++ const applicationId = request.req.application?.id ?? 'all' + -+ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}` ++ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}-${applicationId}` + + if(this.schemaCache.has(cacheKey)) { + return this.schemaCache.get(cacheKey) @@ -106,7 +107,6 @@ index 1684394..dd0773d 100644 + } + } + -+ + const mergedSchemas = (0, schema_1.mergeSchemas)({ + schemas, + }); @@ -125,7 +125,7 @@ index 1684394..dd0773d 100644 // disable logging by default // however, if `true` use fastify logger diff --git a/dist/esm/index.js b/dist/esm/index.js -index 7068c51..8494b69 100644 +index 7068c51..95b4fbe 100644 --- a/dist/esm/index.js +++ b/dist/esm/index.js @@ -1,9 +1,13 @@ @@ -154,7 +154,7 @@ index 7068c51..8494b69 100644 const app = this.httpAdapterHost.httpAdapter.getInstance(); preStartHook?.(app); // nest's logger doesnt have the info method -@@ -39,6 +43,45 @@ export class AbstractYogaDriver extends AbstractGraphQLDriver { +@@ -39,6 +43,46 @@ export class AbstractYogaDriver extends AbstractGraphQLDriver { } const yoga = createYoga({ ...options, @@ -162,8 +162,9 @@ index 7068c51..8494b69 100644 + const workspaceId = request.req.workspace?.id ?? 'anonymous' + const workspaceCacheVersion = request.req.workspaceMetadataVersion ?? '0' + const url = request.req.baseUrl ++ const applicationId = request.req.application?.id ?? 'all' + -+ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}` ++ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}-${applicationId}` + + if (this.schemaCache.has(cacheKey)) { + return this.schemaCache.get(cacheKey) @@ -200,7 +201,7 @@ index 7068c51..8494b69 100644 graphqlEndpoint: options.path, // disable logging by default // however, if `true` use nest logger -@@ -51,11 +94,50 @@ export class AbstractYogaDriver extends AbstractGraphQLDriver { +@@ -51,11 +95,51 @@ export class AbstractYogaDriver extends AbstractGraphQLDriver { this.yoga = yoga; app.use(yoga.graphqlEndpoint, (req, res) => yoga(req, res, { req, res })); } @@ -214,8 +215,9 @@ index 7068c51..8494b69 100644 + const workspaceId = request.req.workspace?.id ?? 'anonymous' + const workspaceCacheVersion = request.req.workspaceMetadataVersion ?? '0' + const url = request.req.baseUrl ++ const applicationId = request.req.application?.id ?? 'all' + -+ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}` ++ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}-${applicationId}` + + if (this.schemaCache.has(cacheKey)) { + return this.schemaCache.get(cacheKey) @@ -330,7 +332,7 @@ index 2c6a965..fd86dac 100644 }): void; subscriptionWithFilter(instanceRef: unknown, filterFn: (payload: TPayload, variables: TVariables, context: TContext) => boolean | Promise, createSubscribeContext: Function): (args_0: TPayload, args_1: TVariables, args_2: TContext) => Promise>; diff --git a/src/index.ts b/src/index.ts -index ce142f6..d3e7bab 100644 +index ce142f6..95a1faf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ @@ -349,7 +351,7 @@ index ce142f6..d3e7bab 100644 @@ -11,23 +12,31 @@ import { SubscriptionConfig, } from '@nestjs/graphql'; - + +export type YogaSchemaDefinition = + | PromiseOrValue> + | (( @@ -357,7 +359,7 @@ index ce142f6..d3e7bab 100644 + ) => PromiseOrValue>); + export type YogaDriverPlatform = 'express' | 'fastify'; - + export type YogaDriverServerContext = Platform extends 'fastify' - ? { @@ -376,7 +378,7 @@ index ce142f6..d3e7bab 100644 + req: ExpressRequest; + res: ExpressResponse; + }; - + export type YogaDriverServerOptions = Omit< YogaServerOptions, never>, 'context' | 'schema' @@ -384,7 +386,7 @@ index ce142f6..d3e7bab 100644 +> & { + conditionalSchema?: YogaSchemaDefinition> | undefined; +}; - + export type YogaDriverServerInstance = YogaServerInstance< YogaDriverServerContext, @@ -53,6 +62,8 @@ export type YogaDriverSubscriptionConfig = { @@ -394,27 +396,28 @@ index ce142f6..d3e7bab 100644 + schemaCache = new Map(); + protected yoga!: YogaDriverServerInstance; - + public async start(options: YogaDriverConfig) { @@ -78,7 +89,7 @@ export abstract class AbstractYogaDriver< } - + protected registerExpress( - options: YogaDriverConfig<'express'>, + { conditionalSchema, ...options }: YogaDriverConfig<'express'>, { preStartHook }: { preStartHook?: (app: Express) => void } = {}, ) { const app: Express = this.httpAdapterHost.httpAdapter.getInstance(); -@@ -98,6 +109,39 @@ export abstract class AbstractYogaDriver< - +@@ -98,6 +109,40 @@ export abstract class AbstractYogaDriver< + const yoga = createYoga>({ ...options, + schema: async request => { + const workspaceId = request.req.workspace.id + const workspaceCacheVersion = request.req.workspaceMetadataVersion + const url = request.req.baseUrl ++ const applicationId = request.req.application?.id ?? 'all' + -+ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}` ++ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}-${applicationId}` + + if (this.schemaCache.has(cacheKey)) { + return this.schemaCache.get(cacheKey) @@ -445,7 +448,7 @@ index ce142f6..d3e7bab 100644 graphqlEndpoint: options.path, // disable logging by default // however, if `true` use nest logger -@@ -105,8 +149,8 @@ export abstract class AbstractYogaDriver< +@@ -105,8 +150,8 @@ export abstract class AbstractYogaDriver< options.logging == null ? false : options.logging @@ -454,27 +457,28 @@ index ce142f6..d3e7bab 100644 + ? new LoggerWithInfo('YogaDriver') + : options.logging, }); - + this.yoga = yoga as YogaDriverServerInstance; -@@ -115,7 +159,7 @@ export abstract class AbstractYogaDriver< +@@ -115,7 +160,7 @@ export abstract class AbstractYogaDriver< } - + protected registerFastify( - options: YogaDriverConfig<'fastify'>, + { conditionalSchema, ...options }: YogaDriverConfig<'fastify'>, { preStartHook }: { preStartHook?: (app: FastifyInstance) => void } = {}, ) { const app: FastifyInstance = this.httpAdapterHost.httpAdapter.getInstance(); -@@ -124,6 +168,39 @@ export abstract class AbstractYogaDriver< - +@@ -124,6 +169,40 @@ export abstract class AbstractYogaDriver< + const yoga = createYoga>({ ...options, + schema: async request => { + const workspaceId = request.req.workspace.id + const workspaceCacheVersion = request.req.workspaceMetadataVersion + const url = request.req.baseUrl ++ const applicationId = request.req.application?.id ?? 'all' + -+ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}` ++ const cacheKey = `${workspaceId}-${url}-${workspaceCacheVersion}-${applicationId}` + + if (this.schemaCache.has(cacheKey)) { + return this.schemaCache.get(cacheKey) @@ -505,7 +509,7 @@ index ce142f6..d3e7bab 100644 graphqlEndpoint: options.path, // disable logging by default // however, if `true` use fastify logger -@@ -191,8 +268,8 @@ export class YogaDriver< +@@ -191,8 +270,8 @@ export class YogaDriver< const config: SubscriptionConfig = options.subscriptions === true ? { @@ -514,5 +518,5 @@ index ce142f6..d3e7bab 100644 + 'graphql-ws': true, + } : options.subscriptions; - + if (config['graphql-ws']) { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index b67f63dedf..6c7715a183 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -85,14 +85,14 @@ export class GraphQLConfigService resolverSchemaScope: 'core', buildSchemaOptions: {}, conditionalSchema: async (context) => { - const { workspace, user } = context.req; + const { workspace, user, application } = context.req; try { if (!isDefined(workspace)) { return new GraphQLSchema({}); } - return await this.createSchema(context, workspace); + return await this.createSchema(context, workspace, application?.id); } catch (error) { if (error instanceof UnauthorizedException) { throw new GraphQLError('Unauthenticated', { @@ -159,6 +159,7 @@ export class GraphQLConfigService async createSchema( context: YogaDriverServerContext<'express'> & YogaInitialContext, workspace: WorkspaceEntity, + applicationId?: string, ): Promise>> { // Create a new contextId for each request const contextId = ContextIdFactory.create(); @@ -177,6 +178,6 @@ export class GraphQLConfigService }, ); - return await workspaceFactory.createGraphQLSchema(workspace); + return await workspaceFactory.createGraphQLSchema(workspace, applicationId); } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index 5191edfa65..864d4267db 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -16,8 +16,14 @@ import { FlatEntityMapsExceptionCode, } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; @Injectable() export class WorkspaceSchemaFactory { @@ -32,6 +38,7 @@ export class WorkspaceSchemaFactory { async createGraphQLSchema( workspace: WorkspaceEntity, + applicationId?: string, ): Promise { const dataSourcesMetadata = await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId( @@ -42,32 +49,68 @@ export class WorkspaceSchemaFactory { return new GraphQLSchema({}); } - const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatIndexMaps } = - await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId: workspace.id, - flatMapsKeys: [ - 'flatObjectMetadataMaps', - 'flatFieldMetadataMaps', - 'flatIndexMaps', - ], - }, - ); + const { + flatObjectMetadataMaps: allFlatObjectMetadataMaps, + flatFieldMetadataMaps: allFlatFieldMetadataMaps, + flatIndexMaps: allFlatIndexMaps, + flatApplicationMaps, + } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId: workspace.id, + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatIndexMaps', + 'flatApplicationMaps', + ], + }, + ); - if (!isDefined(flatObjectMetadataMaps)) { + if (!isDefined(allFlatObjectMetadataMaps)) { throw new FlatEntityMapsException( 'Object metadata collection not found', FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, ); } - if (!isDefined(flatFieldMetadataMaps)) { + if (!isDefined(allFlatFieldMetadataMaps)) { throw new FlatEntityMapsException( 'Field metadata collection not found', FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, ); } + let flatObjectMetadataMaps = allFlatObjectMetadataMaps; + let flatFieldMetadataMaps = allFlatFieldMetadataMaps; + let flatIndexMaps = allFlatIndexMaps; + + if (isDefined(applicationId)) { + const twentyStandardApplicationId = + flatApplicationMaps?.idByUniversalIdentifier[ + TWENTY_STANDARD_APPLICATION.universalIdentifier + ]; + + const applicationIds = isDefined(twentyStandardApplicationId) + ? [twentyStandardApplicationId, applicationId] + : [applicationId]; + + flatObjectMetadataMaps = this.filterFlatEntityMapsByApplicationIds( + allFlatObjectMetadataMaps, + applicationIds, + ); + flatFieldMetadataMaps = this.filterFlatEntityMapsByApplicationIds( + allFlatFieldMetadataMaps, + applicationIds, + ); + + if (isDefined(allFlatIndexMaps)) { + flatIndexMaps = this.filterFlatEntityMapsByApplicationIds( + allFlatIndexMaps, + applicationIds, + ); + } + } + let metadataVersion = await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); @@ -88,11 +131,13 @@ export class WorkspaceSchemaFactory { let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs( workspace.id, metadataVersion, + applicationId, ); let usedScalarNames = await this.workspaceCacheStorageService.getGraphQLUsedScalarNames( workspace.id, metadataVersion, + applicationId, ); if (!typeDefs || !usedScalarNames) { @@ -111,11 +156,13 @@ export class WorkspaceSchemaFactory { workspace.id, metadataVersion, typeDefs, + applicationId, ); await this.workspaceCacheStorageService.setGraphQLUsedScalarNames( workspace.id, metadataVersion, usedScalarNames, + applicationId, ); } @@ -140,4 +187,16 @@ export class WorkspaceSchemaFactory { return executableSchema; } + + private filterFlatEntityMapsByApplicationIds< + T extends FlatObjectMetadata | FlatFieldMetadata | FlatIndexMetadata, + >( + flatEntityMaps: FlatEntityMaps, + applicationIds: string[], + ): FlatEntityMaps { + return getSubFlatEntityMapsByApplicationIdsOrThrow({ + applicationIds, + flatEntityMaps, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts index 1e26795748..8caa1c5747 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts @@ -7,6 +7,7 @@ import { MarketplaceResolver } from 'src/engine/core-modules/application/resolve import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service'; import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service'; import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module'; @@ -24,6 +25,7 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf TypeOrmModule.forFeature([FileEntity]), ApplicationModule, ApplicationVariableEntityModule, + TokenModule, WorkspaceMigrationModule, PermissionsModule, ObjectPermissionModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/generate-application-token.input.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/generate-application-token.input.ts new file mode 100644 index 0000000000..28deb3bf6f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/generate-application-token.input.ts @@ -0,0 +1,13 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ArgsType() +export class GenerateApplicationTokenInput { + @Field(() => UUIDScalarType) + @IsNotEmpty() + @IsUUID() + applicationId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts index 6053bbf638..02eafd2070 100644 --- a/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/resolvers/application.resolver.ts @@ -10,8 +10,11 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { PermissionFlagType } from 'twenty-shared/constants'; import { FileFolder } from 'twenty-shared/types'; +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; + import type { FileUpload } from 'graphql-upload/processRequest.mjs'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter'; import { @@ -20,18 +23,22 @@ import { } from 'src/engine/core-modules/application/application.exception'; import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input'; +import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input'; +import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input'; import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input'; import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput'; import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput'; import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @@ -39,7 +46,6 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; -import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input'; @UseGuards( WorkspaceAuthGuard, @@ -54,6 +60,8 @@ export class ApplicationResolver { private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, private readonly applicationSyncService: ApplicationSyncService, private readonly applicationService: ApplicationService, + private readonly applicationTokenService: ApplicationTokenService, + private readonly twentyConfigService: TwentyConfigService, private readonly fileStorageService: FileStorageService, private readonly workspaceCacheService: WorkspaceCacheService, ) {} @@ -96,6 +104,33 @@ export class ApplicationResolver { }); } + @Mutation(() => AuthToken) + @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) + async generateApplicationToken( + @Args() { applicationId }: GenerateApplicationTokenInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ): Promise { + const nodeEnv = this.twentyConfigService.get('NODE_ENV'); + + if ( + nodeEnv !== NodeEnvironment.DEVELOPMENT && + nodeEnv !== NodeEnvironment.TEST + ) { + throw new ApplicationException( + 'This endpoint is only available in development mode', + ApplicationExceptionCode.FORBIDDEN, + ); + } + + const APPLICATION_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60; + + return this.applicationTokenService.generateApplicationToken({ + workspaceId, + applicationId, + expiresInSeconds: APPLICATION_TOKEN_EXPIRY_SECONDS, + }); + } + @Mutation(() => ApplicationDTO) @RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED) async createOneApplication( diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/get-sub-application-from-to-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/get-sub-application-from-to-all-flat-entity-maps.util.ts index 5eeb479293..26548def2f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/utils/get-sub-application-from-to-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/utils/get-sub-application-from-to-all-flat-entity-maps.util.ts @@ -7,7 +7,7 @@ import { } from 'src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util'; import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; -import { getSubFlatEntityMapsByApplicationIdOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util'; +import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util'; import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; export type FromToApplicationManifestAllUniversalFlatEntityMaps = { @@ -34,10 +34,10 @@ export const getSubApplicationFromToAllFlatEntityMaps = ({ const toFlatEntityMaps = toAllUniversalFlatEntityMaps[flatEntityMapsKey]; const fromTo = { - from: getSubFlatEntityMapsByApplicationIdOrThrow< + from: getSubFlatEntityMapsByApplicationIdsOrThrow< MetadataFlatEntity >({ - applicationId, + applicationIds: [applicationId], flatEntityMaps: fromFlatEntityMaps, }), to: toFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util.ts similarity index 61% rename from packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util.ts rename to packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util.ts index ec4709ce1d..069340c79e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util.ts @@ -3,22 +3,28 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ import { findFlatEntitiesByApplicationId } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entities-by-application-id.util'; import { getSubFlatEntityByIdsMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-by-ids-maps-or-throw.util'; -export const getSubFlatEntityMapsByApplicationIdOrThrow = < +export const getSubFlatEntityMapsByApplicationIdsOrThrow = < T extends SyncableFlatEntity, >({ - applicationId, + applicationIds, flatEntityMaps, }: { - applicationId: string; + applicationIds: string[]; flatEntityMaps: FlatEntityMaps; }) => { - const allApplicationFlatEntity = findFlatEntitiesByApplicationId({ - applicationId, - flatEntityMaps, + const allFlatEntityIds = applicationIds.flatMap((applicationId) => { + const entities = findFlatEntitiesByApplicationId({ + applicationId, + flatEntityMaps, + }); + + return entities.map((entity) => entity.id); }); + const uniqueFlatEntityIds = [...new Set(allFlatEntityIds)]; + return getSubFlatEntityByIdsMapsOrThrow({ - flatEntityIds: allApplicationFlatEntity.map((flatEntity) => flatEntity.id), + flatEntityIds: uniqueFlatEntityIds, flatEntityMaps, }); }; diff --git a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts index c9e5343376..fb858071a9 100644 --- a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts +++ b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts @@ -91,9 +91,12 @@ export class WorkspaceCacheStorageService { workspaceId: string, metadataVersion: number, typeDefs: string, + applicationId?: string, ): Promise { + const applicationSuffix = applicationId ? `:${applicationId}` : ''; + return this.cacheStorageService.set( - `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}`, + `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}${applicationSuffix}`, typeDefs, TTL_ONE_WEEK, ); @@ -102,9 +105,12 @@ export class WorkspaceCacheStorageService { getGraphQLTypeDefs( workspaceId: string, metadataVersion: number, + applicationId?: string, ): Promise { + const applicationSuffix = applicationId ? `:${applicationId}` : ''; + return this.cacheStorageService.get( - `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}`, + `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}${applicationSuffix}`, ); } @@ -112,9 +118,12 @@ export class WorkspaceCacheStorageService { workspaceId: string, metadataVersion: number, usedScalarNames: string[], + applicationId?: string, ): Promise { + const applicationSuffix = applicationId ? `:${applicationId}` : ''; + return this.cacheStorageService.set( - `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}`, + `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}${applicationSuffix}`, usedScalarNames, TTL_ONE_WEEK, ); @@ -123,9 +132,12 @@ export class WorkspaceCacheStorageService { getGraphQLUsedScalarNames( workspaceId: string, metadataVersion: number, + applicationId?: string, ): Promise { + const applicationSuffix = applicationId ? `:${applicationId}` : ''; + return this.cacheStorageService.get( - `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}`, + `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}${applicationSuffix}`, ); } diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts index 98a61c123c..82aa9987cd 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts @@ -8,7 +8,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; -import { getSubFlatEntityMapsByApplicationIdOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util'; +import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.util'; import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -94,10 +94,10 @@ export class TwentyStandardApplicationService { const fromFlatEntityMaps = fromTwentyStandardAllFlatEntityMaps[flatEntityMapsKey]; const fromTo = { - from: getSubFlatEntityMapsByApplicationIdOrThrow< + from: getSubFlatEntityMapsByApplicationIdsOrThrow< MetadataFlatEntity >({ - applicationId: twentyStandardFlatApplication.id, + applicationIds: [twentyStandardFlatApplication.id], flatEntityMaps: fromFlatEntityMaps, }), to: toTwentyStandardAllFlatEntityMaps[flatEntityMapsKey], diff --git a/packages/twenty-server/test/integration/metadata/suites/application/application-token-schema-filtering.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/application-token-schema-filtering.integration-spec.ts new file mode 100644 index 0000000000..820a410d18 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/application-token-schema-filtering.integration-spec.ts @@ -0,0 +1,95 @@ +import request from 'supertest'; +import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util'; +import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util'; + +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; + +const INTROSPECTION_QUERY = ` + query IntrospectionQuery { + __schema { + types { + kind + name + } + } + } +`; + +// Custom objects seeded in the dev workspace that should NOT appear +// in a schema scoped to the Twenty Standard Application +const CUSTOM_OBJECT_TYPE_NAMES = [ + 'Rocket', + 'Pet', + 'PetCareAgreement', + 'SurveyResult', + 'EmploymentHistory', +]; + +const makeGraphqlIntrospectionRequest = async ( + token: string, +): Promise => { + const client = request(`http://localhost:${APP_PORT}`); + + const response = await client + .post('/graphql') + .set('Authorization', `Bearer ${token}`) + .send({ query: INTROSPECTION_QUERY }); + + return response; +}; + +describe('Application token schema filtering', () => { + let standardAppToken: string; + + beforeAll(async () => { + const { data: applicationsData } = await findManyApplications({ + expectToFail: false, + }); + + const standardApp = applicationsData.findManyApplications.find( + (application) => + application.universalIdentifier === + TWENTY_STANDARD_APPLICATION.universalIdentifier, + ); + + expect(standardApp).toBeDefined(); + + const { data: tokenData } = await generateApplicationToken({ + applicationId: standardApp!.id, + expectToFail: false, + }); + + standardAppToken = tokenData.generateApplicationToken.token; + }); + + it('should not include custom objects in the schema when using a standard app token', async () => { + const response = await makeGraphqlIntrospectionRequest(standardAppToken); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.__schema).toBeDefined(); + + const typeNames: string[] = response.body.data.__schema.types.map( + (type: { name: string }) => type.name, + ); + + for (const customTypeName of CUSTOM_OBJECT_TYPE_NAMES) { + expect(typeNames).not.toContain(customTypeName); + expect(typeNames).not.toContain(`${customTypeName}Edge`); + expect(typeNames).not.toContain(`${customTypeName}Connection`); + } + }); + + it('should include standard objects in the schema when using a standard app token', async () => { + const response = await makeGraphqlIntrospectionRequest(standardAppToken); + + expect(response.body.errors).toBeUndefined(); + + const typeNames: string[] = response.body.data.__schema.types.map( + (type: { name: string }) => type.name, + ); + + expect(typeNames).toContain('Person'); + expect(typeNames).toContain('Company'); + expect(typeNames).toContain('Opportunity'); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/generate-application-token.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/generate-application-token.integration-spec.ts new file mode 100644 index 0000000000..4c5d446771 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/generate-application-token.integration-spec.ts @@ -0,0 +1,55 @@ +import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util'; +import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util'; + +describe('generateApplicationToken', () => { + let applicationId: string; + + beforeAll(async () => { + const { data } = await findManyApplications({ + expectToFail: false, + }); + + const application = data.findManyApplications[0]; + + expect(application).toBeDefined(); + + applicationId = application.id; + }); + + it('should generate an application token with admin access token', async () => { + const { data } = await generateApplicationToken({ + applicationId, + expectToFail: false, + }); + + expect(data.generateApplicationToken).toBeDefined(); + expect(data.generateApplicationToken.token).toBeDefined(); + expect(typeof data.generateApplicationToken.token).toBe('string'); + expect(data.generateApplicationToken.token.length).toBeGreaterThan(0); + expect(data.generateApplicationToken.expiresAt).toBeDefined(); + }); + + it('should generate an application token with API key access token', async () => { + const { data } = await generateApplicationToken({ + applicationId, + expectToFail: false, + token: API_KEY_ACCESS_TOKEN, + }); + + expect(data.generateApplicationToken).toBeDefined(); + expect(data.generateApplicationToken.token).toBeDefined(); + expect(typeof data.generateApplicationToken.token).toBe('string'); + expect(data.generateApplicationToken.token.length).toBeGreaterThan(0); + expect(data.generateApplicationToken.expiresAt).toBeDefined(); + }); + + it('should fail with a non-existent application id', async () => { + const { errors } = await generateApplicationToken({ + applicationId: '00000000-0000-0000-0000-000000000000', + expectToFail: true, + }); + + expect(errors).toBeDefined(); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/utils/generate-application-token-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/application/utils/generate-application-token-query-factory.util.ts new file mode 100644 index 0000000000..fa7569c183 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/utils/generate-application-token-query-factory.util.ts @@ -0,0 +1,19 @@ +import gql from 'graphql-tag'; + +export const generateApplicationTokenQueryFactory = ({ + applicationId, +}: { + applicationId: string; +}) => ({ + query: gql` + mutation GenerateApplicationToken($applicationId: UUID!) { + generateApplicationToken(applicationId: $applicationId) { + token + expiresAt + } + } + `, + variables: { + applicationId, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/utils/generate-application-token.util.ts b/packages/twenty-server/test/integration/metadata/suites/application/utils/generate-application-token.util.ts new file mode 100644 index 0000000000..5f7910fee6 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/utils/generate-application-token.util.ts @@ -0,0 +1,41 @@ +import { generateApplicationTokenQueryFactory } from 'test/integration/metadata/suites/application/utils/generate-application-token-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; + +export const generateApplicationToken = async ({ + applicationId, + expectToFail = false, + token, +}: { + applicationId: string; + expectToFail?: boolean; + token?: string; +}): CommonResponseBody<{ + generateApplicationToken: AuthToken; +}> => { + const graphqlOperation = generateApplicationTokenQueryFactory({ + applicationId, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Generate application token should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Generate application token has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/yarn.lock b/yarn.lock index 13d1b251d4..73046067e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7016,14 +7016,14 @@ __metadata: "@graphql-yoga/nestjs@patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch::locator=twenty-server%40workspace%3Apackages%2Ftwenty-server": version: 2.1.0 - resolution: "@graphql-yoga/nestjs@patch:@graphql-yoga/nestjs@npm%3A2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch::version=2.1.0&hash=d6801e&locator=twenty-server%40workspace%3Apackages%2Ftwenty-server" + resolution: "@graphql-yoga/nestjs@patch:@graphql-yoga/nestjs@npm%3A2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch::version=2.1.0&hash=aafdfe&locator=twenty-server%40workspace%3Apackages%2Ftwenty-server" peerDependencies: "@nestjs/common": ^10.0.0 "@nestjs/core": ^10.0.0 "@nestjs/graphql": ^12.0.0 graphql: ^15.0.0 || ^16.0.0 graphql-yoga: ^4.0.4 - checksum: 10c0/929da1f7265003cb5f43291d6db96e752bd2ac5d81e21fb3af9b245e54e732f5862789d7e5941e893d85255919b4c6a5a37adb16ef6594fab606e8eec742891c + checksum: 10c0/7ebb5bd5bf630cbad7ef99ac4a04017c1294a6a40623b0142cf4990d790fb4252efa71abe3a7923f66845320fb9cba536426743eb5d93a1e9353700b730efb28 languageName: node linkType: hard