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();
}
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);

View File

@ -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

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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<void>}
*/
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<any>}
*/
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);