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’
nickname
s - 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 User
s: Event
s, Task
s, Recipe
s. All the same principles apply. Hopefully this pattern can help you make your schema even more of a pleasure to work with.
Top comments (0)