From 87ff1b5ec5af947cbe52861571a1d00cc0b84554 Mon Sep 17 00:00:00 2001 From: a-alle Date: Mon, 4 Mar 2024 11:28:16 +0000 Subject: [PATCH 1/7] support authentication on root custom resolver fields --- packages/graphql/src/classes/Neo4jGraphQL.ts | 17 +- .../src/classes/utils/wrap-resolvers.ts | 65 +++ .../graphql/src/schema-model/Operation.ts | 19 + .../src/schema-model/OperationAdapter.ts | 8 + .../src/schema-model/generate-model.ts | 16 +- .../src/schema/make-augmented-schema.ts | 9 + .../valid-directive-field-location.ts | 16 +- .../validation/validate-document.test.ts | 50 +- .../authorization/check-authentication.ts | 38 +- .../utils/apply-authentication.ts | 13 +- .../translate/translate-top-level-cypher.ts | 6 +- .../tests/integration/issues/3746.int.test.ts | 465 ++++++++++++++++++ 12 files changed, 657 insertions(+), 65 deletions(-) create mode 100644 packages/graphql/src/classes/utils/wrap-resolvers.ts create mode 100644 packages/graphql/tests/integration/issues/3746.int.test.ts diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 9f9ad5913d..273c25774c 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -23,7 +23,7 @@ import type { IExecutableSchemaDefinition } from "@graphql-tools/schema"; import { addResolversToSchema, makeExecutableSchema } from "@graphql-tools/schema"; import { forEachField, getResolversFromSchema } from "@graphql-tools/utils"; import Debug from "debug"; -import type { DocumentNode, GraphQLSchema } from "graphql"; +import { DocumentNode, GraphQLSchema } from "graphql"; import type { Driver, SessionConfig } from "neo4j-driver"; import { DEBUG_ALL } from "../constants"; import { makeAugmentedSchema } from "../schema"; @@ -50,6 +50,7 @@ import { Neo4jGraphQLSubscriptionsDefaultEngine } from "./subscription/Neo4jGrap import type { AssertIndexesAndConstraintsOptions } from "./utils/asserts-indexes-and-constraints"; import { assertIndexesAndConstraints } from "./utils/asserts-indexes-and-constraints"; import checkNeo4jCompat from "./utils/verify-database"; +import { wrapQueryFields, wrapMutationFields } from "./utils/wrap-resolvers"; type TypeDefinitions = string | DocumentNode | TypeDefinitions[] | (() => TypeDefinitions); @@ -284,23 +285,23 @@ class Neo4jGraphQL { jwtPayloadFieldsMap: this.jwtFieldsMap, }; - const resolversComposition = { - "Query.*": [wrapQueryAndMutation(wrapResolverArgs)], - "Mutation.*": [wrapQueryAndMutation(wrapResolverArgs)], - }; + const queryResolvers = wrapQueryFields(this.schemaModel, [wrapQueryAndMutation(wrapResolverArgs)]); + const mutationResolvers = wrapMutationFields(this.schemaModel, [wrapQueryAndMutation(wrapResolverArgs)]); + const subscriptionResolvers = {}; if (this.features.subscriptions) { - resolversComposition["Subscription.*"] = wrapSubscription({ + const wrapSubscriptionResolverArgs = { subscriptionsEngine: this.features.subscriptions, schemaModel: this.schemaModel, authorization: this.authorization, jwtPayloadFieldsMap: this.jwtFieldsMap, - }); + }; + subscriptionResolvers["Subscription.*"] = [wrapSubscription(wrapSubscriptionResolverArgs)]; } // Merge generated and custom resolvers const mergedResolvers = mergeResolvers([...asArray(resolvers), ...asArray(this.resolvers)]); - return composeResolvers(mergedResolvers, resolversComposition); + return composeResolvers(mergedResolvers, { ...queryResolvers, ...mutationResolvers, ...subscriptionResolvers }); } private composeSchema(schema: GraphQLSchema): GraphQLSchema { diff --git a/packages/graphql/src/classes/utils/wrap-resolvers.ts b/packages/graphql/src/classes/utils/wrap-resolvers.ts new file mode 100644 index 0000000000..a1a33b628f --- /dev/null +++ b/packages/graphql/src/classes/utils/wrap-resolvers.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel"; +import { isAuthenticated } from "../../translate/authorization/check-authentication"; + +export function wrapQueryFields( + schemaModel: Neo4jGraphQLSchemaModel, + wrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[] +) { + const { userCustomResolverPattern, generatedResolverPattern } = getPathMatcherForRootType("Query", schemaModel); + return { + [`Query.${userCustomResolverPattern}`]: [...wrappers, isAuthenticated(["READ"], schemaModel.operations.Query)], + [`Query.${generatedResolverPattern}`]: wrappers, + }; +} + +export function wrapMutationFields( + schemaModel: Neo4jGraphQLSchemaModel, + wrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[] +) { + const { userCustomResolverPattern, generatedResolverPattern } = getPathMatcherForRootType("Mutation", schemaModel); + return { + [`Mutation.${userCustomResolverPattern}`]: [ + ...wrappers, + isAuthenticated(["CREATE", "UPDATE", "DELETE"], schemaModel.operations.Mutation), + ], + [`Mutation.${generatedResolverPattern}`]: wrappers, + }; +} + +function getPathMatcherForRootType( + rootType: "Query" | "Mutation", + schemaModel: Neo4jGraphQLSchemaModel +): { + userCustomResolverPattern: string; + generatedResolverPattern: string; +} { + const operation = schemaModel.operations[rootType]; + if (!operation) { + return { userCustomResolverPattern: "*", generatedResolverPattern: "*" }; + } + const userDefinedFields = Array.from(operation.userResolvedAttributes.keys()); + if (!userDefinedFields.length) { + return { userCustomResolverPattern: "*", generatedResolverPattern: "*" }; + } + const userCustomResolverPattern = `{${userDefinedFields.join(", ")}}`; + return { userCustomResolverPattern, generatedResolverPattern: `!${userCustomResolverPattern}` }; +} diff --git a/packages/graphql/src/schema-model/Operation.ts b/packages/graphql/src/schema-model/Operation.ts index 9991145023..ad2440315a 100644 --- a/packages/graphql/src/schema-model/Operation.ts +++ b/packages/graphql/src/schema-model/Operation.ts @@ -26,15 +26,18 @@ export class Operation { public readonly name: string; // only includes custom Cypher fields public readonly attributes: Map = new Map(); + public readonly userResolvedAttributes: Map = new Map(); public readonly annotations: Partial; constructor({ name, attributes = [], + userResolvedAttributes = [], annotations = {}, }: { name: string; attributes?: Attribute[]; + userResolvedAttributes?: Attribute[]; annotations?: Partial; }) { this.name = name; @@ -43,16 +46,32 @@ export class Operation { for (const attribute of attributes) { this.addAttribute(attribute); } + for (const attribute of userResolvedAttributes) { + this.addUserResolvedAttributes(attribute); + } } public findAttribute(name: string): Attribute | undefined { return this.attributes.get(name); } + public findUserResolvedAttributes(name: string): Attribute | undefined { + return this.userResolvedAttributes.get(name); + } + private addAttribute(attribute: Attribute): void { if (this.attributes.has(attribute.name)) { throw new Neo4jGraphQLSchemaValidationError(`Attribute ${attribute.name} already exists in ${this.name}`); } this.attributes.set(attribute.name, attribute); } + + private addUserResolvedAttributes(attribute: Attribute): void { + if (this.userResolvedAttributes.has(attribute.name)) { + throw new Neo4jGraphQLSchemaValidationError( + `User Resolved Attribute ${attribute.name} already exists in ${this.name}` + ); + } + this.userResolvedAttributes.set(attribute.name, attribute); + } } diff --git a/packages/graphql/src/schema-model/OperationAdapter.ts b/packages/graphql/src/schema-model/OperationAdapter.ts index da2fcdcd76..e3d0302f37 100644 --- a/packages/graphql/src/schema-model/OperationAdapter.ts +++ b/packages/graphql/src/schema-model/OperationAdapter.ts @@ -25,11 +25,13 @@ import type { Operation } from "./Operation"; export class OperationAdapter { public readonly name: string; public readonly attributes: Map = new Map(); + public readonly userResolvedAttributes: Map = new Map(); public readonly annotations: Partial; constructor(entity: Operation) { this.name = entity.name; this.initAttributes(entity.attributes); + this.initUserResolvedAttributes(entity.userResolvedAttributes); this.annotations = entity.annotations; } @@ -39,6 +41,12 @@ export class OperationAdapter { this.attributes.set(attributeName, attributeAdapter); } } + private initUserResolvedAttributes(attributes: Map) { + for (const [attributeName, attribute] of attributes.entries()) { + const attributeAdapter = new AttributeAdapter(attribute); + this.userResolvedAttributes.set(attributeName, attributeAdapter); + } + } public get objectFields(): AttributeAdapter[] { return Array.from(this.attributes.values()).filter((attribute) => attribute.isObjectField()); diff --git a/packages/graphql/src/schema-model/generate-model.ts b/packages/graphql/src/schema-model/generate-model.ts index 6cae8e1d15..b930863127 100644 --- a/packages/graphql/src/schema-model/generate-model.ts +++ b/packages/graphql/src/schema-model/generate-model.ts @@ -543,13 +543,23 @@ function generateOperation( definition: ObjectTypeDefinitionNode, definitionCollection: DefinitionCollection ): Operation { - const attributes = (definition.fields || []) + const { attributes, userResolvedAttributes } = (definition.fields || []) .map((fieldDefinition) => parseAttribute(fieldDefinition, definitionCollection)) - .filter((attribute) => attribute.annotations.cypher); - + .reduce<{ attributes: Attribute[]; userResolvedAttributes: Attribute[] }>( + (acc, attribute) => { + if (attribute.annotations.cypher) { + acc.attributes.push(attribute); + } else { + acc.userResolvedAttributes.push(attribute); + } + return acc; + }, + { attributes: [], userResolvedAttributes: [] } + ); return new Operation({ name: definition.name.value, attributes, + userResolvedAttributes, annotations: parseAnnotations(definition.directives || []), }); } diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 2bf6bf4b30..d4d0d9af87 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -347,6 +347,15 @@ function makeAugmentedSchema({ objectComposer.addFields({ [attributeAdapter.name]: { ...composedField, ...customResolver } }); } + + for (const attributeAdapter of operationAdapter.userResolvedAttributes.values()) { + const composedField = attributeAdapterToComposeFields([attributeAdapter], userDefinedFieldDirectives)[ + attributeAdapter.name + ]; + if (composedField) { + objectComposer.addFields({ [attributeAdapter.name]: composedField }); + } + } }); if (!Object.values(composer.Mutation.getFields()).length) { diff --git a/packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts b/packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts index 2f2c0aa5b3..095790ae1a 100644 --- a/packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts +++ b/packages/graphql/src/schema/validation/custom-rules/directives/valid-directive-field-location.ts @@ -145,7 +145,7 @@ function noDirectivesAllowedAtLocation({ } } -/** only the @cypher directive is valid on fields of Root types: Query, Mutation; no directives valid on fields of Subscription */ +/** only the @cypher and @authentication directives are valid on fields of Root types: Query, Mutation; no directives valid on fields of Subscription */ function validFieldOfRootTypeLocation({ directiveNode, traversedDef, @@ -161,20 +161,14 @@ function validFieldOfRootTypeLocation({ // @cypher is valid return; } + if (directiveNode.name.value === "authentication") { + // @authentication is valid + return; + } const isDirectiveCombinedWithCypher = traversedDef.directives?.some( (directive) => directive.name.value === "cypher" ); - if (directiveNode.name.value === "authentication" && isDirectiveCombinedWithCypher) { - // @cypher @authentication combo is valid - return; - } // explicitly checked for "enhanced" error messages - if (directiveNode.name.value === "authentication" && !isDirectiveCombinedWithCypher) { - throw new DocumentValidationError( - `Invalid directive usage: Directive @authentication is not supported on fields of the ${parentDef.name.value} type unless it is a @cypher field.`, - [`@${directiveNode.name.value}`] - ); - } if (directiveNode.name.value === "authorization" && isDirectiveCombinedWithCypher) { throw new DocumentValidationError( `Invalid directive usage: Directive @authorization is not supported on fields of the ${parentDef.name.value} type. Did you mean to use @authentication?`, diff --git a/packages/graphql/src/schema/validation/validate-document.test.ts b/packages/graphql/src/schema/validation/validate-document.test.ts index 59ff13f102..27d27e2f97 100644 --- a/packages/graphql/src/schema/validation/validate-document.test.ts +++ b/packages/graphql/src/schema/validation/validate-document.test.ts @@ -3436,35 +3436,6 @@ describe("validation 2.0", () => { expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@relationship"]); }); - test("@authentication can't be used on the field of a root type", () => { - const doc = gql` - type Query { - someActors: [Actor!]! @authentication - } - - type Actor { - name: String - } - `; - - const executeValidate = () => - validateDocument({ - document: doc, - additionalDefinitions, - features: {}, - }); - - const errors = getError(executeValidate); - - expect(errors).toHaveLength(1); - expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError); - expect(errors[0]).toHaveProperty( - "message", - "Invalid directive usage: Directive @authentication is not supported on fields of the Query type unless it is a @cypher field." - ); - expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@authentication"]); - }); - test("@authorization can't be used on the field of a root type", () => { const doc = gql` type Query { @@ -3597,6 +3568,27 @@ describe("validation 2.0", () => { expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@populatedBy"]); }); + test("@authentication ok to be used on the field of a root type", () => { + const doc = gql` + type Query { + someActors: [Actor!]! @authentication + } + + type Actor { + name: String + } + `; + + const executeValidate = () => + validateDocument({ + document: doc, + additionalDefinitions, + features: {}, + }); + + expect(executeValidate).not.toThrow(); + }); + test("@authentication with @cypher ok to be used on the field of a root type", () => { const doc = gql` type Query { diff --git a/packages/graphql/src/translate/authorization/check-authentication.ts b/packages/graphql/src/translate/authorization/check-authentication.ts index 767892425e..b6ee74f524 100644 --- a/packages/graphql/src/translate/authorization/check-authentication.ts +++ b/packages/graphql/src/translate/authorization/check-authentication.ts @@ -25,6 +25,7 @@ import type { } from "../../schema-model/annotation/AuthenticationAnnotation"; import { applyAuthentication } from "./utils/apply-authentication"; import type { Neo4jGraphQLTranslationContext } from "../../types/neo4j-graphql-translation-context"; +import { Operation } from "../../schema-model/Operation"; export function checkAuthentication({ context, @@ -65,12 +66,7 @@ export function checkEntityAuthentication({ }) { const schemaLevelAnnotation = context.schemaModel.annotations.authentication; if (schemaLevelAnnotation) { - const requiresAuthentication = targetOperations.some( - (targetOperation) => schemaLevelAnnotation && schemaLevelAnnotation.operations.has(targetOperation) - ); - if (requiresAuthentication) { - applyAuthentication({ context, annotation: schemaLevelAnnotation }); - } + applyAuthentication({ context, annotation: schemaLevelAnnotation, targetOperations }); } const annotation: AuthenticationAnnotation | undefined = field @@ -78,11 +74,29 @@ export function checkEntityAuthentication({ : entity.annotations.authentication; if (annotation) { - const requiresAuthentication = targetOperations.some( - (targetOperation) => annotation && annotation.operations.has(targetOperation) - ); - if (requiresAuthentication) { - applyAuthentication({ context, annotation }); - } + applyAuthentication({ context, annotation, targetOperations }); } } + +export const isAuthenticated = + (targetOperations: AuthenticationOperation[], entity: Operation | undefined) => + (next) => + (root, args, context, info) => { + const schemaLevelAnnotation = context.schemaModel.annotations.authentication; + if (schemaLevelAnnotation) { + applyAuthentication({ context, annotation: schemaLevelAnnotation, targetOperations }); + } + + if (entity) { + const { fieldName } = info; + const annotation: AuthenticationAnnotation | undefined = + entity.annotations.authentication || + (fieldName && entity.findUserResolvedAttributes(fieldName)?.annotations.authentication); + + if (annotation) { + applyAuthentication({ context, annotation, targetOperations }); + } + } + + return next(root, args, context, info); + }; diff --git a/packages/graphql/src/translate/authorization/utils/apply-authentication.ts b/packages/graphql/src/translate/authorization/utils/apply-authentication.ts index d2b66bd980..8ef9cb8a7c 100644 --- a/packages/graphql/src/translate/authorization/utils/apply-authentication.ts +++ b/packages/graphql/src/translate/authorization/utils/apply-authentication.ts @@ -21,15 +21,26 @@ import { Neo4jGraphQLError } from "../../../classes"; import { AUTHORIZATION_UNAUTHENTICATED } from "../../../constants"; import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context"; import { filterByValues } from "./filter-by-values"; -import type { AuthenticationAnnotation } from "../../../schema-model/annotation/AuthenticationAnnotation"; +import type { + AuthenticationAnnotation, + AuthenticationOperation, +} from "../../../schema-model/annotation/AuthenticationAnnotation"; export function applyAuthentication({ context, annotation, + targetOperations, }: { context: Neo4jGraphQLTranslationContext; annotation: AuthenticationAnnotation; + targetOperations: AuthenticationOperation[]; }): void { + const requiresAuthentication = targetOperations.some((targetOperation) => + annotation.operations.has(targetOperation) + ); + if (!requiresAuthentication) { + return; + } if (!context.authorization.isAuthenticated) { throw new Neo4jGraphQLError(AUTHORIZATION_UNAUTHENTICATED); } diff --git a/packages/graphql/src/translate/translate-top-level-cypher.ts b/packages/graphql/src/translate/translate-top-level-cypher.ts index 0d80933c72..234a01c2fd 100644 --- a/packages/graphql/src/translate/translate-top-level-cypher.ts +++ b/packages/graphql/src/translate/translate-top-level-cypher.ts @@ -26,6 +26,7 @@ import type { Neo4jGraphQLTranslationContext } from "../types/neo4j-graphql-tran import { applyAuthentication } from "./authorization/utils/apply-authentication"; import { QueryASTContext, QueryASTEnv } from "./queryAST/ast/QueryASTContext"; import { QueryASTFactory } from "./queryAST/factory/QueryASTFactory"; +import { AuthenticationOperation } from "../schema-model/annotation/AuthenticationAnnotation"; const debug = Debug(DEBUG_TRANSLATE); @@ -51,7 +52,10 @@ export function translateTopLevelCypher({ const annotation = operationField.annotations.authentication; if (annotation) { - applyAuthentication({ context, annotation }); + const targetOperations: AuthenticationOperation[] = + type === "Query" ? ["READ"] : ["CREATE", "UPDATE", "DELETE"]; + + applyAuthentication({ context, annotation, targetOperations }); } const { resolveTree } = context; diff --git a/packages/graphql/tests/integration/issues/3746.int.test.ts b/packages/graphql/tests/integration/issues/3746.int.test.ts new file mode 100644 index 0000000000..944ff05d4b --- /dev/null +++ b/packages/graphql/tests/integration/issues/3746.int.test.ts @@ -0,0 +1,465 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { generate } from "randomstring"; +import Neo4jHelper from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; +import { createBearerToken } from "../../utils/create-bearer-token"; + +describe("https://github.com/neo4j/graphql/issues/3746", () => { + let driver: Driver; + let neo4j: Neo4jHelper; + const secret = "secret"; + + beforeAll(async () => { + neo4j = new Neo4jHelper(); + driver = await neo4j.getDriver(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should apply field-level authentication to root field on Query - pass", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User @authentication(operations: ["READ"]) + you: User @authentication(operations: ["READ"]) + } + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const token = createBearerToken(secret, { sub: userId }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ token }), + }); + + expect(gqlResult.errors).toBeUndefined(); + expect((gqlResult.data as any).me.customId).toEqual(userId); + }); + + test("should apply field-level authentication to root field on Query - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User @authentication(operations: ["READ"]) + you: User + } + `; + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply type-level authentication to root field on Query - pass", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query @authentication(operations: ["READ"]) { + me: User + you: User + } + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const token = createBearerToken(secret, { sub: userId }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ token }), + }); + + expect(gqlResult.errors).toBeUndefined(); + expect((gqlResult.data as any).me.customId).toEqual(userId); + }); + + test("should apply type-level authentication to root field on Query - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query @authentication(operations: ["READ"]) { + me: User + you: User + } + `; + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply field-level authentication to root field on Mutation - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User @authentication(operations: ["READ"]) + you: User + } + + type Mutation { + updateMe(id: ID): User @authentication(operations: ["CREATE"]) + } + `; + + const query = /* GraphQL */ ` + mutation { + updateMe(id: 3) { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + Mutation: { updateMe: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply type-level authentication to root field on Mutation - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User @authentication(operations: ["READ"]) + you: User + } + + type Mutation @authentication(operations: ["CREATE"]) { + updateMe(id: ID): User + } + `; + + const query = /* GraphQL */ ` + mutation { + updateMe(id: 3) { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + Mutation: { updateMe: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply schema-level defined authentication to root field on Query - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User + you: User + } + + extend schema @authentication(operations: ["READ"]) + `; + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); + + test("should apply schema-level defined authentication to root field on Query - pass", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User + you: User + } + + extend schema @authentication(operations: ["READ"]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const query = /* GraphQL */ ` + { + me { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const token = createBearerToken(secret, { sub: userId }); + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ token }), + }); + + expect(gqlResult.errors).toBeUndefined(); + expect((gqlResult.data as any).me.customId).toEqual(userId); + }); + + test("should apply schema-level defined authentication to root field on Mutation - throw unauthenticated", async () => { + const typeDefs = /* GraphQL */ ` + type User { + customId: ID + } + + type Query { + me: User + you: User + } + + type Mutation { + updateMe(id: ID): User + } + + extend schema @authentication(operations: ["UPDATE"]) + `; + + const query = /* GraphQL */ ` + mutation { + updateMe(id: 3) { + customId + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { + Query: { me: () => ({}), you: () => ({}) }, + User: { customId: (_, __, ctx) => ctx.jwt.sub }, + Mutation: { updateMe: () => ({}) }, + }, + features: { + authorization: { + key: secret, + }, + }, + }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({}), + }); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Unauthenticated"); + }); +}); From 2fe42efbae520da01dc4fffdcd44e1573fd73ab5 Mon Sep 17 00:00:00 2001 From: a-alle Date: Mon, 4 Mar 2024 11:30:25 +0000 Subject: [PATCH 2/7] add changeset --- .changeset/chilly-panthers-love.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chilly-panthers-love.md diff --git a/.changeset/chilly-panthers-love.md b/.changeset/chilly-panthers-love.md new file mode 100644 index 0000000000..6ad88f9607 --- /dev/null +++ b/.changeset/chilly-panthers-love.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graphql": patch +--- + +Adds support for the `@authentication` directive on custom resolved fields of root types Query and Mutation From b0df4907b43cf0206ac85dd5aefe4a783f634da7 Mon Sep 17 00:00:00 2001 From: a-alle Date: Mon, 4 Mar 2024 11:43:07 +0000 Subject: [PATCH 3/7] fix type imports --- packages/graphql/src/classes/Neo4jGraphQL.ts | 2 +- packages/graphql/src/classes/utils/wrap-resolvers.ts | 2 +- .../graphql/src/translate/authorization/check-authentication.ts | 2 +- packages/graphql/src/translate/translate-top-level-cypher.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 273c25774c..d361745391 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -23,7 +23,7 @@ import type { IExecutableSchemaDefinition } from "@graphql-tools/schema"; import { addResolversToSchema, makeExecutableSchema } from "@graphql-tools/schema"; import { forEachField, getResolversFromSchema } from "@graphql-tools/utils"; import Debug from "debug"; -import { DocumentNode, GraphQLSchema } from "graphql"; +import type { DocumentNode, GraphQLSchema } from "graphql"; import type { Driver, SessionConfig } from "neo4j-driver"; import { DEBUG_ALL } from "../constants"; import { makeAugmentedSchema } from "../schema"; diff --git a/packages/graphql/src/classes/utils/wrap-resolvers.ts b/packages/graphql/src/classes/utils/wrap-resolvers.ts index a1a33b628f..8ceb76195f 100644 --- a/packages/graphql/src/classes/utils/wrap-resolvers.ts +++ b/packages/graphql/src/classes/utils/wrap-resolvers.ts @@ -17,7 +17,7 @@ * limitations under the License. */ -import { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel"; +import type { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel"; import { isAuthenticated } from "../../translate/authorization/check-authentication"; export function wrapQueryFields( diff --git a/packages/graphql/src/translate/authorization/check-authentication.ts b/packages/graphql/src/translate/authorization/check-authentication.ts index b6ee74f524..ec0cafa6b1 100644 --- a/packages/graphql/src/translate/authorization/check-authentication.ts +++ b/packages/graphql/src/translate/authorization/check-authentication.ts @@ -25,7 +25,7 @@ import type { } from "../../schema-model/annotation/AuthenticationAnnotation"; import { applyAuthentication } from "./utils/apply-authentication"; import type { Neo4jGraphQLTranslationContext } from "../../types/neo4j-graphql-translation-context"; -import { Operation } from "../../schema-model/Operation"; +import type { Operation } from "../../schema-model/Operation"; export function checkAuthentication({ context, diff --git a/packages/graphql/src/translate/translate-top-level-cypher.ts b/packages/graphql/src/translate/translate-top-level-cypher.ts index 234a01c2fd..fe72291c78 100644 --- a/packages/graphql/src/translate/translate-top-level-cypher.ts +++ b/packages/graphql/src/translate/translate-top-level-cypher.ts @@ -26,7 +26,7 @@ import type { Neo4jGraphQLTranslationContext } from "../types/neo4j-graphql-tran import { applyAuthentication } from "./authorization/utils/apply-authentication"; import { QueryASTContext, QueryASTEnv } from "./queryAST/ast/QueryASTContext"; import { QueryASTFactory } from "./queryAST/factory/QueryASTFactory"; -import { AuthenticationOperation } from "../schema-model/annotation/AuthenticationAnnotation"; +import type { AuthenticationOperation } from "../schema-model/annotation/AuthenticationAnnotation"; const debug = Debug(DEBUG_TRANSLATE); From b0238e2e1adf407c339a4797693702b0d87f0c2c Mon Sep 17 00:00:00 2001 From: a-alle Date: Mon, 4 Mar 2024 12:22:14 +0000 Subject: [PATCH 4/7] refactor wrappers --- packages/graphql/src/classes/Neo4jGraphQL.ts | 36 +++++---- .../utils/generate-resolvers-composition.ts | 81 +++++++++++++++++++ .../src/classes/utils/wrap-resolvers.ts | 65 --------------- 3 files changed, 102 insertions(+), 80 deletions(-) create mode 100644 packages/graphql/src/classes/utils/generate-resolvers-composition.ts delete mode 100644 packages/graphql/src/classes/utils/wrap-resolvers.ts diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index d361745391..c2fc4e3fe3 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -33,7 +33,7 @@ import { getDefinitionNodes } from "../schema/get-definition-nodes"; import { makeDocumentToAugment } from "../schema/make-document-to-augment"; import type { WrapResolverArguments } from "../schema/resolvers/composition/wrap-query-and-mutation"; import { wrapQueryAndMutation } from "../schema/resolvers/composition/wrap-query-and-mutation"; -import { wrapSubscription } from "../schema/resolvers/composition/wrap-subscription"; +import { wrapSubscription, type WrapSubscriptionArgs } from "../schema/resolvers/composition/wrap-subscription"; import { defaultFieldResolver } from "../schema/resolvers/field/defaultField"; import { validateDocument } from "../schema/validation"; import { validateUserDefinition } from "../schema/validation/schema-validation"; @@ -50,7 +50,7 @@ import { Neo4jGraphQLSubscriptionsDefaultEngine } from "./subscription/Neo4jGrap import type { AssertIndexesAndConstraintsOptions } from "./utils/asserts-indexes-and-constraints"; import { assertIndexesAndConstraints } from "./utils/asserts-indexes-and-constraints"; import checkNeo4jCompat from "./utils/verify-database"; -import { wrapQueryFields, wrapMutationFields } from "./utils/wrap-resolvers"; +import { generateResolverComposition } from "./utils/generate-resolvers-composition"; type TypeDefinitions = string | DocumentNode | TypeDefinitions[] | (() => TypeDefinitions); @@ -284,24 +284,30 @@ class Neo4jGraphQL { authorization: this.authorization, jwtPayloadFieldsMap: this.jwtFieldsMap, }; + const queryAndMutationWrappers = [wrapQueryAndMutation(wrapResolverArgs)]; - const queryResolvers = wrapQueryFields(this.schemaModel, [wrapQueryAndMutation(wrapResolverArgs)]); - const mutationResolvers = wrapMutationFields(this.schemaModel, [wrapQueryAndMutation(wrapResolverArgs)]); - const subscriptionResolvers = {}; + const isSubscriptionEnabled = !!this.features.subscriptions; + const wrapSubscriptionResolverArgs = { + subscriptionsEngine: this.features.subscriptions, + schemaModel: this.schemaModel, + authorization: this.authorization, + jwtPayloadFieldsMap: this.jwtFieldsMap, + }; + const subscriptionWrappers = isSubscriptionEnabled + ? [wrapSubscription(wrapSubscriptionResolverArgs as WrapSubscriptionArgs)] + : []; - if (this.features.subscriptions) { - const wrapSubscriptionResolverArgs = { - subscriptionsEngine: this.features.subscriptions, - schemaModel: this.schemaModel, - authorization: this.authorization, - jwtPayloadFieldsMap: this.jwtFieldsMap, - }; - subscriptionResolvers["Subscription.*"] = [wrapSubscription(wrapSubscriptionResolverArgs)]; - } + const resolversComposition = generateResolverComposition({ + schemaModel: this.schemaModel, + isSubscriptionEnabled, + queryAndMutationWrappers, + subscriptionWrappers, + }); // Merge generated and custom resolvers + // Merging must be done before composing because wrapper won't run otherwise const mergedResolvers = mergeResolvers([...asArray(resolvers), ...asArray(this.resolvers)]); - return composeResolvers(mergedResolvers, { ...queryResolvers, ...mutationResolvers, ...subscriptionResolvers }); + return composeResolvers(mergedResolvers, resolversComposition); } private composeSchema(schema: GraphQLSchema): GraphQLSchema { diff --git a/packages/graphql/src/classes/utils/generate-resolvers-composition.ts b/packages/graphql/src/classes/utils/generate-resolvers-composition.ts new file mode 100644 index 0000000000..aa40847f10 --- /dev/null +++ b/packages/graphql/src/classes/utils/generate-resolvers-composition.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ResolversComposerMapping } from "@graphql-tools/resolvers-composition"; +import type { IResolvers } from "@graphql-tools/utils"; +import type { GraphQLResolveInfo } from "graphql"; +import type { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel"; +import { isAuthenticated } from "../../translate/authorization/check-authentication"; + +export function generateResolverComposition({ + schemaModel, + isSubscriptionEnabled, + queryAndMutationWrappers, + subscriptionWrappers, +}: { + schemaModel: Neo4jGraphQLSchemaModel; + isSubscriptionEnabled: boolean; + queryAndMutationWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[]; + subscriptionWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[]; +}): ResolversComposerMapping, any>> { + let resolverComposition = {}; + const { + userCustomResolverPattern: customResolverQueryPattern, + generatedResolverPattern: generatedResolverQueryPattern, + } = getPathMatcherForRootType("Query", schemaModel); + resolverComposition[`Query.${customResolverQueryPattern}`] = [ + ...queryAndMutationWrappers, + isAuthenticated(["READ"], schemaModel.operations.Query), + ]; + resolverComposition[`Query.${generatedResolverQueryPattern}`] = queryAndMutationWrappers; + + const { + userCustomResolverPattern: customResolverMutationPattern, + generatedResolverPattern: generatedResolverMutationPattern, + } = getPathMatcherForRootType("Mutation", schemaModel); + resolverComposition[`Mutation.${customResolverMutationPattern}`] = [ + ...queryAndMutationWrappers, + isAuthenticated(["CREATE", "UPDATE", "DELETE"], schemaModel.operations.Mutation), + ]; + resolverComposition[`Mutation.${generatedResolverMutationPattern}`] = queryAndMutationWrappers; + + if (isSubscriptionEnabled) { + resolverComposition["Subscription.*"] = subscriptionWrappers; + } + return resolverComposition; +} + +function getPathMatcherForRootType( + rootType: "Query" | "Mutation", + schemaModel: Neo4jGraphQLSchemaModel +): { + userCustomResolverPattern: string; + generatedResolverPattern: string; +} { + const operation = schemaModel.operations[rootType]; + if (!operation) { + return { userCustomResolverPattern: "*", generatedResolverPattern: "*" }; + } + const userDefinedFields = Array.from(operation.userResolvedAttributes.keys()); + if (!userDefinedFields.length) { + return { userCustomResolverPattern: "*", generatedResolverPattern: "*" }; + } + const userCustomResolverPattern = `{${userDefinedFields.join(", ")}}`; + return { userCustomResolverPattern, generatedResolverPattern: `!${userCustomResolverPattern}` }; +} diff --git a/packages/graphql/src/classes/utils/wrap-resolvers.ts b/packages/graphql/src/classes/utils/wrap-resolvers.ts deleted file mode 100644 index 8ceb76195f..0000000000 --- a/packages/graphql/src/classes/utils/wrap-resolvers.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) "Neo4j" - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel"; -import { isAuthenticated } from "../../translate/authorization/check-authentication"; - -export function wrapQueryFields( - schemaModel: Neo4jGraphQLSchemaModel, - wrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[] -) { - const { userCustomResolverPattern, generatedResolverPattern } = getPathMatcherForRootType("Query", schemaModel); - return { - [`Query.${userCustomResolverPattern}`]: [...wrappers, isAuthenticated(["READ"], schemaModel.operations.Query)], - [`Query.${generatedResolverPattern}`]: wrappers, - }; -} - -export function wrapMutationFields( - schemaModel: Neo4jGraphQLSchemaModel, - wrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[] -) { - const { userCustomResolverPattern, generatedResolverPattern } = getPathMatcherForRootType("Mutation", schemaModel); - return { - [`Mutation.${userCustomResolverPattern}`]: [ - ...wrappers, - isAuthenticated(["CREATE", "UPDATE", "DELETE"], schemaModel.operations.Mutation), - ], - [`Mutation.${generatedResolverPattern}`]: wrappers, - }; -} - -function getPathMatcherForRootType( - rootType: "Query" | "Mutation", - schemaModel: Neo4jGraphQLSchemaModel -): { - userCustomResolverPattern: string; - generatedResolverPattern: string; -} { - const operation = schemaModel.operations[rootType]; - if (!operation) { - return { userCustomResolverPattern: "*", generatedResolverPattern: "*" }; - } - const userDefinedFields = Array.from(operation.userResolvedAttributes.keys()); - if (!userDefinedFields.length) { - return { userCustomResolverPattern: "*", generatedResolverPattern: "*" }; - } - const userCustomResolverPattern = `{${userDefinedFields.join(", ")}}`; - return { userCustomResolverPattern, generatedResolverPattern: `!${userCustomResolverPattern}` }; -} From 4bddf4862938f2e01f48489e2f27ddfd8d20938b Mon Sep 17 00:00:00 2001 From: Alle <111279668+a-alle@users.noreply.github.com> Date: Mon, 4 Mar 2024 12:22:33 +0000 Subject: [PATCH 5/7] Update .changeset/chilly-panthers-love.md Co-authored-by: Darrell Warde <8117355+darrellwarde@users.noreply.github.com> --- .changeset/chilly-panthers-love.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/chilly-panthers-love.md b/.changeset/chilly-panthers-love.md index 6ad88f9607..37c44653eb 100644 --- a/.changeset/chilly-panthers-love.md +++ b/.changeset/chilly-panthers-love.md @@ -1,5 +1,5 @@ --- -"@neo4j/graphql": patch +"@neo4j/graphql": minor --- Adds support for the `@authentication` directive on custom resolved fields of root types Query and Mutation From f7e2f93019320190f6c72c6bf871ac139181f471 Mon Sep 17 00:00:00 2001 From: a-alle Date: Mon, 4 Mar 2024 12:26:57 +0000 Subject: [PATCH 6/7] add comment --- packages/graphql/src/schema/make-augmented-schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index d4d0d9af87..813cd7bda9 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -348,6 +348,7 @@ function makeAugmentedSchema({ objectComposer.addFields({ [attributeAdapter.name]: { ...composedField, ...customResolver } }); } + // this is to remove library directives from custom resolvers on root type fields in augmented schema for (const attributeAdapter of operationAdapter.userResolvedAttributes.values()) { const composedField = attributeAdapterToComposeFields([attributeAdapter], userDefinedFieldDirectives)[ attributeAdapter.name From ca69be9693e100371223a23f2f724026437bb0b0 Mon Sep 17 00:00:00 2001 From: a-alle Date: Thu, 7 Mar 2024 14:24:18 +0000 Subject: [PATCH 7/7] fix reviewdog --- .../graphql/src/classes/utils/generate-resolvers-composition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graphql/src/classes/utils/generate-resolvers-composition.ts b/packages/graphql/src/classes/utils/generate-resolvers-composition.ts index aa40847f10..8093b6d5dc 100644 --- a/packages/graphql/src/classes/utils/generate-resolvers-composition.ts +++ b/packages/graphql/src/classes/utils/generate-resolvers-composition.ts @@ -34,7 +34,7 @@ export function generateResolverComposition({ queryAndMutationWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[]; subscriptionWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[]; }): ResolversComposerMapping, any>> { - let resolverComposition = {}; + const resolverComposition = {}; const { userCustomResolverPattern: customResolverQueryPattern, generatedResolverPattern: generatedResolverQueryPattern,