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
Fetch an object from the database
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:
Less code to maintain - many of our query resolvers will no longer require any permission checks
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.