Hi! It's me again. Welcome to this fifth article in my series on discretionary access control with the GRANDstack. The past couple posts have ventured into some highly theoretical territory. After "losing" a weekend for some snowboarding (aka shredding the gnar), I have finally caught my code up to actually do all the things I talked about doing. I don't know about you, but I am super duper excited.
This article will cover the features implemented currently, lay out limitations that I intend to address with later enhancements (i.e. future articles), and demonstrate how this tool might be integrated into a neo4j-graphql-js
-generated endpoint. First things first, let me show you the code:
imkleats / neo4j-graphql-deepauth
Directive-based support for fine-grained access control in neo4j-graphql-js GraphQL endpoints
Disclaimer and Reminder
The importance of data privacy cannot be overstated. Aside from any legal obligations, we have a moral responsibility as coders/developers to ensure the safety of those using our products. It is not hyperbole to say that poorly constructed access control can literally put people's lives at risk.
At this stage, please do not assume my work is production-ready. I make no guarantees of its quality or potential flaws. If you want to use this code, be responsible in writing your own unit and integration tests.
@deepAuth MVP build
Minimum Viable Features
-
Simplicity: Anyone building a GraphQL backend using
neo4j-graphql-js
should be able to add fine-grained access control to their read-resources in three easy steps.- Add schema definition for
@deepAuth
directive to your SDL. - Add directive to user-defined types.
- Modify resolvers to replace the
resolveInfo.operation
andresolveInfo.fragments
used byneo4jgraphql()
with the pieces of your transformed query.
- Add schema definition for
-
Powerful Security: Clients should be able to access only the information for which they have been granted permission.
- Leverage Neo4j's graph database capabilities to efficiently traverse arbitrarily complex access control relationships.
- Prevents inference of unauthorized nested data by removing any client-defined filter arguments prior to execution. (Future enhancement to allow and dynamically modify client-defined filter arguments.)
-
Flexibility & Freedom: In designing
@deepAuth
, a heavy premium was placed on extensibility.- Strive for great access control functionality out-of-the-box, but recognize that others might have different needs or ideas about what works for them.
- Users are free to extend or modify the default behavior of
@deepAuth
by creating their own TranslationRules. - This TranslationRule pattern/approach is also not limited to directives. Get creative with it!
Enhancement Roadmap
-
Object-levelComplete@deepAuth
directive support. -
Remove client-definedCompletefilter
arguments on GraphQL queries - Field-level
@deepAuth
directive support.- Path argument will define path to a fieldPermissions node.
- TranslationRule will add this fieldPermissions node to selectionSet.
- Apollo tooling will be used to validate field-level permissions based on this extra data.
- Nested filter support.
- Restore client ability to supply filter arguments.
- Use additional TranslationRule visitors to traverse existing filter arguments.
- Wrap components of the existing filter argument with applicable
@deepAuth
filter.
- Mutation support.
- Attach newly-created nodes to a defined access control structure.
- Use an
OperationDefinition
visitor in the TranslationRule to generate additional dependent mutations. - Submit all dependent mutations as a single database transaction.
Demonstration of Intended Flow
1. 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
`
Note that, under its current implementation, the behavior of @deepAuth
will only be applied to Objects. Field-level access control will be the next topic I cover and implement. For forward-compatibility, you can safely use on OBJECT | FIELD_DEFINITION
.
2. 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
`
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
.
Please note that, while the path
argument generally corresponds to the filter argument that would define the existence of the ACL structure, it must be written without being enclosed by brackets at the outermost level (i.e. just path
not { path }
).
3. Modify resolvers and request context
Unfortunately, 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);
},
}
};
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. That might look something like:
import { neo4jgraphql } from "neo4j-graphql-js";
import { applyDeepAuth } from "../neo4j-graphql-deepauth";
const resolvers = {
// entry point to GraphQL service
Query: {
User(object, params, ctx, resolveInfo) {
const authResolveInfo = applyDeepAuth(params, ctx, resolveInfo);
return neo4jgraphql(object, params, ctx, authResolveInfo);
},
Task(object, params, ctx, resolveInfo) {
const authResolveInfo = applyDeepAuth(params, ctx, resolveInfo);
return neo4jgraphql(object, params, ctx, authResolveInfo);
},
}
};
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 ApolloServer:
const server = new ApolloServer({
context: ({req}) => ({
driver,
deepAuthParams: {
$user_id: req.user.id
}
})
})
Where do we go from here?
Hmmm, good question. I still need to build a lot of tests for the code I've written. Of the three items on my "Enhancement Roadmap", getting nested filter functionality restored is probably the most important, but it's also the most technically challenging.
Field-level access control is probably the easiest, and mutations are fairly straightforward but to introduce database transactions requires re-implementing some parts of neo4jgraphql()
. So who knows. I'm leaning towards field-level access control so I can focus on tests.
Thanks for joining me on my journey. We're in a pretty good spot, but there's a fair distance we have yet to travel. Till next time!
Top comments (0)