From 9c485809917d5fad1e30128e34c5f23d0b1af42f Mon Sep 17 00:00:00 2001 From: Convly Date: Mon, 13 Sep 2021 10:48:26 +0200 Subject: [PATCH] Handle authorization for GraphQL queries & mutations --- packages/core/strapi/lib/Strapi.js | 6 +- packages/plugins/graphql/server/bootstrap.js | 52 ++++++++++------ .../builders/mutations/collection-type.js | 24 ++++++++ .../builders/mutations/single-type.js | 18 ++++++ .../builders/queries/collection-type.js | 18 ++++++ .../services/builders/queries/single-type.js | 12 ++++ .../services/content-api/wrap-resolvers.js | 61 ++++++++++++++----- 7 files changed, 155 insertions(+), 36 deletions(-) diff --git a/packages/core/strapi/lib/Strapi.js b/packages/core/strapi/lib/Strapi.js index e2dbba44cc..85ab8d8b27 100644 --- a/packages/core/strapi/lib/Strapi.js +++ b/packages/core/strapi/lib/Strapi.js @@ -115,6 +115,10 @@ class Strapi { return this.container.get('apis').getAll(); } + get auth() { + return this.container.get('auth'); + } + async start() { try { if (!this.isLoaded) { @@ -400,7 +404,7 @@ class Strapi { /** * Binds queries with a specific model * @param {string} uid - * @returns {} + * @returns {*} */ query(uid) { return this.db.query(uid); diff --git a/packages/plugins/graphql/server/bootstrap.js b/packages/plugins/graphql/server/bootstrap.js index 1dcb46d512..97870341a7 100644 --- a/packages/plugins/graphql/server/bootstrap.js +++ b/packages/plugins/graphql/server/bootstrap.js @@ -3,8 +3,8 @@ const { isEmpty, mergeWith, isArray } = require('lodash/fp'); const { ApolloServer } = require('apollo-server-koa'); const { - ApolloServerPluginLandingPageLocalDefault, - ApolloServerPluginLandingPageProductionDefault, + ApolloServerPluginLandingPageDisabled, + ApolloServerPluginLandingPageGraphQLPlayground, } = require('apollo-server-core'); const depthLimit = require('graphql-depth-limit'); const { graphqlUploadKoa } = require('graphql-upload'); @@ -35,15 +35,10 @@ module.exports = async strapi => { schema, // Initialize loaders for this request. - context: ({ ctx }) => { - // TODO: set loaders in the context not globally - strapi - .plugin('graphql') - .service('old') - ['data-loaders'].initializeLoader(); - - return ctx; - }, + context: ({ ctx }) => ({ + state: ctx.state, + koaContext: ctx, + }), // Validation validationRules: [depthLimit(config('depthLimit'))], @@ -54,10 +49,9 @@ module.exports = async strapi => { bodyParserConfig: true, plugins: [ - // Specify which GraphQL landing page we want for the different env. - process.env.NODE_ENV !== 'production' - ? ApolloServerPluginLandingPageLocalDefault({ footer: false }) - : ApolloServerPluginLandingPageProductionDefault({ footer: false }), + process.env.NODE_ENV === 'production' + ? ApolloServerPluginLandingPageDisabled() + : ApolloServerPluginLandingPageGraphQLPlayground(), ], }; @@ -77,10 +71,30 @@ module.exports = async strapi => { } // Link the Apollo server & the Strapi app - server.applyMiddleware({ - app: strapi.server.app, - path: config('endpoint', '/graphql'), - }); + strapi.server.routes([ + { + method: 'ALL', + path: config('endpoint', '/graphql'), + handler: [ + (ctx, next) => { + ctx.state.route = { + info: { + // Indicate it's a content API route + type: 'content-api', + }, + }; + + return strapi.auth.authenticate(ctx, next); + }, + + // Apollo Server + server.getMiddleware({ path: config('endpoint', '/graphql') }), + ], + config: { + auth: false, + }, + }, + ]); // Register destroy behavior // We're doing it here instead of exposing a destroy method to the strapi-server.js diff --git a/packages/plugins/graphql/server/services/builders/mutations/collection-type.js b/packages/plugins/graphql/server/services/builders/mutations/collection-type.js index 51f517d40e..477c3ab8b8 100644 --- a/packages/plugins/graphql/server/services/builders/mutations/collection-type.js +++ b/packages/plugins/graphql/server/services/builders/mutations/collection-type.js @@ -133,6 +133,30 @@ module.exports = ({ strapi }) => { return { buildCollectionTypeMutations(contentType) { + getService('extension') + .for('content-api') + .use(() => ({ + resolversConfig: { + [`Mutation.${getCreateMutationTypeName(contentType)}`]: { + auth: { + scope: [`${contentType.uid}.create`], + }, + }, + + [`Mutation.${getUpdateMutationTypeName(contentType)}`]: { + auth: { + scope: [`${contentType.uid}.update`], + }, + }, + + [`Mutation.${getDeleteMutationTypeName(contentType)}`]: { + auth: { + scope: [`${contentType.uid}.delete`], + }, + }, + }, + })); + return extendType({ type: 'Mutation', diff --git a/packages/plugins/graphql/server/services/builders/mutations/single-type.js b/packages/plugins/graphql/server/services/builders/mutations/single-type.js index d193ce532d..d81d299a9d 100644 --- a/packages/plugins/graphql/server/services/builders/mutations/single-type.js +++ b/packages/plugins/graphql/server/services/builders/mutations/single-type.js @@ -92,6 +92,24 @@ module.exports = ({ strapi }) => { return { buildSingleTypeMutations(contentType) { + getService('extension') + .for('content-api') + .use(() => ({ + resolversConfig: { + [`Mutation.${getUpdateMutationTypeName(contentType)}`]: { + auth: { + scope: [`${contentType.uid}.update`], + }, + }, + + [`Mutation.${getDeleteMutationTypeName(contentType)}`]: { + auth: { + scope: [`${contentType.uid}.delete`], + }, + }, + }, + })); + return extendType({ type: 'Mutation', diff --git a/packages/plugins/graphql/server/services/builders/queries/collection-type.js b/packages/plugins/graphql/server/services/builders/queries/collection-type.js index a9a6c6d316..8cbb103a04 100644 --- a/packages/plugins/graphql/server/services/builders/queries/collection-type.js +++ b/packages/plugins/graphql/server/services/builders/queries/collection-type.js @@ -16,6 +16,24 @@ module.exports = ({ strapi }) => { } = naming; const buildCollectionTypeQueries = contentType => { + getService('extension') + .for('content-api') + .use(() => ({ + resolversConfig: { + [`Query.${getFindOneQueryName(contentType)}`]: { + auth: { + scope: [`${contentType.uid}.findOne`], + }, + }, + + [`Query.${getFindQueryName(contentType)}`]: { + auth: { + scope: [{ name: `${contentType.uid}.find`, type: 'read-only' }], + }, + }, + }, + })); + return extendType({ type: 'Query', diff --git a/packages/plugins/graphql/server/services/builders/queries/single-type.js b/packages/plugins/graphql/server/services/builders/queries/single-type.js index 43d1f90544..83b521332c 100644 --- a/packages/plugins/graphql/server/services/builders/queries/single-type.js +++ b/packages/plugins/graphql/server/services/builders/queries/single-type.js @@ -11,6 +11,18 @@ module.exports = ({ strapi }) => { const { getFindOneQueryName, getEntityResponseName } = naming; const buildSingleTypeQueries = contentType => { + getService('extension') + .for('content-api') + .use(() => ({ + resolversConfig: { + [`Query.${getFindOneQueryName(contentType)}`]: { + auth: { + scope: [`${contentType.uid}.findOne`], + }, + }, + }, + })); + return extendType({ type: 'Query', diff --git a/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js b/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js index 585b9e3f47..ac40e98dad 100644 --- a/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js +++ b/packages/plugins/graphql/server/services/content-api/wrap-resolvers.js @@ -1,9 +1,19 @@ 'use strict'; -const { isNil, get, getOr, isFunction, first } = require('lodash/fp'); +const { get, getOr, isFunction, first } = require('lodash/fp'); +const { GraphQLObjectType } = require('graphql'); const { createPoliciesMiddleware } = require('./policy'); +const ignoredObjectTypes = [ + '__Schema', + '__Type', + '__Field', + '__InputValue', + '__EnumValue', + '__Directive', +]; + /** * Wrap the schema's resolvers if they've been * customized using the GraphQL extension service @@ -19,31 +29,31 @@ const wrapResolvers = ({ schema, strapi, extension = {} }) => { // Fields filters const isValidFieldName = ([field]) => !field.startsWith('__'); - const hasResolverConfig = type => ([field]) => !isNil(resolversConfig[`${type}.${field}`]); - const typeMap = get('_typeMap', schema); + const typeMap = schema.getTypeMap(); // Iterate over every field from every type within the // schema's type map and wrap its resolve attribute if needed Object.entries(typeMap).forEach(([type, definition]) => { - const fields = get('_fields', definition); + const isGraphQLObjectType = definition instanceof GraphQLObjectType; + const isIgnoredType = ignoredObjectTypes.includes(type); - if (isNil(fields)) { + if (!isGraphQLObjectType || isIgnoredType) { return; } - const fieldsToProcess = Object.entries(fields) - // Ignore patterns such as "__FooBar" - .filter(isValidFieldName) - // Don't augment the types if there isn't any configuration defined for them - .filter(hasResolverConfig(type)); + const fields = definition.getFields(); + const fieldsToProcess = Object.entries(fields).filter(isValidFieldName); for (const [fieldName, fieldDefinition] of fieldsToProcess) { + const defaultResolver = get(fieldName); + const path = `${type}.${fieldName}`; - const resolverConfig = resolversConfig[path]; + const resolverConfig = getOr({}, path, resolversConfig); - const { resolve: baseResolver = get(fieldName) } = fieldDefinition; + const { resolve: baseResolver = defaultResolver } = fieldDefinition; + // Parse & initialize the middlewares const middlewares = parseMiddlewares(resolverConfig, strapi); // Generate the policy middleware @@ -62,16 +72,35 @@ const wrapResolvers = ({ schema, strapi, extension = {} }) => { ); }); - // Replace the base resolver by a custom function which will handle authorization, middlewares & policies - fieldDefinition.resolve = async (parent, args, context, info) => { - if (resolverConfig.auth !== false) { + /** + * GraphQL authorization flow + * @param {object} context + * @return {Promise} + */ + const authorize = async ({ context }) => { + const authConfig = get('auth', resolverConfig); + const authContext = get('state.auth', context); + + if (authConfig !== false) { try { - await strapi.auth.verify(context.state.auth, resolverConfig.auth); + await strapi.auth.verify(authContext, authConfig); } catch (error) { // TODO: [v4] Throw GraphQL Error instead throw new Error('Forbidden access'); } } + }; + + /** + * Base resolver wrapper that handles authorization, middlewares & policies + * @param {object} parent + * @param {object} args + * @param {object} context + * @param {object} info + * @return {Promise} + */ + fieldDefinition.resolve = async (parent, args, context, info) => { + await authorize({ context }); // Execute middlewares (including the policy middleware which will always be included) return first(boundMiddlewares).call(null, parent, args, context, info);