DEV Community

Cover image for GraphQL RBAC without JWT Roles
verneleem
verneleem

Posted on

GraphQL RBAC without JWT Roles

Before I continue from my last post GraphQL Auth Without Middleware, a question came up:

Is it possible to use Role Based Access Control (RBAC) in Dgraph Authorization rules, when my authentication and JWT provider is not capable of putting roles into the JWT given to clients?

Yes, this is possible. Before I give a brief work around to enable this with a full example, I want to first show another reason why someone might want to not put user roles into the JWT.

If you are familiar with JWTs, you will know that a JWT is secure only in that it is signed with a private key. This signature on the JWT validates that the payload is authenticated, but it does not prevent anyone else from decoding the payload. It does prevent JWT modification without access to the signing private key. The kicker though is that under most implementation you cannot revoke JWT tokens once issued.

Once issued, access tokens and ID tokens cannot be revoked in the same way as cookies with session IDs for server-side sessions.

As a result, tokens should be issued for relatively short periods, and then refreshed periodically if the user remains active.

Auth0 Docs

So then let's play out a situation: A admin comes to the sight and is issued a JWT with a role of admin giving him full control. Even if this JWT is only short lived 24 hours, let's say this specific JWT is compromised is some way. Now how do you handle this?

  • Do you change the private key which would also invalidate ALL of the other users tokens?
  • Do you change all of your auth rules to use a new role and trash that old role?

Remember time is ticking and you have a hacker out there with a full access token. You want to block them NOW, but you cannot just stop your service for all other users.

There really isn't a good solution. There are some other hack around solutions such as How do I revoke JWT everytime a new token is geneated using django-graphql, but nothing that works really well because a user may actually want to be signed into multiple devices at the same time, and this hack around would prevent that from being allowed.

So my solution is: never put the role into the JWT.

How then can you use a role in my auth rules if it is not in the JWT? You just need to create a path between all of your data needing RBAC to all of your users and put their role in the database. By keeping the role in the db instead of the JWT you can grant/revoke access without the user needing to reauthenticate and without the need to revoke compromised JWTs.

Linking all types needing RBAC to all your users sounds like a daunting task, but let me show you how easy it is.

Let's start with three types: User, Pages, Comments. To set this linking up, we will add a 4th type Link that will be this linking node.

type User {
  username: String! @id
  comments: [Comment]
  link: Link!
}
type Page {
  id: ID
  title: String! @search
  content: String
  comments: [Comment]
  link: Link!
}
type Comment {
  id: ID
  comment: String! @search
  by: User!
  for: Page! @hasInverse(field: "comments")
  link: Link!
}
type Link {
  id: String! @id
  users: [Link] @hasInverse(field: "link")
}
Enter fullscreen mode Exit fullscreen mode

Purposefully, the Link id is of type String instead of ID and it has the @id directive. This will make it a unique string id to help us create something we can easily reference later on.

After deploying this GraphQL schema to Dgraph, we need to create a single node for this linking node and then lock it down so no new nodes for this type will be created later. We can create a node by using the addLink mutation:

mutation {
  addLink(input: [{id: "link"}]) {
    link {
      id
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Very simply this created a single Link node with the id equal to "link". If you go back up and look at the schema, you will notice that every type links to the type Link through the field link, but the link node only has a field connecting it inversely back to users. Also by making the link field a required field, every user, page, and comment must have a link. We want all of them to use the same link. So the next step is going to disable any future links from being created by the GraphQL endpoint. We do this by first disabling the mutations to add, update, and delete links, and then we create an auth rule on the Link type that will always be false, in essence never allowing a parent to create a new child link, but forcing it to use the existing link already created. We don't want to expose the root level queries for the Link type either, so we will also disable those from being generated by Dgraph.

type User {
  id: ID!
  username: String! @id
  comments: [Comment]
  link: Link!
}
type Page {
  id: ID
  title: String! @search
  content: String
  comments: [Comment]
  link: Link!
}
type Comment {
  id: ID
  comment: String! @search
  by: User!
  for: Page! @hasInverse(field: "comments")
  link: Link!
}
type Link @generate(
  query: {
    get: false,
    query: false,
    aggregate: false
  },
  mutation: {
    add: false,
    update: false,
    delete: false
  }
) @auth(
  add: { rule: "{$ALWAYS: { eq: \"_NEVER_\" }}" },
  update: { rule: "{$ALWAYS: { eq: \"_NEVER_\" }}" },
  delete: { rule: "{$ALWAYS: { eq: \"_NEVER_\" }}" }
) {
  id: String! @id
  users: [Link] @hasInverse(field: "link")
}
Enter fullscreen mode Exit fullscreen mode

Deploying this GraphQL schema to Dgraph (after following the steps above) will leave us with a single Link node that is immutable. The auth rules {$ALWAYS: { eq: \"_NEVER_\" }} is a trick I use that makes the schema rules easy to read, and should always equate to false as long as the JWT does not contain a claim for ALWAYS: "_NEVER_" which should not be there unless you specifically craft it that way by your authentication jwt generator.

Now that we have this in place, we can start adding users, pages, and comments. Here are a few mutations to get you started:

mutation addFooBar {
  addUser(input: [
    { username: "foo", link: { id: "link" } },
    { username: "bar", link: { id: "link" } }
  ]) {
    user {
      id
      username
    }
  }
}
mutation addNewPageWithComments {
  addPage(input: [
    {
      title: "Dgraph makes GraphQL Easy",
      content: "Did you know you can do role-less RBAC with Dgraph GraphQL?",
      link: { id: "link" },
      comments: [
        {
          comment: "I never thought this would be possible!",
          by: { username: "bar" },
          link: { id: "link" }
        }, {
          comment: "This has made my life so much easier.",
          by: { username: "foo" },
          link: { id: "link" }
        }
      ] },
  ]) {
    page {
      id
      title
      content
      comments {
        id
        comment
        by {
          id
          username
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that we have a GraphQL API with our singular link node, 2 users, 1 page, and 2 comments, let's add user roles and auth rules to do the following:

  • Only let an Editor edit pages.
  • Only let an Admin delete pages.
  • Let an Admin delete any comment.
  • Let a user delete their own comment.
  • Only let a Subscriber add a new comment.

type User {
  id: ID!
  username: String! @id
  roles: [Role] @search
  comments: [Comment]
  link: Link!
}
enum Role {
  Admin
  Editor
  Author
  Subscriber
  Disabled
}
type Page @auth(
  # Only let an Editor edit pages
  update: { rule: """
    query ($username: String!) {
      queryPage {
        link {
          users(filter: {
            username: { eq: $username },
            roles: { eq: Editor }
          }) {
            id
          }
        }
      }
    }
  """ },
  # Only let an Admin delete pages
  delete: { rule: """
    query ($username: String!) {
      queryPage {
        link {
          users(filter: {
            username: { eq: $username },
            roles: { eq: Admin }
          }) {
            id
          }
        }
      }
    }
  """ }
) {
  id: ID
  title: String! @search
  content: String
  comments: [Comment]
  link: Link!
}
type Comment @auth(
  delete: { OR: [
    # Let an Admin delete any comment
    { rule: """
      query ($username: String!) {
        queryComment {
          link {
            users(filter: {
              username: { eq: $username },
              roles: { eq: Admin }
            }) {
              id
            }
          }
        }
      }
    """ },
    # Let a user delete their own comment
    { rule: """
      query ($username: String!) {
        queryComment {
          by(filter: {
            username: { eq: $username },
          }) {
            id
          }
        }
      }
    """ },
  ] },
  add: { rule: """
    query ($username: String!) {
      queryComment {
        link {
          users(filter: {
            username: { eq: $username },
            roles: { eq: Subscriber }
          }) {
            id
          }
        }
      }
    }
  """ }
) {
  id: ID
  comment: String! @search
  by: User!
  for: Page! @hasInverse(field: "comments")
  link: Link!
}
type Link @generate(
  query: {
    get: false,
    query: false,
    aggregate: false
  },
  mutation: {
    add: false,
    update: false,
    delete: false
  }
) @auth(
  add: { rule: "{$ALWAYS: { eq: \"_NEVER_\" }}" },
  update: { rule: "{$ALWAYS: { eq: \"_NEVER_\" }}" },
  delete: { rule: "{$ALWAYS: { eq: \"_NEVER_\" }}" }
) {
  id: String! @id
  users: [Link] @hasInverse(field: "link")
}
# Dgraph.Authorization {"VerificationKey":"your-256-bit-secret","Header":"X-My-App-Auth","Namespace":"https://dev.to/verneleem/example","Algo":"HS256"}
Enter fullscreen mode Exit fullscreen mode

You will need to setup an authentication service to generate JWTs for your users with their username in the claim. See Dgraph Docs for more information. To aide in this process we created some JWTs you can use below.

This new schema with auth rules covers just the rules listed above. You will want to flesh out this schema with more rules to meet your own rule set for your application needs.

After deploying this GraphQL schema to Dgraph GraphQL, you will mostly likely want to add some roles to users. Here are a few mutation blocks to help do this:

mutation makeFooSubscriber {
  updateUser(input: {
    filter: {
      username: { eq: "foo" }
    }
    set: {
      roles: [Subscriber]
    }
  }) {
    user {
      id
      username
      roles
    }
  }
}
mutation makeBarEditor {
  updateUser(input: {
    filter: {
      username: { eq: "bar" }
    }
    set: {
      roles: [Editor]
    }
  }) {
    user {
      id
      username
      roles
    }
  }
}
mutation addBazAdmin {
  addUser(input: [{
    username: "baz",
    roles: [Admin],
    link: { id: "link" }
  }]) {
    user {
      id
      username
      roles
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

After making the mutations adding roles to your users and adding the new user baz, you can then use the correlating JWTs below sending them in the header of the GraphQL request in the X-My-App-Auth key.

foo: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NDExNjgwMDAsImV4cCI6NDEwMjQ0NDgwMCwiaXNzIjoiand0LmlvIiwiaHR0cHM6Ly9kZXYudG8vdmVybmVsZWVtL2V4YW1wbGUiOnsidXNlcm5hbWUiOiJmb28ifX0.zoCgwCRKGATrL_9sw180Xb1X2PnsH-y62AuTeKKQ5MU
bar: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NDExNjgwMDAsImV4cCI6NDEwMjQ0NDgwMCwiaXNzIjoiand0LmlvIiwiaHR0cHM6Ly9kZXYudG8vdmVybmVsZWVtL2V4YW1wbGUiOnsidXNlcm5hbWUiOiJiYXIifX0.Vk_ihPY-QZfAjD5r5HB4Z8T9aaCoO9bPYV3aU5XCx5c
baz: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NDExNjgwMDAsImV4cCI6NDEwMjQ0NDgwMCwiaXNzIjoiand0LmlvIiwiaHR0cHM6Ly9kZXYudG8vdmVybmVsZWVtL2V4YW1wbGUiOnsidXNlcm5hbWUiOiJiYXoifX0.W4A_QDJyqeir7FZgHCkJLadoPI__-ZMIDpo2yQb9q4c

Pick one of these JWTs and try to do the following tasks

  • Add a new Comment
  • Delete all comments
  • Delete a Page
  • Edit a Page

Hope this helps you see how you can accomplish the feat of Role Based Access Control without sacrificing your roles into your tokens.

What is great about this, is that is you update a user's role you can use the same token above and it will immediately resolve the rules for their new role. No longer will you have to force users to sign out and sign back in to grant additional roles. Also the best benefit in terms of security, is that if a user becomes compromised, you can immediately revoke the role for that specific user without waiting for them to destroying their token or even worse invalidating all users tokens to make the application secure.

Photo by Clay Banks on Unsplash

Top comments (5)

Collapse
 
jdgamble555 profile image
Jonathan Gamble

Only think I would add, is that I would secure the entire schema first (so you don't have to mess with it later), and then add the one link through dql:

JSON DQL

{
  "dgraph.type": "Link",
  "Link.id": "link"
}
Enter fullscreen mode Exit fullscreen mode

OR

DQL triples

_:new <dgraph.type> "Link"
_:new <Link.id> "link"
Enter fullscreen mode Exit fullscreen mode

J

Collapse
 
verneleem profile image
verneleem

Sure you could do that. I was just doing this more in a simplistic approach keeping it all in GraphQL and build upon precepts tutorial style.

Collapse
 
cyberhck profile image
Nishchal Gautam

Jwts aren't issued for a day, it's at most 30 mins, 15 being a good default

Collapse
 
verneleem profile image
verneleem

this is dependent on the issuer of the JWT, you could be using a service that issues JWTs valid for 100 years, or you could be using a 3rd party JWT issueing service that doesn't support custom roles in the JWT on the free plan too ;)

Collapse
 
cyberhck profile image
Nishchal Gautam

Obviously what I meant here was one shouldn't issue a jwt which is valid for too long, if third party doesn't allow, don't use that service