Integrating access control/authorization to your express apps
Integrating access control/authorization policies to your app can be difficult, from defining it to actually checking it on every route. Here are some tips to make it easier!
Web applications nowadays are more complex than ever.
And trying to prevent people from doing what they're not allowed to do can get really complicated, and with that complexity comes with increased surface area for attack - especially if you have to manage your application's access control on every route, for every user role, for every resource.
For example, even for a typical webapp with the concept of "users" (and I assume 99% of apps have this functionality), just trying to manage user permissions can get real complicated, real fast:
- A
User
resource can't be created if you're already logged in. - When creating a
User
, the user's role cannot be manually set. - Others can't read a user's email address, unless if the user set their email to public.
- Only admins can promote a user to admin status or otherwise change their role.
- A user can only delete their own account, and to prevent them from accidentally deleting their account, require them to "confirm" the deletion.
...and so on. Of course, your policies can and will get more complicated as your application expands in scope. Microservices can only help you so much.
So here are my tips for simplifying this whole process:
Define policies via templates
In general, I recommend writing your access control list (ACL) via some template, instead of generating it dynamically. There are two reasons for this:
- YAML just reads easier than some javascript DSL (cough looking at you
cancan
/casl
cough). - You can load the templates from database should you want users to be able to change access settings on-the-fly (so you can just load the JSON template).
- You can easily integrate it into your frontend. We all talk about "isomorphism" and sharing code between the frontend and the backend, but with templates, you can enable/disable fields on-the-fly based on the ACL!
As for actually defining the template, there are a few things to be aware of:
- Really, really give YAML templates a go!
- If your needs aren't terribly complex, try going with role-based access control (RBAC). It will simplify your ACL as you'd only need to write access settings for each role (instead of having to write the entire access control on a per-user basis) and can extend "roles" off of each other.
Btw, if you don't know what RBAC is, think of it as "classes" of users. E.g. a user
might be able to create a post, and a moderator
might be able to do everything a user
can, on top of being able to, well, moderate posts.
Check access control on the resource level
Yes, we all have done it - either shoving all of the access control logic into our express routers/controllers, or having a centralized ACL but checking it on every route manually - after all, you have to know who's trying to do what, and directly taking things off of the router's req
is the most obvious way to do so.
But it really doesn't scale as your application grows more complex and you have increasing number of user contexts, resources and the different actions they can take - you will end up making a mistake (or two or a hundred) as the number of routes increase and there will be bugs.
// A typical, naive access control approach with express.js
app.put('/posts/:id', async (req, res) => {
// if you're not logged in, you can't create a post
if (!req.user) return res.sendStatus(401)
const post = await Post.query().findById(req.params.id)
if (post.authorId === req.user) {
// allow users to edit their own posts,
// except for the title/slug/creator
if (req.body.title || req.body.slug || req.body.creator)
return res.sendStatus(400)
await post.$query().update(req.body)
} else if (req.user.role === 'moderator') {
// allow moderators to edit ONLY the visibility of posts
await post.$query({visibility: req.body.visibility})
}
res.end()
})
This example is just one route, and yet already you can see how you could easily make a mistake when you need to write tens or hundreds of more routes. Not to mention, you would need to test both your ACL and your routes for the same features if you take this approach, and that's just increasing the surface area of what is supposed to be delegated to just one component - the ACL!
So, what do I do?
Whether you're using REST or GraphQL, your APIs have the concept of a "resource", and you're already specifying all of the relevant context - user, action, resource - when you call a method on your model (e.g. in the above case, it's Post.findById()
and Post.update()
).
So using that information, we can deduplicate the logic by implementing ACL on the resource level!
To go about doing this, I recommend that you (ab)use your ORM/ODM's hooks to automatically gather the context to make the authorization decision by the time the query has been built.
To clarify, this isn't a "new" idea. In fact, there's already libraries like @casl/mongoose
that does exactly that.
But I didn't like casl
.
And I wasn't using mongoose
(I always use Objection + knex combo on any of my projects that use relational databases, which happens to be 99% of them).
So I ended up writing my own library - an Objection.js ORM plugin - to handle this automatically for you. While this is specific to Objection.js, as I demonstrated earlier, you can implement this with any access control library and any ORM/ODM of your choice.
You can check out the library below (objection-authorize
on npm). It recently hit 1.0!
What does resource-level access control look like?
You can read through the README of the library to install/setup the plugin (which is really easy), and once you do, your routes will instead look like this:
// this does the SAME thing as the example above!
app.put('/posts/:id', async (req, res) => {
const post = await Post.query().findById(req.params.id)
await post.$query().authorize(req.user).update(req.body)
res.end()
})
It's a night-and-day difference from the previous example, and best of all, your routers/controllers are lean now, meaning you can just test the ACL to check that no one can do what they aren't supposed to do!
For more examples, I highly recommend that you check out the documentation on how you can integrate this with express and how you can configure it (e.g. by default, it throws a 401 error if your user is unauthenticated and 403 if whatever your user is doing is unauthorized).
But I think the beauty of all this is that you no longer have to manually:
- Check the request body to see if the request is trying to access/modify something it isn't allowed to
- Wire the routes and the ACL together, especially figuring out which resource and what action to check the user's grants against
- Filter the response body's fields so that users don't see whatever they're not allowed to (e.g. hide password field by default, or show email only to self)
Closing Remarks
I hope I convinced you how complex access policies can be, especially when you're trying to integrate it within your web application, and I hope you learned how best to separate out the concerns so that your routes don't have to know how to do authorization.
Even if you don't use my library, I hope reading this has enabled you to write similar plugins for your stack of choice (even if it's not express or even node.js). But if you do, please feel free to open issues or make PRs - although it may be stable, I'd still love help with stuff like adding typescript definitions and supporting related queries (which is especially going to be helpful with GraphQL APIs)!
Thank you!