Konstantin Kuznetsov
Konstantin Kuznetsov

Reputation: 903

How to limit query introspection

I have a node.js project powered by apollo-server. I use custom @admin directive that does permission checking on queries, mutations and object fields. For queries and mutation this directive throws errors, for fields it returns null instead of real value.

Now, I want to add graphiql ui to my project, so fellow developers can explore my graphql schema. However, I want them to see the schema as anonymous user sees it, i.e. they should not know about existence of @admin fields and @admin queries and all mutations (even non-admin ones). Even those who have credentials to execute these operations (i.e. logged in as admin) should not see those parts of schema.

As far as I understand, graphiql sends special introspection query, which contains __schema and __type fields to display schema and its documentation.

Is it possible to somehow modify my schema, which is constructed using makeExecutableSchema from graphql-tools to achieve my goal?

Upvotes: 5

Views: 3263

Answers (3)

Vertex
Vertex

Reputation: 271

With graphql-introspection-filtering it is possible to achieve it with single schema. There is an example with authentication directive in docs. I tested it with simple use cases only, but it looks promising.

I tried it with graphql@^14.0.0 and graphql-tools@^4.0.0.

Upvotes: 1

Konstantin Kuznetsov
Konstantin Kuznetsov

Reputation: 903

Ended up having two schemas, as Tal Z suggested. Only I create two identical schemas and then modify one:

import { makeExecutableSchema, visitSchema } from 'graphql-tools';
import { LimitIntrospection } from './IntrospectionVisitor';

export async function getGraphiqlSchema(): Promise<GraphQLSchema> {
  if (!graphiqlSchema) {
    graphiqlSchema = await loadSchema(); // same as for "private" schema
    const visitor = new LimitIntrospection();
    visitSchema(graphiqlSchema, () => [visitor]);
  }
  return graphiqlSchema;
}

Here is the visitor. Tried to visit fields, but was unable to delete field from its parent from inside the field visitor. So resorted to visiting objects. Also have to set _mutationType to null, because you cannot delete all fields form mutation.

import { DirectiveNode, GraphQLField, GraphQLObjectType, GraphQLSchema } from 'graphql';
import { SchemaVisitor } from 'graphql-tools';

const isAdmin = (field: GraphQLField<any, any>): boolean =>
  !!field.astNode &&
  !!field.astNode.directives &&
  field.astNode.directives.some((d: DirectiveNode) => d.name.value === 'admin');

export class LimitIntrospection extends SchemaVisitor {

  visitSchema(schema: GraphQLSchema) {
    (schema as any)._mutationType = null;
  }

  visitObject(object: GraphQLObjectType) {
    const fields = object.getFields();
    const adminKeys = Object.values(fields).reduce(
      (ak, field) => isAdmin(field) ? [...ak, field.name] : ak,
      [],
    );
    adminKeys.forEach(key => delete fields[key]);
  }
}

I'm still looking for a solution with one schema.

Upvotes: 0

Tal Z
Tal Z

Reputation: 3210

Here's one way to do that

You can use an extended schema for the main endpoint and use the same schema without the extension for the graphiql endpoint.

Let's take this schema definition as an example:

// schemaDef
type Query {
  anonQuery: QueryResult
  adminQuery: AdminQueryResult @admin
}

And the executable schema:

const schema = makeExecutableSchema({
  typeDefs: [schemaDef /* ... additional schema files */],
  resolvers: merge(schemaResolvers/* ... additional resolvers */)
})  

Now, let's split the schema definitions with the help of the extend keyword. Read here about Extending Types and the extend keyword.

// anonymous part of the original schema definition:
type Query {
  anonQuery: QueryResult    
}

// admin extensions definitions:
extend type Query {    
  adminQuery: AdminQueryResult @admin
}

To avoid having some warnings about resolvers not defined in the schema, you'll probably want to split the admin related resolvers to another file or another resolver map.

Now you'll have 2 executable schemas:

const mainSchema = makeExecutableSchema({
  typeDefs: [schemaDef /* ... additional schema files */],
  resolvers: merge(schemaResolvers/* ... additional resolvers */)
})

const extendedSchema = makeExecutableSchema({
  typeDefs: [schemaDef, adminSchemaExtensions /* ... additional schema files */],
  resolvers: merge(schemaResolvers, adminSchemaResolvers /* ... additional resolvers */)
})

Your main endpoint should use the extended schema.

router.use('/graphql', /* some middlewares */, graphqlExpress({schema: extendedSchema}))

Since the GraphiQL endpoint expects a GraphQL endpoint, you'll have to create another one specifically for the second schema. Perhaps something like this:

router.use('/graphql-anon', /* some middlewares */, graphqlExpress({schema: mainSchema}))

router.use('/graphiql', /* some middlewares */, graphiqlExpress({endpointURL: '/graphql-anon'}))

That's it!

Now most of your code is shared and only part of the schema is accessible using the GraphiQL interface.

Putting the admin definitions in separate files could be seen as more convenient or less convenient, depending on your project, code and preferences.

Upvotes: 1

Related Questions