(Opinionated) Authorisation framework for Absinthe.
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
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).
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.
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.
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!
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
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
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.
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
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.