Authorising Users: More complex scenarios

ยท

11 min read

In parts 1 and 2 we covered the basics of authorisation, and how to set up a permissions system in situations where the permissions vary only by the user's roles and memberships. We'll be building on the code presented in those parts, so it's a good idea to read through them if you haven't already.

In this article we'll describe how to handle situations where the user's access to an object depends on the properties of that object. For example what a user can do with an individual document might depend on who authored it or whether it has been published.

Scenario 4: Permissions vary by object

In this scenario, even if a user is a member of a team, they do not necessarily have full access to all of that team's objects. For example, maybe they can only view documents that have been shared with them. In this case, we need to be able to pass an object to the authorisation system and ask it what permissions they have for that object.

First we define a set of permissions for each type of object:

enum ImagePermission {
  VIEW: "view"
  DELETE: "delete"
}

enum DocumentPermission {
  VIEW: "view",
  COMMENT: "comment",
  EDIT: "edit",
}

Then for each type of object, we create a function that returns whether the user has permission or not, which we integrate into our can() function described in part 2:

function getCan(userPermissions: UserPermissions): (permission: ImagePermission | DocumentPermission, object?: unknown) => boolean {
  return (permission) => {
    // To keep this short we have not included the permissions code from
    // part 2
    if (Object.values(ImagePermission).includes(permission)) {
      return hasImagePermission(userPermissions, permission, object)
    } else if (Object.values(DocumentPermission).includes(permission) {
      return hasDocumentPermission(userPermissions, permission, object)
    } else {
      return false
    }
  }
}

function hasDocumentPermission(userPermissions, permission, doc) {

  if (!doc) {
    // Gracefully handle a missing document. This means that the calling
    // code doesn't need to check that the object exists before calling can()
    return false
  } else if (!(userPermissions[doc.teamId] ?? []).includes(TeamPermission.VIEW_DOCUMENTS) {
    // You must be a member of the document's team to access it
    return false
  }

  if (permission === DocumentPermission.VIEW || DocumentPermission.COMMENT) {
    // All team members can view and comment on documents
    return true
  } else if (permission === DocumentPermission.EDIT) {
    // Only the author can edit it
    return doc.authorId === userPermissions.userId
  }
}

And our resolver code can check that the user has the required permission:

class DocumentResolver {
  Mutation: {
    async setTitle(_, { documentId, title }, { can }) {
      const doc = await this.entityManager.findOneBy(Document, { id: documentId })

      if (!can(DocumentPermission.EDIT, doc)) {
        throw new ForbiddenError("You do not have permission to edit this document")
      }

      // add code to edit document

      return doc
  }
}

Again, with this design our resolver code doesn't need to know who can edit documents or why, it simply asks can(). It's important to note that can() is synchronous. That means that it cannot fetch any additional data from the database. In cases where information from another table is required, this information should be added to the object before can() is called, eg:

class DocumentResolver {
  Mutation: {
    async setTitle(_, { id, title }, { filters }) {
      const doc = await this.entityManager.findBy(
        Document, 
        { id },
        // joins the team table so that doc.team is available for can() to use
        { relations: { team: true } }
      )

      if (!can(DocumentPermission.EDIT, doc)) {
        throw new Error("Forbidden")
      }

      // save the new document title
    }
  }
}

Sometimes can() might accidentally be passed a document where team hasn't been set. In this case it should fail safe and return false - even if you and your QA team don't catch the bug, your users will report it to you very quickly ๐Ÿ™‚

Scenario 5: Listing objects

The final scenario that occurs in most applications is listing objects. We need to ensure that we don't return any objects that the user doesn't have permission to access.

The way to do this is to create a function that returns a filter. This filter object is then combined into the database query when fetching the objects. For example:

function getDocumentFilter(permissions) {
  const allowedTeams = 
    Object.keys(permissions.teams)
      .filter(teamId => hasPermission(
        TeamPermission.VIEW_DOCUMENTS, 
        permissions.teams[teamId]))

  return { teamId: { $in: allowedTeams } }
}

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),
       filters: {
         document: getDocumentFilter(permissions)
       }
     }
   }
})

This filter is then passed to the database or service that provides the objects as part of the query:

class DocumentResolver {
  Query: {
    async documents(_, { title }, { filters }) {
      const documentFilter = getDocumentFilter(ctx.permissions)

      return this.entityManager.findBy(
        Document, 
        { $and: [{ title }, filters.document] }
      )
    }
  }
}

By using $and (or the equivalent in the ORM or query language you're using) you can ensure that regardless of the user's request, they only see the documents they are entitled to see.

Since it's passed directly to the database rather than inspected by the calling code, it can be as complex as it needs to be, for example forbidding users from accessing things created before a certain date, or that are still drafts etc.

If permissions vary by object in your application (scenario 4, above), it's important to ensure that the returned filter exactly matches the objects that your can() function returns true for (usually for the VIEW permission). The best way to do this is to create a test that checks that the objects that are retrieved from the database match the documents that can() says the user is allowed to view:

test.each([
  { userId: "1", teams: { team1: [TeamPermission.VIEW_DOCUMENTS] } },
  { userId: "1", teams: { team2: [TeamPermission.VIEW_INFO] } },
]("getDocumentFilter", async (permissions) => {
  const allDocuments = [
    { id: "doc1", teamId: "team1", ... }, 
    { id: "doc2", teamId: "team2", ... }
  ]
  const expectedDocumentIds = allDocuments
    .filter(doc => can(DocumentPermission.VIEW, doc))
    .map(doc => doc.id)
    .sort()
  const filter = getDocumentFilter(permissions)
  const receivedDocuments = await entityManager.findBy(Document, filter)
  const receivedDocumentIds = receivedDocuments
    .map(doc => doc.id)
    .sort()

  expect(receivedDocumentIds).toEqual(expectedDocumentIds)
})

When editing either getXXXFilter() or can(), you should run this test alone with code coverage enabled to ensure that the permissions and documents you're testing with are exercising all of the branches in those functions.

Appendix

Included here are some useful ideas that aren't strictly necessary to implement authorisation but can make your life a little easier:

Optimisation: Accessing individual objects

One optimisation that's frequently used to avoid the n+1 problem is Facebook's dataloader library. This improves performance by collecting the individual queries for a type of object and combining them into a single bulk query. For example:

const apolloServer = new ApolloServer({
   // ...
   context: async ({ req }) => {
     // req.user was added by passport or some other code
     const user = req.user

     return {
       user,
       permissions: await authService.getPermissions(user),
       // A set of dataloaders is created for each request
       loaders: {
         documentById: createDocumentByIdLoader()
       }
     }
   }
})

function createDocumentByIdLoader() {
  return new DataLoader(async ids => {
    const documents = await entityManager.findBy(
      Document, 
      { id: { $in: ids } }
    )

    // We must ensure the array we return is in the same order as
    // the ids array
    return ids.map(id => documents.find(doc => doc.id === id))
  })
}

class ImageResolver {
  Image {
    // Returns the document an image belongs to
    document: async (image, _, ctx) => {
      return ctx.loaders.documentById.load(image.documentId)
    }
  }

Now if the user queries image.document on multiple images, our documentById loader will collate the queries into a single database request, greatly improving performance.

You may have noticed that in the scenarios above we had a repeated pattern of

  1. Fetch an object from the database

  2. Check that the user has permission to access that object

We can simplify this by moving the permission check into the dataloader and using the loader to load the objects instead. This has two benefits:

  1. Less code to maintain - many of our query resolvers will no longer require any permission checks

  2. We no longer have to worry about a user being able to walk the GraphQL tree to access objects they shouldn't be able to. For example, if an organisation a user is not a member of shares an image publically, that user should probably not be able to see any details about the organisation. By placing the permission check in the dataloader we can ensure this is the case.

To add this query check to the dataloader, we use the query filter we described in scenario 4:

const apolloServer = new ApolloServer({
   // ...
   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,
       permissions,
       // We now provide the permissions to the dataloader
       loaders: {
         documentById: createDocumentByIdLoader(permissions)
       }
     }
   }
})

function createDocumentByIdLoader(permissions) {
  return new DataLoader(async ids => {
    const documentFilter = getDocumentFilter(ctx.permissions)
    const documents = await entityManager.findBy(
      Document, 
      { $and: [{ id: { $in: ids } }, documentFilter] }
    )

    // We must ensure the array we return is in the same order as
    // the ids array
    return ids.map(id => {
      const doc = documents.find(doc => doc.id === id)

      // If we return an error here, the corresponding call 
      // to .load() rejects the promise
      return doc ?? new NotFoundError("Document not found")
    })
  })
}

When we return an error in our dataloader result array, the corresponding load() functions reject their promises with it. How we handle this depends on what we want our resolver's behaviour to be.

If the user has directly requested this object (eg most query resolvers) we can allow the rejection to flow back to Apollo:

class DocumentResolver {
  Query: {
    async getDocument(_, { documentId }, ctx) {
      // If the document doesn't exist or the user is not allowed
      // to access it, we return a NotFoundError back to the client
      return ctx.loaders.documentById.load(documentId)
    }

    async setTitle(_, { documentId, title }, ctx) {
      // You may have noticed that the previous setTitle
      // example would return an Internal Server Error if the
      // user attempted to set the title of a document that 
      // doesn't exist - getDocumentPermissions expects doc
      // to be defined. 
      //
      // In this version, if the document doesn't exist (or 
      // the user doesn't have access to it) we return a 
      // NotFoundError instead
      const doc = await ctx.loaders.documentById.load(documentId)
      // Unlike the first version, doc is guaranteed to exist here
      const docPermissions = getDocumentPermissions(doc, ctx.permissions)

      if (!hasPermission(DocumentPermission.EDIT, docPermissions)) {
        throw new ForbiddenError("You do not have permission to edit this document")
      }

      // add code to edit document

      return doc
  }
}

If the user has not directly requested this particular object (for example, a field resolver that returns null if the user cannot access the object), we catch the error and return null:

class ImageResolver {
  Image {
    // Returns the document an image belongs to if the user
    // has permission to view it, or null if they don't. 
    document: async (image, _, ctx) => {
      try {
        // Note we must use await here, otherwise we'll pass the
        // rejected promise back to Apollo instead of handling it
        // ourselves
        return await ctx.loaders.documentById.load(image.documentId)
      } catch (e) {
        if (e instanceof NotFoundError) {
          return null
        } else {
          // If it's some other problem (for example a database error)
          // we should pass it back to Apollo. Since this field is 
          // nullable, to the client it will just look like user doesn't
          // have permission to this document, however Apollo will log
          // the error and it will be included in the response's errors
          // array. 
          throw e
        }
      }
    }
  }

Listing permissions

Sometimes you want to provide a list of permissions the user has on an object. This is generally so that the frontend can know which buttons to display and what operations should be allowed. Because can() only returns true or false, there's no direct way to obtain a list of permissions. But since our permissions are represented in an enum, we can iterate through them:

class MyResolver {
  Team: {
    permissions({ id }, _, { can }) {
      return TeamPermission.filter((permission) => can(permission, id))
    }
  }
}

Sending permission lists via GraphQL

If you're sending a list of permissions to the frontend via GraphQL, you may wish to represent them as GraphQL enums instead of strings. There's two things to note:

1. Generally you will need to send the enum keys instead of the values. This means if your TeamPermissions looks like this:

enum TeamPermission {
  VIEW = "team.view",
  CREATE_DOCUMENT = "team.createDocument"
}

Your GraphQL enum will look like this:

enum TeamPermission {
  VIEW
  CREATE_DOCUMENT
}

Since internally your server code is using team.view and team.createDocument, you can use the following function to convert them to their respective keys:

function getKey(enum, value) {
  return Object.keys(enum).find((key) => enum[key] === value)
}

2. You need to keep your code enums and GraphQL enums in sync, otherwise if you add a permission that's not defined in your GraphQL schema your requests will break. The best way to handle this is via some form of codegen - either use GraphQL Code Generator to generate TypeScript enums from your GraphQL schema, or write some code to output the GraphQL enum from the one defined in your code.

Summing up

Implementing a robust and flexible authorisation system is crucial for any multi-user system. By following the patterns and best practices outlined above, you'll have a maintainable and secure authorisation system while keeping the logic centralised and easy to understand.

I'm keen to know all your access control horror stories - pop a message in your comments about the bugs you've seen or some hideously complex business rules you've had to write code for ๐Ÿ˜„

For a complete, working app, check out the example app on Github.

Acknowledgements

A big thank you to Jason McIver for his feedback on the original drafts of the articles in this series and the ideas within. In particular, the handling of scenario 3 has been greatly improved thanks to his suggestions.

ย