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:
- Only a logged-in user can create a post
- 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)
}
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')
}
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)
}
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:
- Only logged in users can vote
- Post creators CANNOT vote on the posts they created
- 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
})
}
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
})
}
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:
- Deploy or run Hasura locally with Docker.
- Create the tables we have in the model diagram above.
- 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:
- User A should only edit user A’s post (row rule)
- 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:
- Only a logged-in user can create a post
- Only admin users can delete post(s)
- Only logged in users can vote
- Post creators CANNOT vote on the posts they created
- 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 avote
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
}
}
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:
- Use Event Triggers to write custom validation logic. With this custom logic, you can query and count.
- 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;
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
}
}
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:
- The user trying to vote did not create the post (implemented)
- 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:
- A user can vote if the
count
field in the view is less than 1. - 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:
- An
and
operator checks:- That the voter is not the owner of the post
- That the
or
operator resolves to true
- The
or
operator checks either of these and if any is true would resolve to true:- The user’s vote count is less than 1 (so 0)
- 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.