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:
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
import { CreateContextFactory } from './create-context.factory';
|
||||
|
||||
export const graphQLFactories = [CreateContextFactory];
|
||||
@ -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 {}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user