DEV Community

Cover image for GraphQL Auth without Middleware
verneleem
verneleem

Posted on

GraphQL Auth without Middleware

What if I told you, that you could have one of the most advanced auth built into your GraphQL API without adding any middleware?

Before we begin though, let's break apart Auth into its two sub-categories, Authentication and Authorization. Authentication is "who you are" while Authorization is "what you are allowed" to do. For this short article, we will be covering the latter of these two concepts.

Since GraphQL is a stateless API, it does not create sessions between the client and the API. The common method to authorize clients to query or mutate data is by passing a token known as a JSON Web token (JWT). The common way for a client to obtain a JWT is by authenticating through some auth provider who returns a JWT to the client. This JWT then provided by the client to the GraphQL API in the request is used in the GraphQL API to authorize actions to perform create, read, update, and delete (CRUD) operations on data.

In almost every GraphQL API, GraphQL developers need to add middleware to create rules for the authorization of these CRUD actions. There is on implementation of GraphQL however that stands above the competition, Dgraph! Dgraph has engineered their GraphQL API which is embedded into the core of the database to generate CRUD operations with authorization built-in. How is this possible? When a developer deploys a schema, they simply need to create rules using the @auth directive. Dgraph will then apply these rules and run them as a single query operation (avoiding the N+1 problem) when querying and mutating data.

Let's think of some rules that we would want in place in our API continuing with the example in the previous post, A Backend with Flexibility, with three types: User, Pet, and Breed.

  1. Only users with an admin role OR a special system account should be allowed to create new users.
  2. Only users with an admin role should be allowed to update or delete users.
  3. Users should only be allowed to see themselves, friends, and friends of friends.
  4. Users can set their profile to public.
  5. Only pets can be added that you own
  6. Users can only update pets that they own
  7. Users can only delete pets that they own
  8. Users can only see their own pets or pets of their friends
  9. Breeds can only be created, updated, and deleted by users with an admin role.
  10. Anybody can read breeds.

Three of these rules (#1, #2, and #9) are simple Role Based Access Control (RBAC) rules. The rest of these rules, get more complicated into what is known as Attribute Based Access Control (ABAC) rules. And some of these can seem even more complex (#3 & #8) that they fall into a different category of rules I would call Graph Based Access Control (GBAC) rules.

To help you better understand how these rules will work, here is a sample JWT and decoded payload from an authenticated user signed with the key super-secret:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQxMDI0NjYzOTksInN1YiI6InZlcm5lbGVlbSIsIm5hbWUiOiJ2ZXJuZWxlZW0iLCJpYXQiOjE2MzY4NjU3OTYsImlzcyI6Imp3dC5pbyIsImh0dHBzOi8vZGV2LnRvL3Zlcm5lbGVlbSI6eyJyb2xlIjoiYWRtaW4iLCJ1c2VybmFtZSI6InZlcm5lbGVlbSJ9fQ.S3Alqp8y_qFVibFvuVWyFR8Ybg26ksvWSwyWIE22W5w

{
  "exp": 4102466399,
  "sub": "verneleem",
  "name": "verneleem",
  "iat": 1636865796,
  "iss": "jwt.io",
  "https://dev.to/verneleem":{
    "role":"admin",
    "username":"verneleem"
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: JWTs payloads are not natively encrypted. They are only secure by the signing secret. This makes it important to keep the secret used to sign and validate signed JWTs a secret and not easily guessable. DO not use something like super-secret as the secret for your JWT signing provider. This then also shows the importance of keeping sensitive private user data out of your JWT payload. JWTs for the most part should not contain emails, and should definitely not contain the authenticated user's password!

When you look at the payload above, you will see JSON Object keys. These keys are called claims. Some of these claims are standard claims such as the expiration (exp) and issued at (iat) timestamps of the token, but we can use the claims in the custom claim https://dev.to/verneleem for the rules in our API. This custom claim need not be a URI, but needs to be something unique that does not conflict with standard claims or any other custom claims your JWT provider may be issuing. You can refer to the Dgraph Authorization Docs to see how to provide this custom claim, a.k.a. namespace, to Dgraph.

So we have access to two identifications for the user from the JWT claims: role and username. We will use these in our rules as $role and $username.

For the RBAC rules we can write them as:

  1. {$role:{eq:"admin"}}
  2. {$role:{eq:"system"}}

This rule #1 would pass for the given JWT because the role is equal to "admin". However rule #2 would be denied because the role is not equal to "system" for the given JWT.

To put these rules together we can use logical combinators. Let's flesh out rules #1, #2, and #9 using what we know so far with RBAC rules.

type User @auth(
  add: {
    OR: [
      { rule: "{$role:{eq:\"admin\"}}" }
      { rule: "{$role:{eq:\"system\"}}" }
    ]
  }
  update: { rule: "{$role:{eq:\"admin\"}}" }
  delete: { rule: "{$role:{eq:\"admin\"}}" }
) {
  id: ID!
  name: String! @search
  friends: [User] @hasInverse(field: "friends")
  pets: [Pet]
}
type Pet {
  id: ID!
  name: String! @search
  breed: Breed!
  owner: User! @hasInverse(field: "pets")
}
type Breed @auth(
  add: { rule: "{$role:{eq:\"admin\"}}" }
  update: { rule: "{$role:{eq:\"admin\"}}" }
  delete: { rule: "{$role:{eq:\"admin\"}}" }
) {
  name: String! @id
  pets: [Pet] @hasInverse(field: "breed")
}
Enter fullscreen mode Exit fullscreen mode

If we stopped right there and deployed this schema in Dgraph, we would have a fully functioning GraphQL API with Authorization that obeys these RBAC rules we defined in these two types.

Moving on to more complex rules, let's look at ABAC rules. These rules look at attributes of the data to grant access to only specific data nodes.

The best part that makes ABAC rules easy to use, is that these rules are written using plain GraphQL queries. Yes! You read that right. Write a rule for the GraphQL API that itself, the rule, is a GraphQL query.

Rules #5, #6, and #7 all use the owner attribute of each pet to determine if the client should be granted access to perform the specific action. The query to find this is simply:

query ($username: String!) {
  queryPet {
    owner(filter:{name:{eq:$username}}) {
      id
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Rule # 4 uses a field of the user to see if they have made their profile public. We will need to add the isPublic field and then we can use this GraphQL query rule:

query {
  queryUser(filter:{isPublic:true}) {
    id
  }
}
Enter fullscreen mode Exit fullscreen mode

Now putting this rule in our schema makes it as follows:

type User @auth(
  add: {
    OR: [
      { rule: "{$role:{eq:\"admin\"}}" }
      { rule: "{$role:{eq:\"system\"}}" }
    ]
  }
  update: { rule: "{$role:{eq:\"admin\"}}" }
  delete: { rule: "{$role:{eq:\"admin\"}}" }
  query: { rule: "query { queryUser(filter:{isPublic:true}) { id } }" }
) {
  id: ID!
  name: String! @search
  friends: [User] @hasInverse(field: "friends")
  pets: [Pet]
  isPublic: Boolean @search
}
type Pet @auth(
  add: { rule: "query($username: String!){queryPet{owner(filter:{name:{eq:$username}}){id}}}" }
  update: { rule: "query($username: String!){queryPet{owner(filter:{name:{eq:$username}}){id}}}" }
  delete: { rule: "query($username: String!){queryPet{owner(filter:{name:{eq:$username}}){id}}}" }
) {
  id: ID!
  name: String! @search
  breed: Breed!
  owner: User! @hasInverse(field: "pets")
}
type Breed @auth(
  add: { rule: "{$role:{eq:\"admin\"}}" }
  update: { rule: "{$role:{eq:\"admin\"}}" }
  delete: { rule: "{$role:{eq:\"admin\"}}" }
) {
  name: String! @id
  pets: [Pet] @hasInverse(field: "breed")
}
Enter fullscreen mode Exit fullscreen mode

Now here is where it starts to get fun. Looking again at rules #3 and #8, you will see that they are more complex, and do not depend on just a single level down into the graph, but actually multiple levels down:

Rule #3. Users should only be allowed to see themselves, friends, and friends of friends.
Rule #8. Users can only see their own pets or pets of their friends.

To make rule #3 you will need to break it down into three pieces. The first piece is Users should be allowed to see themselves. Here is the query for this piece:

query ($username:String!) {
  queryUser(filter:{name:{eq:$username}}) {
    id
  }
}
Enter fullscreen mode Exit fullscreen mode

And then for the next piece, you need to query to see if the user is your friend. Here is the query for this next piece:

query ($username:String!) {
  queryUser {
    friends(filter:{name:{eq:$username}}) {
      id
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And then traversing down the graph one step further, this next query will find all root nodes who are friends of friends:

query ($username:String!) {
  queryUser {
    friends {
      friends(filter:{name:{eq:$username}}) {
        id
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If we put all three of these pieces together we can write a set of rules that does an advanced Graph Based Access Control rule to grant access to data based upon the graph of data we are trying to access.

Taking this idea further for just a minute, imagine how this might be used to perform graph based access control that not only uses the graph of data to grant access but also your own relationship within that graph. If you want to follow this theory through then check out Putting it All Together - Dgraph Authentication, Authorization, and Granular Access Control Getting back to the schema and auth rules we have been working through let's add the above rule set and a rule to grant query access to user's own pets and their friends pets too.


type User @auth(
  add: {
    OR: [
      { rule: "{$role:{eq:\"admin\"}}" }
      { rule: "{$role:{eq:\"system\"}}" }
    ]
  }
  update: { rule: "{$role:{eq:\"admin\"}}" }
  delete: { rule: "{$role:{eq:\"admin\"}}" }
  query: { OR: [
    {rule: "query { queryUser(filter:{isPublic:true}) { id } }" }
    {rule: "query ($username:String!) { queryUser(filter:{name:{eq:$username}}) { id } }"}
    {rule: "query ($username:String!) { queryUser { friends(filter:{name:{eq:$username}}) { id } } }"}
    {rule: "query ($username:String!) { queryUser { friends { friends(filter:{name:{eq:$username}}) { id } } } }"}
  ]}
) {
  id: ID!
  name: String! @search
  friends: [User] @hasInverse(field: "friends")
  pets: [Pet]
  isPublic: Boolean @search
}
type Pet @auth(
  add: { rule: "query($username: String!){queryPet{owner(filter:{name:{eq:$username}}){id}}}" }
  update: { rule: "query($username: String!){queryPet{owner(filter:{name:{eq:$username}}){id}}}" }
  delete: { rule: "query($username: String!){queryPet{owner(filter:{name:{eq:$username}}){id}}}" }
  query: { OR: [
    { rule: "query ($username: String!){queryPet{owner(filter:{name:{eq:$username}}){id}} }" }
    { rule: "query ($username: String!){queryPet{owner{friends(filter:{name:{eq:$username}}){id}}}}" }
  ] }
) {
  id: ID!
  name: String! @search
  breed: Breed!
  owner: User! @hasInverse(field: "pets")
}
type Breed @auth(
  add: { rule: "{$role:{eq:\"admin\"}}" }
  update: { rule: "{$role:{eq:\"admin\"}}" }
  delete: { rule: "{$role:{eq:\"admin\"}}" }
) {
  name: String! @id
  pets: [Pet] @hasInverse(field: "breed")
}
Enter fullscreen mode Exit fullscreen mode

So if we take this schema with these @auth directives, add the Dgraph.Authorization comment string to parse JWTs, and deploy it on Dgraph Cloud, we would have a full backend generated with a complete CRUD GraphQL API that is backed by a real graph database (all in one layer) that can scale to terabytes of data. But that was not all, now you can see how it can also handle auth in the core without adding any middleware add-on.

We did not just add a single type of authorization, but we did added rules with three forms:

  • Role Based Access Control (RBAC) rules that use roles in the JWT to grant access
  • Attribute Based Access Control (ABAC) rules that use a GraphQL query to filter the root nodes looking for a specific attribute property such as isPublic
  • Graph Based Access Control (GBAC) rules that traverse the graph to find deeply nested related nodes that can be related to data in the claims of your token.

If you are interested in learning more, stay tuned for my next article on using custom queries and mutations to link to external data and performing advanced queries using DQL, the query language for Dgraph developed with GraphQL in mind, but with functionality needed for a database query language.

Photo by FLY:D on Unsplash

Top comments (2)

Collapse
 
jurijurka profile image
Juri Jurka

im so in love with dgraph, it makes my life so much easier!

Collapse
 
koder profile image
Ben Woodward

Super useful. Instantly bookmarked!