Access control

Access control in any API is about the definition of who can do what. For example, there might be users who can add or remove other users, or some data is only accessible to one group but not to others. This is especially important for multitenant applications where you have data for multiple customers available in a single API. But some sort of access control is needed for almost all real-world use-cases.

GraphQL is about graphs, so it helps to think in nodes (objects) and edges that connect them. This way, the main question of access control is: what nodes a user can reach?

In this chapter, we'll explore the various approaches to control access in a GraphQL API. The methods here are generic to all GraphQL implementations, and not just AWS AppSync.

Example data model

In this chapter we'll use a data model that has Users, Groups, and Articles. Users belong to groups, and these provide strict separation, such as Slack workspaces. Users in one group should not know about users in another group.

Users in the same group can be friends. This provides a link between the User objects.

Finally, users can write articles. These are public, so articles published by a user in a group can be read by users in another group too.

Articleid: ID!text: String!Userid: ID!name: String!role: User | AdminGroupid: ID!name: String!friendsauthorgroup
Data model
Run the example

Deploy the schema in your account and run these queries live. You'll need an AWS account, the AWS CLI, and Terraform.

How to deploy and more info in the GitHub repository: https://github.com/sashee/graphql-access-control-example

The schema for these types:

type User {
  username: String!
  friends: [User]
  # we'll use this to see
  # how a naive implementation can be insecure
  group_unsafe: Group
  group: Group
}

type Group {
  name: String
  users: [User]
}

type Article {
  text: String
  author: User
}

Then the API allows these queries:

type Query {
  user(username: String!): User

  allUsers: [User]
  @aws_cognito_user_pools(cognito_groups: ["admin"])
  @aws_auth(cognito_groups: ["admin"])

  allArticles: [Article]
}
«user»user1«user»user2«user»user3«admin»admin1«admin»admin2group1name: Group 1group2name: Group 2article1text: Lorem ipsumarticle2text: Dolor set amethfriendsfriendsgroupgroupgroupgroupgroupauthorauthor
Example object graph

Entry points

Entry points are the queries and the mutations the API defines. They can return a type that provides fields the query can use to move to other objects. These are the easiest to secure, as usually there are not that many of them.

«user»user1«user»user2«user»user3«admin»admin1«admin»admin2group1name: Group 1group2name: Group 2article1text: Lorem ipsumarticle2text: Dolor set amethfriendsfriendsgroupgroupgroupgroupgroupauthorauthoruser(username: "user1")
User query

For example, the user(username: String!): User query returns a User object. The query can then define the fields it needs and those can include other types too. Such as this query:

query MyQuery {
  user(username: "user1") {
    username
    friends {
      username
    }
  }
}

The user query returns a User object, then the friends returns other Users. Here, the query provides the entry point to the object graph. If a user can't send a query then they can't enter the graph there.

For example, the allUsers provides a way to retrieve multiple User objects. If a normal (non-admin) user can't call it then they can't accidentally get users from other groups.

Both queries and mutations can return complex types that allow moving to other objects in the graph, but mutations also make changes, so it's especially important to implement access control for them.

Traversals

When a query (or a mutation) has access to an object, it can move to other objects from there, allowing it to reach data deeper in the graph. In the previous example we saw that a Query can return an object and using its friends field it is possible to get other users:

query MyQuery {
  user(username: "user1") {
    username
    friends {
      username
    }
  }
}
«user»user1«user»user2«user»user3«admin»admin1«admin»admin2group1name: Group 1group2name: Group 2article1text: Lorem ipsumarticle2text: Dolor set amethfriendsfriendsgroupgroupgroupgroupgroupauthorauthoruser(username: "user1") {friends}
Friends field

The above query returns the user1 object, as it is returned by the user query, and all its friends as they are returned by the friends field. Moreover, a query can also fetch the friends of the user's friends:

query MyQuery {
  user(username: "user1") {
    username
    friends {
      username
      friends {
        username
      }
    }
  }
}
«user»user1«user»user2«user»user3«admin»admin1«admin»admin2group1name: Group 1group2name: Group 2article1text: Lorem ipsumarticle2text: Dolor set amethfriendsfriendsgroupgroupgroupgroupgroupauthorauthoruser(username: "user1") {friends {friends}}
Friends' friends

A more complex example

But there is a more sinister path built-in to the data model that can expose more data. Let's say the entry points are secure: an administrator can use the user Query to get users in its own group. With this, admin1 can only get three users: user1, user3, and admin1, and none from the other group. Since groups are supposed to provide hard boundaries between users, this is the expected functionality.

But articles are outside the group separation, and they have an author. Because of this, users from one group should have limited access to other groups.

Visually, an administrator should be able to reach these objects:

Group 1Group 2«user»user1«admin»admin1«user»user3group1name: Group 1«user»user2«admin»admin2group2name: Group 2article1text:Lorem ipsumarticle2text:Dolor set amethfriendsfriendsgroupgroupgroupgroupgroupauthorauthor
Objects admin1 can reach

Let's focus on the User.group field! Without taking articles into account, limiting the entry points is enough: if a user can only get users in the same group, then there is no way allowing access to the group field would create a vulnerability.

Group 1Group 2«user»user1«admin»admin1«user»user3group1name: Group 1«user»user2«admin»admin2group2name: Group 2friendsfriendsgroupgroupgroupgroupgroupuser(username: "user1") {group_unsafe {users {username}}}
Navigating to the group and listing the users

But add the articles and suddenly a user from one group can navigate to another group and list users there:

Group 1Group 2«user»user1«admin»admin1«user»user3group1name: Group 1«user»user2«admin»admin2group2name: Group 2article1text:Lorem ipsumarticle2text:Dolor set amethfriendsfriendsgroupgroupgroupgroupgroupauthorauthorallArticles {author {group_unsafe {users {username}}}}
Navigating from the article to the author and then the group

This shows how entry points and traversals together define what objects are accessible to users. Securing entry points alone is misleading: an unrelated change (adding a new object type) can open ways to navigate from the allowed portion of the object graph to other parts.

Implementation

Now that we've discussed the concepts of GraphQL access control, let's see how to implement it! We'll first look into configuring access on the schema-level, as that is usually the easiest to apply. Then we'll move on the resolver-based implementation, which gives more freedom but also more nuanced.

Schema-based access control

The easiest approach is to look up what directives the server you are using supports and add them to the Query/Mutation or Type fields you want to restrict access to. This makes security easy to reason about, as it is defined in the schema with the rest of the data model.

In the case of AppSync, it supports the @aws_auth directive with the cognito_groups argument that denies access to the field for any users not in the specified Cognito group:

type Query {
  # only admins can call this
  allUsers: [User]
  @aws_auth(cognito_groups: ["admin"])
}

When a non-administrator sends a query:

query MyQuery {
  allUsers {
    username
  }
}

The GraphQL server returns an error without running the resolver:

{
  "data": {
    "allUsers": null
  },
  "errors": [
    {
      "path": [
        "allUsers"
      ],
      "data": null,
      "errorType": "Unauthorized",
      "errorInfo": null,
      "locations": [
        {
          "line": 2,
          "column": 3,
          "sourceName": null
        }
      ],
      "message": "Not Authorized to access
        allUsers on type Query"
    }
  ]
}

Directives for access control works for both entry points (Query.allUsers) and fields (User.group, User.friends). They are great to deny access to data for groups of users.

The main problem with directives is that they are usually all-or-nothing. If a user is in the group then they can use that field, if they are not then they are denied. This level of granularity is good for the allUsers query, but it is not possible to restrict the user(username: String!) to allow users to fetch only themselves.

The second problem with directives is that GraphQL does not define a common set of them for access control. This means you need to check what the server you are using supports. And even then it can be confusing, for example, AppSync supports the @aws_auth(cognito_groups) as well as the aws_cognito_user_pools directives and they behave differently depending on the API configuration.

Resolver-based access control

Resolvers provide the implementation how the GraphQL server returns the results for a query. As we've seen in the Resolvers chapter, they work on the field-level and the server runs them independently depending on what fields are requested by the client.

They are written in a language the GraphQL server supports, but a common property of all implementations is that they provide fine-level control over how they work. For example, AppSync allows a Lambda function to act as a resolver, and you can write arbitrary code there. This means that you can implement access control in the resolver code and that provides freedom to define whatever access control scheme you want.

GraphQLbackenddatabasequery MyQuery {user(username: "user1") {usernamefriends {username}}}resolve Query.userCheck username === user.usernamequery userusername = "user1"userresolve User.friendsCheck username === user.usernameFetch friendsresolve usernames...response
Resolvers can implement access control

If user1 is sending the request to fetch itself:

query MyQuery {
  user(username: "user1") {
    username
  }
}

The result is the User object:

{
  "data": {
    "user": {
      "username": "user1"
    }
  }
}

But querying a different user:

query MyQuery {
  user(username: "user3") {
    username
  }
}

Returns an error:

{
  "data": {
    "user": null
  },
  "errors": [
    {
      "path": [
        "user"
      ],
      "data": null,
      "errorType": "Unauthorized",
      "errorInfo": null,
      "locations": [
        {
          "line": 2,
          "column": 3,
          "sourceName": null
        }
      ],
      "message": "Not Authorized to access user on type User"
    }
  ]
}

A resolver can provide a safe way to query the group for a User. It needs to fetch the current user (the one who is sending the query) and see if the source's group is the same as the current user's group. If they are different, return an error.

GraphQLbackenddatabasequery MyQuery {user(username: "user1") {group {name}}}resolve Query.userCheck username === user.usernamequery userusername = "user1"userresolve User.groupFetch caller useruser1Check caller.group === source.groupFetch groupresolve group...response
Access control for the User.group field

While resolver-based access control implementations are not constrained by the limited set of directives the GraphQL server offers, they require more careful analysis to see if they indeed deny access when they should. Since the security decision is buried under some amount of code, it is usually not as easy to reason about as the schema-based implementations. My recommendation is to use directives when they are enough (all-or-nothing decisions based on only the caller identity) and move to resolvers only when needed.

Filtering results

So far, all the access control implementations we discussed were about whether to allow fetching a field or not. But in some cases, access control is not about whether the field is accessible or not but what items it returns. This usually happens with list results.

For example, administrators can get all users using the allUsers query. But since groups are hard boundaries, this should only return users in the same group. In this case, we'll end up with two access control implementations: one is shema-based to prevent non-admins from calling it, and the other is resolver-based that removes the unwanted results.

When admin1 calls it:

query MyQuery {
  allUsers {
    username
  }
}

It returns only the three users in group1:

{
  "data": {
    "allUsers": [
      {
        "username": "admin1"
      },
      {
        "username": "user1"
      },
      {
        "username": "user3"
      }
    ]
  }
}

How it is implemented is up to what the GraphQL server supports. But the general idea is the same everywhere: implement the resolver so that it takes the caller's group into account and return only users in the same group.

GraphQLbackenddatabasequery MyQuery {allUsers {username}}resolve Query.allUsersget all usersusersget caller useradmin1filter admin1.group === user.groupresolve users...response
Filter the result collection
Master AppSync and GraphQL
Support this book and get all future updates and extra chapters in ebook format.