Authorising Users: A Complete Walkthrough

Authorising Users: A Complete Walkthrough

How to write access control code that's simple, safe and extensible

ยท

7 min read

If there's one part of an application that developers don't like touching, it's authorisation. A critical piece of any multi-user application, it's the code that ensures the user can only do the things they're meant to be able to do.

Most of the tutorials and libraries out there only cover the simple cases, for example Sally is allowed to upload images but Mary is not. They don't show you how to filter database queries or how you should structure your application. In this series of articles, we'll show several clean and effective patterns for implementing authorisation in your application.

The examples shown here assume an application using NodeJS, Express and Apollo Server. The patterns shown here are not specific to this stack however - it should be straightforward to adapt this code to your language or environment.

Background

Authentication vs Authorisation

These two topics are often covered together, but they represent different stages of processing a request.

Authentication means determining who is performing this request. This usually involves reading a session token, checking whether it's still valid and who it's owned by. At the end of the authentication stage, you know which user is performing the request.

Authorisation means determining what the user making the request is allowed to do. At the end of the authorisation stage you have the list of things the user is allowed to do.

This article only covers authorisation - for authentication, you should generally use a library like Passport.js or a service such as Auth0.

An Introduction to RBAC

RBAC, or Role-Based Access Control, is a system that assigns specific roles and permissions to users within an application. This approach simplifies access management, allowing admins to have full control while limiting regular users to certain actions.

The key concept of RBAC is that users are not directly assigned permissions. Instead they are assigned one or more roles, and from those roles we calculate the set of permissions they should have.

For example, Susan has the roles auditor and submitter which together confer the permissions read, create, list, and update. Our users table in the database might look like this:

user_idnameroles
0001Susanauditor, submitter
0002Markguest

We also have a permissions table (whether in code or the database) that we use to map roles to permissions:

RolePermissions
auditorread, list
submitterread, list, create, update
guestlist

You can see that the permissions are additive - we determine Susan's permissions by adding up all the permissions granted by each role she has. Mark only has the guest role, so he cannot create or update documents.

In an RBAC system, only the authorisation and user management systems know or care about a user's roles. Every other part of the application deals only with permissions. So the application code looks like this:

// โœ… When deciding whether to perform an action, check
// the specific permission for that action
if (userPermissions.includes(Permission.CREATE)) {
  await entityManager.save(document)
}

Not

// โŒ When deciding whether to perform an action, don't 
// examine the roles directly
if (user.roles.includes(UserRole.SUBMITTER)) {
  await entityManager.save(document)
}

The reason for this is twofold:

  • For safety we want our authorisation logic to be as simple as possible. This means there should be only a single part of the code responsible for deciding what the user is allowed to do

  • We may create additional roles that provide the Permission.CREATE permission. We would have far more code to update if we followed the second pattern, and it's likely that mistakes will eventually be made.

General RBAC tips:

๐Ÿ’ก Make permissions granular. It makes the code that checks the permission easier to understand, and you can more easily create specific roles when needed.

๐Ÿ’ก Ensure permission names are globally unique. For example the permissions Book.VIEW and Movie.VIEW should have different values (eg book.view and movie.view). This is so that we can create a single can() function that can handle any permission in the system.

๐Ÿ’ก Avoid creating systems where certain roles subtract permissions unless absolutely necessary. It makes things much harder to understand and in most cases, you can achieve what you want by creating narrower roles that only provide the desired permissions.

Designing your authorisation

It's really important to keep your authorisation logic as centralised as possible. You want a single place that's responsible for deciding what the user is allowed to do. Ensure the quality of this code is as high as possible, with close to 100% test coverage and good documentation that describes how to use the code. Finally, there should be a clear boundary between this module and the rest of the code. If your project is in a monorepo you can create a package or library for it, otherwise you should place the code in its own directory.

Central to authorisation is the can() function. It takes two arguments, the permission and optionally an object (or its ID). It returns true if the user has that permission on the given object, or false otherwise. In the example below, we use the expression can(SitePermission.VIEW_DOCUMENTS), which returns true if the user is allowed to list documents.

At the start of each request, we pass the user to the authorisation system and receive a set of permissions, which we use to obtain the can() function. The rest of our code doesn't know or care what roles the user may have. If you're using Apollo Server as your gateway, your code might look something like this:

const apolloServer = new ApolloServer({
   // Build the request context object that's passed to all of the
   // resolver functions
   context: async ({ req }) => {
     // req.user was added by passport or some other code
     const user = req.user
     const permissions = await authService.getPermissions(user)

     return {
       user,
       can: authService.getCan(permissions) 
     }
   }
})

Then, in your resolvers, you can use something like this:

class MyResolver {
  Query: {
    async getDocuments(_, args, context) {
      if (!context.can(SitePermission.VIEW_DOCUMENTS)) {
        throw new ForbiddenError("I can't let you do that, Dave")
      }

      return [
        { id: "doc1", title: "2001 A Space Odyssey" }
      ]
    }
  }
}

The third argument to the resolver function is the request context - it's the same object that was created by the context() function in the snippet immediately above.

This code looks quite simple, and that's exactly the point. Our system may have quite complex permission rules, but these are all encapsulated in the can() function. For example:

  • Users should only be able to access objects belonging to teams that they are members of

  • Users should not be able to edit documents created by other users

  • Users should only be able to view documents that have been explicitly shared with them

The good news is that with a good design it's simple to build a system that can handle all these scenarios while being easy to maintain and modify. In the second two articles, we'll see how to build our can() function to handle all these scenarios and more.

Each project is different though, so rather than prescribing "This is how you do auth" (or even "this is how you write can()", I'm going to cover several major scenarios and provide a pattern for each of them. These scenarios are not mutually exclusive and most projects will use several of them. The solutions presented here can be easily combined to meet the needs of your application.

These are the scenarios we're going to cover:

  • Scenario 1: Application-wide permissions (part 2)

  • Scenario 2: Permissions vary by group (part 2)

  • Scenario 3: Permissions vary by group, with hierarchies (part 2)

  • Scenario 4: Permissions vary by object (part 3)

  • Scenario 5: Retrieving objects from a database (part 3)

Let's jump to part 2, where we cover scenarios 1, 2, and 3. If you'd like to see a full implementation of these ideas, check out

ย