Datacop

An authorization library with Dataloader and Absinthe support.

This library is heavily inspired by bodyguard. Our authorization rules not always simple, so datacop allows you to deal with n+1 queries using dataloader.

Installation

The package can be installed by adding datacop and optionally absinthe to your list of dependencies in mix.exs:

def deps do
  [
    {:datacop, "~> 0.1"},
    {:absinthe, "~> 1.6"}
  ]
end

Usage

Define a Policy module with authorization rules

This module should contain authorization rules or redirect resolution to dataloader for batch resolutions. Try to keep authorize callback pure and redirect side effects to dataloader.

defmodule MyApp.Blog.Policy do
  @behaviour Datacop.Policy

  @imp true
  def authorize(:delete_post, actor, _post), do: actor.id == post.author_id or actor.admin?

  def authorize(:view_stats, actor, post) do
    if actor.admin? do
      {:dataloader,
       %{
         source_name: MyApp.Blog,
         batch_key: {:one, MyApp.Blog.Post},
         inputs: [{{:can_admin_view_stats?, actor.id}, post.id}]
       }}
    else
      false
    end
  end
end

Define a Data module for integration with dataloader

A typical module for working with dataloader. In this example we use Dataloader.Ecto. See documentation for this module for detailed explanation how it works. Batch query should return a list of boolean values in the same order which post_ids has.

defmodule MyApp.Blog.Data do
  def data do
    Dataloader.Ecto.new(MyApp.Repo, run_batch: &run_batch/5)
  end

  def run_batch(queryable, _query, {:can_admin_view_stats?, admin_id}, post_ids, repo_opts, _params) do
    result =
      queryable
      |> very_complex_query_returns_posts_which_are_managed_by_admin(admin_id, post_ids)
      |> select([posts], {posts.id, true})
      |> MyApp.Repo.all(repo_opts)
      |> Map.new()

    Enum.map(post_ids, &Map.get(result, &1, false))
  end
end

Use context module as a proxy

It is not necessary to do this, but otherwise you'll have to refer to Data and Policy modules directly in places where corresponding functions are invoked.

defmodule MyApp.Blog do
  defdelegate authorize(action, actor, params), to: __MODULE__.Policy
  defdelegate data, to: __MODULE__.Data
end

Setup dataloader in Absinthe schema

See this guide for reference. In general implementation of Absinthe.Schema.context/1 should look like this:

def context(ctx) do
  loader =
    Dataloader.new() |> Dataloader.add_source(MyApp.Blog, MyApp.Blog.data())

  Map.put(ctx, :loader, loader)
end

Use as a single action

Because absinthe defines :loader in Absinthe.Schema.context/1 callback, we can reuse it in resolver functions by passing :loader option explicitly:

def delete_post(params, %{context: %{actor: actor, loader: loader}}) do
  with {:ok, post} <- MyApp.Blog.fetch_post(params.post_id),
       :ok <- Datacop.permit(MyApp.Blog, :delete_post, actor, subject: post, loader: loader) do
    MyApp.Blog.delete_post(post, params)
  end
end

If you don't pass :loader, then datacop checks if passed module (in example above it is MyApp.Blog) has data/0 function. If yes, then loader can be lazily initiated by datacop for single source with passed module as a :source_name.

For our example this call will work:

Datacop.permit(MyApp.Blog, :view_stats, actor, subject: post)

which is a short version of:

Datacop.permit(MyApp.Blog, :view_stats, actor,
  subject: post,
  loader: Dataloader.new() |> Dataloader.add_source(MyApp.Blog, MyApp.Blog.data())
)

but this won't (MyApp.Blog.Policy doesn't implement data/0):

Datacop.permit(MyApp.Blog.Policy, :view_stats, actor, subject: post)

The next example works fine, as :delete_post action doesn't use dataloader:

Datacop.permit(MyApp.Blog.Policy, :delete_post, actor, subject: post)

With Datacop.permit?/4 it's also possible to work with booleans:

Datacop.permit?(MyApp.Blog, :search, actor, loader: loader)}

Use as Absinthe middleware

In order to leverage full potential of datacop it is recommended to use it with absinthe.

alias Datacop.AbsintheMiddleware.Authorize

object :post do
  field :id, :id
  field :stats, :stats do
    middleware(Authorize, {MyApp.Blog, :view_stats, loader: &(&1.loader), actor: &(&1.actor)})
    resolve(...)
  end
end

In order to DRY you may want to provide a custom middleware on top of existing one:

defmodule MyApp.Schema.Middleware.Authorize do
  @behaviour Absinthe.Middleware

  @impl Absinthe.Middleware
  def call(resolution, {action, context_module}) do
    call(resolution, {action, context_module, []})
  end

  @impl Absinthe.Middleware
  def call(resolution, {action, context_module, opts}) do
    opts =
      opts
      |> Keyword.put_new(:actor, &(&1.actor))
      |> Keyword.put_new(:loader, &(&1.loader))

    params = {action, context_module, opts}

    %{resolution | middleware: [{Authorization.AbsintheMiddleware.Authorize, params} | resolution.middleware]}
  end
end

and a helper on top of it

def authorize(action, module, opts \\ []) do
  {:middleware, PtWeb.Schema.Middleware.Authorize, {action, module, opts}}
end

so block with :stats contains less noise:

field :stats, :stats do
  authorize(MyApp.Blog, :view_stats)
  resolve(...)
end

That's it. Now if you request list of posts, then authorization will be performed in batches.