Skip to content

Instantly share code, notes, and snippets.

@kiennt
Created December 20, 2016 03:54
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kiennt/1a0670559ae172949c915e290f35b335 to your computer and use it in GitHub Desktop.
Save kiennt/1a0670559ae172949c915e290f35b335 to your computer and use it in GitHub Desktop.
Absinthe authorization example
defimpl Kipatest.Can, for: Kipatest.User do
use Kipatest.Web, :model
def can?(%User{} = subject, :owner, %User{} = user) do
user.id == subject.id
end
end
defmodule Kipatest.Permission do
@moduledoc false
use Kipatest.Web, :shared
@type result :: :ok | :error | {:error, String.t}
@type t :: (any, map, map -> result) | (map, map -> result)
@spec can?(atom) :: Permission.t
@spec can?(atom, string) :: Permission.t
@doc """
Check does this request has a valid access token or not
"""
def can?(action, message \\ nil) when is_atom(action) do
fn
parent, _, %{context: %{current_user: user}} ->
if Kipatest.Can.can?(parent, action, user) do
:ok
else
error_message(message)
end
parent, args, info ->
error_message(message)
end
end
defp error_message(nil), do: :error
defp error_message(message), do: {:error, message}
end
defmodule Kipatest.Resolver do
@moduledoc false
use Kipatest.Web, :shared
@type resolve_result :: {:ok, any} | {:error, String.t} | {:error, map}
@type t :: (any, map, map -> resolve_result) | (map, map -> resolve_result)
defp default_callback(_, _, info) do
{:ok, Map.get(info.source, String.to_atom(info.definition.name))}
end
@doc """
When we resolve a field, we will need to check does current user has
permission to view this field.
For example, each user have a field `password` but only this user could view
that field, other user when view user profile could not see this one.
Our resolve system needs to support mechanism to allow developers check
permissions while resolving. This module address this issue by support
2 functions `Resolver.permission/2` and `Resolver.permission/1`
### `Resolver.permission/2`
This function takes 2 parameters
+ first parameter is callback function. This is a resolver function of
Absinthe
+ second parameter is a list of item. Each item in this list is either
a `permission function`, or an atom
A `permission function` is a function take 3 parameters as a normal
Absinthe resolver function, and return `:ok` if user has permission,
or `:error` if permission is not passed. In case developer want to
specified error message, he could make permission function return
`{:error, message}`
We also support a specical kind of permission function by allow developer
pass an `permission name` in to the list. When receiving a `permission
name`, this function will automatically create online a `permission`
function to check does `current_user` of this request have `permission
name` on the `parent` of the resolver
Absinthe resolver be called only if all of permission function is passed.
### `Resolver.permission/1`
This function is similar to `Resolver.permission/2` except it automatically
choose the Resolver is default Resolver of Absinthe
### Example of permission function
```elixir
object :user do
field :name, :string
field :company_name, :string
field :email, :string, resolve:
(&Resolver.User.get_email/3)
|> Resolver.permission([
&Permission.is_authenticated/3,
&Permission.is_owner/3,
])
end
```
In this example, `email` field only be read if current user is `owner` of
that field, to do that, we need to defines 2 permission function
`Permission.is_authenticated/3` and `Permission.is_owner/3`
Here is how `Permission.is_owner` and `Permission.is_authenticated`
were defined
```elixir
def is_owner(%User{} = user, _args, %{context: %{current_user: current_user}}) do
case current_user.id == user.id do
true -> :ok
false -> :error
end
end
def is_owner(_, _, _) do
:error
end
def is_authenticated(_, _, %{context: %{error: error}}) do
{:error, error}
end
def is_authenticated(parent, args, %{context: %{current_user: user}} = info) do
:ok
end
```
### Example of permission name
```elixir
object :user do
field :name, :string
field :company_name, :string
field :email, :string, resolve: permission([:owner])
end
```
In this case, we only allow user read `email` field if `current user` of
request has permission `owner` with user of email.
We could define `owner` permission like this
```elixir
# file web/model/user.ex
defimpl Kipatest.Can, for: Kipatest.User do
use Kipatest.Web, :model
def can?(%User{} = subject, :owner, %User{} = user) do
user.id == subject.id
end
end
```
Passing a list of permission name will allow developer create a very consice
API like this
```elixir
object :company do
field :name, :string
connection field :teams, node_type: :team do
resolve (&Company.teams_query/1)
|> list
|> permission([:read_teams, :read_tests, :read_submissions])
end
end
```
"""
@spec permission(list) :: Resolver.t
def permission(permissions) do
permission(&default_callback/3, permissions)
end
@spec permission(Resolver.t, list) :: Resolver.t
def permission(callback, permissions) do
fn parent, args, info ->
result = Enum.reduce(permissions, :ok, fn
permission_fun, :ok when is_function(permission_fun) ->
permission_fun.(parent, args, info)
permission_name, :ok when is_atom(permission_name) ->
Kipatest.Permission.can?(permission_name).(parent, args, info)
_, error ->
error
end)
case result do
:ok -> Absinthe.Resolution.call(callback, parent, args, info)
:error -> {:error, dgettext("errors", "permission denied")}
{:error, message} -> {:error, message}
end
end
end
@doc """
Check does this request has a valid access token or not
We could implement `is_authenticated` as permission function as describe
in `permission/2` document.
But if we do that, we have to call it like this
```elixir
object :user do
field :name, :string
field :company_name, :string
field :email, :string, resolve:
(&Resolver.User.get_email/3)
|> Resolver.permission([
&Permission.is_authenticated/3,
:owner,
])
end
```
`is_authenticated/1` is another way to implement this function, this
implementation allow us to write resolve like this
```elixir
object :user do
field :name, :string
field :joined_company, :company, resolve:
:joined_company
|> assoc
|> permission([:owner])
|> is_authenticated
end
```
"""
@spec is_authenticated(Resolver.t) :: Resolver.t
def is_authenticated(callback) do
fn
_, _, %{context: %{error: error}} ->
{:error, error}
parent, args, %{context: %{current_user: _user}} = info ->
Absinthe.Resolution.call(callback, parent, args, info)
end
end
end
defmodule Kipatest.Schema.User do
@moduledoc false
use Kipatest.Web, :schema
import Kipatest.Resolver, only: [
permission: 1,
permission: 2,
is_authenticated: 1,
]
connection node_type: :user
object :access_token do
field :value, :string do
resolve fn token, _, _ -> {:ok, token.id} end
end
field :expired_at, :string
field :refresh_token_expired_at, :string
field :refresh_token, :string
end
object :user do
field :name, :string
field :company_name, :string
field :email, :string, resolve: permission([:owner])
field :password, :string, resolve: permission([:owner])
field :timezone, :string
end
end
@hickscorp
Copy link

hickscorp commented Aug 20, 2018

A few pointers:

resolver.ex#10 should read: {:ok, Map.get(info.source, info.definition.schema_node.identifier)}, otherwise the String.to_atom sometimes will convert camel-case into atom, and the accessor won't be usable by Absinthe.Resolution....

permission.ex#17 seems wrong to me - the subject of a permission should be last, not first. The first parameter should be the "session user". In our case, we changed the function to something like this:

  def can?(action, msg \\ nil) when is_atom(action) do
    fn
      %{context: %{user: user}}, subject ->
        case Canada.Can.can?(user, action, subject) do
          true -> :ok
          _ -> error_message(msg)
        end

      _info, _subject ->
        error_message(msg)
    end
  end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment