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"}
]
endDocumentation can be found at https://hexdocs.pm/datacop.
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
endUse 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
endSetup 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)
endUse 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
endIf 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
endIn 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
endand a helper on top of it
def authorize(action, module, opts \\ []) do
{:middleware, PtWeb.Schema.Middleware.Authorize, {action, module, opts}}
endso block with :stats contains less noise:
field :stats, :stats do
authorize(MyApp.Blog, :view_stats)
resolve(...)
endThat's it. Now if you request list of posts, then authorization will be performed in batches.