feat: schema version header check (#4563)

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 <jeremy.magrin@gmail.com>
This commit is contained in:
rostaklein
2024-04-04 09:52:45 +02:00
committed by GitHub
parent eab65f34f9
commit 306ef1df9c
20 changed files with 182 additions and 87 deletions

View File

@@ -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<Scalars['Float']['output']>;
@@ -110,6 +132,10 @@ export type ClientConfig = {
telemetry: Telemetry;
};
export type CreateAppTokenInput = {
expiresAt: Scalars['DateTime']['input'];
};
export type CreateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']['input']>;
description?: InputMaybe<Scalars['String']['input']>;
@@ -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<Scalars['String']['input']>;
fromDescription?: InputMaybe<Scalars['String']['input']>;
@@ -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<RemoteTable>;
@@ -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<Array<BillingSubscription>>;
createdAt: Scalars['DateTime']['output'];
currentBillingSubscription?: Maybe<BillingSubscription>;
currentCacheVersion?: Maybe<Scalars['String']['output']>;
deletedAt?: Maybe<Scalars['DateTime']['output']>;
displayName?: Maybe<Scalars['String']['output']>;
domainName?: Maybe<Scalars['String']['output']>;

View File

@@ -831,6 +831,7 @@ export type Workspace = {
billingSubscriptions?: Maybe<Array<BillingSubscription>>;
createdAt: Scalars['DateTime'];
currentBillingSubscription?: Maybe<BillingSubscription>;
currentCacheVersion?: Maybe<Scalars['String']>;
deletedAt?: Maybe<Scalars['DateTime']>;
displayName?: Maybe<Scalars['String']>;
domainName?: Maybe<Scalars['String']>;
@@ -2393,6 +2394,7 @@ export const GetCurrentUserDocument = gql`
value
workspaceId
}
currentCacheVersion
currentBillingSubscription {
status
interval

View File

@@ -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<Options<any>> = {}) => {
// eslint-disable-next-line @nx/workspace-no-state-useref
const apolloRef = useRef<ApolloFactory<NormalizedCacheObject> | null>(null);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const [isDebugMode] = useRecoilState(isDebugModeState);
const navigate = useNavigate();
@@ -29,6 +31,11 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
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<Options<any>> = {}) => {
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)) {

View File

@@ -60,6 +60,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
return {
headers: {
...headers,
...options.headers,
authorization: this.tokenPair?.accessToken.token
? `Bearer ${this.tokenPair?.accessToken.token}`
: '',

View File

@@ -13,6 +13,7 @@ export type CurrentWorkspace = Pick<
| 'subscriptionStatus'
| 'activationStatus'
| 'currentBillingSubscription'
| 'currentCacheVersion'
>;
export const currentWorkspaceState = createState<CurrentWorkspace | null>({

View File

@@ -35,6 +35,7 @@ export const GET_CURRENT_USER = gql`
value
workspaceId
}
currentCacheVersion
currentBillingSubscription {
status
interval

View File

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

View File

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

View File

@@ -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<GraphQLContext> {
// 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;
}
}

View File

@@ -1,3 +0,0 @@
import { CreateContextFactory } from './create-context.factory';
export const graphQLFactories = [CreateContextFactory];

View File

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

View File

@@ -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<YogaDriverConfig<'express'>>
{
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) => {

View File

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

View File

@@ -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<YogaDriverConfig> => {
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({

View File

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

View File

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

View File

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

View File

@@ -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<string | null> {
return this.workspaceCacheVersionService.getVersion(workspace.id);
}
@ResolveField(() => BillingSubscription)
async currentBillingSubscription(
@Parent() workspace: Workspace,

View File

@@ -49,7 +49,7 @@ export const useExceptionHandler = <PluginContext extends GraphQLContext>(
(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 = <PluginContext extends GraphQLContext>(
},
};
},
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.`,
);
}
}
},
};
};

View File

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