From b0a34e22bba66236d798a35ba261cb06ae69c44a Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 9 Nov 2021 18:09:34 +0100 Subject: [PATCH 01/33] wip: raw demo code --- index.js | 14 ++++++++++ lib/auth.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 93 insertions(+) diff --git a/index.js b/index.js index 92065b2..6bb5c6d 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,8 @@ const plugin = fp( // Get auth policy const authSchema = auth.getPolicy(app.graphql.schema) + // console.log(app.graphql.schema) + // Wrap resolvers with auth handlers auth.registerAuthHandlers(app.graphql.schema, authSchema) @@ -26,6 +28,18 @@ const plugin = fp( if (typeof opts.authContext !== 'undefined') { app.graphql.addHook('preExecution', auth.authContextHook.bind(auth)) } + + app.graphql.addHook('preExecution', async function filterHook (schema, document, context) { + const filteredSchema = auth.filterDirectives(schema, authSchema, context) + return { + schema: filteredSchema + } + }) + + // app.graphql.addHook('onResolution', async (execution, context) => { + // require('fs').writeFileSync('./exe.json', JSON.stringify(execution, null, 2)) + // return execution + // }) }, { name: 'mercurius-auth', diff --git a/lib/auth.js b/lib/auth.js index ae9c289..34317ae 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,5 +1,7 @@ 'use strict' +const { MapperKind, mapSchema } = require('@graphql-tools/utils') + const { kApplyPolicy, kAuthContext, @@ -15,6 +17,21 @@ const { } = require('./symbols') const { MER_AUTH_ERR_FAILED_POLICY_CHECK } = require('./errors') +const { + GraphQLSchema, + GraphQLObjectType, + GraphQLFieldConfigMap, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLScalarType, + GraphQLEnumType, + GraphQLInputFieldConfigMap, + DirectiveNode, + GraphQLFieldConfigArgumentMap, + isNonNullType +} = require('graphql') + class Auth { constructor ({ applyPolicy, authContext, authDirective, mode, policy }) { this[kApplyPolicy] = applyPolicy @@ -110,6 +127,67 @@ class Auth { return this[kBuildPolicy](graphQLSchema) } + filterDirectives (graphQLSchema, policy, context) { + const { request } = context.reply + const policyKeys = Object.keys(policy) + + return mapSchema(graphQLSchema, { + [MapperKind.OBJECT_TYPE]: (sdlNode) => { + console.log('OBJECT_TYPE', sdlNode.name) + + if (!policyKeys.includes(sdlNode.name)) { + return sdlNode + } + + const policyItem = policy[sdlNode.name] + const policyFields = Object.keys(policyItem) + + // I should filter + const config = sdlNode.toConfig() + const newFields = {} + for (const [name, fieldConfig] of Object.entries(config.fields)) { + // TODO should check if fieldConfig.type is public as well + if (!policyFields.includes(name)) { + newFields[name] = fieldConfig + continue + } + + // TODO should manage fieldConfig.args? + } + + if (Object.keys(newFields).length === 0) { + return null + } + config.fields = newFields + return new GraphQLObjectType(config) + }, + [MapperKind.INPUT_OBJECT_TYPE]: (sdlNode) => { + console.log('INPUT_OBJECT_TYPE', sdlNode.name) + return sdlNode + }, + [MapperKind.DIRECTIVE]: (sdlNode) => { + console.log('DIRECTIVE', sdlNode.name) + return sdlNode + }, + [MapperKind.UNION_TYPE]: (sdlNode) => { + console.log('UNION_TYPE', sdlNode.name) + return sdlNode + }, + [MapperKind.INTERFACE_TYPE]: (sdlNode) => { + console.log('INTERFACE_TYPE', sdlNode.name) + return sdlNode + }, + [MapperKind.SCALAR_TYPE]: (sdlNode) => { + console.log('SCALAR_TYPE', sdlNode.name) + return sdlNode + }, + [MapperKind.ENUM_TYPE]: (sdlNode) => { + console.log('ENUM_TYPE', sdlNode.name) + return sdlNode + } + }) + } + registerAuthHandlers (graphQLSchema, policy) { for (const [typeName, typePolicy] of Object.entries(policy)) { const schemaType = graphQLSchema.getType(typeName) diff --git a/package.json b/package.json index e7706c1..2b684a4 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "wait-on": "^6.0.0" }, "dependencies": { + "@graphql-tools/utils": "^8.5.3", "fastify-error": "^0.3.0", "fastify-plugin": "^3.0.0", "graphql": "^15.4.0" From c6c7c04803831d84752f955e5003230dd6ee0f08 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Thu, 11 Nov 2021 11:03:23 +0100 Subject: [PATCH 02/33] wip --- index.js | 2 +- lib/auth.js | 180 ++++++++++++++++++++++++++++++++++++--------------- package.json | 3 +- 3 files changed, 131 insertions(+), 54 deletions(-) diff --git a/index.js b/index.js index 6bb5c6d..940ec62 100644 --- a/index.js +++ b/index.js @@ -30,7 +30,7 @@ const plugin = fp( } app.graphql.addHook('preExecution', async function filterHook (schema, document, context) { - const filteredSchema = auth.filterDirectives(schema, authSchema, context) + const filteredSchema = await auth.filterDirectives(schema, authSchema, context) return { schema: filteredSchema } diff --git a/lib/auth.js b/lib/auth.js index 34317ae..f016b68 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,5 +1,20 @@ 'use strict' +const { + wrapSchema, + PruneSchema, + RemoveObjectFieldDeprecations, + RemoveObjectFieldDirectives, + RemoveObjectFieldsWithDeprecation, + RemoveObjectFieldsWithDirective, + TransformRootFields, + TransformObjectFields, + TransformInterfaceFields, + TransformCompositeFields, + TransformInputObjectFields, + TransformEnumValues +} = require('@graphql-tools/wrap') + const { MapperKind, mapSchema } = require('@graphql-tools/utils') const { @@ -127,65 +142,125 @@ class Auth { return this[kBuildPolicy](graphQLSchema) } - filterDirectives (graphQLSchema, policy, context) { - const { request } = context.reply - const policyKeys = Object.keys(policy) - - return mapSchema(graphQLSchema, { - [MapperKind.OBJECT_TYPE]: (sdlNode) => { - console.log('OBJECT_TYPE', sdlNode.name) + async filterDirectives (graphQLSchema, policy, context) { + const resolverTracker = [ - if (!policyKeys.includes(sdlNode.name)) { - return sdlNode - } + ] + for (const [typeName, typePolicy] of Object.entries(policy)) { + const schemaType = graphQLSchema.getType(typeName) - const policyItem = policy[sdlNode.name] - const policyFields = Object.keys(policyItem) - - // I should filter - const config = sdlNode.toConfig() - const newFields = {} - for (const [name, fieldConfig] of Object.entries(config.fields)) { - // TODO should check if fieldConfig.type is public as well - if (!policyFields.includes(name)) { - newFields[name] = fieldConfig - continue - } + if (!(schemaType instanceof GraphQLObjectType)) { + continue + } - // TODO should manage fieldConfig.args? - } + const config = schemaType.toConfig() + const policyFields = Object.keys(typePolicy) - if (Object.keys(newFields).length === 0) { - return null + for (const [name, fieldConfig] of Object.entries(config.fields)) { + if (policyFields.includes(name)) { + const resolver = fieldConfig.resolve + resolverTracker.push({ + typeName, + fieldName: name, + schemaType, + policyAst: typePolicy[name], + resolver + }) } - config.fields = newFields - return new GraphQLObjectType(config) - }, - [MapperKind.INPUT_OBJECT_TYPE]: (sdlNode) => { - console.log('INPUT_OBJECT_TYPE', sdlNode.name) - return sdlNode - }, - [MapperKind.DIRECTIVE]: (sdlNode) => { - console.log('DIRECTIVE', sdlNode.name) - return sdlNode - }, - [MapperKind.UNION_TYPE]: (sdlNode) => { - console.log('UNION_TYPE', sdlNode.name) - return sdlNode - }, - [MapperKind.INTERFACE_TYPE]: (sdlNode) => { - console.log('INTERFACE_TYPE', sdlNode.name) - return sdlNode - }, - [MapperKind.SCALAR_TYPE]: (sdlNode) => { - console.log('SCALAR_TYPE', sdlNode.name) - return sdlNode - }, - [MapperKind.ENUM_TYPE]: (sdlNode) => { - console.log('ENUM_TYPE', sdlNode.name) - return sdlNode } + } + const { request } = context.reply + + const authDirectives = resolverTracker.map(({ schemaType, fieldName, resolver }) => { + // authDirectiveAST, parent, args, context, info + return resolver({}, null, {}, context, {}) + }) + const resolutions = await Promise.all(authDirectives) + + const policyKeys = Object.keys(policy) + console.log('-c-c-c-c-c-c-c-c-c-c-c-c-cc-c-c-c-c') + return wrapSchema({ + schema: graphQLSchema, + transforms: [ + // A modified version of the element config. + // An array with a modified field name and new element config. + // null to omit the element from the schema. + // undefined to leave the element unchanged. + // new TransformRootFields((operationName, fieldName, fieldConfig) => fieldConfig), + new TransformObjectFields((typeName, fieldName, fieldConfig) => { + console.log(fieldName) + }) + // new TransformInterfaceFields((typeName, fieldName, fieldConfig) => null), + // new TransformCompositeFields((typeName, fieldName, fieldConfig) => undefined), + // new TransformInputObjectFields((typeName, fieldName, inputFieldConfig) => [`new_${fieldName}`, inputFieldConfig]), + // new TransformEnumValues((typeName, enumValue, enumValueConfig) => [`NEW_${enumValue}`, enumValueConfig]) + + // new RemoveObjectFieldDeprecations(/^gateway access only/), + // new RemoveObjectFieldDirectives('deprecated', { reason: /^gateway access only/ }), + // new RemoveObjectFieldsWithDeprecation(/^gateway access only/), + // new RemoveObjectFieldsWithDirective('deprecated', { reason: /^gateway access only/ }) + ] }) + + // return mapSchema(graphQLSchema, { + // [MapperKind.OBJECT_TYPE]: (sdlNode) => { + // console.log('OBJECT_TYPE', sdlNode.name) + + // if (!policyKeys.includes(sdlNode.name)) { + // return sdlNode + // } + + // const policyItem = policy[sdlNode.name] + // const policyFields = Object.keys(policyItem) + + // // I should filter + // const config = sdlNode.toConfig() + // const newFields = {} + // for (const [name, fieldConfig] of Object.entries(config.fields)) { + // // TODO should check if fieldConfig.type is public as well + // if (!policyFields.includes(name)) { + // newFields[name] = fieldConfig + // continue + // } else { + // // try run the resolver! + // // TODO how to check async resolvers?????? + // const resolver = fieldConfig.resolve + // } + + // // TODO should manage fieldConfig.args? + // } + + // if (Object.keys(newFields).length === 0) { + // return null + // } + // config.fields = newFields + // return new GraphQLObjectType(config) + // }, + // [MapperKind.INPUT_OBJECT_TYPE]: (sdlNode) => { + // console.log('INPUT_OBJECT_TYPE', sdlNode.name) + // return sdlNode + // }, + // [MapperKind.DIRECTIVE]: (sdlNode) => { + // console.log('DIRECTIVE', sdlNode.name) + // return sdlNode + // }, + // [MapperKind.UNION_TYPE]: (sdlNode) => { + // console.log('UNION_TYPE', sdlNode.name) + // return sdlNode + // }, + // [MapperKind.INTERFACE_TYPE]: (sdlNode) => { + // console.log('INTERFACE_TYPE', sdlNode.name) + // return sdlNode + // }, + // [MapperKind.SCALAR_TYPE]: (sdlNode) => { + // console.log('SCALAR_TYPE', sdlNode.name) + // return sdlNode + // }, + // [MapperKind.ENUM_TYPE]: (sdlNode) => { + // console.log('ENUM_TYPE', sdlNode.name) + // return sdlNode + // } + // }) } registerAuthHandlers (graphQLSchema, policy) { @@ -216,6 +291,7 @@ class Auth { } async authContextHook (_schema, _document, context) { + console.log('authContextHook - ', this[kAuthDirective]) const auth = await this[kAuthContext](context) Object.assign(context, { auth }) } diff --git a/package.json b/package.json index 2b684a4..ce2a92b 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "autocannon": "^7.0.5", "concurrently": "^6.1.0", "fastify": "^3.0.2", - "mercurius": "^8.0.0", + "mercurius": "^8.9.0", "pre-commit": "^1.2.2", "snazzy": "^9.0.0", "standard": "^16.0.3", @@ -49,6 +49,7 @@ }, "dependencies": { "@graphql-tools/utils": "^8.5.3", + "@graphql-tools/wrap": "^8.3.2", "fastify-error": "^0.3.0", "fastify-plugin": "^3.0.0", "graphql": "^15.4.0" From 526d42b91e6ef2cebb9a92d40653cf0307119c8e Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Fri, 12 Nov 2021 09:40:11 +0100 Subject: [PATCH 03/33] single hook --- index.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index 940ec62..369023e 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,8 @@ const fp = require('fastify-plugin') const Auth = require('./lib/auth') const { validateOpts } = require('./lib/validation') +const kSchemaFilterHook = Symbol('schemaFilterHook') + const plugin = fp( async function (app, opts) { validateOpts(opts) @@ -14,8 +16,6 @@ const plugin = fp( // Get auth policy const authSchema = auth.getPolicy(app.graphql.schema) - // console.log(app.graphql.schema) - // Wrap resolvers with auth handlers auth.registerAuthHandlers(app.graphql.schema, authSchema) @@ -29,12 +29,15 @@ const plugin = fp( app.graphql.addHook('preExecution', auth.authContextHook.bind(auth)) } - app.graphql.addHook('preExecution', async function filterHook (schema, document, context) { - const filteredSchema = await auth.filterDirectives(schema, authSchema, context) - return { - schema: filteredSchema - } - }) + if (!app[kSchemaFilterHook]) { + app.graphql.addHook('preExecution', async function filterHook (schema, document, context) { + const filteredSchema = await auth.filterDirectives(schema, authSchema, context) + return { + schema: filteredSchema + } + }) + app[kSchemaFilterHook] = true + } // app.graphql.addHook('onResolution', async (execution, context) => { // require('fs').writeFileSync('./exe.json', JSON.stringify(execution, null, 2)) From 222494031b0a19707fe6854faa05963e33771ec6 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Fri, 12 Nov 2021 09:44:19 +0100 Subject: [PATCH 04/33] wip filtering inspection schema --- lib/auth.js | 149 ++++++++++------------------------------------------ 1 file changed, 27 insertions(+), 122 deletions(-) diff --git a/lib/auth.js b/lib/auth.js index d9be8ef..e413ee2 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -3,19 +3,10 @@ const { wrapSchema, PruneSchema, - RemoveObjectFieldDeprecations, - RemoveObjectFieldDirectives, - RemoveObjectFieldsWithDeprecation, - RemoveObjectFieldsWithDirective, - TransformRootFields, - TransformObjectFields, - TransformInterfaceFields, - TransformCompositeFields, - TransformInputObjectFields, - TransformEnumValues + TransformObjectFields } = require('@graphql-tools/wrap') -const { MapperKind, mapSchema } = require('@graphql-tools/utils') +// const { MapperKind, mapSchema } = require('@graphql-tools/utils') const { kApplyPolicy, @@ -32,21 +23,6 @@ const { } = require('./symbols') const { MER_AUTH_ERR_FAILED_POLICY_CHECK } = require('./errors') -const { - GraphQLSchema, - GraphQLObjectType, - GraphQLFieldConfigMap, - GraphQLInputObjectType, - GraphQLInterfaceType, - GraphQLUnionType, - GraphQLScalarType, - GraphQLEnumType, - GraphQLInputFieldConfigMap, - DirectiveNode, - GraphQLFieldConfigArgumentMap, - isNonNullType -} = require('graphql') - class Auth { constructor ({ applyPolicy, authContext, authDirective, mode, policy }) { this[kApplyPolicy] = applyPolicy @@ -143,53 +119,42 @@ class Auth { } async filterDirectives (graphQLSchema, policy, context) { - const resolverTracker = [ + const filterDirectiveMap = {} - ] for (const [typeName, typePolicy] of Object.entries(policy)) { - const schemaType = graphQLSchema.getType(typeName) - - if (!(schemaType instanceof GraphQLObjectType)) { - continue - } - - const config = schemaType.toConfig() - const policyFields = Object.keys(typePolicy) - - for (const [name, fieldConfig] of Object.entries(config.fields)) { - if (policyFields.includes(name)) { - const resolver = fieldConfig.resolve - resolverTracker.push({ - typeName, - fieldName: name, - schemaType, - policyAst: typePolicy[name], - resolver - }) + filterDirectiveMap[typeName] = {} + for (const [fieldName, fieldPolicy] of Object.entries(typePolicy)) { + let canShowDirectiveField = true + try { + // TODO parameters + // canShowDirectiveField = await this[kApplyPolicy](policy, parent, args, context, info) + canShowDirectiveField = await this[kApplyPolicy](fieldPolicy, null, {}, context, {}) + if (canShowDirectiveField instanceof Error) { + canShowDirectiveField = false + } + if (!canShowDirectiveField) { + canShowDirectiveField = false + } + } catch (error) { + canShowDirectiveField = false } + + filterDirectiveMap[typeName][fieldName] = canShowDirectiveField } } - const { request } = context.reply - const authDirectives = resolverTracker.map(({ schemaType, fieldName, resolver }) => { - // authDirectiveAST, parent, args, context, info - return resolver({}, null, {}, context, {}) - }) - const resolutions = await Promise.all(authDirectives) + debugger - const policyKeys = Object.keys(policy) - console.log('-c-c-c-c-c-c-c-c-c-c-c-c-cc-c-c-c-c') return wrapSchema({ schema: graphQLSchema, transforms: [ - // A modified version of the element config. - // An array with a modified field name and new element config. - // null to omit the element from the schema. - // undefined to leave the element unchanged. - // new TransformRootFields((operationName, fieldName, fieldConfig) => fieldConfig), new TransformObjectFields((typeName, fieldName, fieldConfig) => { - console.log(fieldName) - }) + if (filterDirectiveMap[typeName] && filterDirectiveMap[typeName][fieldName] === false) { + return null // omit the field + } + return undefined // unchanged + }), + new PruneSchema() // new TransformInterfaceFields((typeName, fieldName, fieldConfig) => null), // new TransformCompositeFields((typeName, fieldName, fieldConfig) => undefined), // new TransformInputObjectFields((typeName, fieldName, inputFieldConfig) => [`new_${fieldName}`, inputFieldConfig]), @@ -201,66 +166,6 @@ class Auth { // new RemoveObjectFieldsWithDirective('deprecated', { reason: /^gateway access only/ }) ] }) - - // return mapSchema(graphQLSchema, { - // [MapperKind.OBJECT_TYPE]: (sdlNode) => { - // console.log('OBJECT_TYPE', sdlNode.name) - - // if (!policyKeys.includes(sdlNode.name)) { - // return sdlNode - // } - - // const policyItem = policy[sdlNode.name] - // const policyFields = Object.keys(policyItem) - - // // I should filter - // const config = sdlNode.toConfig() - // const newFields = {} - // for (const [name, fieldConfig] of Object.entries(config.fields)) { - // // TODO should check if fieldConfig.type is public as well - // if (!policyFields.includes(name)) { - // newFields[name] = fieldConfig - // continue - // } else { - // // try run the resolver! - // // TODO how to check async resolvers?????? - // const resolver = fieldConfig.resolve - // } - - // // TODO should manage fieldConfig.args? - // } - - // if (Object.keys(newFields).length === 0) { - // return null - // } - // config.fields = newFields - // return new GraphQLObjectType(config) - // }, - // [MapperKind.INPUT_OBJECT_TYPE]: (sdlNode) => { - // console.log('INPUT_OBJECT_TYPE', sdlNode.name) - // return sdlNode - // }, - // [MapperKind.DIRECTIVE]: (sdlNode) => { - // console.log('DIRECTIVE', sdlNode.name) - // return sdlNode - // }, - // [MapperKind.UNION_TYPE]: (sdlNode) => { - // console.log('UNION_TYPE', sdlNode.name) - // return sdlNode - // }, - // [MapperKind.INTERFACE_TYPE]: (sdlNode) => { - // console.log('INTERFACE_TYPE', sdlNode.name) - // return sdlNode - // }, - // [MapperKind.SCALAR_TYPE]: (sdlNode) => { - // console.log('SCALAR_TYPE', sdlNode.name) - // return sdlNode - // }, - // [MapperKind.ENUM_TYPE]: (sdlNode) => { - // console.log('ENUM_TYPE', sdlNode.name) - // return sdlNode - // } - // }) } registerAuthHandlers (graphQLSchema, policy) { From 2217b463bdb8d74c18287a1ff3ca6ea71414b2d9 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 15 Nov 2021 14:46:38 +0100 Subject: [PATCH 05/33] fix prune --- lib/auth.js | 20 ++++++++------------ package.json | 3 +-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/lib/auth.js b/lib/auth.js index e413ee2..97789ad 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -6,7 +6,7 @@ const { TransformObjectFields } = require('@graphql-tools/wrap') -// const { MapperKind, mapSchema } = require('@graphql-tools/utils') +const { GraphQLObjectType } = require('graphql') const { kApplyPolicy, @@ -143,7 +143,7 @@ class Auth { } } - debugger + // debugger return wrapSchema({ schema: graphQLSchema, @@ -154,16 +154,12 @@ class Auth { } return undefined // unchanged }), - new PruneSchema() - // new TransformInterfaceFields((typeName, fieldName, fieldConfig) => null), - // new TransformCompositeFields((typeName, fieldName, fieldConfig) => undefined), - // new TransformInputObjectFields((typeName, fieldName, inputFieldConfig) => [`new_${fieldName}`, inputFieldConfig]), - // new TransformEnumValues((typeName, enumValue, enumValueConfig) => [`NEW_${enumValue}`, enumValueConfig]) - - // new RemoveObjectFieldDeprecations(/^gateway access only/), - // new RemoveObjectFieldDirectives('deprecated', { reason: /^gateway access only/ }), - // new RemoveObjectFieldsWithDeprecation(/^gateway access only/), - // new RemoveObjectFieldsWithDirective('deprecated', { reason: /^gateway access only/ }) + new PruneSchema({ + skipPruning (type) { + // skip pruning if the type is the Query or Mutation object + return type instanceof GraphQLObjectType && (type.name === 'Query' || type.name === 'Mutation') + } + }) ] }) } diff --git a/package.json b/package.json index ea842d4..d7eb6da 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "autocannon": "^7.0.5", "concurrently": "^6.1.0", "fastify": "^3.0.2", - "mercurius": "^8.9.0", + "mercurius": "github:mercurius-js/mercurius#master", "pre-commit": "^1.2.2", "snazzy": "^9.0.0", "standard": "^16.0.3", @@ -48,7 +48,6 @@ "wait-on": "^6.0.0" }, "dependencies": { - "@graphql-tools/utils": "^8.5.3", "@graphql-tools/wrap": "^8.3.2", "fastify-error": "^0.3.0", "fastify-plugin": "^3.0.0", From 30a3d2f4232c430fdbf2992c9b3135b940351281 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 15 Nov 2021 15:37:08 +0100 Subject: [PATCH 06/33] fix tests --- index.js | 18 +++++++++++++----- lib/auth.js | 1 - 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 369023e..a54da15 100644 --- a/index.js +++ b/index.js @@ -31,6 +31,9 @@ const plugin = fp( if (!app[kSchemaFilterHook]) { app.graphql.addHook('preExecution', async function filterHook (schema, document, context) { + if (!isIntrospection(document)) { + return + } const filteredSchema = await auth.filterDirectives(schema, authSchema, context) return { schema: filteredSchema @@ -38,11 +41,6 @@ const plugin = fp( }) app[kSchemaFilterHook] = true } - - // app.graphql.addHook('onResolution', async (execution, context) => { - // require('fs').writeFileSync('./exe.json', JSON.stringify(execution, null, 2)) - // return execution - // }) }, { name: 'mercurius-auth', @@ -52,3 +50,13 @@ const plugin = fp( ) module.exports = plugin + +function isIntrospection (document) { + const queryTypes = document.definitions.filter(def => def.operation === 'query') + for (const qt of queryTypes) { + if (qt.selectionSet.selections.some(sel => sel.name.value === '__schema')) { + return true + } + } + return false +} diff --git a/lib/auth.js b/lib/auth.js index 97789ad..707752c 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -192,7 +192,6 @@ class Auth { } async authContextHook (_schema, _document, context) { - console.log('authContextHook - ', this[kAuthDirective]) const auth = await this[kAuthContext](context) const authMerge = Object.assign({}, context.auth, auth) Object.assign(context, { auth: authMerge }) From cac0cfb8a7d46a554a9277918e1758067dbd6522 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 15 Nov 2021 17:08:56 +0100 Subject: [PATCH 07/33] wip: test --- index.js | 7 +- lib/auth.js | 5 +- test/introspection-filter.js | 185 +++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 test/introspection-filter.js diff --git a/index.js b/index.js index a54da15..3fc5f67 100644 --- a/index.js +++ b/index.js @@ -52,9 +52,14 @@ const plugin = fp( module.exports = plugin function isIntrospection (document) { + // TODO switch the logic: exit when one non-introspection operation is found const queryTypes = document.definitions.filter(def => def.operation === 'query') for (const qt of queryTypes) { - if (qt.selectionSet.selections.some(sel => sel.name.value === '__schema')) { + // TODO: __Schema, __Type, __TypeKind, __Field, __InputValue, __EnumValue, __Directive + if (qt.selectionSet.selections.some(sel => ( + sel.name.value === '__schema' || + sel.name.value === '__type' + ))) { return true } } diff --git a/lib/auth.js b/lib/auth.js index 707752c..370c5c8 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -129,10 +129,7 @@ class Auth { // TODO parameters // canShowDirectiveField = await this[kApplyPolicy](policy, parent, args, context, info) canShowDirectiveField = await this[kApplyPolicy](fieldPolicy, null, {}, context, {}) - if (canShowDirectiveField instanceof Error) { - canShowDirectiveField = false - } - if (!canShowDirectiveField) { + if (canShowDirectiveField instanceof Error || !canShowDirectiveField) { canShowDirectiveField = false } } catch (error) { diff --git a/test/introspection-filter.js b/test/introspection-filter.js new file mode 100644 index 0000000..d860294 --- /dev/null +++ b/test/introspection-filter.js @@ -0,0 +1,185 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const mercurius = require('mercurius') +const mercuriusAuth = require('..') + +const schema = ` + directive @auth on OBJECT | FIELD_DEFINITION + directive @hasRole (role: String!) on OBJECT | FIELD_DEFINITION + directive @hasPermission (grant: String!) on OBJECT | FIELD_DEFINITION + + type Message { + title: String! + message: String @auth + password: String @hasPermission(grant: "see-all") + } + + type AdminMessage @hasRole(role: "admin") { + title: String! + message: String @auth + } + + type Query { + publicMessages(org: String): [Message!] + semiPublicMessages(org: String): [Message!] @auth + privateMessages(org: String): [Message!] @auth @hasRole(role: "admin") + cryptoMessages(org: String): [AdminMessage!] + } +` + +const messages = [ + { + title: 'one', + message: 'acme one', + password: 'acme-one' + }, + { + title: 'two', + message: 'acme two', + password: 'acme-two' + } +] + +const resolvers = { + Query: { + publicMessages: async (parent, args, context, info) => { return messages }, + semiPublicMessages: async (parent, args, context, info) => { return messages }, + privateMessages: async (parent, args, context, info) => { return messages }, + cryptoMessages: async (parent, args, context, info) => { return messages } + } +} + +test('should be able to access the query to determine that users have sufficient access to run related operations', async (t) => { + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema, + resolvers + }) + + app.register(mercuriusAuth, { + authContext: authContext, + applyPolicy: authPolicy, + authDirective: 'auth' + }) + app.register(mercuriusAuth, { + authContext: hasRoleContext, + applyPolicy: hasRolePolicy, + authDirective: 'hasRole' + }) + app.register(mercuriusAuth, { + authContext: hasPermissionContext, + applyPolicy: hasPermissionPolicy, + authDirective: 'hasPermission' + }) + + const queryListBySchema = `{ + __schema { + queryType { + name + fields{ + name + } + } + } + }` + + const queryListByType = `{ + __type(name:"Query"){ + name + fields{ + name + } + } + }` + + ;[ + { + name: 'filter @auth queries using __type', + query: queryListByType, + result: { + data: { + __type: { + name: 'Query', + fields: [ + { name: 'publicMessages' }, + { name: 'cryptoMessages' } + ] + } + } + } + }, + { + name: 'filter @auth queries using __schema', + query: queryListBySchema, + result: { + data: { + __schema: { + queryType: { + name: 'Query', + fields: [ + { name: 'publicMessages' }, + { name: 'cryptoMessages' } + ] + } + } + } + } + }, + { + name: '@auth user queries using __type', + query: queryListByType, + headers: { + 'x-token': 'token' + }, + result: { + data: { + __type: { + name: 'Query', + fields: [ + { name: 'publicMessages' }, + { name: 'semiPublicMessages' }, + { name: 'privateMessages' }, // TODO should be filtered + { name: 'cryptoMessages' } + ] + } + } + } + } + ].forEach(({ name, query, result, headers }) => { + t.test(name, async t => { + t.plan(1) + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + t.same(response.json(), result) + }) + }) +}) + +function authContext (context) { + return { token: context.reply.request.headers['x-token'] || false } +} +async function authPolicy (authDirectiveAST, parent, args, context, info) { + return context.auth.token !== false +} + +function hasRoleContext (context) { + return { role: context.reply.request.headers['x-role'] } +} +async function hasRolePolicy (authDirectiveAST, parent, args, context, info) { + return context.auth.role === authDirectiveAST.arguments.find(arg => arg.name.value === 'role').value.value +} + +function hasPermissionContext (context) { + return { permission: context.reply.request.headers['x-permission'] } +} +async function hasPermissionPolicy (authDirectiveAST, parent, args, context, info) { + return context.auth.permission === authDirectiveAST.arguments.find(arg => arg.name.value === 'grant').value.value +} From adfab9921d1e0cf8a531db67331bd13463eff46b Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 15 Nov 2021 17:16:24 +0100 Subject: [PATCH 08/33] fix policy execution --- index.js | 25 +++++++++++++------------ test/introspection-filter.js | 1 - 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/index.js b/index.js index 3fc5f67..815efb6 100644 --- a/index.js +++ b/index.js @@ -29,18 +29,19 @@ const plugin = fp( app.graphql.addHook('preExecution', auth.authContextHook.bind(auth)) } - if (!app[kSchemaFilterHook]) { - app.graphql.addHook('preExecution', async function filterHook (schema, document, context) { - if (!isIntrospection(document)) { - return - } - const filteredSchema = await auth.filterDirectives(schema, authSchema, context) - return { - schema: filteredSchema - } - }) - app[kSchemaFilterHook] = true - } + // if (!app[kSchemaFilterHook]) { + app.graphql.addHook('preExecution', async function filterHook (schema, document, context) { + if (!isIntrospection(document)) { + // TODO check once if the document is introspection - now it's done for each directive + return + } + const filteredSchema = await auth.filterDirectives(schema, authSchema, context) + return { + schema: filteredSchema + } + }) + app[kSchemaFilterHook] = true + // } }, { name: 'mercurius-auth', diff --git a/test/introspection-filter.js b/test/introspection-filter.js index d860294..ffa92f9 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -142,7 +142,6 @@ test('should be able to access the query to determine that users have sufficient fields: [ { name: 'publicMessages' }, { name: 'semiPublicMessages' }, - { name: 'privateMessages' }, // TODO should be filtered { name: 'cryptoMessages' } ] } From 94b46b7e3ef75a95e7f1b8c56bf0732685f6c271 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 15 Nov 2021 17:21:57 +0100 Subject: [PATCH 09/33] full coverage --- test/introspection-filter.js | 52 +++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/test/introspection-filter.js b/test/introspection-filter.js index ffa92f9..f1df1ca 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -147,6 +147,51 @@ test('should be able to access the query to determine that users have sufficient } } } + }, + { + name: '@auth user with valid role queries using __schema', + query: queryListBySchema, + headers: { + 'x-token': 'token', + 'x-role': 'admin' + }, + result: { + data: { + __schema: { + queryType: { + name: 'Query', + fields: [ + { name: 'publicMessages' }, + { name: 'semiPublicMessages' }, + { name: 'privateMessages' }, + { name: 'cryptoMessages' } + ] + } + } + } + } + }, + { + name: '@auth user with INVALID role queries using __schema', + query: queryListBySchema, + headers: { + 'x-token': 'token', + 'x-role': 'viewer' + }, + result: { + data: { + __schema: { + queryType: { + name: 'Query', + fields: [ + { name: 'publicMessages' }, + { name: 'semiPublicMessages' }, + { name: 'cryptoMessages' } + ] + } + } + } + } } ].forEach(({ name, query, result, headers }) => { t.test(name, async t => { @@ -180,5 +225,10 @@ function hasPermissionContext (context) { return { permission: context.reply.request.headers['x-permission'] } } async function hasPermissionPolicy (authDirectiveAST, parent, args, context, info) { - return context.auth.permission === authDirectiveAST.arguments.find(arg => arg.name.value === 'grant').value.value + const needed = authDirectiveAST.arguments.find(arg => arg.name.value === 'grant').value.value + const hasGrant = context.auth.permission === needed + if (!hasGrant) { + throw new Error(`Needed ${needed} grant`) + } + return true } From ff27b42ae54082a89e860aacf0b4c75ede90e1cf Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 16 Nov 2021 12:40:26 +0100 Subject: [PATCH 10/33] feat: namespace --- index.js | 51 +++++++++++++++++++++++++++--------- lib/auth.js | 51 ------------------------------------ lib/filter-schema.js | 50 +++++++++++++++++++++++++++++++++++ test/introspection-filter.js | 42 +++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 63 deletions(-) create mode 100644 lib/filter-schema.js diff --git a/index.js b/index.js index 815efb6..0a849cf 100644 --- a/index.js +++ b/index.js @@ -3,8 +3,9 @@ const fp = require('fastify-plugin') const Auth = require('./lib/auth') const { validateOpts } = require('./lib/validation') +const { filterSchema } = require('./lib/filter-schema') -const kSchemaFilterHook = Symbol('schemaFilterHook') +const kDirectiveNamespace = Symbol('mercurius-auth.namespace') const plugin = fp( async function (app, opts) { @@ -29,19 +30,25 @@ const plugin = fp( app.graphql.addHook('preExecution', auth.authContextHook.bind(auth)) } - // if (!app[kSchemaFilterHook]) { - app.graphql.addHook('preExecution', async function filterHook (schema, document, context) { - if (!isIntrospection(document)) { - // TODO check once if the document is introspection - now it's done for each directive - return + if (opts.namespace && opts.authDirective) { + if (!app[kDirectiveNamespace]) { + app[kDirectiveNamespace] = {} + + // the filter hook must be the last one to be executed (after all the authContextHook ones) + app.ready(err => { + // todo recreate this use case + /* istanbul ignore next */ + if (err) throw err + app.graphql.addHook('preExecution', filterGraphQLSchemaHook(opts.namespace).bind(app)) + }) } - const filteredSchema = await auth.filterDirectives(schema, authSchema, context) - return { - schema: filteredSchema + + if (app[kDirectiveNamespace][opts.namespace]) { + app[kDirectiveNamespace][opts.namespace].push({ authSchema, authFunction: opts.applyPolicy }) + } else { + app[kDirectiveNamespace][opts.namespace] = [{ authSchema, authFunction: opts.applyPolicy }] } - }) - app[kSchemaFilterHook] = true - // } + } }, { name: 'mercurius-auth', @@ -52,6 +59,26 @@ const plugin = fp( module.exports = plugin +function filterGraphQLSchemaHook (namespace) { + return async function filterHook (schema, document, context) { + if (!isIntrospection(document)) { + // TODO check once if the document is introspection - now it's done for each directive + return + } + let filteredSchema = schema + const authSchemaArray = this[kDirectiveNamespace][namespace] + + // TODO: merge the authSchema into one object and call filterSchema only once + for (const { authSchema, authFunction } of authSchemaArray) { + filteredSchema = await filterSchema(filteredSchema, authSchema, authFunction, context) + } + + return { + schema: filteredSchema + } + } +} + function isIntrospection (document) { // TODO switch the logic: exit when one non-introspection operation is found const queryTypes = document.definitions.filter(def => def.operation === 'query') diff --git a/lib/auth.js b/lib/auth.js index 370c5c8..68059be 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,13 +1,5 @@ 'use strict' -const { - wrapSchema, - PruneSchema, - TransformObjectFields -} = require('@graphql-tools/wrap') - -const { GraphQLObjectType } = require('graphql') - const { kApplyPolicy, kAuthContext, @@ -118,49 +110,6 @@ class Auth { return this[kBuildPolicy](graphQLSchema) } - async filterDirectives (graphQLSchema, policy, context) { - const filterDirectiveMap = {} - - for (const [typeName, typePolicy] of Object.entries(policy)) { - filterDirectiveMap[typeName] = {} - for (const [fieldName, fieldPolicy] of Object.entries(typePolicy)) { - let canShowDirectiveField = true - try { - // TODO parameters - // canShowDirectiveField = await this[kApplyPolicy](policy, parent, args, context, info) - canShowDirectiveField = await this[kApplyPolicy](fieldPolicy, null, {}, context, {}) - if (canShowDirectiveField instanceof Error || !canShowDirectiveField) { - canShowDirectiveField = false - } - } catch (error) { - canShowDirectiveField = false - } - - filterDirectiveMap[typeName][fieldName] = canShowDirectiveField - } - } - - // debugger - - return wrapSchema({ - schema: graphQLSchema, - transforms: [ - new TransformObjectFields((typeName, fieldName, fieldConfig) => { - if (filterDirectiveMap[typeName] && filterDirectiveMap[typeName][fieldName] === false) { - return null // omit the field - } - return undefined // unchanged - }), - new PruneSchema({ - skipPruning (type) { - // skip pruning if the type is the Query or Mutation object - return type instanceof GraphQLObjectType && (type.name === 'Query' || type.name === 'Mutation') - } - }) - ] - }) - } - registerAuthHandlers (graphQLSchema, policy) { for (const [typeName, typePolicy] of Object.entries(policy)) { const schemaType = graphQLSchema.getType(typeName) diff --git a/lib/filter-schema.js b/lib/filter-schema.js new file mode 100644 index 0000000..d098573 --- /dev/null +++ b/lib/filter-schema.js @@ -0,0 +1,50 @@ +'use strict' + +const { + wrapSchema, + PruneSchema, + TransformObjectFields +} = require('@graphql-tools/wrap') + +const { GraphQLObjectType } = require('graphql') + +module.exports.filterSchema = async function filter (graphQLSchema, policy, authFunction, context) { + const filterDirectiveMap = {} + + for (const [typeName, typePolicy] of Object.entries(policy)) { + filterDirectiveMap[typeName] = {} + for (const [fieldName, fieldPolicy] of Object.entries(typePolicy)) { + let canShowDirectiveField = true + try { + // TODO parameters + // canShowDirectiveField = await this[kApplyPolicy](policy, parent, args, context, info) + canShowDirectiveField = await authFunction(fieldPolicy, null, {}, context, {}) + if (canShowDirectiveField instanceof Error || !canShowDirectiveField) { + canShowDirectiveField = false + } + } catch (error) { + canShowDirectiveField = false + } + + filterDirectiveMap[typeName][fieldName] = canShowDirectiveField + } + } + + return wrapSchema({ + schema: graphQLSchema, + transforms: [ + new TransformObjectFields((typeName, fieldName, fieldConfig) => { + if (filterDirectiveMap[typeName] && filterDirectiveMap[typeName][fieldName] === false) { + return null // omit the field + } + return undefined // unchanged + }), + new PruneSchema({ + skipPruning (type) { + // skip pruning if the type is the Query or Mutation object + return type instanceof GraphQLObjectType && (type.name === 'Query' || type.name === 'Mutation') + } + }) + ] + }) +} diff --git a/test/introspection-filter.js b/test/introspection-filter.js index f1df1ca..80b8763 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -63,16 +63,19 @@ test('should be able to access the query to determine that users have sufficient app.register(mercuriusAuth, { authContext: authContext, applyPolicy: authPolicy, + namespace: 'authorization-filtering', authDirective: 'auth' }) app.register(mercuriusAuth, { authContext: hasRoleContext, applyPolicy: hasRolePolicy, + namespace: 'authorization-filtering', authDirective: 'hasRole' }) app.register(mercuriusAuth, { authContext: hasPermissionContext, applyPolicy: hasPermissionPolicy, + namespace: 'authorization-filtering', authDirective: 'hasPermission' }) @@ -97,6 +100,18 @@ test('should be able to access the query to determine that users have sufficient }` ;[ + { + name: 'simple not introspection query', + query: '{ publicMessages { title } }', + result: { + data: { + publicMessages: [ + { title: 'one' }, + { title: 'two' } + ] + } + } + }, { name: 'filter @auth queries using __type', query: queryListByType, @@ -207,6 +222,33 @@ test('should be able to access the query to determine that users have sufficient }) }) +test('the single filter preExecution lets the app crash', async (t) => { + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema, + resolvers + }) + + app.register(mercuriusAuth, { + authContext: authContext, + applyPolicy: authPolicy, + namespace: 'authorization-filtering', + authDirective: 'auth' + }) + + app.register(async function plugin () { + throw new Error('boom') + }) + + try { + await app.listen(0) + } catch (error) { + t.equal(error.message, 'boom') + } +}) + function authContext (context) { return { token: context.reply.request.headers['x-token'] || false } } From bf9705684e6deb26a5e13870dcfd2448b2f2ad0b Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 16 Nov 2021 16:17:31 +0100 Subject: [PATCH 11/33] fix filter schema once --- index.js | 20 +++++++++++--------- lib/filter-schema.js | 44 ++++++++++++++++++++++++++++++-------------- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/index.js b/index.js index 0a849cf..b53dc2d 100644 --- a/index.js +++ b/index.js @@ -44,9 +44,15 @@ const plugin = fp( } if (app[kDirectiveNamespace][opts.namespace]) { - app[kDirectiveNamespace][opts.namespace].push({ authSchema, authFunction: opts.applyPolicy }) + app[kDirectiveNamespace][opts.namespace].push({ + policy: authSchema, + policyFunction: opts.applyPolicy + }) } else { - app[kDirectiveNamespace][opts.namespace] = [{ authSchema, authFunction: opts.applyPolicy }] + app[kDirectiveNamespace][opts.namespace] = [{ + policy: authSchema, + policyFunction: opts.applyPolicy + }] } } }, @@ -62,16 +68,12 @@ module.exports = plugin function filterGraphQLSchemaHook (namespace) { return async function filterHook (schema, document, context) { if (!isIntrospection(document)) { - // TODO check once if the document is introspection - now it's done for each directive return } - let filteredSchema = schema - const authSchemaArray = this[kDirectiveNamespace][namespace] - // TODO: merge the authSchema into one object and call filterSchema only once - for (const { authSchema, authFunction } of authSchemaArray) { - filteredSchema = await filterSchema(filteredSchema, authSchema, authFunction, context) - } + const filteredSchema = await filterSchema(schema, + this[kDirectiveNamespace][namespace], + context) return { schema: filteredSchema diff --git a/lib/filter-schema.js b/lib/filter-schema.js index d098573..bbdaa85 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -8,25 +8,41 @@ const { const { GraphQLObjectType } = require('graphql') -module.exports.filterSchema = async function filter (graphQLSchema, policy, authFunction, context) { +module.exports.filterSchema = async function filter (graphQLSchema, policies, context) { const filterDirectiveMap = {} - for (const [typeName, typePolicy] of Object.entries(policy)) { - filterDirectiveMap[typeName] = {} - for (const [fieldName, fieldPolicy] of Object.entries(typePolicy)) { - let canShowDirectiveField = true - try { - // TODO parameters - // canShowDirectiveField = await this[kApplyPolicy](policy, parent, args, context, info) - canShowDirectiveField = await authFunction(fieldPolicy, null, {}, context, {}) - if (canShowDirectiveField instanceof Error || !canShowDirectiveField) { + // each `policies` item is a directive + for (const { policy, policyFunction } of policies) { + // each `policy` contains all the GraphQL OBJECT and FIELDS that are affected by the directive + for (const [typeName, typePolicy] of Object.entries(policy)) { + // different `policies` item can affect the same GraphQL OBJECT + if (!filterDirectiveMap[typeName]) { + filterDirectiveMap[typeName] = {} + } + + for (const [fieldName, fieldPolicy] of Object.entries(typePolicy)) { + // each `fieldName` is a single GraphQL item associated with the directive + + if (filterDirectiveMap[typeName][fieldName] === false) { + // if we have already decided to filter out this field + // it does not need to be checked again + continue + } + + let canShowDirectiveField = true + try { + // TODO parameters + // canShowDirectiveField = await this[kApplyPolicy](policy, parent, args, context, info) + canShowDirectiveField = await policyFunction(fieldPolicy, null, {}, context, {}) + if (canShowDirectiveField instanceof Error || !canShowDirectiveField) { + canShowDirectiveField = false + } + } catch (error) { canShowDirectiveField = false } - } catch (error) { - canShowDirectiveField = false - } - filterDirectiveMap[typeName][fieldName] = canShowDirectiveField + filterDirectiveMap[typeName][fieldName] = canShowDirectiveField + } } } From 31ebe245da40a04df40c9b55a889fd21c2cab5fb Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 16 Nov 2021 17:29:31 +0100 Subject: [PATCH 12/33] filter schema once --- lib/filter-schema.js | 17 +++++++- test/introspection-filter.js | 76 ++++++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/lib/filter-schema.js b/lib/filter-schema.js index bbdaa85..69f84c9 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -3,6 +3,7 @@ const { wrapSchema, PruneSchema, + FilterTypes, TransformObjectFields } = require('@graphql-tools/wrap') @@ -16,8 +17,11 @@ module.exports.filterSchema = async function filter (graphQLSchema, policies, co // each `policy` contains all the GraphQL OBJECT and FIELDS that are affected by the directive for (const [typeName, typePolicy] of Object.entries(policy)) { // different `policies` item can affect the same GraphQL OBJECT - if (!filterDirectiveMap[typeName]) { + if (filterDirectiveMap[typeName] === undefined) { filterDirectiveMap[typeName] = {} + } else if (filterDirectiveMap[typeName] === false) { + // if the object has already been filtered, we can skip the field processing + continue } for (const [fieldName, fieldPolicy] of Object.entries(typePolicy)) { @@ -41,7 +45,12 @@ module.exports.filterSchema = async function filter (graphQLSchema, policies, co canShowDirectiveField = false } - filterDirectiveMap[typeName][fieldName] = canShowDirectiveField + if (canShowDirectiveField === false && fieldName === '__typePolicy') { + // the directive is assigned to a GraphQL OBJECT so we need to filter out all the fields + filterDirectiveMap[typeName] = canShowDirectiveField + } else { + filterDirectiveMap[typeName][fieldName] = canShowDirectiveField + } } } } @@ -49,6 +58,10 @@ module.exports.filterSchema = async function filter (graphQLSchema, policies, co return wrapSchema({ schema: graphQLSchema, transforms: [ + new FilterTypes(type => { + // should we filter out this whole type? + return filterDirectiveMap[type.name] !== false + }), new TransformObjectFields((typeName, fieldName, fieldConfig) => { if (filterDirectiveMap[typeName] && filterDirectiveMap[typeName][fieldName] === false) { return null // omit the field diff --git a/test/introspection-filter.js b/test/introspection-filter.js index 80b8763..b65baec 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -3,6 +3,7 @@ const { test } = require('tap') const Fastify = require('fastify') const mercurius = require('mercurius') +const { getIntrospectionQuery } = require('graphql') const mercuriusAuth = require('..') const schema = ` @@ -19,13 +20,22 @@ const schema = ` type AdminMessage @hasRole(role: "admin") { title: String! message: String @auth + password: String @hasPermission(grant: "see-all-admin") } + type SimpleMessage { + title: String! + message: String @auth + } + + union MessageUnion = AdminMessage | SimpleMessage + type Query { publicMessages(org: String): [Message!] semiPublicMessages(org: String): [Message!] @auth privateMessages(org: String): [Message!] @auth @hasRole(role: "admin") - cryptoMessages(org: String): [AdminMessage!] + cryptoMessages(org: String): [MessageUnion!] + adminMessages(org: String): [AdminMessage!] } ` @@ -47,7 +57,8 @@ const resolvers = { publicMessages: async (parent, args, context, info) => { return messages }, semiPublicMessages: async (parent, args, context, info) => { return messages }, privateMessages: async (parent, args, context, info) => { return messages }, - cryptoMessages: async (parent, args, context, info) => { return messages } + cryptoMessages: async (parent, args, context, info) => { return messages }, + adminMessages: async (parent, args, context, info) => { return messages } } } @@ -99,6 +110,15 @@ test('should be able to access the query to determine that users have sufficient } }` + const queryObjectMessage = `{ + __type(name: "Message") { + name + fields { + name + } + } + }` + ;[ { name: 'simple not introspection query', @@ -122,6 +142,8 @@ test('should be able to access the query to determine that users have sufficient fields: [ { name: 'publicMessages' }, { name: 'cryptoMessages' } + // notes that the adminMessages query is filtered out + // because we don't satisfy the AdminMessage @hasRole(role: "admin") ] } } @@ -179,7 +201,8 @@ test('should be able to access the query to determine that users have sufficient { name: 'publicMessages' }, { name: 'semiPublicMessages' }, { name: 'privateMessages' }, - { name: 'cryptoMessages' } + { name: 'cryptoMessages' }, + { name: 'adminMessages' } ] } } @@ -207,6 +230,44 @@ test('should be able to access the query to determine that users have sufficient } } } + }, + { + name: 'Message type with INVALID permission', + query: queryObjectMessage, + headers: { + 'x-token': 'token', + 'x-permission': 'none' + }, + result: { + data: { + __type: { + name: 'Message', + fields: [ + { name: 'title' }, + { name: 'message' } + ] + } + } + } + }, + { + name: 'Complete introspection query', + query: getIntrospectionQuery(), + headers: { + 'x-token': 'token', + 'x-role': 'not-an-admin', + 'x-permission': 'see-all' + }, + result (t, responseJson) { + t.plan(3) + const { types } = responseJson.data.__schema + + t.notOk(types.find(type => type.name === 'AdminMessage'), 'the AdminMessage type has been filtered') + + const objMessage = types.find(type => type.name === 'Message') + t.ok(objMessage, 'the Message type is present') + t.ok(objMessage.fields.find(field => field.name === 'password'), 'role is right') + } } ].forEach(({ name, query, result, headers }) => { t.test(name, async t => { @@ -217,7 +278,14 @@ test('should be able to access the query to determine that users have sufficient url: '/graphql', body: JSON.stringify({ query }) }) - t.same(response.json(), result) + + if (typeof result !== 'function') { + t.same(response.json(), result) + } else { + t.test('response', t => { + result(t, response.json()) + }) + } }) }) }) From 8cbfca8b6c398f9af79649e47f4a76199e0f9d5d Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 16 Nov 2021 17:57:58 +0100 Subject: [PATCH 13/33] add UNION tests --- test/introspection-filter.js | 176 +++++++++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 29 deletions(-) diff --git a/test/introspection-filter.js b/test/introspection-filter.js index b65baec..6e22e23 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -39,6 +39,35 @@ const schema = ` } ` +const queryListBySchema = `{ + __schema { + queryType { + name + fields{ + name + } + } + } +}` + +const queryListByType = `{ + __type(name:"Query"){ + name + fields{ + name + } + } +}` + +const queryObjectMessage = `{ + __type(name: "Message") { + name + fields { + name + } + } +}` + const messages = [ { title: 'one', @@ -90,35 +119,6 @@ test('should be able to access the query to determine that users have sufficient authDirective: 'hasPermission' }) - const queryListBySchema = `{ - __schema { - queryType { - name - fields{ - name - } - } - } - }` - - const queryListByType = `{ - __type(name:"Query"){ - name - fields{ - name - } - } - }` - - const queryObjectMessage = `{ - __type(name: "Message") { - name - fields { - name - } - } - }` - ;[ { name: 'simple not introspection query', @@ -290,6 +290,124 @@ test('should be able to access the query to determine that users have sufficient }) }) +test('UNION check filtering', async (t) => { + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @unionCheck on UNION | FIELD_DEFINITION + + type Message { + title: String! + password: String @unionCheck + } + + type SimpleMessage { + title: String! + message: String + } + + union MessageUnion @unionCheck = Message | SimpleMessage + + type Query { + publicMessages(org: String): [MessageUnion!] + } + ` + }) + + app.register(mercuriusAuth, { + applyPolicy: async function hasRolePolicy (authDirectiveAST, parent, args, context, info) { + return context.reply.request.headers['x-union'] === 'show' + }, + namespace: 'authorization-filtering', + authDirective: 'unionCheck' + }) + + ;[ + { + name: 'show UNION type', + query: queryListByType, + headers: { + 'x-union': 'show' + }, + result: { + data: { + __type: { + name: 'Query', + fields: [ + { name: 'publicMessages' } + ] + } + } + } + }, + { + name: 'hide UNION type', + query: queryListByType, + headers: { + 'x-union': 'hide' + }, + result: { + data: { + __type: { + name: 'Query', + fields: [] + } + } + } + }, + { + name: 'show UNION type', + query: queryObjectMessage, + headers: { + 'x-union': 'show' + }, + result: { + data: { + __type: { + name: 'Message', + fields: [ + { name: 'title' }, + { name: 'password' } + ] + } + } + } + }, + { + name: 'hide UNION type - cannot access to this type', + query: queryObjectMessage, + headers: { + 'x-union': 'hide' + }, + result: { + data: { + __type: null + } + } + } + ].forEach(({ name, query, result, headers }) => { + t.test(name, async t => { + t.plan(1) + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + if (typeof result !== 'function') { + t.same(response.json(), result) + } else { + t.test('response', t => { + result(t, response.json()) + }) + } + }) + }) +}) + test('the single filter preExecution lets the app crash', async (t) => { const app = Fastify() t.teardown(app.close.bind(app)) From e1ed78d93e372cb99c08039599179eb48bd7ae1c Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 16 Nov 2021 18:41:07 +0100 Subject: [PATCH 14/33] refactor --- index.js | 59 ++--------------------------------- lib/filter-schema.js | 60 +++++++++++++++++++++++++++++++++++- test/introspection-filter.js | 2 +- 3 files changed, 62 insertions(+), 59 deletions(-) diff --git a/index.js b/index.js index b53dc2d..4b7ce10 100644 --- a/index.js +++ b/index.js @@ -3,9 +3,7 @@ const fp = require('fastify-plugin') const Auth = require('./lib/auth') const { validateOpts } = require('./lib/validation') -const { filterSchema } = require('./lib/filter-schema') - -const kDirectiveNamespace = Symbol('mercurius-auth.namespace') +const filterSchema = require('./lib/filter-schema') const plugin = fp( async function (app, opts) { @@ -31,29 +29,7 @@ const plugin = fp( } if (opts.namespace && opts.authDirective) { - if (!app[kDirectiveNamespace]) { - app[kDirectiveNamespace] = {} - - // the filter hook must be the last one to be executed (after all the authContextHook ones) - app.ready(err => { - // todo recreate this use case - /* istanbul ignore next */ - if (err) throw err - app.graphql.addHook('preExecution', filterGraphQLSchemaHook(opts.namespace).bind(app)) - }) - } - - if (app[kDirectiveNamespace][opts.namespace]) { - app[kDirectiveNamespace][opts.namespace].push({ - policy: authSchema, - policyFunction: opts.applyPolicy - }) - } else { - app[kDirectiveNamespace][opts.namespace] = [{ - policy: authSchema, - policyFunction: opts.applyPolicy - }] - } + filterSchema(app, authSchema, opts) } }, { @@ -64,34 +40,3 @@ const plugin = fp( ) module.exports = plugin - -function filterGraphQLSchemaHook (namespace) { - return async function filterHook (schema, document, context) { - if (!isIntrospection(document)) { - return - } - - const filteredSchema = await filterSchema(schema, - this[kDirectiveNamespace][namespace], - context) - - return { - schema: filteredSchema - } - } -} - -function isIntrospection (document) { - // TODO switch the logic: exit when one non-introspection operation is found - const queryTypes = document.definitions.filter(def => def.operation === 'query') - for (const qt of queryTypes) { - // TODO: __Schema, __Type, __TypeKind, __Field, __InputValue, __EnumValue, __Directive - if (qt.selectionSet.selections.some(sel => ( - sel.name.value === '__schema' || - sel.name.value === '__type' - ))) { - return true - } - } - return false -} diff --git a/lib/filter-schema.js b/lib/filter-schema.js index 69f84c9..4255a80 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -7,9 +7,67 @@ const { TransformObjectFields } = require('@graphql-tools/wrap') +const kDirectiveNamespace = Symbol('mercurius-auth.namespace') + const { GraphQLObjectType } = require('graphql') -module.exports.filterSchema = async function filter (graphQLSchema, policies, context) { +module.exports = function filterIntrospectionSchema (app, policy, { namespace, applyPolicy: policyFunction }) { + if (!app[kDirectiveNamespace]) { + app[kDirectiveNamespace] = {} + + // the filter hook must be the last one to be executed (after all the authContextHook ones) + app.ready(err => { + /* istanbul ignore next */ + if (err) throw err + app.graphql.addHook('preExecution', filterGraphQLSchemaHook(namespace).bind(app)) + }) + } + + if (app[kDirectiveNamespace][namespace]) { + app[kDirectiveNamespace][namespace].push({ + policy, + policyFunction + }) + } else { + app[kDirectiveNamespace][namespace] = [{ + policy, + policyFunction + }] + } +} + +function filterGraphQLSchemaHook (namespace) { + return async function filterHook (schema, document, context) { + if (!isIntrospection(document)) { + return + } + + const filteredSchema = await filterSchema(schema, + this[kDirectiveNamespace][namespace], + context) + + return { + schema: filteredSchema + } + } +} + +function isIntrospection (document) { + // TODO switch the logic: exit when one non-introspection operation is found + const queryTypes = document.definitions.filter(def => def.operation === 'query') + for (const qt of queryTypes) { + // TODO: __Schema, __Type, __TypeKind, __Field, __InputValue, __EnumValue, __Directive + if (qt.selectionSet.selections.some(sel => ( + sel.name.value === '__schema' || + sel.name.value === '__type' + ))) { + return true + } + } + return false +} + +async function filterSchema (graphQLSchema, policies, context) { const filterDirectiveMap = {} // each `policies` item is a directive diff --git a/test/introspection-filter.js b/test/introspection-filter.js index 6e22e23..8333df9 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -429,7 +429,7 @@ test('the single filter preExecution lets the app crash', async (t) => { }) try { - await app.listen(0) + await app.ready() } catch (error) { t.equal(error.message, 'boom') } From 0d9d7331cc6e9686fd507af8ba58d7f7cdca14a3 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 16 Nov 2021 18:53:45 +0100 Subject: [PATCH 15/33] check optimization --- lib/filter-schema.js | 13 ++++++++----- test/introspection-filter.js | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/filter-schema.js b/lib/filter-schema.js index 4255a80..69a3c51 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -53,11 +53,14 @@ function filterGraphQLSchemaHook (namespace) { } function isIntrospection (document) { - // TODO switch the logic: exit when one non-introspection operation is found - const queryTypes = document.definitions.filter(def => def.operation === 'query') - for (const qt of queryTypes) { - // TODO: __Schema, __Type, __TypeKind, __Field, __InputValue, __EnumValue, __Directive - if (qt.selectionSet.selections.some(sel => ( + for (const queryType of document.definitions) { + if (queryType.operation !== 'query') { + // if there is a mutation or subscription, we can skip the introspection check + break + } + + // if there is an introspection operation, we must filter the schema + if (queryType.selectionSet.selections.some(sel => ( sel.name.value === '__schema' || sel.name.value === '__type' ))) { diff --git a/test/introspection-filter.js b/test/introspection-filter.js index 8333df9..217a07d 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -132,6 +132,32 @@ test('should be able to access the query to determine that users have sufficient } } }, + { + name: 'simple query with auth failing', + query: '{ semiPublicMessages { title } }', + result: { + data: { semiPublicMessages: null }, + errors: [{ + message: 'Failed auth policy check on semiPublicMessages', + locations: [{ line: 1, column: 3 }], + path: ['semiPublicMessages'] + }] + } + }, + { + name: 'simple query within an inspection query filter the schema and avoid triggering errors', + query: `{ + __type(name:"Query"){ name } + semiPublicMessages { title } + }`, + result: { + data: { + __type: { + name: 'Query' + } + } + } + }, { name: 'filter @auth queries using __type', query: queryListByType, From 18fd4fd8052e3ad390b2fe71784ab91343f3d5a0 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 16 Nov 2021 19:04:22 +0100 Subject: [PATCH 16/33] fix coverage --- test/introspection-filter.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/introspection-filter.js b/test/introspection-filter.js index 217a07d..67da5c7 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -37,6 +37,10 @@ const schema = ` cryptoMessages(org: String): [MessageUnion!] adminMessages(org: String): [AdminMessage!] } + + type Mutation { + createMessage(txt: String!): Boolean! + } ` const queryListBySchema = `{ @@ -88,6 +92,9 @@ const resolvers = { privateMessages: async (parent, args, context, info) => { return messages }, cryptoMessages: async (parent, args, context, info) => { return messages }, adminMessages: async (parent, args, context, info) => { return messages } + }, + Mutation: { + createMessage: async () => true } } @@ -132,6 +139,15 @@ test('should be able to access the query to determine that users have sufficient } } }, + { + name: 'simple not introspection query', + query: 'mutation { createMessage(txt:"hello") }', + result: { + data: { + createMessage: true + } + } + }, { name: 'simple query with auth failing', query: '{ semiPublicMessages { title } }', From dd0e7bda02c9c77cc233d7e91b6d2c6ab28c52de Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Thu, 18 Nov 2021 13:33:46 +0100 Subject: [PATCH 17/33] fix info argument --- lib/filter-schema.js | 33 ++++++++-- test/introspection-filter.js | 113 +++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/lib/filter-schema.js b/lib/filter-schema.js index 69a3c51..cff851a 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -74,6 +74,7 @@ async function filterSchema (graphQLSchema, policies, context) { const filterDirectiveMap = {} // each `policies` item is a directive + let skipFiltering = true for (const { policy, policyFunction } of policies) { // each `policy` contains all the GraphQL OBJECT and FIELDS that are affected by the directive for (const [typeName, typePolicy] of Object.entries(policy)) { @@ -85,20 +86,38 @@ async function filterSchema (graphQLSchema, policies, context) { continue } + const schemaType = graphQLSchema.getType(typeName) + const schemaTypeFields = typeof schemaType.getFields === 'function' + ? schemaType.getFields() + : {} for (const [fieldName, fieldPolicy] of Object.entries(typePolicy)) { // each `fieldName` is a single GraphQL item associated with the directive - if (filterDirectiveMap[typeName][fieldName] === false) { + if (filterDirectiveMap[typeName] === false || filterDirectiveMap[typeName][fieldName] === false) { // if we have already decided to filter out this field // it does not need to be checked again continue } let canShowDirectiveField = true + const isObjectPolicy = fieldName === '__typePolicy' try { - // TODO parameters - // canShowDirectiveField = await this[kApplyPolicy](policy, parent, args, context, info) - canShowDirectiveField = await policyFunction(fieldPolicy, null, {}, context, {}) + // https://github.com/graphql/graphql-js/blob/main/src/type/definition.ts#L974 + const info = { + fieldName, + fieldNodes: schemaType.astNode.fields, + returnType: isObjectPolicy ? '' : schemaTypeFields[fieldName].type, // TODO + parentType: schemaType, + schema: graphQLSchema, + fragments: {}, + rootValue: {}, + operation: { kind: 'OperationDefinition', operation: 'query' }, + variableValues: {} + } + // The undefined parameters are: https://graphql.org/learn/execution/#root-fields-resolvers + // - parent: it is not possible know it since the resolver is not executed yet + // - args: it is not expected that the introspection query will have arguments for the directives policies + canShowDirectiveField = await policyFunction(fieldPolicy, undefined, undefined, context, info) if (canShowDirectiveField instanceof Error || !canShowDirectiveField) { canShowDirectiveField = false } @@ -106,7 +125,8 @@ async function filterSchema (graphQLSchema, policies, context) { canShowDirectiveField = false } - if (canShowDirectiveField === false && fieldName === '__typePolicy') { + skipFiltering = skipFiltering && canShowDirectiveField + if (canShowDirectiveField === false && isObjectPolicy) { // the directive is assigned to a GraphQL OBJECT so we need to filter out all the fields filterDirectiveMap[typeName] = canShowDirectiveField } else { @@ -116,6 +136,9 @@ async function filterSchema (graphQLSchema, policies, context) { } } + if (skipFiltering) { + return graphQLSchema + } return wrapSchema({ schema: graphQLSchema, transforms: [ diff --git a/test/introspection-filter.js b/test/introspection-filter.js index 67da5c7..bbdf461 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -477,6 +477,119 @@ test('the single filter preExecution lets the app crash', async (t) => { } }) +test("check directive's arguments on FIELD_DEFINITION", async (t) => { + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @auth on FIELD_DEFINITION + + type Query { + publicMessages: [String!] @auth + } + `, + resolvers: { + Query: { + publicMessages: async (parent, args, context, info) => { + return ['messages'] + } + } + } + }) + + let schemaFilteringRun + app.register(mercuriusAuth, { + applyPolicy: async function (authDirectiveAST, parent, args, context, info) { + if (!schemaFilteringRun) { + // this is the schema filtering execution + schemaFilteringRun = { authDirectiveAST, parent, args, context, info } + } else { + t.same(schemaFilteringRun.authDirectiveAST, authDirectiveAST, 'authDirectiveAST') + t.notOk(schemaFilteringRun.parent, 'parent') + t.notOk(schemaFilteringRun.args, 'args') + t.same(schemaFilteringRun.context, context, 'context') + // t.same(schemaFilteringRun.info, info, 'info', { skip: 1, todo: 1 }) + } + return true + }, + namespace: 'authorization-filtering', + authDirective: 'auth' + }) + + const query = `{ + __type(name:"Query"){ name } + publicMessages + }` + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + t.equal(response.statusCode, 200) + t.notOk(response.json().errors) +}) + +test("check directive's arguments on OBJECT", async (t) => { + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @auth on OBJECT + + type Message { + message: String! + } + type Query @auth{ + publicMessages: [Message!] + } + `, + resolvers: { + Query: { + publicMessages: async (parent, args, context, info) => { + return messages + } + } + } + }) + + let schemaFilteringRun + app.register(mercuriusAuth, { + applyPolicy: async function (authDirectiveAST, parent, args, context, info) { + if (!schemaFilteringRun) { + // this is the schema filtering execution + schemaFilteringRun = { authDirectiveAST, parent, args, context, info } + } else { + t.same(schemaFilteringRun.authDirectiveAST, authDirectiveAST, 'authDirectiveAST') + t.notOk(schemaFilteringRun.parent, 'parent') + t.notOk(schemaFilteringRun.args, 'args') + t.same(schemaFilteringRun.context, context, 'context') + // t.same(schemaFilteringRun.info, info, 'info', { skip: 1 }) + } + return true + }, + namespace: 'authorization-filtering', + authDirective: 'auth' + }) + + const query = `{ + __type(name:"Query"){ name } + publicMessages { message } + }` + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + t.equal(response.statusCode, 200) + t.notOk(response.json().errors) +}) + function authContext (context) { return { token: context.reply.request.headers['x-token'] || false } } From f31db99c2cbc00db4a97311a7113ce2dc31853b3 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Fri, 19 Nov 2021 16:57:35 +0100 Subject: [PATCH 18/33] add docs --- docs/auth-context.md | 1 + docs/schema-filtering.md | 119 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 docs/schema-filtering.md diff --git a/docs/auth-context.md b/docs/auth-context.md index 9a21cfe..3b1a07c 100644 --- a/docs/auth-context.md +++ b/docs/auth-context.md @@ -37,6 +37,7 @@ const resolvers = { } } +const app = Fastify() app.register(mercurius, { schema, resolvers diff --git a/docs/schema-filtering.md b/docs/schema-filtering.md new file mode 100644 index 0000000..92d6217 --- /dev/null +++ b/docs/schema-filtering.md @@ -0,0 +1,119 @@ +# GraphQL Schema Filtering + +Using mercurius you can optionally filter the GraphQL schema based on the user's permissions. +This feature limits the [Introspection queries](https://graphql.org/learn/introspection/) visibility. +Doing so, the user will only be able to see the fields that are accessible to them. + +To enable this feature, you can use the `namespace` plugin's option: + +```js +const Fastify = require('fastify') +const mercurius = require('mercurius') +const mercuriusAuth = require('mercurius-auth') + +const schema = ` + directive @hasPermission (grant: String!) on OBJECT | FIELD_DEFINITION + + type Message { + title: String! + message: String! + notes: String @hasPermission(grant: "see-all") + } + + type Query { + publicMessages: [Message!] + } +` + +const app = Fastify() +app.register(mercurius, { + schema, + resolvers +}) + +app.register(mercuriusAuth, { + namespace: 'introspection-filtering', + authDirective: 'hasPermission', + authContext: function (context) { + return { permission: context.reply.request.headers['x-permission'] } + }, + applyPolicy: async function hasPermissionPolicy (authDirectiveAST, parent, args, context, info) { + const needed = authDirectiveAST.arguments.find(arg => arg.name.value === 'grant').value.value + const hasGrant = context.auth.permission === needed + if (!hasGrant) { + throw new Error(`Needed ${needed} grant`) + } + return true + } +}) + +app.listen(3000) +``` + +After starting the server, you can use the following GraphQL query to test the filtering: + +```graphql +{ + __type (name:"Message") { + name + fields { + name + } + } +} +``` + +You should get the following response: + +```json +{ + "data": { + "__type": { + "name": "Message", + "fields": [ + { + "name": "title" + }, + { + "name": "message" + } + ] + } + } +} +``` + +The `notes` field is not accessible to the user because the user doesn't have the `see-all` permission. + +Adding the Request Headers as follows: + +```json +{ + "x-permission": "see-all" +} +``` + +Will make the user able to see the `notes` field. + +### Implementations details + +You must be informed about some details about the filtering feature. + +- During the introspection query, the `applyPolicy` function is executed. +- The `applyPolicy` function doesn't have the input `parent` and `args` arguments set during the introspection run. +- When the HTTP request payload contains an introspection query and a user-land query, you will not get auth errors because the introspection query is executed before the user-land query and filters the schema. Note that the protected fields will **not** be returned as expected, without any security implications. Here is an example of a GraphQL query that will not throw an error: + +```graphql +{ + __type (name:"Message") { + name + fields { + name + } + } + + publicMessages { + notes + } +} +``` From 990d13ef1b5c6f859645cc01de6a15e7aace9b90 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 22 Nov 2021 15:58:40 +0100 Subject: [PATCH 19/33] rename option to filterSchema --- docs/schema-filtering.md | 4 +- index.js | 2 +- lib/filter-schema.js | 50 ++++----- lib/validation.js | 4 + test/introspection-filter-basics.js | 160 ++++++++++++++++++++++++++++ test/introspection-filter.js | 14 +-- test/registration.js | 19 +++- 7 files changed, 213 insertions(+), 40 deletions(-) create mode 100644 test/introspection-filter-basics.js diff --git a/docs/schema-filtering.md b/docs/schema-filtering.md index 92d6217..d6da251 100644 --- a/docs/schema-filtering.md +++ b/docs/schema-filtering.md @@ -4,7 +4,7 @@ Using mercurius you can optionally filter the GraphQL schema based on the user's This feature limits the [Introspection queries](https://graphql.org/learn/introspection/) visibility. Doing so, the user will only be able to see the fields that are accessible to them. -To enable this feature, you can use the `namespace` plugin's option: +To enable this feature, you can use the `filterSchema` plugin's option: ```js const Fastify = require('fastify') @@ -32,7 +32,7 @@ app.register(mercurius, { }) app.register(mercuriusAuth, { - namespace: 'introspection-filtering', + filterSchema: true, authDirective: 'hasPermission', authContext: function (context) { return { permission: context.reply.request.headers['x-permission'] } diff --git a/index.js b/index.js index 4b7ce10..f7be1bd 100644 --- a/index.js +++ b/index.js @@ -28,7 +28,7 @@ const plugin = fp( app.graphql.addHook('preExecution', auth.authContextHook.bind(auth)) } - if (opts.namespace && opts.authDirective) { + if (opts.filterSchema === true) { filterSchema(app, authSchema, opts) } }, diff --git a/lib/filter-schema.js b/lib/filter-schema.js index cff851a..aa23628 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -6,49 +6,39 @@ const { FilterTypes, TransformObjectFields } = require('@graphql-tools/wrap') - -const kDirectiveNamespace = Symbol('mercurius-auth.namespace') - const { GraphQLObjectType } = require('graphql') -module.exports = function filterIntrospectionSchema (app, policy, { namespace, applyPolicy: policyFunction }) { - if (!app[kDirectiveNamespace]) { - app[kDirectiveNamespace] = {} +const kDirectiveGrouping = Symbol('mercurius-auth.filtering.group') + +module.exports = function filterIntrospectionSchema (app, policy, { filterSchema, applyPolicy: policyFunction }) { + if (!app[kDirectiveGrouping]) { + app[kDirectiveGrouping] = [] // the filter hook must be the last one to be executed (after all the authContextHook ones) app.ready(err => { /* istanbul ignore next */ if (err) throw err - app.graphql.addHook('preExecution', filterGraphQLSchemaHook(namespace).bind(app)) + app.graphql.addHook('preExecution', filterGraphQLSchemaHook.bind(app)) }) } - if (app[kDirectiveNamespace][namespace]) { - app[kDirectiveNamespace][namespace].push({ - policy, - policyFunction - }) - } else { - app[kDirectiveNamespace][namespace] = [{ - policy, - policyFunction - }] - } + app[kDirectiveGrouping].push({ + policy, + policyFunction + }) } -function filterGraphQLSchemaHook (namespace) { - return async function filterHook (schema, document, context) { - if (!isIntrospection(document)) { - return - } +async function filterGraphQLSchemaHook (schema, document, context) { + if (!isIntrospection(document)) { + return + } - const filteredSchema = await filterSchema(schema, - this[kDirectiveNamespace][namespace], - context) + const filteredSchema = await filterSchema(schema, + this[kDirectiveGrouping], + context) - return { - schema: filteredSchema - } + return { + schema: filteredSchema } } @@ -103,6 +93,7 @@ async function filterSchema (graphQLSchema, policies, context) { const isObjectPolicy = fieldName === '__typePolicy' try { // https://github.com/graphql/graphql-js/blob/main/src/type/definition.ts#L974 + // TODO check gq.typeFromAST const info = { fieldName, fieldNodes: schemaType.astNode.fields, @@ -139,6 +130,7 @@ async function filterSchema (graphQLSchema, policies, context) { if (skipFiltering) { return graphQLSchema } + return wrapSchema({ schema: graphQLSchema, transforms: [ diff --git a/lib/validation.js b/lib/validation.js index e74669e..94658a6 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -26,6 +26,10 @@ function validateOpts (opts) { } } } + + if (opts.filterSchema === true) { + throw new MER_AUTH_ERR_INVALID_OPTS('opts.filterSchema cannot be used when mode is external.') + } // Default mode } else { // Mandatory diff --git a/test/introspection-filter-basics.js b/test/introspection-filter-basics.js new file mode 100644 index 0000000..7e624d5 --- /dev/null +++ b/test/introspection-filter-basics.js @@ -0,0 +1,160 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const mercurius = require('mercurius') +const mercuriusAuth = require('..') + +const queryObjectMessage = `{ + __type(name: "Message") { + name + fields { + name + } + } +}` + +test('mixed directives: one filtered out and one not', async (t) => { + t.plan(3) + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @hideMe on FIELD_DEFINITION + directive @showMe on FIELD_DEFINITION + + type Message { + message: String @showMe + password: String @hideMe + } + + type Query { + publicMessages(org: String): [Message!] + } + ` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'hideMe', + applyPolicy: async () => { + t.pass('should be called on an introspection query') + return false + } + }) + + app.register(mercuriusAuth, { + filterSchema: false, + authDirective: 'showMe', + applyPolicy: async () => { + t.fail('should not be called on an introspection query') + return true + } + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryObjectMessage }) + }) + + t.same(response.json(), { + data: { + __type: { + name: 'Message', + fields: [ + { name: 'message' } + ] + } + } + }) + + checkInternals(t, app, { directives: 1 }) +}) + +test('multiple filtered directives on different contexts', async (t) => { + t.plan(6) + + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @root on FIELD_DEFINITION + directive @child on FIELD_DEFINITION + directive @subChild on FIELD_DEFINITION + + type Message { + message: String @root + password: String @child + title: String @subChild + } + + type Query { + publicMessages(org: String): [Message!] @subChild + } + ` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'root', + applyPolicy: async () => { + t.pass('root called') + return false + } + }) + + app.register(function plugin (instance, opts, next) { + instance.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'child', + applyPolicy: async () => { + t.pass('child called') + return false + } + }) + + instance.register(function plugin (instance, opts, next) { + instance.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'subChild', + applyPolicy: async () => { + t.pass('subChild called twice because it appears on two different fields') + return true + } + }) + next() + }) + + next() + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryObjectMessage }) + }) + + t.same(response.json(), { + data: { + __type: { + name: 'Message', + fields: [ + { name: 'title' } + ] + } + } + }) + + checkInternals(t, app, { directives: 3 }) +}) + +function checkInternals (t, app, { directives }) { + const checkGrouping = Object.getOwnPropertySymbols(app) + const groupSym = checkGrouping.find(sym => sym.toString().includes('mercurius-auth.filtering.group')) + t.equal(app[groupSym].length, directives) +} diff --git a/test/introspection-filter.js b/test/introspection-filter.js index bbdf461..90d9dee 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -110,19 +110,19 @@ test('should be able to access the query to determine that users have sufficient app.register(mercuriusAuth, { authContext: authContext, applyPolicy: authPolicy, - namespace: 'authorization-filtering', + filterSchema: true, authDirective: 'auth' }) app.register(mercuriusAuth, { authContext: hasRoleContext, applyPolicy: hasRolePolicy, - namespace: 'authorization-filtering', + filterSchema: true, authDirective: 'hasRole' }) app.register(mercuriusAuth, { authContext: hasPermissionContext, applyPolicy: hasPermissionPolicy, - namespace: 'authorization-filtering', + filterSchema: true, authDirective: 'hasPermission' }) @@ -362,7 +362,7 @@ test('UNION check filtering', async (t) => { applyPolicy: async function hasRolePolicy (authDirectiveAST, parent, args, context, info) { return context.reply.request.headers['x-union'] === 'show' }, - namespace: 'authorization-filtering', + filterSchema: true, authDirective: 'unionCheck' }) @@ -462,7 +462,7 @@ test('the single filter preExecution lets the app crash', async (t) => { app.register(mercuriusAuth, { authContext: authContext, applyPolicy: authPolicy, - namespace: 'authorization-filtering', + filterSchema: true, authDirective: 'auth' }) @@ -513,7 +513,7 @@ test("check directive's arguments on FIELD_DEFINITION", async (t) => { } return true }, - namespace: 'authorization-filtering', + filterSchema: true, authDirective: 'auth' }) @@ -571,7 +571,7 @@ test("check directive's arguments on OBJECT", async (t) => { } return true }, - namespace: 'authorization-filtering', + filterSchema: true, authDirective: 'auth' }) diff --git a/test/registration.js b/test/registration.js index bf3f83e..65e1725 100644 --- a/test/registration.js +++ b/test/registration.js @@ -138,7 +138,7 @@ test('should error if mode is not a string', async (t) => { }) test('registration - external policy', t => { - t.plan(3) + t.plan(4) t.test('should error if policy is not an object', async (t) => { t.plan(1) @@ -200,4 +200,21 @@ test('registration - external policy', t => { }) t.ok('mercurius auth plugin is registered') }) + + t.test('cannot filter introspection schema on external mode', async (t) => { + t.plan(1) + + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema, + resolvers + }) + await t.rejects(app.register(mercuriusAuth, { + applyPolicy: () => {}, + mode: 'external', + filterSchema: true + }), new MER_AUTH_ERR_INVALID_OPTS('opts.filterSchema cannot be used when mode is external.')) + }) }) From 1e7566286148ddde96f45b2abebc464e17e02253 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 22 Nov 2021 16:37:29 +0100 Subject: [PATCH 20/33] repeatable directive test --- docs/schema-filtering.md | 2 +- lib/filter-schema.js | 4 +-- test/introspection-filter-basics.js | 51 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/schema-filtering.md b/docs/schema-filtering.md index d6da251..2bbe0d0 100644 --- a/docs/schema-filtering.md +++ b/docs/schema-filtering.md @@ -99,7 +99,7 @@ Will make the user able to see the `notes` field. You must be informed about some details about the filtering feature. -- During the introspection query, the `applyPolicy` function is executed. +- During the introspection query, the `applyPolicy` function is executed once per single GraphQL object. - The `applyPolicy` function doesn't have the input `parent` and `args` arguments set during the introspection run. - When the HTTP request payload contains an introspection query and a user-land query, you will not get auth errors because the introspection query is executed before the user-land query and filters the schema. Note that the protected fields will **not** be returned as expected, without any security implications. Here is an example of a GraphQL query that will not throw an error: diff --git a/lib/filter-schema.js b/lib/filter-schema.js index aa23628..6a0c9d8 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -105,10 +105,10 @@ async function filterSchema (graphQLSchema, policies, context) { operation: { kind: 'OperationDefinition', operation: 'query' }, variableValues: {} } - // The undefined parameters are: https://graphql.org/learn/execution/#root-fields-resolvers + // The null parameters are: https://graphql.org/learn/execution/#root-fields-resolvers // - parent: it is not possible know it since the resolver is not executed yet // - args: it is not expected that the introspection query will have arguments for the directives policies - canShowDirectiveField = await policyFunction(fieldPolicy, undefined, undefined, context, info) + canShowDirectiveField = await policyFunction(fieldPolicy, null, null, context, info) if (canShowDirectiveField instanceof Error || !canShowDirectiveField) { canShowDirectiveField = false } diff --git a/test/introspection-filter-basics.js b/test/introspection-filter-basics.js index 7e624d5..f1d3ec6 100644 --- a/test/introspection-filter-basics.js +++ b/test/introspection-filter-basics.js @@ -153,6 +153,57 @@ test('multiple filtered directives on different contexts', async (t) => { checkInternals(t, app, { directives: 3 }) }) +test('repeatable directive', async (t) => { + t.plan(4) + + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @counter repeatable on FIELD_DEFINITION + + type Message { + title: String + message: String @counter @counter @counter + } + + type Query { + publicMessages(org: String): [Message!] + }` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'counter', + applyPolicy: async () => { + t.pass('should be called once') + t.todo('should be called three times but repeatable directives are not supported') + return false + } + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryObjectMessage }) + }) + + t.same(response.json(), { + data: { + __type: { + name: 'Message', + fields: [ + { name: 'title' } + ] + } + } + }) + + checkInternals(t, app, { directives: 1 }) +}) + function checkInternals (t, app, { directives }) { const checkGrouping = Object.getOwnPropertySymbols(app) const groupSym = checkGrouping.find(sym => sym.toString().includes('mercurius-auth.filtering.group')) From 21f839214ffeda1d7ce2ebc1800ff33acb0dd7c3 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 22 Nov 2021 17:04:17 +0100 Subject: [PATCH 21/33] add gateway test --- test/basic-gateway.js | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/basic-gateway.js b/test/basic-gateway.js index b3828e0..28aa72c 100644 --- a/test/basic-gateway.js +++ b/test/basic-gateway.js @@ -439,3 +439,51 @@ test('gateway - should handle when auth context is not defined', async (t) => { } }) }) + +test('gateway - should filter the schema output', async (t) => { + t.plan(4) + + const order = [ + 'topPosts', + 'name', + 'author' + ] + + const app = await createTestGatewayServer(t, { + filterSchema: true, + async applyPolicy (authDirectiveAST, parent, args, context, info) { + t.equal(info.fieldName, order.shift()) + return false + }, + authDirective: 'auth' + }) + + const query = `{ + __type(name: "Query") { + name + fields { + name + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user': 'admin' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(res.json(), { + data: { + __type: { + name: 'Query', + fields: [ + { + name: 'me' + } + ] + } + } + }) +}) From 1052b4623875e432f56a8a86d8aacd45720700ad Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 22 Nov 2021 17:59:17 +0100 Subject: [PATCH 22/33] add tests --- test/introspection-filter-basics.js | 161 ++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/test/introspection-filter-basics.js b/test/introspection-filter-basics.js index f1d3ec6..a3aa82d 100644 --- a/test/introspection-filter-basics.js +++ b/test/introspection-filter-basics.js @@ -14,6 +14,167 @@ const queryObjectMessage = `{ } }` +const queryArguments = `{ + __type(name: "Query") { + name + fields { + name + args { + name + } + } + } +}` + +const queryListAll = `{ + __schema { + types { + name + kind + } + } +}` + +test('TypeSystemDirectiveLocation: OBJECT', async (t) => { + t.plan(3) + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @hideMe on OBJECT + + type Message @hideMe { + message: String + password: String + } + + type Query { + publicMessages(org: String): [Message!] + } + ` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'hideMe', + applyPolicy: async () => { + t.pass('should be called on an introspection query') + return false + } + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryObjectMessage }) + }) + + t.same(response.json(), { + data: { + __type: null + } + }) + + checkInternals(t, app, { directives: 1 }) +}) + +test('TypeSystemDirectiveLocation: ARGUMENT_DEFINITION', { todo: 'not supported. Need FilterInputObjectFields' }, async (t) => { + t.plan(3) + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @hideMe on ARGUMENT_DEFINITION + + type Message { + message: String + password: String + } + + type Query { + publicMessages(org: String @hideMe): [Message!] + }` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'hideMe', + applyPolicy: async () => { + t.pass('should be called on an introspection query') + return false + } + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryArguments }) + }) + + t.same(response.json(), { + data: { + __type: { + name: 'Query', + fields: [ + { + name: 'publicMessages', + args: null + } + ] + } + } + }) + + checkInternals(t, app, { directives: 1 }) +}) + +test('TypeSystemDirectiveLocation: INTERFACE', async (t) => { + t.plan(3) + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @hideMe on INTERFACE + + interface BasicMessage @hideMe { + message: String + } + + type Message implements BasicMessage { + title: String + message: String + } + + type Query { + publicMessages(org: String): [Message!] + } + ` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'hideMe', + applyPolicy: async () => { + t.pass('should be called on an introspection query') + return false + } + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryListAll }) + }) + t.notOk(response.json().data.__schema.types.find(({ name }) => name === 'BasicMessage'), 'should not have BasicMessage') + checkInternals(t, app, { directives: 1 }) +}) + test('mixed directives: one filtered out and one not', async (t) => { t.plan(3) const app = Fastify() From 4da1bfebcaf5457339131159dd913b8650aff3ea Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Mon, 22 Nov 2021 19:33:32 +0100 Subject: [PATCH 23/33] add tests --- docs/schema-filtering.md | 11 +- lib/filter-schema.js | 18 +- test/introspection-filter-basics.js | 321 ++++++++++++++++++++++++++++ test/introspection-filter.js | 118 ---------- 4 files changed, 342 insertions(+), 126 deletions(-) diff --git a/docs/schema-filtering.md b/docs/schema-filtering.md index 2bbe0d0..43d9de4 100644 --- a/docs/schema-filtering.md +++ b/docs/schema-filtering.md @@ -95,7 +95,7 @@ Adding the Request Headers as follows: Will make the user able to see the `notes` field. -### Implementations details +## Implementations details You must be informed about some details about the filtering feature. @@ -117,3 +117,12 @@ You must be informed about some details about the filtering feature. } } ``` + +### Special usages + +Depending on the [`DirectiveLocations`](https://github.com/graphql/graphql-spec/blob/main/spec/Section%203%20--%20Type%20System.md#directives) you will have two main behaviors: + +- The normal behaviour is when the `DirectiveLocations` is hidden from the introspection query. +- The augmented behaviour is when the schema has additional hidden entities from the introspection query. It happens when: + - The directive is `on INPUT_FIELD_DEFINITION`: the whole `input` item is hidden + - The `Query` is hidden if the user doesn't have access to the `input` or `output` object types. diff --git a/lib/filter-schema.js b/lib/filter-schema.js index 6a0c9d8..67f3a0e 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -4,7 +4,8 @@ const { wrapSchema, PruneSchema, FilterTypes, - TransformObjectFields + TransformObjectFields, + FilterInputObjectFields } = require('@graphql-tools/wrap') const { GraphQLObjectType } = require('graphql') @@ -138,12 +139,8 @@ async function filterSchema (graphQLSchema, policies, context) { // should we filter out this whole type? return filterDirectiveMap[type.name] !== false }), - new TransformObjectFields((typeName, fieldName, fieldConfig) => { - if (filterDirectiveMap[typeName] && filterDirectiveMap[typeName][fieldName] === false) { - return null // omit the field - } - return undefined // unchanged - }), + new TransformObjectFields(filterField), + new FilterInputObjectFields(filterField), new PruneSchema({ skipPruning (type) { // skip pruning if the type is the Query or Mutation object @@ -152,4 +149,11 @@ async function filterSchema (graphQLSchema, policies, context) { }) ] }) + + function filterField (typeName, fieldName, fieldConfig) { + if (filterDirectiveMap[typeName] && filterDirectiveMap[typeName][fieldName] === false) { + return null // omit the field + } + return undefined // unchanged + } } diff --git a/test/introspection-filter-basics.js b/test/introspection-filter-basics.js index a3aa82d..7f095e2 100644 --- a/test/introspection-filter-basics.js +++ b/test/introspection-filter-basics.js @@ -14,6 +14,15 @@ const queryObjectMessage = `{ } }` +const queryListByType = `{ + __type(name:"Query"){ + name + fields{ + name + } + } +}` + const queryArguments = `{ __type(name: "Query") { name @@ -35,6 +44,36 @@ const queryListAll = `{ } }` +const queryEnumValues = `{ + __type(name: "Role") { + kind + name + enumValues { + name + } + } +}` + +const queryInputFields = `{ + __type(name: "Query") { + kind + name + fields { + name + args { + name + type { + name + inputFields { + name + } + } + } + } + } +} +` + test('TypeSystemDirectiveLocation: OBJECT', async (t) => { t.plan(3) const app = Fastify() @@ -175,6 +214,288 @@ test('TypeSystemDirectiveLocation: INTERFACE', async (t) => { checkInternals(t, app, { directives: 1 }) }) +test('TypeSystemDirectiveLocation: UNION', async (t) => { + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @unionCheck on UNION | FIELD_DEFINITION + + type Message { + title: String! + password: String @unionCheck + } + + type SimpleMessage { + title: String! + message: String + } + + union MessageUnion @unionCheck = Message | SimpleMessage + + type Query { + publicMessages(org: String): [MessageUnion!] + } + ` + }) + + app.register(mercuriusAuth, { + applyPolicy: async function hasRolePolicy (authDirectiveAST, parent, args, context, info) { + return context.reply.request.headers['x-union'] === 'show' + }, + filterSchema: true, + authDirective: 'unionCheck' + }) + + ;[ + { + name: 'show UNION type', + query: queryListByType, + headers: { + 'x-union': 'show' + }, + result: { + data: { + __type: { + name: 'Query', + fields: [ + { name: 'publicMessages' } + ] + } + } + } + }, + { + name: 'hide UNION type', + query: queryListByType, + headers: { + 'x-union': 'hide' + }, + result: { + data: { + __type: { + name: 'Query', + fields: [] + } + } + } + }, + { + name: 'show UNION type', + query: queryObjectMessage, + headers: { + 'x-union': 'show' + }, + result: { + data: { + __type: { + name: 'Message', + fields: [ + { name: 'title' }, + { name: 'password' } + ] + } + } + } + }, + { + name: 'hide UNION type - cannot access to this type', + query: queryObjectMessage, + headers: { + 'x-union': 'hide' + }, + result: { + data: { + __type: null + } + } + } + ].forEach(({ name, query, result, headers }) => { + t.test(name, async t => { + t.plan(1) + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + if (typeof result !== 'function') { + t.same(response.json(), result) + } else { + t.test('response', t => { + result(t, response.json()) + }) + } + }) + }) +}) + +test('TypeSystemDirectiveLocation: ENUM_VALUE', { todo: 'not supported. Need TransformEnumValues' }, async (t) => { + t.plan(3) + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @hideMe on ENUM_VALUE + + enum Role { + ADMIN + REVIEWER + USER + SECRET @hideMe + } + + type Query { + publicMessages(org: String): [String!] + } + ` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'hideMe', + applyPolicy: async () => { + t.pass('should be called on an introspection query') + return false + } + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryEnumValues }) + }) + + require('fs').writeFileSync('./asd.json', JSON.stringify(response.json(), null, 2)) + + t.same(response.json(), { + data: { + __type: { + kind: 'ENUM', + name: 'Role', + enumValues: [ + { name: 'ADMIN' }, + { name: 'REVIEWER' }, + { name: 'USER' } + ] + } + } + }) + + checkInternals(t, app, { directives: 1 }) +}) + +test('TypeSystemDirectiveLocation: INPUT_OBJECT', async (t) => { + t.plan(3) + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @hideMe on INPUT_OBJECT + + input MessageInput @hideMe { + message: String + password: String + } + + type Query { + publicMessages(org: MessageInput): [String!] + } + ` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'hideMe', + applyPolicy: async () => { + t.pass('should be called on an introspection query') + return false + } + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryInputFields }) + }) + + t.same(response.json(), { + data: { + __type: { + kind: 'OBJECT', + name: 'Query', + fields: [ + { + name: 'publicMessages', + args: [] + } + ] + } + } + }) + + checkInternals(t, app, { directives: 1 }) +}) + +test('TypeSystemDirectiveLocation: INPUT_FIELD_DEFINITION', async (t) => { + t.plan(3) + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: ` + directive @hideMe on INPUT_FIELD_DEFINITION + + input MessageInput { + message: String! + password: String @hideMe + } + + type Query { + publicMessages(org: MessageInput): [String!] + }` + }) + + app.register(mercuriusAuth, { + filterSchema: true, + authDirective: 'hideMe', + applyPolicy: async () => { + t.pass('should be called on an introspection query') + return false + } + }) + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query: queryInputFields }) + }) + + t.same(response.json(), { + data: { + __type: { + kind: 'OBJECT', + name: 'Query', + fields: [ + { + name: 'publicMessages', + args: [] + } + ] + } + } + }) + + checkInternals(t, app, { directives: 1 }) +}) + test('mixed directives: one filtered out and one not', async (t) => { t.plan(3) const app = Fastify() diff --git a/test/introspection-filter.js b/test/introspection-filter.js index 90d9dee..44505dd 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -332,124 +332,6 @@ test('should be able to access the query to determine that users have sufficient }) }) -test('UNION check filtering', async (t) => { - const app = Fastify() - t.teardown(app.close.bind(app)) - - app.register(mercurius, { - schema: ` - directive @unionCheck on UNION | FIELD_DEFINITION - - type Message { - title: String! - password: String @unionCheck - } - - type SimpleMessage { - title: String! - message: String - } - - union MessageUnion @unionCheck = Message | SimpleMessage - - type Query { - publicMessages(org: String): [MessageUnion!] - } - ` - }) - - app.register(mercuriusAuth, { - applyPolicy: async function hasRolePolicy (authDirectiveAST, parent, args, context, info) { - return context.reply.request.headers['x-union'] === 'show' - }, - filterSchema: true, - authDirective: 'unionCheck' - }) - - ;[ - { - name: 'show UNION type', - query: queryListByType, - headers: { - 'x-union': 'show' - }, - result: { - data: { - __type: { - name: 'Query', - fields: [ - { name: 'publicMessages' } - ] - } - } - } - }, - { - name: 'hide UNION type', - query: queryListByType, - headers: { - 'x-union': 'hide' - }, - result: { - data: { - __type: { - name: 'Query', - fields: [] - } - } - } - }, - { - name: 'show UNION type', - query: queryObjectMessage, - headers: { - 'x-union': 'show' - }, - result: { - data: { - __type: { - name: 'Message', - fields: [ - { name: 'title' }, - { name: 'password' } - ] - } - } - } - }, - { - name: 'hide UNION type - cannot access to this type', - query: queryObjectMessage, - headers: { - 'x-union': 'hide' - }, - result: { - data: { - __type: null - } - } - } - ].forEach(({ name, query, result, headers }) => { - t.test(name, async t => { - t.plan(1) - const response = await app.inject({ - method: 'POST', - headers: { 'content-type': 'application/json', ...headers }, - url: '/graphql', - body: JSON.stringify({ query }) - }) - - if (typeof result !== 'function') { - t.same(response.json(), result) - } else { - t.test('response', t => { - result(t, response.json()) - }) - } - }) - }) -}) - test('the single filter preExecution lets the app crash', async (t) => { const app = Fastify() t.teardown(app.close.bind(app)) From 9ace190bc9ce66e6054b7b1e3850b83ff94e093d Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 23 Nov 2021 09:36:44 +0100 Subject: [PATCH 24/33] fix gateway refresh --- index.js | 3 + lib/filter-schema.js | 9 +- test/refresh.js | 244 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index f7be1bd..6b7b292 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,9 @@ const plugin = fp( app.graphql.addHook('onGatewayReplaceSchema', async (instance, schema) => { const authSchema = auth.getPolicy(schema) auth.registerAuthHandlers(schema, authSchema) + if (opts.filterSchema === true) { + filterSchema.updatePolicy(app, authSchema, opts) + } }) if (typeof opts.authContext !== 'undefined') { diff --git a/lib/filter-schema.js b/lib/filter-schema.js index 67f3a0e..79d348e 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -11,7 +11,9 @@ const { GraphQLObjectType } = require('graphql') const kDirectiveGrouping = Symbol('mercurius-auth.filtering.group') -module.exports = function filterIntrospectionSchema (app, policy, { filterSchema, applyPolicy: policyFunction }) { +module.exports = filterIntrospectionSchema + +function filterIntrospectionSchema (app, policy, { applyPolicy: policyFunction }) { if (!app[kDirectiveGrouping]) { app[kDirectiveGrouping] = [] @@ -29,6 +31,11 @@ module.exports = function filterIntrospectionSchema (app, policy, { filterSchema }) } +filterIntrospectionSchema.updatePolicy = function (app, policy, { applyPolicy: policyFunction }) { + const storedPolicy = app[kDirectiveGrouping].find(({ policyFunction: storedPolicy }) => storedPolicy === policyFunction) + storedPolicy.policy = policy +} + async function filterGraphQLSchemaHook (schema, document, context) { if (!isIntrospection(document)) { return diff --git a/test/refresh.js b/test/refresh.js index c025c00..2497371 100644 --- a/test/refresh.js +++ b/test/refresh.js @@ -192,3 +192,247 @@ test('polling interval with a new schema should trigger refresh of schema policy }) } }) + +test('polling a filtered schema should complete the refresh succesfully', async (t) => { + t.plan(8) + + const clock = FakeTimers.install({ + shouldAdvanceTime: true, + advanceTimeDelta: 40 + }) + t.teardown(() => clock.uninstall()) + + const user = { + id: 'u1', + name: 'John', + lastName: 'Doe' + } + + const resolvers = { + Query: { + me: (root, args, context, info) => user + }, + User: { + __resolveReference: (user, args, context, info) => user + } + } + + const userService = Fastify() + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + }) + + userService.register(mercurius, { + schema: ` + directive @auth on OBJECT | FIELD_DEFINITION + + extend type Query { + me: User + } + + type User @key(fields: "id") { + id: ID! + name: String @auth + } + `, + resolvers: resolvers, + federationMetadata: true + }) + + await userService.listen(0) + + const userServicePort = userService.server.address().port + + await gateway.register(mercurius, { + gateway: { + services: [ + { + name: 'user', + url: `http://localhost:${userServicePort}/graphql` + } + ], + pollingInterval: 2000 + } + }) + + await gateway.register(mercuriusAuth, { + filterSchema: true, + authContext (context) { + return { + identity: context.reply.request.headers['x-user'] + } + }, + async applyPolicy (authDirectiveAST, parent, args, context, info) { + t.ok('should be called') + return context.auth.identity === 'admin' + }, + authDirective: 'auth' + }) + + { + const query = `query { + me { + id + name + } + }` + + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user': 'user' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: null + } + }, + errors: [ + { + message: 'Failed auth policy check on name', + locations: [ + { + line: 4, + column: 7 + } + ], + path: [ + 'me', + 'name' + ] + } + ] + }) + } + + { + const query = `{ + __type(name:"User"){ + name + fields{ + name + } + } + }` + + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user': 'user' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(res.json(), { + data: { + __type: { + name: 'User', + fields: [ + { name: 'id' } + ] + } + } + }) + } + + userService.graphql.replaceSchema( + mercurius.buildFederationSchema(` + directive @auth on OBJECT | FIELD_DEFINITION + + extend type Query { + me: User + } + + type User @key(fields: "id") { + id: ID! + name: String + lastName: String @auth + } + `) + ) + userService.graphql.defineResolvers(resolvers) + + await clock.tickAsync(2000) + + // We need the event loop to actually spin twice to + // be able to propagate the change + await immediate() + await immediate() + + { + const query = `query { + me { + id + name + lastName + } + }` + + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user': 'user' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + lastName: null + } + }, + errors: [ + { + message: 'Failed auth policy check on lastName', + locations: [ + { + line: 5, + column: 7 + } + ], + path: [ + 'me', + 'lastName' + ] + } + ] + }) + } + + { + const query = `{ + __type(name:"User"){ + name + fields{ + name + } + } + }` + + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', 'x-user': 'user' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(res.json(), { + data: { + __type: { + name: 'User', + fields: [ + { name: 'id' }, + { name: 'name' } + ] + } + } + }) + } +}) From fe33ea7054caba7b7ce5171938783a3eae91917c Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 23 Nov 2021 09:44:14 +0100 Subject: [PATCH 25/33] add types --- index.d.ts | 4 ++++ test/types/index.test-d.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/index.d.ts b/index.d.ts index 1cf622b..c19d7c0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -62,6 +62,10 @@ export interface MercuriusAuthDirectiveOptions extends MercuriusAuthBaseOptions { diff --git a/test/types/index.test-d.ts b/test/types/index.test-d.ts index b1fbed9..4eecec7 100644 --- a/test/types/index.test-d.ts +++ b/test/types/index.test-d.ts @@ -41,6 +41,7 @@ interface CustomContext extends MercuriusContext { auth?: { identity?: string }; } const authOptions: MercuriusAuthOptions = { + filterSchema: true, authDirective: 'auth', async applyPolicy ( authDirectiveAST, From 9a768d311b3cfe100d0d8c2aa11511df36d15c23 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 23 Nov 2021 14:55:33 +0100 Subject: [PATCH 26/33] improved docs --- README.md | 1 + docs/api/options.md | 2 ++ index.d.ts | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 72c0ba3..88c4a18 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Features: - [Auth Context](docs/auth-context.md) - [Apply Policy](docs/apply-policy.md) - [Auth Directive](docs/auth-directive.md) + - [Schema filtering](docs/schema-filtering.md) - [External Policy](docs/external-policy.md) - [Errors](docs/errors.md) - [Federation](docs/federation.md) diff --git a/docs/api/options.md b/docs/api/options.md index f914227..6a95d95 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -14,6 +14,8 @@ * **authDirective** `string` - the name of the directive that the Mercurius auth plugin will look for within the GraphQL schema in order to identify protected fields. For example, for directive definition `directive @auth on OBJECT | FIELD_DEFINITION`, the corresponding name would be `auth`. +* **filterSchema** `boolean` - when `true` the GraphQL elements within the directive will be filtered during [Introspection queries](https://graphql.org/learn/introspection/) if the `applyPolicy` function will not be satisfied by the HTTP Request. + ### `external` mode * **policy** `MercuriusAuthPolicy` (optional) - the auth policy definition. The field definition is passed as the first argument when `applyPolicy` is called for the associated field. diff --git a/index.d.ts b/index.d.ts index c19d7c0..d883b23 100644 --- a/index.d.ts +++ b/index.d.ts @@ -63,7 +63,7 @@ export interface MercuriusAuthDirectiveOptions Date: Tue, 23 Nov 2021 15:49:46 +0100 Subject: [PATCH 27/33] fix todos --- lib/filter-schema.js | 3 +-- test/introspection-filter.js | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/filter-schema.js b/lib/filter-schema.js index 79d348e..b21fcf0 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -101,11 +101,10 @@ async function filterSchema (graphQLSchema, policies, context) { const isObjectPolicy = fieldName === '__typePolicy' try { // https://github.com/graphql/graphql-js/blob/main/src/type/definition.ts#L974 - // TODO check gq.typeFromAST const info = { fieldName, fieldNodes: schemaType.astNode.fields, - returnType: isObjectPolicy ? '' : schemaTypeFields[fieldName].type, // TODO + returnType: isObjectPolicy ? schemaType : schemaTypeFields[fieldName].type, parentType: schemaType, schema: graphQLSchema, fragments: {}, diff --git a/test/introspection-filter.js b/test/introspection-filter.js index 44505dd..6747622 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -422,10 +422,10 @@ test("check directive's arguments on OBJECT", async (t) => { schema: ` directive @auth on OBJECT - type Message { + type Message @auth { message: String! } - type Query @auth{ + type Query { publicMessages: [Message!] } `, @@ -449,7 +449,7 @@ test("check directive's arguments on OBJECT", async (t) => { t.notOk(schemaFilteringRun.parent, 'parent') t.notOk(schemaFilteringRun.args, 'args') t.same(schemaFilteringRun.context, context, 'context') - // t.same(schemaFilteringRun.info, info, 'info', { skip: 1 }) + t.same(schemaFilteringRun.info.fieldName, '__typePolicy', 'info') } return true }, From 73a56e92c3376281cacf9ee469d54812a60b5c3f Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 23 Nov 2021 15:57:06 +0100 Subject: [PATCH 28/33] trigger ci --- lib/filter-schema.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/filter-schema.js b/lib/filter-schema.js index b21fcf0..7b6020a 100644 --- a/lib/filter-schema.js +++ b/lib/filter-schema.js @@ -44,10 +44,7 @@ async function filterGraphQLSchemaHook (schema, document, context) { const filteredSchema = await filterSchema(schema, this[kDirectiveGrouping], context) - - return { - schema: filteredSchema - } + return { schema: filteredSchema } } function isIntrospection (document) { From 925f2fb784e14e2cba016b97656eaf34b438ea8b Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 23 Nov 2021 16:09:51 +0100 Subject: [PATCH 29/33] trigger ci - takes 2 --- test/introspection-filter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/introspection-filter.js b/test/introspection-filter.js index 6747622..83b0bc3 100644 --- a/test/introspection-filter.js +++ b/test/introspection-filter.js @@ -422,10 +422,10 @@ test("check directive's arguments on OBJECT", async (t) => { schema: ` directive @auth on OBJECT - type Message @auth { + type Message { message: String! } - type Query { + type Query @auth { publicMessages: [Message!] } `, From 34d8b6eb08471586f9c229bec4d40be688f8aa7c Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 23 Nov 2021 23:26:37 +0100 Subject: [PATCH 30/33] Update docs/api/options.md Co-authored-by: Simone Busoli --- docs/api/options.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/api/options.md b/docs/api/options.md index 6a95d95..b717ed0 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -14,8 +14,7 @@ * **authDirective** `string` - the name of the directive that the Mercurius auth plugin will look for within the GraphQL schema in order to identify protected fields. For example, for directive definition `directive @auth on OBJECT | FIELD_DEFINITION`, the corresponding name would be `auth`. -* **filterSchema** `boolean` - when `true` the GraphQL elements within the directive will be filtered during [Introspection queries](https://graphql.org/learn/introspection/) if the `applyPolicy` function will not be satisfied by the HTTP Request. - +* **filterSchema** `boolean` - when `true`, [introspection queries](https://graphql.org/learn/introspection/) will only return the parts of the schema which are accessible based on the applied policies. ### `external` mode * **policy** `MercuriusAuthPolicy` (optional) - the auth policy definition. The field definition is passed as the first argument when `applyPolicy` is called for the associated field. From d50f00c4bec95f8a88658253ba713442829db579 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Wed, 24 Nov 2021 10:05:36 +0100 Subject: [PATCH 31/33] docs feedback --- docs/schema-filtering.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/schema-filtering.md b/docs/schema-filtering.md index 43d9de4..b5d0f28 100644 --- a/docs/schema-filtering.md +++ b/docs/schema-filtering.md @@ -97,8 +97,6 @@ Will make the user able to see the `notes` field. ## Implementations details -You must be informed about some details about the filtering feature. - - During the introspection query, the `applyPolicy` function is executed once per single GraphQL object. - The `applyPolicy` function doesn't have the input `parent` and `args` arguments set during the introspection run. - When the HTTP request payload contains an introspection query and a user-land query, you will not get auth errors because the introspection query is executed before the user-land query and filters the schema. Note that the protected fields will **not** be returned as expected, without any security implications. Here is an example of a GraphQL query that will not throw an error: From c71139001f7265f6863ac1e48a5496255a4390a8 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Wed, 24 Nov 2021 14:32:54 +0100 Subject: [PATCH 32/33] update dep --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d7eb6da..9b9dfc0 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "autocannon": "^7.0.5", "concurrently": "^6.1.0", "fastify": "^3.0.2", - "mercurius": "github:mercurius-js/mercurius#master", + "mercurius": "^8.10.0", "pre-commit": "^1.2.2", "snazzy": "^9.0.0", "standard": "^16.0.3", From 96b72fe2b052b81850de553a6100af870a5459c7 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Wed, 24 Nov 2021 14:34:44 +0100 Subject: [PATCH 33/33] add docs --- docs/schema-filtering.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/schema-filtering.md b/docs/schema-filtering.md index b5d0f28..f11167f 100644 --- a/docs/schema-filtering.md +++ b/docs/schema-filtering.md @@ -99,6 +99,7 @@ Will make the user able to see the `notes` field. - During the introspection query, the `applyPolicy` function is executed once per single GraphQL object. - The `applyPolicy` function doesn't have the input `parent` and `args` arguments set during the introspection run. +- The `applyPolicy` function receives the `info` argument which contains basic information about the GraphQL entity assinged to the directive. - When the HTTP request payload contains an introspection query and a user-land query, you will not get auth errors because the introspection query is executed before the user-land query and filters the schema. Note that the protected fields will **not** be returned as expected, without any security implications. Here is an example of a GraphQL query that will not throw an error: ```graphql