Authorization for GraphQL

Description of the GraphqlQL API authorization in ACP authorizers

GraphQL authorization

Over the last decade, companies that develop and provide Internet services faced a new challenge; how to protect their customers data and, at the same time, provide the users with a possibility to make a conscious decision on whether they want to grant their consent for sharing their data or not. Frameworks like, for example, broadly-known OAuth 2.0 emerged to tackle that challenge and provide clear guidelines for improving API security.

One of the solutions for API management is GraphQL. It is a query language for APIs that makes sure that client applications receive exactly the data they requested for and no more. It is an alternative for REST APIs and it lets the developers construct a request that pull data from multiple data sources in a single API call. Can GraphQL also make it possible to provide access control?

The GraphQL specification do not provide any guidelines on authorization. It is, however, possible. Between approaches on how to add access control to GraphQL, the difference lays mostly on making a decision on when to execute authorization.

The idea

What if we can have already an authorized request in the GraphQL server?

One of the greatest GraphQL capabilities is its schema definition language. It precisely describes the operations the client application can do, and the data the client can receive or send.

Using this knowledge, it’s possible to offload the authorization to an external microservice - an authorizer. The authorizer works like a reverse proxy. It checks if the GraphQL query is authorized. If true, it passes the request to the GraphQL server, otherwise, it returns an error.

[mermaid-begin]
graph LR A(Client) B(Authorizer) C(GraphQL server) A-->B B-->C

Declarative policies

If the authorizer knows the GraphQL schema, it can validate the GraphQL query syntax and check if the query is valid against the schema. The last missing part is the @auth directive.

directive @auth(
  policy: ID,
) on FIELD_DEFINITION | OBJECT | INTERFACE

The @auth directive is used to apply different policies in GraphQL schema by defining a policy for a given definition. The policy can be applied to a field, object, or interface, and contains a policy identifier that is evaluated only when the definition is queried in the request.

At this point, only one policy can be applied to a single definition.

How the authorizer works

The authorizer starts with loading the GraphQL server’s schema it guards. It compiles the schema to AST tree for further use. During the compilation, it looks for the @auth directives.

When the request comes in:

  1. The authorizer parses the query and detects GraphQL syntax errors. If there are erorrs, the request processing is over and the error is returned to the client.

  2. At this point, a query syntax is valid, and the authorizer parses the query against the provided GraphQL schema. It checks if every operation and data requested in the query is defined in the schema. If the query is not valid, an error is returned to the client.

  3. It checks if field definition, object, or interface from the query has a corresponding policy attached in the schema. If so, the policy is executed, and based on its result, the request processing is aborted or not.

Policy directives examples

Object policies

The policy can guard an object. Let’s consider the example:

type Query {
    getPerson(id: ID): Person!
    getCity(name: String): City
    getNumber(): Int
}

type Person @auth(policy: "person-policy-id") {
  id: ID
  name: String
}

type City {
    ID: ID
    name: String
    country: String
    citizens: [Person]!
}

The policy guards the Person object and it’s executed for all operations that return it- getPerson and getCity, but only when the object is requested.

The query below is not going to evaluate the person-policy-id policy, because the Person object is not requested.

{
    getCity(id:1) {
        name
    }
}

If a query contains more than one reference to the same policy, the policy is executed only once:

{
    p1: getPerson(id:1) {
        name
    }

    p2: getPerson(id:1) {
        name
    }
}

Queries and mutations are also objects. It is possible to guard them too, but it may lead to a situation where a given object is returned in a different query, for example, the Person object can be listed using the getCity query.

type Query {
    getPerson(id: ID): Person! @auth(policy: "get-person-policy-id")
    getCity(name: String): City
    getNumber(): Int
}

Field policies

The policy can guard a single object field.

type Query {
    getPerson(id: ID): Person!
    getCity(name: String): City
    getNumber(): Int
}

type Person @auth(policy: "get-person-policy-id") {
  id: ID
  name: String
  ssn: String @auth(policy: "social-security-number-policy-id")
}

type City {
    ID: ID
    name: String
    country: String
    citizens: [Person]!
}

A query that requests social security number has to pass two policies: get-person-policy-id and ssn-policy-id.

Interface policies

The policy can guard an interface.


interface NameInterface @auth(policy: "interface-policy-id") {
  name: String
}

type Query {
    getPerson(id: ID): Person!
    getCity(name: String): City
    getNumber(): Int
    getAllNames(): [NameInterface]
}

type Person implements NameInterface @auth(policy: "get-person-policy-id") {
  id: ID
  name: String
  ssn: String @auth(policy: "social-security-number-policy-id")
}

type City implements NameInterface {
    ID: ID
    name: String
    country: String
    citizens: [Person]!
}

type City implements NameInterface {
    ID: ID
    name: String
    country: String
    citizens: [Person]!
}

In the schema getAllNames returns a NameInterface with the interface-policy-id policy. The interface is implemented by two objects - Person and City. In a simple scenario shown below, only the interface-policy-id policy is evaluated, because the query does not specify which objects are returned.

{
    getAllNames() {
        name
    }
}

If an inline fragment is used to access a particular data, policies defined on a given object are evaluated. In the example below, three policies are evaluated: interface-policy-id, get-person-policy-id and social-security-number-policy-id.

{
    getAllNames() {
        name
        ... on Person {
            ssn
        }
    }
}

It’s also possible to guard interface fields.

interface NameInterface {
  name: String @auth(policy: "name-policy-id")
}

Points to consider

One of the GraphQL authorization approaches assumes that the authorization is in the resolver, and it doesn’t fail when the request is not authorized but returns a null instead. This allows combining partial results from different resolvers. In theory, it’s possible to do it by modifying the query by removing unauthorized definitions from the query.

It’s also not possible to limit results based on authorization context. Imagine a getPerson(id: ID) query which, based on the authorization result, can return current users or any user. To solve it, you can split the query into to separate queries: getMe() and getPerson(id: ID) and set up different authorization policies.