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. :read
s 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?