loading...

Modelling Authorisation in GraphQL

sgwilym profile image Sam Gwilym ・7 min read

In this article, we'll explore how to expose different levels of information on a GraphQL schema depending on who makes the request, i.e. authorisation, using only GraphQL's built-in schema definition capabilities.

By the end, we'll have an intelligent, authorised type that can be reused at any level of the graph with no extra effort.


Authorisation in GraphQL is often treated as a failure state, either reporting failed authorisations in the GraphQL response's errors field, or in the worst case throwing the entire response altogether.

But returning a different kind of response based on who asks for it isn't exceptional. Just because we're not allowed to see everything doesn't mean we shouldn't see anything, and that should be reflected in our schemas. GraphQL gives us everything we need to express the different layers of access we'd like to expose.

Users Bare All

Let's start with a service where we can query information about its users:

Schema

type Query {
  users: [User!]!
}

type User {
    nickname: String!
    email: String!
    billingAddress: String!
}

Query

{
    users {
        nickname
        email
        billingAddress
    }
}

Response

{
    "data": {
        "users": [
            {
                "nickname": "Jenny Me",
                "email": "jenz@itsame.com",
                "billingAddress": "123 Open Lane"
            },
            {
                "nickname": "Freddy Friend",
                "email": "fred@buds.com",
                "billingAddress": "22a Sharing Avenue"
            },
            {
                "nickname": "mr. private",
                "email": "bill@respectmysolitude.biz",
                "billingAddress": "36bis Ivory Tower"       
            }
        ]
    }
}

Not good: there’s information that both we as developers and our users clearly wouldn't want anyone to be able to query for, like their email or billingAddress.

How can we withhold that information from unauthorised users?

Users with nullable fields

The first approach we could take is to detect unauthorised operations during field resolution.

// As an Apollo Server style resolver
User: {
    // ...
    billingAddress: (user, args, ctx) => {
        if (user.id === ctx.currentUser.id) {
            return user.billingAddress;
        }
        return null;
    }
    // ...
}

Now if someone requests a field they're not allowed to see, they'll get null instead.

Schema

type Query {
  users: [User!]!
}

type User {
    nickname: String!
    # These fields are all nullable now
    email: String
    billingAddress: String
}

Query

{
    users {
        nickname
        email
        billingAddress
    }
}

Response

{
    "data": {
        "users": [
            {
                "nickname": "Jenny Me",
                "email": "jenz@itsame.com",
                "billingAddress": "123 Open Lane"
            },
            {
                "nickname": "Freddy Friend",
                "email": "fred@amicus.com",
                "billingAddress": null
            },
            {
                "nickname": "mr. private",
                "email": null
                "billingAddress": null  
            }
        ]
    }
}

We’re logged in as Jenny Me, so we can see all of our information, including billingAddress. The second user, Freddy Friend is, well, our friend, so we can see their email too. The third user, mr. private, wants nothing to do with us, so we're only able to see their nickname.

Here we've managed to only expose the information Jenny Me is allowed to see, but this rough approach to schema-based authorisation has many downsides.

The first is that by making everything nullable, your clients can’t ensure the presence of data even for situations where they’re pretty sure it should be available: imagine a BillingAddressForm component which is expecting a User, which strictly speaking may or may not have a billingAddress. Clients now need to handle all the cases where a field could have a null value.

The second downside is that this approach burdens your schema’s field resolvers with authorisation logic. Resolvers which only had to return a value now need to have all kinds of checks inside them.

Pretty soon these two issues would make both making our client and server codebase far more laborious to work with.

Many kinds of User

What we really want is to be able to know that we’re going to get the data we’re asking for – not just guess from the presence of absence of data.

In the case of our user type, we have three levels of authorisation:

  • Public information: Anyone can see users’ nicknames
  • Friends-only information: Friends can also see each other’s email.
  • Current user only information: Only the user themselves should be able to get their own billingAddress.

Querying for a user can give us three different kinds of results, so let’s model that in GraphQL with a union type:

Schema

type Query {
  users: [User!]!
}

type User {
    nickname: String!
}

type FriendUser {
    nickname: String!
    # Email isn't nullable anymore!
    email: String!
}

type CurrentUser {
    nickname: String!
    email: String!
    billingAddress: String!
}

# Wrap all our users in a User union type
type User = PublicUser | FriendUser | CurrentUser

Query

{
    users {
        __typename
        ... on PublicUser {
            nickname
            # PublicUser has no email field, so you can't even request it!
        }
        ... on FriendUser {
            nickname
            email
        }
        ... on CurrentUser {
            nickname
            email
            billingAddress
        }
    }
}

Response

{
    "data": {
        "users": [
            {
                "__typename": "CurrentUser",
                "nickname": "Jenny Me",
                "email": "jenz@itsame.com",
                "billingAddress": "123 Open Lane"
            },
            {
                "__typename": "FriendUser",
                "nickname": "Freddy Friend",
                "email": "fred@amicus.com",
            },
            {
                "__typename": "PublicUser",
                "nickname": "mr. private",
            }
        ]
    }
}

This improves on our previous nullable approach in a number of ways.

Now when we query our users field, we can be certain that fields like email or billingAddress will return a result – no more checking for null in our client code.

Because we have different types for each kind of User returned, we can also easily make our clients serve different UIs just by checking the typename:

const UserDescription = ({user}) => {
    if (user.__typename === "CurrentUser") {
        return <div>Hey, it's you!</div>;
    } else if (user.__typename === "FriendUser") {
        return <div>`It's your friend ${user.nickname}, email them at ${user.email}!`</div>
    } else {
        return <div>`Someone called ${user.nickname}.`</div>
    }
}

We've also removed most cases where we'd to put authorisation checks inside of our field resolvers anymore — instead we check for which kind of result to return in our union type’s resolve type method:

// In an Apollo Server style resolvers file
User: {
    resolveType: (user, args, ctx) => {
        if (user.id === ctx.currentUser) {
            return "CurrentUser";
        } else if (ctx.currentUserHasFriend(user.id)) {
            return "FriendUser"
        }
        return "PublicUser"
    }
}

But, again, this solution is not going to scale well.

You may have noticed the repetition in our different user types:

type User {
    nickname: String!
}

type FriendUser {
    nickname: String!
    email: String!
}

type CurrentUser {
    nickname: String!
    email: String!
    billingAddress: String!
}

This isn’t only undesirable because of the repetition, but because every addition, removal or change to these fields needs to be coordinated between all of our types. It would cause a lot of confusion if you renamed nickname to name, but only on PublicUser. What if we had eight levels of authorisation, or these types were split up into different files where a big team wouldn’t necessarily know who was changing what?

Adding to the problems, querying these types is not very ergonomic:

{
    users {
        __typename
        ... on PublicUser {
            nickname
        }
        ... on FriendUser {
            nickname
            email
        }
        ... on CurrentUser {
            nickname
            email
            billingAddress
        }
    }
}

Again, the repetition is tedious (and that with only three types), but we could also run into trouble if we’re writing client code that expects the presence of these fields on all of our User types – a risk that grows with the number of fields on our types.

Users with interfaces

It would be good if we could make our different User types promise that they'll definitely have certain fields. Fortunately we can do this with GraphQL interfaces.

Let’s create an interface for each level of access we want to grant, and assign them to their relevant types:

interface PublicUserInfo {
    nickname: String!
}

interface FriendInfo {
    email: String!
}

interface PrivilegedInfo {
    billingAddress: String!
}

type User implements PublicUserInfo {
    nickname: String!
}

type FriendUser implements PublicUserInfo & FriendInfo {
    nickname: String!
    # If the nickname were missing here GraphQL would tell us about it.
    email: String!
}

type CurrentUser implements PublicUserInfo & FriendInfo & PrivilegedInfo {
    nickname: String!
    email: String!
    billingAddress: String!
}

type User = User | FriendUser | CurrentUser

Now if we add, remove or change a field, that change will be expected in all of our different types, and GraphQL will warn us which types are not fulfilling these interfaces.

This change also means we can rewrite our queries in a more ergonomic fashion:

{
    users {
        __typename
        # You can write fragments for interfaces, cool!
        ... on PublicInfo {
            nickname
        }
        ... on FriendInfo {
            email
        }
        ... on PrivilegedInfo {
            billingAddress
        }
    }
}

And the response, again:

{
    "data": {
        "users": [
            {
                "__typename": "CurrentUser",
                "nickname": "Jenny Me",
                "email": "jenz@itsame.com",
                "billingAddress": "123 Open Lane"
            },
            {
                "__typename": "FriendUser",
                "nickname": "Freddy Friend",
                "email": "fred@amicus.com",
            },
            {
                "__typename": "PublicUser",
                "nickname": "mr. private",
            }
        ]
    }
}

Users everywhere

By combining an authorised union type with matching interfaces for each of its members, we've gained the following:

  • Users only get the information they're authorised to receive.
  • Requested fields always return the data we expect, so no having to check for null.
  • We've been able to move many authorisation checks from field resolvers to the union type resolver. This effect can be total for authorisation checks that depend on a top-level value, like the current user (thanks to Andrew Ingram for pointing out that this doesn't totally obviate the need for auth checks in field resolvers).
  • Trivial to determine the level of authorisation for any returned type via __typename.
  • Confidence that our types will have common sets of fields that return the same type of data – GraphQL will ensure it through our interfaces.

The combination of multiple interface and type definitions may look like a lot of boilerplate, but it saves a lot of lines of code elsewhere on the GraphQL server, and creates an excellent experience for the clients querying it, especially if types are being used.

Best of all, our User type can be used anywhere in our schema, without having to write any new auth code. Imagine if we wanted to add a new friends field to PublicInfo:

Schema

interface PublicUserInfo {
    nickname: String!
    friends: [User!]!
}

interface FriendInfo {
    email: String!
}

interface PrivilegedInfo {
    billingAddress: String!
}

type User implements PublicUserInfo {
    nickname: String!
}

type FriendUser implements PublicUserInfo & FriendInfo {
    nickname: String!
    # If the nickname were missing here GraphQL would tell us about it.
    email: String!
}

type CurrentUser implements PublicUserInfo & FriendInfo & PrivilegedInfo {
    nickname: String!
    email: String!
    billingAddress: String!
}

type User = User | FriendUser | CurrentUser

Query

{
    users {
        __typename
        ... on PublicInfo {
            nickname
            friends {
                __typename
                ... on PublicInfo {
                    nickname
                }
            }
        }
        ... on FriendInfo {
            email
        }
        ... on PrivilegedInfo {
            billingAddress
        }
    }
}

Response

{
    "data": {
        "users": [
            {
                "__typename": "CurrentUser",
                "nickname": "Jenny Me",
                "email": "jenz@itsame.com",
                "billingAddress": "123 Open Lane"
                "friends": [
                    {
                        "__typename": "FriendUser",
                        "nickname": "Freddy Friend",
                    }
                ]
            },
            {
                "__typename": "FriendUser",
                "nickname": "Freddy Friend",
                "email": "fred@amicus.com",
                "friends": [
                    {
                        // Hey, we could render a little crown over our name in Freddy's friend list!
                        "__typename": "CurrentUser",
                        "nickname": "Jenny Me",
                    }
                ]
            },
            {
                "__typename": "PublicUser",
                "nickname": "mr. private",
                "friends": []
            }
        ]
    }
}

The only code that had to be added for this is a resolver for the friends field on PublicInfo – and no authorisation code would be needed.

This approach can be applied to any kind of type, not just Users: Events, Tasks, Recipes. All the same principles apply. Hopefully this pattern can help you make your schema even more of a pleasure to work with.

Posted on by:

Discussion

markdown guide