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.
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")
}
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
}
}
}
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")
}
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
}
}
}
}
}
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"}
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
}
}
}
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)
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
OR
DQL triples
J
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.
Jwts aren't issued for a day, it's at most 30 mins, 15 being a good default
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 ;)
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