/absinthe_auth

Authorisation for the Absinthe GraphQL library

Primary LanguageElixirOtherNOASSERTION

AbsintheAuth

CircleCI Codecov Hex.pm Hex.pm Inline docs

(Opinionated) Authorisation framework for Absinthe.

Authorisation in the Graph Layer

There are many approaches to doing authorisation in GraphQL. This approach is to do it entirely within the GraphQL layer. This means that backing services don't need to know anything about what rules need to be applied for a given query.

It also means that:

  • authorisation policy is kept within the schema so it's easy to reason about
  • backing services can be simplified
  • authorisation can be applied to fields before resolution (maybe avoiding hitting the backing service at all)
  • different API layers (Graph, REST or anything else) can implement their own permission logic and keep the same backing services

Usage

AbsintheAuth defines a macro policy that can be used inside Absinthe.Schema definitions. It basically just injects a middleware.

defmodule Movies.Schema do
  use Absinthe.Schema
  use AbsintheAuth

  query do
    field :movie, :movie do
      policy MyPolicy, :check
    end
  end
end

policy/3 takes a module and the name of a function to call on that module as well as a list of optional options to pass to the policy (as a list).

Defining Policies

A policy is super generic. It's basically just a middleware but contains some additional logic to ensure requests are denied if no policy matches as well as simplifying how queries and mutations are handled.

A policy can be whatever you like. It's up to you. A really simple policy would be to just deny all access to a field:

defmodule DenyAllPolicy do
  use AbsintheAuth.Policy

  def check(resolution, _opts) do
    deny!(resolution)
  end
  def check(resolution, _parent, _opts) do
    deny!(resolution)
  end
end

Note that there are two versions of check - a 2 arity and a 3 arity function. The 3 arity function will be called when there is a parent record that is not the query or mutation root. Your policy should define both a 2 and a 3 arity version of any functions.

See AbsintheAuth.Policy for more details and examples.

Policy Semantics

Multiple policies can be defined on any field. If no policy is added then the normal resolution process will occur (including any middlewares you have). However, when you add multiple policies, at least one of them will need to explicitly allow the request or else the request will be denied.

object :movie do
  field :id, non_null(:id)
  field :title, :string
  field :budget do
    policy Studio, :allow
    policy Permission, :check
  end
end

Semantically, you could read this as saying that if the viewer of the request works for a studio then allow them to see the budget field. If not, but the user has explicitly been given permission to the budget field on this record then allow them to see it. Otherwise, the request will be denied.

Policies must always return the resolution - either denied, allowed or deferred. See AbsintheAuth.Policy.deny!/1, AbsintheAuth.Policy.allow!/1 and AbsintheAuth.Policy.defer/1 for details.

Using the Absinthe Context

One approach (although there are many others) to verifying permissions within a policy is to use information available in the context. The most obvious idea is to check against the currently logged in user (current_user or viewer depending on your preference.

Suppose you have the viewer set in the context (see Absinthe for more information on this).

%{
  context: %{
    viewer: %{id: 1}
  }
}

We can access this information in the policy.

A simple example:

defmodule OwnerPolicy do
  use AbsintheAuth.Policy
  
  def allow(resolution, _) do
    # Can't be an owner of the root
    deny!(resolution)
  end
  def allow(%{context: %{viewer: %{id: id}}}, %{owner_id: id} = rec, _) do
    # Allow when I'm the owner of the target record
    allow!(resolution)
  end
end

Or, let's say we want to allow if the user is an admin:

defmodule AdminPolicy do
  use AbsintheAuth.Policy

  def allow(%{context: %{viewer: %{id: id}}}, _) do
    with {:ok, user} <- Users.find_user(id),
         true <- Users.is_admin?(user) do

      allow!(resolution)
    else
      _ ->
        deny!(resolution)
    end
  end
end

Of course, this second example might not be very efficient because we could end up calling it many times for a single query. If either of the functions in the Users module need to hit the database this could be problematic indeed!

Applying Policies After Resolution

In general it's preferred to perform policy checks before resolution. And policies checking mutations must always be run before resolution. However, there are sometimes cases when it's helpful to resolve first and then use the resolved value to make a policy decision.

In the following example, we define an :article field in the schema that returns an Article object. An Article has a boolean field indicating whether or not it has been published. However, we do not know if the article with a given ID has been published or not until we retrieve it.

To solve this problem we define a policy called Article that defines two functions. The first, published checks to see if the article has been published while the second (which is only run if the first is deferred) checks to see if the viewer is the owner of the given article.

Of course, if neither policy check allows the resolution then it is denied.

field :article, :article do
  arg :id, non_null(:id)
  resolve &Article.find/2
  policy Policies.Article, :published
  policy Policies.Article, :viewer_is_owner
end
defmodule Policies.Article do
  use AbsintheAuth.Policy

  def published(resolution, _) do
    case resolution.value do
      %{published: true} ->
        allow!(resolution)

      _ ->
        defer(resolution)
    end
  end
end

Prefetching Permissions or Roles

An alternative is to load all the info required to verify access into the context at the start of each request. While this could require a multi-row database query it will only be executed once per query thus avoiding any N+1 query type issues.

%{
  context: %{
    permissions: [
      "view",
      "create_project"
    ]
  }
}

A "permission" policy:

defmodule PermissionPolicy do
  use AbsintheAuth.Policy

  def view(%{context: %{permissions: permissions}}, _) do
    if "view" in permissions do
      allow!(resolution)
    else
      deny!(resolution)
    end
  end
end

Denied Responses

A key principle of GraphQL is that responses should maintain the shape of a request. Therefore, when a field is denied it should still be returned in the response but with it's value set to null.

Additionally, an error message can be included.

Using AbsintheAuth.Policy.deny!/1 will do this for you.

Deferring Authorisation

When using multiple policies for a field, we might not want to deny resolution simply because we didn't allow it. A third case can be useful here: defer.

If a policy does not determine that access is allowed it might choose to defer a decision so that another policy further down the chain could still allow it. Of course, if none of the policies allow access the request will be denied anyway.

So when should you use deny!/1 and when should use defer/1?

  • Use Deny:

    • When it's a hard deny (no other policy would override the decision)
    • When you only use the policy on its own
    • When it's inefficient to traverse multiple policies on a single field
  • Use defer

    • When you want to combine policies
    • When you want to keep your policies flexible

Installation

If available in Hex, the package can be installed by adding absinthe_auth to your list of dependencies in mix.exs:

def deps do
  [
    {:absinthe_auth, "~> 0.1.0"}
  ]
end

Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/absinthe_auth.