Notes to self

Writing Absinthe authorization middleware

Plug is not the only interface with the middleware layer. Absinthe also comes with a middleware layer of its own and we can use it similarly to implement authorization for queries, mutations, and subscriptions.

If we want to authorize our GraphQL API and don’t want to do that within business logic (for various reasons), we can write a middleware that will handle it. I wrote about using Plug for authorization, but using a plain Plug wouldn’t work in this case.

Since GraphQL is essentially a graph, we need to cover authorization for all nested queries. Absinthe middleware can do that for us, and might also be used for specific query fields, which is quite handy.

In the following example, I’ll use Absinthe middleware to authorize queries, mutations, and subscriptions (actions) similarly as I did with the Plug. I assume roles with specific rights having access to specific actions. For example, they might be hardcoded or fetched from a data source.

Here’s the simple AuthorizationMiddleware module implementing the Absinthe.Middleware behaviour:

defmodule MyWeb.AuthorizationMiddleware do
  @behaviour Absinthe.Middleware

  def call(resolution, _config) do
    action = resolution.definition.name |> Macro.underscore()
    required_rights = required_action_rights[action]

    if required_rights do
      case has_rights(resolution, required_rights) do
        false ->
          resolution
          |> unauthorized

        true ->
          resolution
      end
    else
      resolution
      |> unauthorized
    end
  end

  defp unauthorized(resolution) do
    resolution
    |> Absinthe.Resolution.put_result({:error, "unauthorized"})
  end

  defp get_rights_from_token(resolution) do
    case resolution do
      %{context: context} ->
        %{guardian_default_claims: claims} = context

        case claims do
          %{"rights" => rights} ->
            rights

          _ ->
            []
        end

      _ ->
        []
    end
  end

  defp has_rights(resolution, required) when is_list(required) do
    rights_in_token =
      get_rights_from_token(resolution)
      |> Enum.map(&String.to_atom(&1))

    required
    |> Enum.all?(&Enum.member?(rights_in_token, &1))
  end

  # Map query, mutation, and subscription names
  # to required rights
  defp required_action_rights do
    %{
      "users" => [:list_users]
    }
  end
end

The idea is to get the action (such as query) name from the resolution struct and compare it with the user’s access rights (they might be hardcoded or dynamic). The action names are available as resolution.definition.name, and chances are you already have the authenticated user details in the resolution. In my example, I am taking them from the Guardian token. That’s how we can implement the has_rights/1 function.

You can see that most of the module are our logic as Absinthe will take care of the rest.

As with Plug, we have to plug the middleware somewhere. Since we are talking about Absinthe, it will live in the schema:

defmodule MyWeb.Schema do
  ...

  query do
    field :hello, :string do
      middleware MyWeb.AuthorizationMiddleware
      resolve &get_the_string/2
    end
  end
end

We could call it before or after the resolve/1 macro. Since it’s authorization, it goes before!

But because we don’t want to go and insert it in every relevant action, we can use the middleware/3 callback to do it all at once:

defmodule MyWeb.Schema do
  ...

  def middleware(middleware, _field, %Absinthe.Type.Object{identifier: identifier})
      when identifier in [:query, :subscription, :mutation] do
    [MyWeb.AuthorizationMiddleware | middleware]
  end

  def middleware(middleware, _field, _object) do
    middleware
  end
end

Note the _field that I don’t care about but could be used to catch the right field.

Finally, a basic test could look like the following:

defmodule MyWeb.AuthorizationMiddlewareTest do
  use ExUnit.Case
  use MyWeb.ConnCase

  alias MyWeb.Repo
  alias MyWeb.Right
  alias MyWeb.AuthorizationMiddleware

  describe "call/2" do
    test "does allow action" do
      right = Repo.get_by(Right, name: "list_users")

      resolution = %Absinthe.Resolution{
        context: %{
          guardian_default_claims: %{"rights" => [right.name]}
        },
        definition: %{
          name: "users"
        }
      }

      resolution = AuthorizationMiddleware.call(resolution, nil)

      assert resolution.errors == []
    end

    test "does not allow action" do
      admin_users = Repo.get_by(Right, name: "list_users")

      resolution = %Absinthe.Resolution{
        context: %{
          guardian_default_claims: %{"rights" => []}
        },
        definition: %{
          name: "users"
        }
      }

      resolution = AuthorizationMiddleware.call(resolution, nil)

      assert resolution.errors == ["unauthorized"]
    end
  end
end

And that’s it. Remember, you can extend it to specific fields if you need to.

by Josef Strzibny
RSS