DEV Community

Ian Kleats
Ian Kleats

Posted on

GRANDstack Access Control - Query Translation Strategy

Howdy! Welcome back to Part 3 of my series on discretionary access control in the GRANDstack. If you're still with me after that last article, mil gracias. As promised, we are going to define and empower a schema directive to lock down our assets.

Recapping our journey so far...

  • We motivated our work by thinking about a GRANDstack To-do app.
    • Our first to-do? Keep Bob from down the street out of our to-do list.
  • We identified that the powerful nested/relational filtering from neo4j-graphql-js could be used to restrict our query results based on some arbitrary access control structure.
    • Our second to-do? Automate that filter behavior using a schema directive.
  • Then, we needed to explore how GraphQL processes an operation (i.e. query or mutation) from request through result to understand potential implementations.
  • We learned that the JavaScript reference implementation's graphql/language module and its validate function contain utilities and patterns that "fit" the query transformation problem we're trying to solve.

Today, we'll apply the pattern we described by writing a TranslationRule to add a filter argument to our Document ASTs for every type/field that we put this new directive on within our type definitions.

This endeavor will assume you're familiar with (or can reference on the fly) the repository I shared last time that lays out the pattern we'll be using:

GitHub logo imkleats / graphql-ast-tools

Rule-based translation of GraphQL Document ASTs to ASTs of other query languages

Full disclosure:

I'm developing best practices as I write this, so feel free to offer different opinions in the comments or as issues in the above repo.

The other thing is that this is a hobby for me. My biggest well of time to work on this is the weekend... only after devoting time to family and household obligations. In the sake of keeping up the cadence of this series, though, I'm going to be out-pacing some of the code I've already written.

Some code samples might reference utility functions that I haven't created yet, others might be more like meta programming. If you see where I'm coming from / going to and want to fill in the blanks yourself, I welcome it. Just don't expect my code blocks to be copy-pastable and working.

Where do we start?

In thinking through how to approach transformations, let's remind ourselves of the steps involved in the pattern we are going to apply:

  1. Receive an incoming GraphQL request, parse it into an AST, and feed it into translate.
  2. translate traverses the AST and applies a set of visitor functions that are supplied as TranslationRules to each AST node it visits.
  3. These visitor functions, subject to whatever logic you define, emit information that is stored at a specified location in an AstMap.
    • This information may include references to pending values in other locations in the AstMap.
  4. Upon traversal completion, a function is invoked to build a new AST (not necessarily a GraphQL AST) from the components of the AstMap.

I am going to propose that we should start with thinking about the state that needs to exist at the end of Step 3 before heading into Step 4.

In this exercise, we are going to be transforming one GraphQL query into another GraphQL query. Let's think about what that means for the structure of our finalized AstMap.

Scoping our AstMap and Final Coalescer

The first thing that comes to mind as I think about a GraphQL-to-GraphQL transformation is that we could "clone" an entire Document AST with our AstMap.

Did I mention I'm lazy, though? Did I? To misquote one of my favorite mercantilist philosophers, C.J. Sparrow:

You can always trust a lazy person to conduct your business with the utmost efficiency. Honestly. It's the industrious ones you want to watch out for.

(Isn't there inherent value in knowing how to replicate the full-fledged structure of a GraphQL AST?!? Of course there is! But meh...)

To save myself some work, I'm going to ask:

  • What parts of the AST are we interested in changing?
    • Argument nodes with Field node parents.
  • Do we need to care about any of the child nodes of those Argument nodes?
    • The Name and Value nodes tell us if there is an existing "filter" argument.
    • None of those children will hold a subsequent Field Node, so we can kind of treat the entire Argument Node as a leaf.

Ok, why did I ask those questions? It's because I wanted to save myself some work, silly. It sounds like we can get away only needing a list of objects that contain:

  1. A new or modified Argument Node for our ACL filter; and
  2. The location in the Document/Operation AST in which to insert that node.

Our final AstMap (that is generated by our visitor functions, so not actually a piece of code we have to define) might look like:

const AstMap = {
  originalQuery: () => {
    return original_document_ast;
  },
  authFilters: () => {
    return [{path: [path_to_filter_arg_1], node: {new_Argument_Node_1},
            {path: [path_to_filter_arg_2], node: {new_Argument_Node_2},
            ...
            {path: [path_to_filter_arg_N], node: {new_Argument_Node_N}];
  },
} 
Enter fullscreen mode Exit fullscreen mode

This could naturally lead to a final AstCoalescer function (which we do have to define for ourselves) that looks something like this:

import set from 'lodash/set';

const finalCoalescer = (astmap) => {

  const requestAst = astmap['originalQuery'] ? astmap.originalQuery() : {};
  const authFilters = astmap['authFilters'] ? astmap.authFilters() : [];

  authFilters.map( authFilter => set(requestAst, authFilter.path, authFilter.node) );

  return requestAst;
}
Enter fullscreen mode Exit fullscreen mode

Writing a TranslationRule to make that happen

Mmkay, we know what we want at the end. Now we need to think about which visitor function or set of visitor functions will be necessary to create that structure.

Populating originalQuery

If we were going to use a visitor function to produce a copy of the original Document AST, it might make sense to do that at the first node we enter.

Can we stop to ask ourselves why we would do this through a visitor function?

Honestly, having the capacity to produce an original version of our AST is probably going to be needed fairly frequently for other applications. Maybe we should just revise translate to populate this into our AstMap under a reserved key before any visitation.

Populating authFilters

If the originalQuery is taken care of without any TranslationRule, our primary concern is now limited to authFilters. Here's a part -- like I mentioned above -- that I am getting a little ahead of my actual codebase. This next part will be a little more conceptual, and I'll spend more time in the next article to complete the implementation.

Ok, let's take a second to think: Each visitor function could apply to a specific Kind of node. What kind of node(s) are we interested in?

(Duh, we want to modify Argument nodes in the final AST, so we should write an Argument visitor! ...and that is why I am writing this article and not you. Just kidding... mostly. ;p )

Back to reality. Why would we not use an Argument visitor? Because there are some Fields that we will visit which will not currently have any Argument nodes but will need the schema directive applied to them. Uh-oh, visitors can't visit a node that is undefined.

However, nothing prevents us from accessing the Argument node array when stopping at the Field. We want to do our work when visiting each Field node.

Are there any other node Kinds we need to visit? Nope.

So, let's put some bones in our Field visitor.

// Definition of our Rule to add an authorization filter.

export function AuthorizationFilterRule(
  context   // The TranslationContext class we instantiate in translate().
) {
  // Returns an ASTVisitor
  return {
    Field(node, key, parent, path, ancestors) {

      const ToDoList = """
         1a) Check for directive on field's type in schema.
         1b) Check for filter arguments on Field node.
         2a) Modify or remove existing filter arguments.
         2b) If 1a is true, wrap 2a with the ACL filter.
         3)  Discern appropriate path for new/modified filter arguments.
         4a) Get current authFilters list from AstMap using `context`.
         4b) Append object with {path: results_of_3, node: results_of_2}
             to 4a (with a higher order function or something similar).
         4c) Post result of 4b to AstMap using `context`.
      """;

      // The @return value of visitor functions elicits special behavior.
      // In most cases, we just want to return undefined.
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Adding the Directive to our Schema

Alright, we kinda sorta have a skeleton for how we are going to implement our AuthorizationFilterRule. That was a doozy. Take a breath. We've got some momentously difficult work ahead of us ("srs bzns" if you're into the whole brevity thing).

const typeDefs = `
  # Other TypeDefs you defined before
  directive @deepAuth on OBJECT | FIELD_DEFINITION
`
Enter fullscreen mode Exit fullscreen mode

Done. That was it. Seriously. We might think about arguments for that directive, or we might not. I haven't decided yet.

Wrapping Up

That's a wrap on this installment of the series. I wish I could say I wasn't a little bummed out. I really wanted to have an MVP AuthorizationFilterRule to share with you, and I fell short.

But hey! We did cover a lot anyway! Let's choose to be happy. It's probably all for the best in any case since we'll be able to take a deeper dive into those implementation details in the next post. Till next time!

Top comments (0)