Keeping certain parts of your GraphQL schema hidden from Introspection

GraphQL is a popular and powerful way to build your APIs and power your website; we use it for most of our APIs at Anvil. One of the best things about GraphQL is that it provides an Introspection Query capability which allows users to explore and learn about what's possible in a given API. In that way, GraphQL is "self-documenting". Some great tools like Playground and Anvil's own SpectaQL leverage the Introspection Query.

The Problem and Solution

Overall, the Introspection Query is a great thing, but there is one major downside: everything in your GraphQL schema will be visible to the world. Chances are your schema contains some Types, Fields, Queries, Mutations, etc, that you do not want the world to see or know about. Shutting down your Introspetion Query is one way to keep things private, but then your users cannot discover anything about your API. Fortunately there's another option: Directives. In this post, I will talk about how to leverage custom GraphQL Directives to hide sensitive things from the Introspection Query results so they can remain private.

Implementation

At Anvil we are mostly a Node shop and use Apollo as our GraphQL server framework. As such, this post will feature a solution that is specific to that framework, and I highly recommend reading Apollo's docs on Implementing Directives. However, Directives are part of the general GraphQL Specification, so every implementation ought to have a way to accomplish what I'm about to show you.

First, we need to define the directive in SDL by specifying (1) a name for it (2) any arguments to the directive and (3) what locations the directive is applicable on. We'll call our directive undocumented, it won't take any arguments, and it will be applicable to Object, Field and Enum definitions (you can obviously adjust any of this as necessary). Here's the SDL:

"""
A directive that will filter things from the
Introspection Query results
"""
directive @undocumented on 
  | OBJECT | FIELD_DEFINITION | ENUM

Now we may decorate any Objects, Fields and Enums in our schema that we want to hide from the Introspection Query like so:

type User {
  id: Int!
  email: String!
  # Don't show this Field!
  password: String! @undocumented
}

# Don't show this Type!
type SecretThing @undocumented {
  ...
}

...

Easy enough, right? Not so fast - we still have to implement it! As described in the Implementing Directives documentation, we'll want to create a subclass of the SchemaDirectiveVisitor class. Typically, the SchemaDirectiveVisitor class is used to implement the augmentation of data via directives, but we would like to completely remove some things from an Introspection Query result so we'll need a little extra help.

So that we can filter things rather than just augment them, we'll be using the GraphQL Introspection Filtering library1. This library basically hooks into the internals of the graphql library and modifies the introspection module to check for some special, supplemental static methods in your SchemaDirectiveVisitor subclass. The return value of these methods will indicate whether a thing should be hidden or shown. It's probably best understood by an example:

// UndocumentedDirective.js

import { SchemaDirectiveVisitor } from 'graphql-tools'

export default class UndocumentedDirective extends SchemaDirectiveVisitor {

  //****************************************
  // These methods are standard SchemaDirectiveVisitor
  // methods to be overridden. They allow us to "mark"
  // the things that were decorated with this directive
  // by setting the `isDocumented` property to `true`
  // 

  visitObject (subject) {
    subject.isUndocumented = true
  }

  visitEnum (subject) {
    subject.isUndocumented = true
  }

  visitFieldDefinition (subject) {
    subject.isUndocumented = true
  }

  //
  //****************************************

  //****************************************
  // These static methods are used by the
  // graphql-introspection-filtering library to decide
  // whether or not to show or hide things based on their
  // boolean responses
  // 

  static visitTypeIntrospection (type) {
    return UndocumentedDirective.isAccessible(type)
  }

  static visitFieldIntrospection (field) {
    return UndocumentedDirective.isAccessible(field)
  }

  // Don't show that this directive itself exists
  static visitDirectiveIntrospection ({ name }) {
    return name !== 'undocumented'
  }

  //
  //****************************************

  // If the thing has not been marked by the directive to
  // be undocumented, then it's accessible
  static isAccessible (thing) {
    return !thing.isUndocumented
  }
}

Finally, to pull it all together we need to build our executable schema out of all this and pass it along to our Apollo constructor:

import { makeExecutableSchema } from 'graphql-tools'
import makeFilteredSchema, { schemaDirectivesToFilters } from 'graphql-introspection-filtering'
import ApolloServer from 'wherever-is-appropriate-for-your-stack'
import UndocumentedDirective from './UndocumentedDirective'

const typeDefs = `<your SDL here>`
const resolvers = {...}
const schemaDirectives = {
  // The key used here must match the name of the directive
  // we defined in SDL earlier
  undocumented: UndocumentedDirective,
}

const executableSchema = makeExecutableSchema({
  typeDefs,
  resolvers,
  schemaDirectives,
})

// Create a filters structure for any of our schemaDirectives
// that added any special methods for the
// graphql-introspection-filtering library
const filters = schemaDirectivesToFilters(schemaDirectives)

// Augment the schema and Introspection behavior to use the
// filters that were created
const filteredSchema = makeFilteredSchema(executableSchema, filters)

// Create our Apollo Server
const apolloServer = new ApolloServer({
  schema: filteredSchema,
  ...,
})

Profit! Now all Introspection Query responses will have anything decorated with the @undocumented directive removed from the results.

The Catch

While this is super easy to leverage from this point on, there is a catch: You must ensure that any references to definitions that you've hidden are also hidden. If you're not careful about this, you can break your schema for many 3rd party tools (e.g. Playground) that leverage the Introspection Query. Imagine the following bit of SDL:

type Secret @undocumented {
  aField: String
}

type MyType {
  aField: String,
  secretField: Secret
}

Uh oh, the secretField on MyType references a Type that is hidden from the output. Some tools will have trouble dealing with this non-existent reference. You can fix this by adding the @undocumented directive to the secretField definition like so:

type MyType {
  aField: String,
  secretField: Secret @undocumented
}

This requires you to be careful when using the @undocumented directive if you don't want to break some tools by having an incomplete schema. This can be a cumbersome and challenging task. In a future blog post, we'll outline how to make this less painful. Stay tuned!

If you have questions, please do not hesitate to contact us at:
[email protected]

  1. This library is currently on version 2.x, but we are using 1.x. Our examples are therefore only suitable for 1.x

23