Handling Complex User Permissions in GraphQL

Christian Nwamba - Feb 17 '20 - - Dev Community

I have been working on a GraphQL workshop, and it’s been a great learning experience. One of the trickiest things I have had to deal with in GraphQL is handling complex user permissions. It used to be a hassle until a friend pulled my attention to Hasura.

Hasura is an open source engine that connects to your databases & microservices and instantly gives you a production-ready GraphQL API.

Permission in Its Simplest Form

Assuming you have the following model…

And you are asked to ensure a few things:

  1. Only a logged-in user can create a post
  2. Only admin users can delete post(s)

These are doable — you will need to ensure that your GraphQL resolvers handle the logic. Here is what this function that can be called from your resolver could look like:

function createPost(post) {
  const isAuthenticated = await isAuthenticated()
  if(!isAuthenticated) {
    // return 401
  }
  return await Post.create(post)
}
Enter fullscreen mode Exit fullscreen mode

To ensure only admin users can delete posts, you would have to check if the role or grants or permissions of the user has an admin value:

function deletePost(postId, loggedInUserId) {
  const isAuthenticated = await isAuthenticated()
  const isAdmin = await isAdmin(loggedInUserId)
  if(!isAuthenticated && !isAdmin) {
    // return 401
  }
  return await Post.delete(postId)
}

function isAdmin(userId) {
  const userRoles = await getUserRoles(userId);
  return userRoles.includes('admin')
}
Enter fullscreen mode Exit fullscreen mode

Let’s say you shipped this example to production, and your forum or blog or even hacker news clone is live — congratulations. You come back tomorrow to realize that users are updating other users' posts — bummer.

This one is not so difficult to achieve too. You will need to check the id of the logged-in user and compare it with the foreign key user_id on the post that needs to be. If they match, the user can edit, if it don’t then a 401 should be returned:

function updatePost(post, loggedInUserId) {
  if(post.user_id !== loggedInUserId) {
    // return 401
  }
  return Post.update(post)
}
Enter fullscreen mode Exit fullscreen mode

We just saw an everyday CRUD permission strategy -- these kind of authorizations are not very difficult to achieve. Where things get tricky, though, is when you start introducing unique business logic.

When Permissions Gets Tricky

A few weeks later, you want to encourage engagement in whatever social/collaboration project you are working on. A great way to do this is to add a like, vote, or clap feature.

Adding a voting feature is not just adding one more table with the correct relationships. There are standard rules for voting that you want to account for:

  1. Only logged in users can vote
  2. Post creators CANNOT vote on the posts they created
  3. A voter can only vote once on a given post

Here is what this logic might look like:

function vote(post, loggedInUserId) {
  // 1. Must be logged in
  const isAuthenticated = await isAuthenticated()
  if(!isAuthenticated) {
    // return 401
  }

  // 2. Disallow votes for post creators
  if(post.user_id === loggedInUserId) {
    // return 401
  }

  // 3. Disallow multiple votes per voter
  const votes = await Vote.all({
    where: {
      post_id: post.id, 
      user_id: loggedInUserId
    }
  })

  if(votes.length > 0) {
    // return 401
  }

  // Finally, vote
  return await Vote.create({
    post_id: post.id, 
    user_id: loggedInUserId
  })
}
Enter fullscreen mode Exit fullscreen mode

See how this is getting longer as our user requirements keep compounding. There are more opportunities for bugs to slide in. To make things worse, if we keep getting requirements like this, it becomes one hell of a pain to maintain.

Imagine we decide to do what Medium does and add a clapping feature. Note that clapping is different from voting because users are allowed to clap more than once but not clap forever:

function clap(post, loggedInUserId) {
  // 1. Must be logged in

  // 2. Disallow claps for post creators

  // 3. Disallow more than 20 claps per clapper
  const claps = await Clap.all({
    where: {
      post_id: post.id, 
      user_id: loggedInUserId
    }
  })

  if(claps.length > 20) {
    // return 401
  }

  // Finally, clap (again)
  return await Clap.create({
    post_id: post.id, 
    user_id: loggedInUserId
  })
}
Enter fullscreen mode Exit fullscreen mode

You can boast of a working app, but tell me how you feel about managing these few years down the line when more feature requests keep coming in.

All-in-one with Hasura

First thing, first — delete all these resolvers.

Hasura is what I like to call Backend as a Service. It handles data management, relationships, schemas, authorization/permissions, and allows you to focus on custom business logic through its extensibility features.

What this means for you as a developer is that you will spend more time building user experiences on the web and less time on handling repetitive tasks like CRUD, roles, etc.

To see Hasura in action and use it for complex permissions, there are few things you need to setup. I won’t go through them in this article because I won’t be doing the official docs justice:

  1. Deploy or run Hasura locally with Docker.
  2. Create the tables we have in the model diagram above.
  3. Relate the tables to each other using foreign keys as shown the diagram

Here is what a User → Post relationship should look like:

Using Hasura for Permission

Before we see what Permissions in Hasura look like, you need to remember one thing — HTTP is stateless. So for every query or mutation you send to a Hasura backend, you need to ensure that a session variable or JWT is available.

You can set up an auth server or use something like Auth0. Either option you decide to go with, there is a good doc on both to guide you. We are not going to set up authentication just so we can get our hands on a session variable or JWT. We can mock these and use them for testing the API you have set up.

It’s also good to know that Hasura permissions allow you to define access on both rows and columns. For example:

  1. User A should only edit user A’s post (row rule)
  2. User A cannot change the create-on date (column rule)

So far, here are all the permission rules we had from the beginning of this article:

  1. Only a logged-in user can create a post
  2. Only admin users can delete post(s)
  3. Only logged in users can vote
  4. Post creators CANNOT vote on the posts they created
  5. A voter can only vote once on a given post

1. Only a logged-in user can create a post

This one is dead-simple — we need to tell Hasura that before it tries to write/create a post, it should check for a session variable that has the user id to ensure that a user is authenticated.

Authentication is useless if all your data is open to the public. So the first thing you should do before creating authentication and rules is to make your Hasura API private.

Reload your localhost, and you should be asked to provide the secret you used in making Hasura private:

To create permission, click on the Data menu in the navbar, then chose the table from the left that you want to add permissions to. Since we want to make sure only logged in users can create posts, we should be choosing the post table.

Click on the Permissions tab and follow the screenshot below to create insert permission:

We don’t need a custom check on the user for now. All we care about is whether a user is logged in before we can allow an insert. We don’t care which user it is.

We have just defined row permissions, but we also need to set what columns the user can insert values in. We don’t want users editing the id or the foreign key, user_id.

Save the permissions and create a user from the GraphQL menu. We need this user’s Id to create posts.

Set the x-hasura-role header to user which is the only permission we have so far except for the admin. Notice that once you set this value, the operations you can run will be limited down to just insert_post mutation:

2. Only admin users can delete post(s)

Deleting is as simple as just updating x-hasura-role to admin:

3. Only logged in users can vote

The permission is just like what we had for creating posts:

Inserting vote will now be available as an operation as long as the x-hasura-role is set to user:

4. Post creators CANNOT vote on the posts they created

This permission will look slightly different. We have to update the user role we created in the previous step, which allows any user to vote. We need to update it to use a custom check that ensures that the user_id column on the post table is not equal to the X-Hasura-User-Id header value. X-Hasura-User-Id is the authenticated user.

For post table properties to be available in a vote permissions rule, you need to make sure that the relationship does NOT only stop at just adding Foreign keys. You also need to add an object relationship as well.

Save the permissions and try to vote as the user that created the post:

mutation MyMutation {
  insert_vote(objects: {post_id: 1, user_id: 1}) {
    affected_rows
  }
}
Enter fullscreen mode Exit fullscreen mode

You will get a permission error, but when you try as a different logged-in user, you will be able to vote:

(Remember to add a user with a user id value of 2)

5. A voter can only vote once on a given post

To make sure we don’t allow users to vote more than once on a particular post, we need to be able to count how many votes they have so far on that given post. We have two options:

  1. Use Event Triggers to write custom validation logic. With this custom logic, you can query and count.
  2. Use a Postgres View that aggregates rows based on the count of votes. You can then add a manual relationship to this view and use it to create permissions.

I usually go with option 2 because the less code I have to write, the less chances I have to introduce bugs to my project.

Start with creating a View.

Go to the Data menu and click on SQL. Paste the following SQL to create a View:

CREATE OR REPLACE VIEW "public"."vote_count" AS 
  SELECT count(vote.id) AS count,
    vote.user_id,
    vote.post_id
    FROM vote
  GROUP BY vote.user_id, vote.post_id;
Enter fullscreen mode Exit fullscreen mode

Here is a screenshot to guide you:

You should see that this adds a new Table (View) on the list of tables, called vote_count:

To ensure it works, run the following GraphQL query twice:

mutation MyMutation {
  insert_vote(objects: {user_id: 2, post_id: 1}) {
    affected_rows
  }
}
Enter fullscreen mode Exit fullscreen mode

Just like it appears here

Now you tale a look at the vote table you should see we have 2 votes tied to the same user and the same post:

But where it gets interesting is when you take a look at the vote_count View:

Now let’s add a relationship on the vote table to point to the vote_count view. Since Views are technically not Tables, we have to use a manual relationship on Hasura. A manual relationship tells Hasura to treat these entities as related even though they are not both tables.

To create a manual relationship, head to the vote table, choose the relationship tab, and click configure. Next, fill out the form with the following value:

With this view setup, you can use it to determine whether it’s ok for someone to vote by ensuring they haven’t voted yet.

Since we already have the first permission rule that checks if the post does not belong to the user, we have to use an operator to add multiple rules. This operator is _and, which behaves like and operators in programming languages.

The _and operator, as seen in box 1 is an array. Every item in this array has to resolve to true before permission can be granted. This makes sense because we want the following to be true before a user can vote:

  1. The user trying to vote did not create the post (implemented)
  2. Users can only vote once

The second rule is what we will implement in the field highlighted in box 2. Before we do that, we need to discuss the possible cases:

  1. A user can vote if the count field in the view is less than 1.
  2. A user can vote if the user was never in the view.

This might seem like we are saying the same thing, but we aren’t. If you are familiar with JavaScript, it’s like a variable having a value of 0 vs undefined. Case 1 is value 0; case 2 is value undefined. If you only account for 1, you would have an unhandled edge case, which will lead to a bug. We need to make sure that if any of these cases occur, the user should be able to vote.

This is a classic or operator use-case in programming languages. We can use this operator in Hasura as well:

Here is a complete breakdown of what is happening here:

  1. An and operator checks:
    1. That the voter is not the owner of the post
    2. That the or operator resolves to true
  2. The or operator checks either of these and if any is true would resolve to true:
    1. The user’s vote count is less than 1 (so 0)
    2. Or that the row is empty (_not)

With this setup, clear the vote table and try voting as a different user from the one that created the post using the GraphQL query window. Notice the voting goes through the first time. Now try it again, and you will get a permission error.

You just implemented some powerful permission strategies in GraphQL without a single line of code. I am not surprised; it’s 2020!

This is just a tip of the iceberg on what you can do with permissions using Hasura. You can take a look at the docs on what is possible. You can also explore this Github-scale permissions scenario.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .