Implementing Ash Policies for GraphQL


Here's how to implement Ash policies for authentication and authorization in GraphQL.

Ash.Policies provide a way to declare auth around entities defined in the Ash framework. ash_graphql exposes entities via a GraphQl endpoint.

Authentication is proving that someone is who they say they are. Authorization is proving that he has a right to do what he's doing. We'll do both and implement a user user id and then check permissions to edit a book.

Define User and Permissions

Let's define our user first. He has an access token (granted elsewhere) that can expire and that we will use to look up the user. The user's permissions describe the things he's authorized to do.

In user.ex:

defmodule MyProject.Authorization.User do
  # ...
  attributes do
    # ...
    attribute :access_token, :string do
      allow_nil? true
      writable? true
      public? false
    end

    attribute :access_token_expiration, :datetime do
      allow_nil? true
      writable? true
      public? false
    end
  end

  relationships do
    many_to_many :permissions, MyProject.Authorization.Permission do
      description "special operation privileges inside my-project"
      through MyProject.Authorization.UserPermission
      source_attribute_on_join_resource :user_id
      destination_attribute_on_join_resource :permission_id
      public? true
    end
  end

  code_interface do
    define :read_by_access_token, action: :read, get_by: [:access_token]
  end

  actions do
    defaults [:read]

    create :create do
      accept [
        # ...
        :access_token,
        :access_token_expiration,
      ]

      upsert? true
      # ...
    end
  end

  graphql do
    type :user

    mutations do
      create :upsert_user, :create
    end
  end
end

And in permission.ex:

defmodule MyProject.Authorization.Permission do
  # ...
  attributes do
    # ...
    attribute :slug, :string do
      allow_nil? false
      writable? true
      public? true
    end
  end

  graphql do
    type :permission
  end
end

Now we've defined the shape of our user.

Where will user records come from? We'll probably create them upon login, using the upsertUser mutation on the graphql endpoint (not shown in this example). Where will the permissions records come from? Permissions will be provisioned by an admin somewhere who decides what operations in the system are special who can perform them (also not shown). For now, we can assume those records exist.

The Router and the Actor

Ash policies require the existence of an actor. Queries are made against the actor to determine what he can do.

Our actor will be an instance of the User entity above.

Let's write a plug middleware that defines where to look for the actor and then set it for later in the request pipeline. Here's actor-plug.ex:

defmodule MyProject.Router.ActorPlug do
  require Logger

  def init(opts), do: opts

  def call(conn, _opts) do
    Plug.Conn.get_req_header(conn, "authorization")
    |> case do
      [] ->
        Logger.debug("No request token found")
        conn

      [token] ->
        Logger.debug("Token found #{inspect(token)}")

        MyProject.Authorization.User.read_by_access_token(token, load: [:permissions])        
        |> case do
          {:ok, actor} ->
            Logger.debug("Actor set")
            conn |> Ash.PlugHelpers.set_actor(actor)

          {:error, error} ->
            Logger.error("Failed to read actor by token")
            Logger.error(error)
            conn
        end
    end
  end
end

This plug first checks the authorization header. It expects to find an access token value there. It then uses that token to look up the user by the code interface we defined in user.ex, read_by_access_token. If a matching user is found (authentication), then we will use the Ash.PlugHelpers.set_actor function to set the actor for later.

Now we must put our plug in our request pipeline in the router.ex. The ActorPlug needs to come before the AshGraphql.Plug so that the actor is available then.

defmodule MyProjectWeb.Router do
  use MyProjectWeb, :router

  # ...
  pipeline :graphql do
    plug MyProject.Router.ActorPlug
    plug AshGraphql.Plug
  end
end

Define Policies

Finally, the policies. A policy is Ash's way of declaring the security aspect of the entity. An entity defines data and operations on that data. Some operations might be special and require authorization. Policies will define that.

Let's say that I have a Library domain and editing of books is a special operation only allowed by select, elite librarians.

First, for policies to be used at request time, authorization must be turned on at the domain level. Open library.ex and set authorize? true:

defmodule MyProject.Library do
  use Ash.Domain, extensions: [AshGraphql.Domain]

  resources do
    resource MyProject.Library.Book
  end

  graphql do
    authorize? true
  end
end

Next, to our book entity. We'll set the authorizers. Then we'll define a policies block. Within it, we'll filter on which actions we want covered and how. :reads are free game. Anyone can (and should!) read a book. But to :create, :update, and :destroy will take some special consideration. We have two checks. One in the negative forbid_if case, when the token is expired, and one in the positive authorize_if the user is an editor.

defmodule MyProject.Library.Book do
  use Ash.Resource,
    # ...
    authorizers: [Ash.Policy.Authorizer]

  policies do
    policy action_type(:read) do
      authorize_if always()
    end

    policy action_type([:create, :update, :destroy]) do
      access_type :strict

      forbid_if MyProject.Library.Book.Checks.IsTokenExpired
      authorize_if MyProject.Library.Book.Checks.IsEditor
    end
  end
  # ...
end

The two checks, then, are implemented as follows. The describe method is what is returned if you expose how policies fail when actions are run. The match? method is the main method that determines if the check passes or fails, returning a boolean.

The policies interrogate the actor that we set earlier in the router. It is the first argument to match?.

The IsTokenExpired check returns whether or not the access token has expired:

defmodule MyProject.Library.Book.Checks.IsTokenExpired do
  use Ash.Policy.SimpleCheck
  require Logger

  def describe(_) do
    "user access token has expired"
  end

  def match?(
        %MyProject.Authorization.User{access_token_expiration: access_token_expiration} =
          _actor,
        %{resource: MyProject.Library.Book} = _context,
        _opts
      ) do
    is_expired =
      DateTime.compare(access_token_expiration, DateTime.utc_now())
      |> case do
        :lt -> true
        _ -> false
      end

    Logger.debug("Is access token expired?  #{is_expired}")
    is_expired
  end

  def match?(_, _, _), do: false
end

The IsEditor check returns whether or not the user has the permission to edit the book:

defmodule MyProject.Library.Book.Checks.IsEditor do
  use Ash.Policy.SimpleCheck
  require Logger

  def describe(_) do
    "user has permissions to edit books"
  end

  def match?(%MyProject.Authorization.User{permissions: permissions} = _actor, %{resource: MyProject.Library.Book} = _context, _opts) do
    is_editor = Enum.any?(permissions, fn p -> p.slug == "book_edit" end)
    Logger.debug("Is a book editor?  #{is_editor}")
    is_editor
  end

  def match?(_, _, _), do: false
end

To get the policy breakdown messages exposed in the graphql responses, you'll want to add this to your dev.exs:

config :ash_graphql, :policies, show_policy_breakdowns?: true

And that should be it. You have users, permissions, a router that can set an actor, entities to protect and policies that define those protections.

What else do you do to set up good Ash policies?