From 306ef1df9c78d22fa0daf2410970f437e34a282f Mon Sep 17 00:00:00 2001 From: rostaklein Date: Thu, 4 Apr 2024 09:52:45 +0200 Subject: [PATCH] feat: schema version header check (#4563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://github.com/twentyhq/twenty/issues/4479 tried to catch the error inside various places including https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/integrations/exception-handler/exception-handler.service.ts but it seems like the error never reaches the GraphQL module 😮 any idea where we could intercept such an error `Cannot query field`? --------- Co-authored-by: Jérémy Magrin --- .../src/generated-metadata/graphql.ts | 92 ++++++++++++------- .../twenty-front/src/generated/graphql.tsx | 2 + .../modules/apollo/hooks/useApolloFactory.ts | 11 ++- .../modules/apollo/services/apollo.factory.ts | 1 + .../auth/states/currentWorkspaceState.ts | 1 + .../users/graphql/queries/getCurrentUser.ts | 1 + packages/twenty-server/@types/express.d.ts | 10 ++ packages/twenty-server/src/app.module.ts | 21 ++++- .../factories/create-context.factory.ts | 26 ------ .../graphql/graphql-config/factories/index.ts | 3 - .../graphql-config/graphql-config.module.ts | 5 +- .../graphql-config/graphql-config.service.ts | 6 +- .../graphql/metadata-graphql-api.module.ts | 7 +- .../api/graphql/metadata.module-factory.ts | 7 +- .../strategies/google-apis.auth.strategy.ts | 5 +- .../auth/strategies/google.auth.strategy.ts | 5 +- .../workspace/workspace.module.ts | 2 + .../workspace/workspace.resolver.ts | 9 ++ .../hooks/use-exception-handler.hook.ts | 21 ++++- .../middlewares/user-workspace.middleware.ts | 34 +++++++ 20 files changed, 182 insertions(+), 87 deletions(-) create mode 100644 packages/twenty-server/@types/express.d.ts delete mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-config/factories/create-context.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-config/factories/index.ts create mode 100644 packages/twenty-server/src/engine/middlewares/user-workspace.middleware.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a1efde2c8..dd956ae60 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -39,6 +39,23 @@ export type ApiKeyToken = { token: Scalars['String']['output']; }; +export type AppToken = { + __typename?: 'AppToken'; + createdAt: Scalars['DateTime']['output']; + expiresAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + type: Scalars['String']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + +export type AppTokenEdge = { + __typename?: 'AppTokenEdge'; + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']['output']; + /** The node containing the AppToken */ + node: AppToken; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']['output']; @@ -63,6 +80,11 @@ export type AuthTokens = { tokens: AuthTokenPair; }; +export type AuthorizeApp = { + __typename?: 'AuthorizeApp'; + redirectUrl: Scalars['String']['output']; +}; + export type Billing = { __typename?: 'Billing'; billingFreeTrialDurationInDays?: Maybe; @@ -110,6 +132,10 @@ export type ClientConfig = { telemetry: Telemetry; }; +export type CreateAppTokenInput = { + expiresAt: Scalars['DateTime']['input']; +}; + export type CreateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; @@ -138,6 +164,11 @@ export type CreateObjectInput = { nameSingular: Scalars['String']['input']; }; +export type CreateOneAppTokenInput = { + /** The record to create */ + appToken: CreateAppTokenInput; +}; + export type CreateOneFieldMetadataInput = { /** The record to create */ field: CreateFieldInput; @@ -148,20 +179,11 @@ export type CreateOneObjectInput = { object: CreateObjectInput; }; -export type CreateOneRefreshTokenInput = { - /** The record to create */ - refreshToken: CreateRefreshTokenInput; -}; - export type CreateOneRelationInput = { /** The record to create */ relation: CreateRelationInput; }; -export type CreateRefreshTokenInput = { - expiresAt: Scalars['DateTime']['input']; -}; - export type CreateRelationInput = { description?: InputMaybe; fromDescription?: InputMaybe; @@ -215,6 +237,13 @@ export type EmailPasswordResetLink = { success: Scalars['Boolean']['output']; }; +export type ExchangeAuthCode = { + __typename?: 'ExchangeAuthCode'; + accessToken: AuthToken; + loginToken: AuthToken; + refreshToken: AuthToken; +}; + export type FeatureFlag = { __typename?: 'FeatureFlag'; id: Scalars['ID']['output']; @@ -338,11 +367,12 @@ export type LoginToken = { export type Mutation = { __typename?: 'Mutation'; activateWorkspace: Workspace; + authorizeApp: AuthorizeApp; challenge: LoginToken; checkoutSession: SessionEntity; + createOneAppToken: AppToken; createOneField: Field; createOneObject: Object; - createOneRefreshToken: RefreshToken; createOneRelation: Relation; createOneRemoteServer: RemoteServer; deleteCurrentWorkspace: Workspace; @@ -378,6 +408,12 @@ export type MutationActivateWorkspaceArgs = { }; +export type MutationAuthorizeAppArgs = { + clientId: Scalars['String']['input']; + codeChallenge: Scalars['String']['input']; +}; + + export type MutationChallengeArgs = { email: Scalars['String']['input']; password: Scalars['String']['input']; @@ -390,6 +426,11 @@ export type MutationCheckoutSessionArgs = { }; +export type MutationCreateOneAppTokenArgs = { + input: CreateOneAppTokenInput; +}; + + export type MutationCreateOneFieldArgs = { input: CreateOneFieldMetadataInput; }; @@ -400,11 +441,6 @@ export type MutationCreateOneObjectArgs = { }; -export type MutationCreateOneRefreshTokenArgs = { - input: CreateOneRefreshTokenInput; -}; - - export type MutationCreateOneRelationArgs = { input: CreateOneRelationInput; }; @@ -457,7 +493,7 @@ export type MutationImpersonateArgs = { export type MutationRenewTokenArgs = { - refreshToken: Scalars['String']['input']; + appToken: Scalars['String']['input']; }; @@ -576,6 +612,7 @@ export type Query = { clientConfig: ClientConfig; currentUser: User; currentWorkspace: Workspace; + exchangeAuthorizationCode: ExchangeAuthCode; field: Field; fields: FieldConnection; findAvailableRemoteTablesByServerId: Array; @@ -610,6 +647,12 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = { }; +export type QueryExchangeAuthorizationCodeArgs = { + authorizationCode: Scalars['String']['input']; + codeVerifier: Scalars['String']['input']; +}; + + export type QueryFieldArgs = { id: Scalars['ID']['input']; }; @@ -699,22 +742,6 @@ export type QueryValidatePasswordResetTokenArgs = { passwordResetToken: Scalars['String']['input']; }; -export type RefreshToken = { - __typename?: 'RefreshToken'; - createdAt: Scalars['DateTime']['output']; - expiresAt: Scalars['DateTime']['output']; - id: Scalars['ID']['output']; - updatedAt: Scalars['DateTime']['output']; -}; - -export type RefreshTokenEdge = { - __typename?: 'RefreshTokenEdge'; - /** Cursor for this node. */ - cursor: Scalars['ConnectionCursor']['output']; - /** The node containing the RefreshToken */ - node: RefreshToken; -}; - export type RelationConnection = { __typename?: 'RelationConnection'; /** Array of edges. */ @@ -1027,6 +1054,7 @@ export type Workspace = { billingSubscriptions?: Maybe>; createdAt: Scalars['DateTime']['output']; currentBillingSubscription?: Maybe; + currentCacheVersion?: Maybe; deletedAt?: Maybe; displayName?: Maybe; domainName?: Maybe; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 0ef6cf46d..b1f91e56f 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -831,6 +831,7 @@ export type Workspace = { billingSubscriptions?: Maybe>; createdAt: Scalars['DateTime']; currentBillingSubscription?: Maybe; + currentCacheVersion?: Maybe; deletedAt?: Maybe; displayName?: Maybe; domainName?: Maybe; @@ -2393,6 +2394,7 @@ export const GetCurrentUserDocument = gql` value workspaceId } + currentCacheVersion currentBillingSubscription { status interval diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index c7695f52a..00979d6ae 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -1,8 +1,9 @@ import { useMemo, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { InMemoryCache, NormalizedCacheObject } from '@apollo/client'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { previousUrlState } from '@/auth/states/previousUrlState'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState'; @@ -17,6 +18,7 @@ import { ApolloFactory, Options } from '../services/apollo.factory'; export const useApolloFactory = (options: Partial> = {}) => { // eslint-disable-next-line @nx/workspace-no-state-useref const apolloRef = useRef | null>(null); + const currentWorkspace = useRecoilValue(currentWorkspaceState); const [isDebugMode] = useRecoilState(isDebugModeState); const navigate = useNavigate(); @@ -29,6 +31,11 @@ export const useApolloFactory = (options: Partial> = {}) => { apolloRef.current = new ApolloFactory({ uri: `${REACT_APP_SERVER_BASE_URL}/graphql`, cache: new InMemoryCache(), + headers: { + ...(currentWorkspace?.currentCacheVersion && { + 'X-Schema-Version': currentWorkspace.currentCacheVersion, + }), + }, defaultOptions: { query: { fetchPolicy: 'cache-first', @@ -60,7 +67,7 @@ export const useApolloFactory = (options: Partial> = {}) => { return apolloRef.current.getClient(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setTokenPair, isDebugMode, setPreviousUrl]); + }, [setTokenPair, isDebugMode, currentWorkspace?.currentCacheVersion, setPreviousUrl]); useUpdateEffect(() => { if (isDefined(apolloRef.current)) { diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index 53726ae22..bb7f3851e 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -60,6 +60,7 @@ export class ApolloFactory implements ApolloManager { return { headers: { ...headers, + ...options.headers, authorization: this.tokenPair?.accessToken.token ? `Bearer ${this.tokenPair?.accessToken.token}` : '', diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index a1a8c0064..c9187c3ea 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -13,6 +13,7 @@ export type CurrentWorkspace = Pick< | 'subscriptionStatus' | 'activationStatus' | 'currentBillingSubscription' + | 'currentCacheVersion' >; export const currentWorkspaceState = createState({ diff --git a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts index 03a6a06a4..1d61ab1a6 100644 --- a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts @@ -35,6 +35,7 @@ export const GET_CURRENT_USER = gql` value workspaceId } + currentCacheVersion currentBillingSubscription { status interval diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts new file mode 100644 index 000000000..9f5ffd96d --- /dev/null +++ b/packages/twenty-server/@types/express.d.ts @@ -0,0 +1,10 @@ +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +declare module 'express-serve-static-core' { + interface Request { + user?: User; + workspace?: Workspace; + cacheVersion?: string | null; + } +} diff --git a/packages/twenty-server/src/app.module.ts b/packages/twenty-server/src/app.module.ts index fb14f7dfb..3c63eebce 100644 --- a/packages/twenty-server/src/app.module.ts +++ b/packages/twenty-server/src/app.module.ts @@ -1,4 +1,9 @@ -import { DynamicModule, Module } from '@nestjs/common'; +import { + DynamicModule, + MiddlewareConsumer, + Module, + RequestMethod, +} from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ServeStaticModule } from '@nestjs/serve-static'; import { GraphQLModule } from '@nestjs/graphql'; @@ -14,6 +19,8 @@ import { CoreGraphQLApiModule } from 'src/engine/api/graphql/core-graphql-api.mo import { MetadataGraphQLApiModule } from 'src/engine/api/graphql/metadata-graphql-api.module'; import { GraphQLConfigModule } from 'src/engine/api/graphql/graphql-config/graphql-config.module'; import { GraphQLConfigService } from 'src/engine/api/graphql/graphql-config/graphql-config.service'; +import { UserWorkspaceMiddleware } from 'src/engine/middlewares/user-workspace.middleware'; +import { WorkspaceCacheVersionModule } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.module'; import { CoreEngineModule } from './engine/core-modules/core-engine.module'; import { IntegrationsModule } from './engine/integrations/integrations.module'; @@ -42,6 +49,8 @@ import { IntegrationsModule } from './engine/integrations/integrations.module'; CoreEngineModule, // Modules module, contains all business logic modules ModulesModule, + // Needed for the user workspace middleware + WorkspaceCacheVersionModule, // Api modules CoreGraphQLApiModule, MetadataGraphQLApiModule, @@ -65,4 +74,14 @@ export class AppModule { return modules; } + + configure(consumer: MiddlewareConsumer) { + consumer + .apply(UserWorkspaceMiddleware) + .forRoutes({ path: 'graphql', method: RequestMethod.ALL }); + + consumer + .apply(UserWorkspaceMiddleware) + .forRoutes({ path: 'metadata', method: RequestMethod.ALL }); + } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/factories/create-context.factory.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/factories/create-context.factory.ts deleted file mode 100644 index 0071474e0..000000000 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/factories/create-context.factory.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { YogaDriverServerContext } from '@graphql-yoga/nestjs'; - -import { GraphQLContext } from 'src/engine/api/graphql/graphql-config/interfaces/graphql-context.interface'; - -import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; - -@Injectable() -export class CreateContextFactory { - constructor(private readonly tokenService: TokenService) {} - - async create( - context: YogaDriverServerContext<'express'>, - ): Promise { - // Check if token is present in the request - if (this.tokenService.isTokenPresent(context.req)) { - const data = await this.tokenService.validateToken(context.req); - - // Inject user and workspace into the context - return { ...context, ...data }; - } - - return context; - } -} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/factories/index.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/factories/index.ts deleted file mode 100644 index c81ec577e..000000000 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/factories/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { CreateContextFactory } from './create-context.factory'; - -export const graphQLFactories = [CreateContextFactory]; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts index 01bf03a9a..dbfb77932 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts @@ -1,11 +1,10 @@ import { Module } from '@nestjs/common'; import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module'; -import { graphQLFactories } from 'src/engine/api/graphql/graphql-config/factories'; @Module({ imports: [CoreEngineModule], - providers: [...graphQLFactories], - exports: [...graphQLFactories], + providers: [], + exports: [], }) export class GraphQLConfigModule {} 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 8c1044376..b71ff69d4 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 @@ -26,8 +26,6 @@ import { useThrottler } from 'src/engine/api/graphql/graphql-config/hooks/use-th import { JwtData } from 'src/engine/core-modules/auth/types/jwt-data.type'; import { useSentryTracing } from 'src/engine/integrations/exception-handler/hooks/use-sentry-tracing'; -import { CreateContextFactory } from './factories/create-context.factory'; - export interface GraphQLContext extends YogaDriverServerContext<'express'> { user?: User; workspace?: Workspace; @@ -38,7 +36,6 @@ export class GraphQLConfigService implements GqlOptionsFactory> { constructor( - private readonly createContextFactory: CreateContextFactory, private readonly tokenService: TokenService, private readonly exceptionHandlerService: ExceptionHandlerService, private readonly environmentService: EnvironmentService, @@ -52,7 +49,7 @@ export class GraphQLConfigService ttl: this.environmentService.get('API_RATE_LIMITING_TTL'), limit: this.environmentService.get('API_RATE_LIMITING_LIMIT'), identifyFn: (context) => { - return context.user?.id ?? context.req.ip ?? 'anonymous'; + return context.req.user?.id ?? context.req.ip ?? 'anonymous'; }, }), useExceptionHandler({ @@ -65,7 +62,6 @@ export class GraphQLConfigService } const config: YogaDriverConfig = { - context: (context) => this.createContextFactory.create(context), autoSchemaFile: true, include: [CoreEngineModule], conditionalSchema: async (context) => { 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 0ef80a645..9bcf02bef 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 @@ -6,7 +6,6 @@ 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'; @@ -18,11 +17,7 @@ import { ExceptionHandlerService } from 'src/engine/integrations/exception-handl driver: YogaDriver, useFactory: metadataModuleFactory, imports: [GraphQLConfigModule], - inject: [ - EnvironmentService, - ExceptionHandlerService, - CreateContextFactory, - ], + inject: [EnvironmentService, ExceptionHandlerService], }), MetadataEngineModule, WorkspaceMigrationRunnerModule, diff --git a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts index 7252a43f6..97c880477 100644 --- a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts @@ -1,7 +1,6 @@ import { YogaDriverConfig } from '@graphql-yoga/nestjs'; import GraphQLJSON from 'graphql-type-json'; -import { CreateContextFactory } from 'src/engine/api/graphql/graphql-config/factories/create-context.factory'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { ExceptionHandlerService } from 'src/engine/integrations/exception-handler/exception-handler.service'; import { useExceptionHandler } from 'src/engine/integrations/exception-handler/hooks/use-exception-handler.hook'; @@ -12,12 +11,8 @@ import { renderApolloPlayground } from 'src/engine/utils/render-apollo-playgroun export const metadataModuleFactory = async ( environmentService: EnvironmentService, exceptionHandlerService: ExceptionHandlerService, - createContextFactory: CreateContextFactory, ): Promise => { const config: YogaDriverConfig = { - context(context) { - return createContextFactory.create(context); - }, autoSchemaFile: true, include: [MetadataGraphQLApiModule], renderGraphiQL() { @@ -29,7 +24,7 @@ export const metadataModuleFactory = async ( ttl: environmentService.get('API_RATE_LIMITING_TTL'), limit: environmentService.get('API_RATE_LIMITING_LIMIT'), identifyFn: (context) => { - return context.user?.id ?? context.req.ip ?? 'anonymous'; + return context.req.user?.id ?? context.req.ip ?? 'anonymous'; }, }), useExceptionHandler({ diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis.auth.strategy.ts index 2a389774e..ce1dada7d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google-apis.auth.strategy.ts @@ -6,7 +6,10 @@ import { Request } from 'express'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; -export type GoogleAPIsRequest = Request & { +export type GoogleAPIsRequest = Omit< + Request, + 'user' | 'workspace' | 'cacheVersion' +> & { user: { firstName?: string | null; lastName?: string | null; diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts index 1b2ea68d2..ec5669808 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts @@ -6,7 +6,10 @@ import { Request } from 'express'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; -export type GoogleRequest = Request & { +export type GoogleRequest = Omit< + Request, + 'user' | 'workspace' | 'cacheVersion' +> & { user: { firstName?: string | null; lastName?: string | null; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index ea8050b34..63b821476 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -14,6 +14,7 @@ import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file- import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { UserModule } from 'src/engine/core-modules/user/user.module'; import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener'; +import { WorkspaceCacheVersionModule } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.module'; import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { Workspace } from './workspace.entity'; @@ -27,6 +28,7 @@ import { WorkspaceService } from './services/workspace.service'; imports: [ BillingModule, FileUploadModule, + WorkspaceCacheVersionModule, NestjsQueryTypeOrmModule.forFeature( [Workspace, UserWorkspace, FeatureFlagEntity], 'core', diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 5cc961469..3243a07e5 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -24,6 +24,7 @@ import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/a import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingService } from 'src/engine/core-modules/billing/billing.service'; import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard'; +import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.service'; import { Workspace } from './workspace.entity'; @@ -34,6 +35,7 @@ import { WorkspaceService } from './services/workspace.service'; export class WorkspaceResolver { constructor( private readonly workspaceService: WorkspaceService, + private readonly workspaceCacheVersionService: WorkspaceCacheVersionService, private readonly fileUploadService: FileUploadService, private readonly billingService: BillingService, ) {} @@ -105,6 +107,13 @@ export class WorkspaceResolver { return 'inactive'; } + @ResolveField(() => String) + async currentCacheVersion( + @Parent() workspace: Workspace, + ): Promise { + return this.workspaceCacheVersionService.getVersion(workspace.id); + } + @ResolveField(() => BillingSubscription) async currentBillingSubscription( @Parent() workspace: Workspace, diff --git a/packages/twenty-server/src/engine/integrations/exception-handler/hooks/use-exception-handler.hook.ts b/packages/twenty-server/src/engine/integrations/exception-handler/hooks/use-exception-handler.hook.ts index 7d8892d13..c358bc391 100644 --- a/packages/twenty-server/src/engine/integrations/exception-handler/hooks/use-exception-handler.hook.ts +++ b/packages/twenty-server/src/engine/integrations/exception-handler/hooks/use-exception-handler.hook.ts @@ -49,7 +49,7 @@ export const useExceptionHandler = ( (o) => o.kind === Kind.OPERATION_DEFINITION, ) as OperationDefinitionNode; const operationType = rootOperation.operation; - const user = args.contextValue.user; + const user = args.contextValue.req.user; const document = getDocumentString(args.document, print); const opName = args.operationName || @@ -125,5 +125,24 @@ export const useExceptionHandler = ( }, }; }, + onValidate: ({ context, validateFn, params: { documentAST, schema } }) => { + const errors = validateFn(schema, documentAST); + + if (Array.isArray(errors) && errors.length > 0) { + const headers = context.req.headers; + const currentSchemaVersion = context.req.cacheVersion; + + const requestSchemaVersion = headers['x-schema-version']; + + if ( + requestSchemaVersion && + requestSchemaVersion !== currentSchemaVersion + ) { + throw new GraphQLError( + `Schema version mismatch, please refresh the page.`, + ); + } + } + }, }; }; diff --git a/packages/twenty-server/src/engine/middlewares/user-workspace.middleware.ts b/packages/twenty-server/src/engine/middlewares/user-workspace.middleware.ts new file mode 100644 index 000000000..a1ebe7a92 --- /dev/null +++ b/packages/twenty-server/src/engine/middlewares/user-workspace.middleware.ts @@ -0,0 +1,34 @@ +import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; + +import { Request, Response, NextFunction } from 'express'; + +import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; +import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.service'; + +@Injectable() +export class UserWorkspaceMiddleware implements NestMiddleware { + private readonly logger = new Logger(UserWorkspaceMiddleware.name); + + constructor( + private readonly tokenService: TokenService, + private readonly workspaceCacheVersionService: WorkspaceCacheVersionService, + ) {} + + async use(req: Request, res: Response, next: NextFunction) { + if (this.tokenService.isTokenPresent(req)) { + try { + const data = await this.tokenService.validateToken(req); + const cacheVersion = await this.workspaceCacheVersionService.getVersion( + data.workspace.id, + ); + + req.user = data.user; + req.workspace = data.workspace; + req.cacheVersion = cacheVersion; + } catch (error) { + this.logger.error('Error while validating token in middleware.', error); + } + } + next(); + } +}