Handle authorization for GraphQL queries & mutations

This commit is contained in:
Convly 2021-09-13 10:48:26 +02:00
parent ee5e7a026f
commit 9c48580991
7 changed files with 155 additions and 36 deletions

View File

@ -115,6 +115,10 @@ class Strapi {
return this.container.get('apis').getAll(); return this.container.get('apis').getAll();
} }
get auth() {
return this.container.get('auth');
}
async start() { async start() {
try { try {
if (!this.isLoaded) { if (!this.isLoaded) {
@ -400,7 +404,7 @@ class Strapi {
/** /**
* Binds queries with a specific model * Binds queries with a specific model
* @param {string} uid * @param {string} uid
* @returns {} * @returns {*}
*/ */
query(uid) { query(uid) {
return this.db.query(uid); return this.db.query(uid);

View File

@ -3,8 +3,8 @@
const { isEmpty, mergeWith, isArray } = require('lodash/fp'); const { isEmpty, mergeWith, isArray } = require('lodash/fp');
const { ApolloServer } = require('apollo-server-koa'); const { ApolloServer } = require('apollo-server-koa');
const { const {
ApolloServerPluginLandingPageLocalDefault, ApolloServerPluginLandingPageDisabled,
ApolloServerPluginLandingPageProductionDefault, ApolloServerPluginLandingPageGraphQLPlayground,
} = require('apollo-server-core'); } = require('apollo-server-core');
const depthLimit = require('graphql-depth-limit'); const depthLimit = require('graphql-depth-limit');
const { graphqlUploadKoa } = require('graphql-upload'); const { graphqlUploadKoa } = require('graphql-upload');
@ -35,15 +35,10 @@ module.exports = async strapi => {
schema, schema,
// Initialize loaders for this request. // Initialize loaders for this request.
context: ({ ctx }) => { context: ({ ctx }) => ({
// TODO: set loaders in the context not globally state: ctx.state,
strapi koaContext: ctx,
.plugin('graphql') }),
.service('old')
['data-loaders'].initializeLoader();
return ctx;
},
// Validation // Validation
validationRules: [depthLimit(config('depthLimit'))], validationRules: [depthLimit(config('depthLimit'))],
@ -54,10 +49,9 @@ module.exports = async strapi => {
bodyParserConfig: true, bodyParserConfig: true,
plugins: [ plugins: [
// Specify which GraphQL landing page we want for the different env. process.env.NODE_ENV === 'production'
process.env.NODE_ENV !== 'production' ? ApolloServerPluginLandingPageDisabled()
? ApolloServerPluginLandingPageLocalDefault({ footer: false }) : ApolloServerPluginLandingPageGraphQLPlayground(),
: ApolloServerPluginLandingPageProductionDefault({ footer: false }),
], ],
}; };
@ -77,10 +71,30 @@ module.exports = async strapi => {
} }
// Link the Apollo server & the Strapi app // Link the Apollo server & the Strapi app
server.applyMiddleware({ strapi.server.routes([
app: strapi.server.app, {
path: config('endpoint', '/graphql'), 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 // Register destroy behavior
// We're doing it here instead of exposing a destroy method to the strapi-server.js // We're doing it here instead of exposing a destroy method to the strapi-server.js

View File

@ -133,6 +133,30 @@ module.exports = ({ strapi }) => {
return { return {
buildCollectionTypeMutations(contentType) { 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({ return extendType({
type: 'Mutation', type: 'Mutation',

View File

@ -92,6 +92,24 @@ module.exports = ({ strapi }) => {
return { return {
buildSingleTypeMutations(contentType) { 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({ return extendType({
type: 'Mutation', type: 'Mutation',

View File

@ -16,6 +16,24 @@ module.exports = ({ strapi }) => {
} = naming; } = naming;
const buildCollectionTypeQueries = contentType => { 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({ return extendType({
type: 'Query', type: 'Query',

View File

@ -11,6 +11,18 @@ module.exports = ({ strapi }) => {
const { getFindOneQueryName, getEntityResponseName } = naming; const { getFindOneQueryName, getEntityResponseName } = naming;
const buildSingleTypeQueries = contentType => { const buildSingleTypeQueries = contentType => {
getService('extension')
.for('content-api')
.use(() => ({
resolversConfig: {
[`Query.${getFindOneQueryName(contentType)}`]: {
auth: {
scope: [`${contentType.uid}.findOne`],
},
},
},
}));
return extendType({ return extendType({
type: 'Query', type: 'Query',

View File

@ -1,9 +1,19 @@
'use strict'; '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 { createPoliciesMiddleware } = require('./policy');
const ignoredObjectTypes = [
'__Schema',
'__Type',
'__Field',
'__InputValue',
'__EnumValue',
'__Directive',
];
/** /**
* Wrap the schema's resolvers if they've been * Wrap the schema's resolvers if they've been
* customized using the GraphQL extension service * customized using the GraphQL extension service
@ -19,31 +29,31 @@ const wrapResolvers = ({ schema, strapi, extension = {} }) => {
// Fields filters // Fields filters
const isValidFieldName = ([field]) => !field.startsWith('__'); 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 // Iterate over every field from every type within the
// schema's type map and wrap its resolve attribute if needed // schema's type map and wrap its resolve attribute if needed
Object.entries(typeMap).forEach(([type, definition]) => { 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; return;
} }
const fieldsToProcess = Object.entries(fields) const fields = definition.getFields();
// Ignore patterns such as "__FooBar" const fieldsToProcess = Object.entries(fields).filter(isValidFieldName);
.filter(isValidFieldName)
// Don't augment the types if there isn't any configuration defined for them
.filter(hasResolverConfig(type));
for (const [fieldName, fieldDefinition] of fieldsToProcess) { for (const [fieldName, fieldDefinition] of fieldsToProcess) {
const defaultResolver = get(fieldName);
const path = `${type}.${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); const middlewares = parseMiddlewares(resolverConfig, strapi);
// Generate the policy middleware // 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) => { * GraphQL authorization flow
if (resolverConfig.auth !== false) { * @param {object} context
* @return {Promise<void>}
*/
const authorize = async ({ context }) => {
const authConfig = get('auth', resolverConfig);
const authContext = get('state.auth', context);
if (authConfig !== false) {
try { try {
await strapi.auth.verify(context.state.auth, resolverConfig.auth); await strapi.auth.verify(authContext, authConfig);
} catch (error) { } catch (error) {
// TODO: [v4] Throw GraphQL Error instead // TODO: [v4] Throw GraphQL Error instead
throw new Error('Forbidden access'); 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<any>}
*/
fieldDefinition.resolve = async (parent, args, context, info) => {
await authorize({ context });
// Execute middlewares (including the policy middleware which will always be included) // Execute middlewares (including the policy middleware which will always be included)
return first(boundMiddlewares).call(null, parent, args, context, info); return first(boundMiddlewares).call(null, parent, args, context, info);