GraphQL Authorization: Building Authorization in GraphQL

In a previous blog post I gave an overview of patterns for building authorization in GraphQL APIs, but left the implementation details as an exercise for you, dear reader. In this post, I’ll revisit this problem and show an example solution to explain how you can build uniform and consistent authorization in your GraphQL APIs.

To start, let’s revisit some rules of thumb on how to approach building authorization in GraphQL:

  • Build your authorization logic as close to the data as possible, ideally within your GraphQL API.
  • Custom Directives and Middleware are both great ways to do this while keeping your authorization logic decoupled from your schema’s lower levels.
  • Gateways can work as a way of enforcing authorization over multiple GraphQL APIs, but they can limit the types of rules that you can to write.

In this post, I’ll apply this advice by building an example implementation using Custom Directives to enforce authorization requirements over a GraphQL schema.

To support the solution, I’ll use Oso Cloud, an Authorization as a Service product that I work on at Oso. Oso Cloud lets you store roles and policy data in one place, and use this data to make authorization decisions in your applications via an API. I’ll walk you through how to write your own Oso policy based on a GraphQL schema, and then enforce authorization using Custom Directives to achieve uniform and consistent results across your GraphQL API. To follow along, you’ll need an Oso Cloud API key, which you can get by signing up for a free sandbox account.

Background: why use custom directives?

My previous post explored building authorization in different locations of your GraphQL API, from the gateway at the top of the request down to the data access layer underlying each schema. In this post, I’ll build the solution using GraphQL’s Custom Directive functionality. GraphQL schema directives are annotations that let you extend elements of your GraphQL schema with additional configuration data. Directives let you signal that the underlying schema element should be processed differently, for example by enforcing authorization, formatting, or caching behavior.

What makes directives particularly useful is that you can use them to change the behavior of many different elements of a schema with only a single implementation. They’re also entirely de-coupled from the underlying data access mechanism that they enclose, allowing them to work across different types of data sources in your application. With GraphQL your application’s entire API surface is defined in one place. By enforcing authorization at the schema layer with directives you can be confident of complete coverage without also polluting the rest of your code with authorization checks.

Introduction: what does the app do?

Big words! But what does that look like in code? To show the power of GraphQL and Oso Cloud together, I’ll use the example of building a GraphQL API for an application that lets users share Git repositories with each other. To start, I’ll define my schema with a repository type and the query to fetch them individually.

type Repository {
  id: ID!
  name: String!
  members: [String]!
}

type Query {
  repository(id: ID!): Repository
}

Step one: start with the model

The policy in Oso Cloud plays a similar role to that of the GraphQL schema. It defines the different types in your application and how they should be combined to make authorization decisions. Your policy, combined with the roles and relation data that your application sends to Oso Cloud (called Facts) determines the results of an authorization decision.

With no policy or facts in place (as is the default in new environments) all authorize requests will be denied. This is because Oso Cloud operates on a “default deny” safe default basis where requests are only granted through explicit rule matches.

I’ve written the policy below in the Polar language, a declarative logic language that we’ve designed for authorization. It defines a resource and actor type for our Git application – repository and user respectively – and their role and permission structure. To begin, I’ll say that every member of a repository has the permission to read it.

actor User { }

resource Repository {
  permissions = ["read"];
  roles = ["member"];

  "read" if "member";
}

Once I’ve written my policy, I can use the Oso Cloud CLI to upload it to Oso Cloud.

$ oso-cloud policy policy.polar
Policy successfully loaded.

Step two: facts and practice

Now that I’ve told Oso Cloud the rules of the game, I’ll make some test queries to confirm it works as expected before building my new directive.

The first thing to do is confirm an expected safe default result: that users without membership in a repository can’t read it. I’ll use the Oso Cloud CLI to make a test query to see whether my “patrickod” user can read an arbitrary “acme” repository.

$ oso-cloud authorize User:patrickod read Repository:acme
Denied

As expected, because I haven’t given any data to Oso Cloud to link me (“patrickod”) to the repository, my request to read it is denied. To be granted the ability to read the repository, I’ll first have to establish this link by storing a fact in Oso Cloud recording my user as a member of the “acme” repository.

$ oso-cloud tell has_role User:patrickod member Repository:acme

Here again I’m using the Oso Cloud CLI, but you could perform this same request from within your application by using the Oso Cloud client like so:

await oso.tell("has_role", { type: "User", id: user.id }, "member", { type: "Repository", id: repository.id });

Once this fact is stored in Oso Cloud, I can re-issue my query. This time the new persisted fact is combined with the policy, and Oso Cloud tells me that I can indeed read the acme repository as expected.

$ oso-cloud authorize User:patrickod read Repository:acme
Allowed

Step three: enforce with a directive

Now that I have the correct policy in place to check read permissions on repositories, it’s time to bring it all together and enforce these authorization results within my GraphQL API. To do this, I’ll create an @authorize directive that calls Oso Cloud before returning the data, and use this to protect the repository query.

directive @authorize(permission: String! = "read", resource: String!) on OBJECT

type Repository {
  id: ID!
  name: String!
  members: [String]!
}

type Query {
  repository(id: ID!): Repository @authorize(permission: "read", resource: "Repository")
}

Having annotated the bits of my schema that I want to protect, I now need to write the code that will power this directive. The implementation for my directive will have to do two things:

  • Transform any Object or Field element with the @authorize directive, ignoring those without.
  • Wrap the original field’s resolver function in a call to the oso.authorize API, using the actor and resource identity information parsed out of the GraphQL context and query.

First I import the utilities and types from GraphQL that I need to write my directive, and also instantiate the Oso Cloud client using a secret API key credential read out of our application’s environment.

// schema and directive-related implementation tools
const { mapSchema, getDirective, MapperKind } = require("@graphql-tools/utils");
// the default resolver function we'll wrap with authorization
const { defaultFieldResolver } = require("graphql");

// instantiate an Oso Cloud client to use within the authorization directive
// we source the API key secret from the environment
const { Oso } = require("oso-cloud");
const oso = new Oso("https://cloud.osohq.com", process.env["OSO_AUTH"]);

function authorizationDirectiveTransformer(schema) {
  return mapSchema(schema, {
    // our directive applies only to object or field schema definitions
    [MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => {
      // attempt to read any `authorize` directive specified for the field
      const directive = getDirective(schema, fieldConfig, "authorize")?.[0];

      // IFF one exists, wrap the resolver in the necessary Oso Cloud API call.
      // otherwise leave the field unmodified.
      if (directive) {
        // destructure the "permission" and "resource" arguments from the directive
        const { permission, resource } = directive;

        // keep a reference to the original resolve function
        // we'll call this later if the user is successfully authorized
        const { resolve = defaultFieldResolver } = fieldConfig;

        // Overwrite the resolver with your Oso Cloud wrapper
                fieldConfig.resolve = INSERT_YOUR_OSO_CLOUD_RESOLVER_HERE
      }
    },
  });
}

The custom resolver part of the directive is comparatively small. It reads the user and repository identity out of the GraphQL context and args variables respectively, and passes them as inputs to the oso.authorize call. Once it receives an authorization result granting the user’s access to the specific repository, it calls the original resolver to pass back the correct responsive data.

fieldConfig.resolve = async function (source, args, context, info) {
  // fetch the (previously authenticated!) actor ID; fail if we don't find one.
  const userId = context.userId;
  if (!userId) {
    throw new Error("need to log in");
  }

  // call Oso Cloud API to determine whether the user is authorized to perform this action
  const authorized = await oso.authorize({ type: "User", id: userId }, permission, { type: resource, id: args.id });
  if (!authorized) {
    throw new Error("not allowed");
  }
  // return the underlying data if we've successfully authorized the user.
  return resolve(source, args, context, info);
};

A more complicated example: public repositories

One of the most common questions customers ask me when building authorization in GraphQL is whether to enforce authorization alongside authentication at the Gateway level. If you already use a gateway to enforce authentication, it can be tempting to use it as the enforcement point for your application’s authorization too. While this works for some policies, it can complicate matters if/when you you want to control access based at the resource level (and thus need resource-level data to make access decisions). If this data lives in a database in your subgraph you will not yet have access to this information from the Gateway. For this reason I encourage using directives where possible.

Untitled

To see this issue in action, I’ll extend my application with the concept of public repositories. These repositories can be viewed regardless of the user’s authentication status and are instead guarded by a boolean flag stored on each repository object.

type Repository {
  id: ID!
  name: String!
  members: [String]!
  public: Boolean
}

I’ll extend my policy with a new rule that allows anyone to read a repository if the repository is public. The _ used in place of the user variable is Polar’s wildcard syntax, which tells Oso Cloud that this rule matches any user, including the “null” case representing a logged-out user.

allow(_: User, "read", repository: Repository)
  if is_public(repository);

This rule introduces a new fact type to our policy, is_public(repository), which records a repository’s status as public. One way to tell Oso Cloud about this data is to update the relevant portion of your application code to write to Oso Cloud when a user makes changes. For example, my settings page could respond to updates by inserting facts into Oso Cloud code using the Oso Cloud API via the tell method:

await oso.tell("is_public", { type: "Repository", id: repository.id });

Another way to tell Oso Cloud about the repository’s public state is to pass Context Facts as part of your query. Oso Cloud will combine these context facts with those stored in Oso Cloud to render an authorization decision. Building at the resolver layer lets you gather the relevant context facts “just in time.” The upshot is Oso Cloud will always have the most up-to-date data when making authorization decisions.

Untitled

Revisiting the resolver code that I wrote above, I’ll make the following changes to pass a context fact recording the repository’s current “public” status. In this way, any changes to a repository’s public status will be immediately reflected whenever a user asks for it.

fieldConfig.resolve = async function (source, args, context, info) {
  const userId = context.userId;
  // retrieve the repository so that we can inspect its "public" field
  const resolved = resolve(source, args, context, info);
  // pass a context fact if the repository is public, otherwise nothing.
  const contextFacts = resolved.public ? [["is_public", { type: "Repository", id: resolved.id }]] : [];
  const authorized = await oso.authorize(
    { type: "User", id: userId },
    permission,
    { type: resource, id: args.id },
    contextFacts
  );
  if (!authorized) {
    throw new Error("not allowed");
  }
  return resolved;
};

As you can see, the closer to the relevant data you can can enforce your authorization, the easier it is to take advantage of that data as part of your authorization checks. Combining GraphQL directives with context facts in this way lets you powerful and granular authorization.

GraphQL & Oso Cloud: a fit for every schema

Regardless of where you wish to build authorization in your GraphQL application, Oso Cloud makes the process of enforcing consistent authorization throughout. In this post I took an existing GraphQL schema and used Custom Directives to apply a layer of authorization on top that combines local resource data with centrally managed policy. I used Polar to define a policy for my application with safe defaults that constrains user access to repositories based on membership, and that also allows for opt-in public access for individual repositories. My implementation uses stored facts for the user role and relation data that I want to share between multiple GraphQL APIs, and combines them with locally-read context facts to ensure granular authorization results.

Revisiting my initial rules of thumb in the context of Oso Cloud, I think I have only a few tweaks to make. The new answer I give customers is:

  • Build your authorization logic as close to the data as possible, ideally within your GraphQL API.
  • Custom Directives and Middleware are excellent ways of doing this as they come with access to local resource data. Let’s use more of them!
  • Gateways can enforce authorization over multiple GraphQL APIs, but they will incur some additional work to sync the necessary facts with Oso Cloud elsewhere in your application.

I’ve seen customers use directives as a great solution for this in the GraphQL ecosystem, but I’d love to hear from you on your GraphQL needs and experience. You can try Oso Cloud for yourself, or set up a 1x1 with an Oso engineer for advice for how to build authorization in your GraphQL API.

Try it yourself: resources to read next

Want us to remind you?
We'll email you before the event with a friendly reminder.

Write your first policy