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.
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:
-
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.
-
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.
-
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.