DEV Community

loading...

Easy GraphQL Access Control with GRANDstack

Ian Kleats
Graph enthusiast. Hobbyist developer. Philosopher. Economist. Daydreamer. (they/them/their)
・5 min read

This article might be for you if you're interested in...

A quick and flexible development experience to build:

  • Multi-tenant apps
  • Apps that let their users choose:
    • WHAT data they want to share and
    • WHO to share it with
  • Collaboration apps

GRANDstack (i.e. GraphQL, React, Apollo, Neo4j Database) already lowers technical overhead for initial app development, but it can be complicated or difficult to implement the above access-control features yourself. I'd like to share a package that fills those gaps, making the GRANDstack one of the best options for getting your next MVP up and running.

Long ago in a galaxy far, far away...

Exaggeration is fun, but seriously. A while back, I wrote a series of articles exploring some thoughts about GRANDstack (i.e. GraphQL, React, Apollo, Neo4j Database) and how its nested relationship filtering could be applied to access control. It feels like forever ago. Something called 2020 happened, and it took a while for it to go from rough proof-of-concept code to something I could share.

That day has come.

Introducing: neo4j-deepauth v0.2.0 release

Directive-based support for fine-grained access control in neo4j-graphql-js GraphQL endpoints (i.e. GRANDstack apps). Notable improvements from the early thoughts/code I shared include:

  • Restoring baseline filter functionality.
  • Adding support for @deepAuth to be applied to an Interface and any Object Type that implements it.

Using the neo4j-deepauth package

1. Install package via NPM or Yarn

yarn add neo4j-deepauth or npm install neo4j-deepauth

Link to NPM page: https://www.npmjs.com/package/neo4j-deepauth

2. Add schema definition for @deepAuth directive to your SDL.

Your type definitions should include the following:

const typeDefs = `
  # Other TypeDefs you defined before

  directive @deepAuth(
    path: String
    variables: [String]
  ) on OBJECT | INTERFACE
`
Enter fullscreen mode Exit fullscreen mode

Note that, under its current implementation, the behavior of @deepAuth will only be applied to Objects or Interface types. A hotfix is in the works for "Relationship Types" due to the way neo4j-graphql-js generates new Object type definitions Field-level access control can be implemented (rather inelegantly but simply) by moving restricted fields onto their own Object with a one-to-one relationship to the primary Type.

3. Add directive to user-defined types.

Modify your previously-defined type definitions by including @deepAuth on any Object you want it to apply to. Using our To-Do example, that might look like:

const typeDefs = `

type User @deepAuth(
  path: """{ OR: [{userId: "$user_id"},
                {friends_some: {userId: "$user_id"}}] }""",
  variables: ["$user_id"]
){
  userId: ID!
  firstName: String
  lastName: String
  email: String!
  friends: [User] @relation(name: "FRIENDS_WITH", direction: "OUT")
  taskList: [Task] @relation(name: "TO_DO", direction: "OUT")
  visibleTasks: [Task] @relation(name: "CAN_READ", direction: "IN")
}

type Task @deepAuth(
  path: """{ visibleTo_some: {userId: "$user_id"} }"""
  variables: ["$user_id"]
) {
  taskId: ID!
  name: String!
  details: String
  location: Point
  complete: Boolean!
  assignedTo: User @relation(name: "TO_DO", direction: "IN")
  visibleTo: [User] @relation(name: "CAN_READ", direction: "OUT")
}

# ...Directive definition from above
`
Enter fullscreen mode Exit fullscreen mode

Here we've limited access to Users if: a) the client is the User; or b) the client is friends with the User. And we've limited access to Tasks if and only if the client's User has a CAN_READ relationship to the Task. This is not the only or best authorization structure, just a simple example.

Please note that the path argument strongly corresponds to the filter argument Input Types that would define the existence of the ACL structure. Declaring a path argument that does not conform to the correct filter Input Type is a potential cause of errors when applyDeepAuth attempts to coerce the argument value to that type.

4. Modify resolvers and request context

Unless or until @deepAuth is integrated as a broader feature into neo4j-graphql-js, we will not be able to rely on the automatically-generated resolvers. We will have to modify them ourselves.

Per the GRANDstack docs, "inside each resolver, use neo4j-graphql() to generate the Cypher required to resolve the GraphQL query, passing through the query arguments, context and resolveInfo objects." This would normally look like:

import { neo4jgraphql } from "neo4j-graphql-js";

const resolvers = {
  // entry point to GraphQL service
  Query: {
    User(object, params, ctx, resolveInfo) {
      return neo4jgraphql(object, params, ctx, resolveInfo);
    },
    Task(object, params, ctx, resolveInfo) {
      return neo4jgraphql(object, params, ctx, resolveInfo);
    },
  }
};
Enter fullscreen mode Exit fullscreen mode

As alluded to above, we must modify these resolvers to replace the resolveInfo.operation and resolveInfo.fragments used by neo4jgraphql() with the pieces of your transformed query. Additionally, it should be noted that the top-level filter is obtained by neo4jgraphql() from the params argument, while subsequent filters are obtained from the resolveInfo. That might look something like:

import { neo4jgraphql } from "neo4j-graphql-js";
import { applyDeepAuth } from "neo4j-deepauth";

const resolvers = {
  // entry point to GraphQL service
  Query: {
    User(object, params, ctx, resolveInfo) {
      const { authParams, authResolveInfo } = applyDeepAuth(params, ctx, resolveInfo);
      return neo4jgraphql(object, authParams, ctx, authResolveInfo);
    },
    Task(object, params, ctx, resolveInfo) {
      const { authParams, authResolveInfo } = applyDeepAuth(params, ctx, resolveInfo);
      return neo4jgraphql(object, authParams, ctx, authResolveInfo);
    },
  }
};
Enter fullscreen mode Exit fullscreen mode

If you use any variables in your @deepAuth directives, you must define them within your request context with the key as it appears in your variables argument. Here is an example of how to add values to the deepAuthParams in the context using express-graphql (Note: issues with ApolloServer have been diagnosed and resolved in the v0.2.1 release, but we can still give express-graphql some love.):

const app = express();
app.use('/', graphqlHTTP((request) => ({
  schema,
  context: {
    driver,
    deepAuthParams: {
      $user_id: request.user.id
    }
  },
  ...
})));
Enter fullscreen mode Exit fullscreen mode

5. Update Custom Mutations

The automatically-generated mutations will not currently respect or enforce the authorization paths provided on @deepAuth. Also, it will often be helpful or necessary to create/delete additional authorization nodes/relationships in the same transaction as a Create/Delete mutation.

For these reasons, you will need to create your own custom mutation resolvers for pretty much any Type that has @deepAuth applied or has a relationship to a @deepAuthed Type.

Examples

An example of neo4j-deepauth use can be found at github.com/imkleats/neo4j-deepauth-example

GitHub logo imkleats / neo4j-deepauth-example

ApolloServer example with neo4j-graphql-js and neo4j-deepauth

Issues and Contributions

As an early version number release, I'm still working on identifying all edge cases and continually fleshing out the test suite. If you run into any bugs or have ideas for future feature releases, please open up an Issue on the Github repository.

GitHub logo imkleats / neo4j-graphql-deepauth

Directive-based support for fine-grained access control in neo4j-graphql-js GraphQL endpoints

Thanks for listening! Hope you find it useful, and I look forward to hearing from you!

Discussion (0)