Authorization and Policy Scopes for Phoenix Apps

Sapan Sapan Diwakar on

Authorization (not to be confused with authentication) is vital to every application but often isn’t given much thought before implementation. The IETF Site Security Handbook defines authorization as:

The process of granting privileges to processes and, ultimately, users. This differs from authentication in that authentication is the process used to identify a user. Once identified (reliably), the privileges, rights, property, and permissible actions of the user are determined by authorization.

So, in short, authorization is about defining access policies and scoping.

For example, consider a platform like Github. In very simple terms, it must handle which repositories:

If you come from the Rails world, you might be familiar with some gems that provide APIs to handle this, the most popular ones being cancancan and pundit.

In today’s post, we’ll take a close look at the two critical components of authorization — access policies and scoping. I’ll show you how you can roll out your own solution for each in Phoenix and how to leverage the Bodyguard library for quick and easy authorization.

Authorization in Phoenix

There are two parts to authorization that we need to keep in mind:

  1. Access policy — Is this user allowed to perform this operation on this resource?
  2. Policy scope — Which resources is this user allowed to see?

While it is definitely possible to roll out something by hand, it usually makes sense not to reinvent the wheel if well-maintained and tested libraries are available. Canada and Bodyguard are two of the more popular ones that I have seen in the community.

Let’s see what implementation might look like for our own solution and also with Bodyguard. We will use a CMS example similar to what the official Phoenix Context guide uses. This CMS allows users to create pages and share them with everyone. Only the author should be able to edit, update, or delete a page once created, but everyone else should see the page.

Implementing Access Policies

Getting back to our CMS example — when the user is on a page, we need an access policy that decides if the user is allowed to perform an action (say, edit) on the page.

Roll Your Own Access Policy

The following is what the official Phoenix guide suggests. This is also what most of us would do, were we rolling out our own authorization solution:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
defmodule Hello.CMS.PageController do
  plug :authorize_page when action in [:edit, :update, :delete]

  defp authorize_page(conn, _) do
    page = CMS.get_page!(conn.params["id"])

    if conn.assigns.current_author.id == page.author_id do
      assign(conn, :page, page)
    else
      conn
      |> put_flash(:error, "You can't modify that page")
      |> redirect(to: Routes.cms_page_path(conn, :index))
      |> halt()
    end
  end
end

The implementation is straightforward. We use the :authorize_page plug for edit, update, or delete actions. In that plug, we allow the action only if the page’s author is the same as the current user. Otherwise, we redirect to an index page that shows an error.

Use Bodyguard

We can also implement an access policy using Bodyguard.Policy behavior. Depending on the level of access scoping you need, this behavior could be placed on the controller or directly on the underlying context. I usually like to define a separate Policy module to handle this and then delegate the methods from the behavior’s target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
defmodule Hello.CMS.Policy do
  @behaviour Bodyguard.Policy

  alias Hello.Accounts.User

  # Super Admins can do anything
  def authorize(_action, %User{role: :super_admin}, _params), do: true

  # Users can list/get/create anything
  def authorize(action, %User{role: :user}, _params) when action in ~w[index show create]a, do: true

  # Users can edit/update/delete own pages
  def authorize(action, %User{id: id, role: :user} = user, %{author_id: ^id})
    when action in ~w[update edit delete]a,
    do: true

   # Default blacklist
  def authorize(_action, _user, _params), do: false
end

Here, we see that we defined authorize(action, user, params) that returns true/false to permit (or not) the action on the resource. You can also get additional control on the error messages by returning {:error, reason} instead of just false.

The Bodyguard.Policy behavior expects the callbacks to return:

Then, on the context or the controller, all you need is to delegate the authorize method to our Policy module and then call Bodyguard.permit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
defmodule Hello.CMS.PageController do
  plug :authorize_page

  defp authorize_page(conn, _) do
    page = CMS.show(conn.params["id"])

    case Bodyguard.permit(__MODULE__, action, user, page) do
      :ok ->
        assign(conn, :page, page)
      {:error, _reason} ->
        conn
        |> put_flash(:error, "You can't access that page")
        |> redirect(to: Routes.cms_page_path(conn, :index))
        |> halt()
    end
  end

  defdelegate authorize(action, user, params), to: Hello.CMS.Policy
end

To authorize an action, we can call Bodyguard.permit(Hello.CMS.PageController, action, user). Bodyguard.permit/4 also accepts passing a fourth argument’s authorization in the actual resource. This form can be used to authorize actions performed on a single resource (for example, update or delete).

Under the hood, Bodyguard.permit calls authorize on the module we provided as the first argument. The return value is then normalized into :ok or {:error, reason} (regardless of whether we were returning true/:ok or false/:error/{:error, reason} from that method).

While delegating authorize from the controller works well, there might be cases when you need similar access control in multiple places. For example, the same resource could be accessed from your controller and through an Absinthe Resolver exposed through the GraphQL API. For such cases, I suggest delegating authorize/3 on the Phoenix context module:

1
2
3
4
defmodule Hello.CMS do
  # ...
  defdelegate authorize(action, user, params), to: Hello.CMS.Policy
end

Then, when you need to call Bodyguard.permit from your controller (or the Absinthe Resolver), pass in a first argument of that context module:

1
2
3
4
5
6
7
8
9
10
11
defmodule Hello.CMS.PageController do
  defp authorize_page(conn, _) do
    page = CMS.show(conn.params["id"])
    case Bodyguard.permit(CMS, action, user, page) do
      :ok ->
        # OK. Render page
      {:error, reason} ->
        # Error. Show flash and redirect
    end
  end
end

It is also very easy to write tests for the above policy. Here’s what the tests might look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
test "allows users to list pages" do
  user = Hello.Accounts.Fixtures.fixture(:user)
  assert :ok = Bodyguard.permit(Hello.CMS, :index, user)
end

test "allows user to update/delete own pages" do
  user = Hello.Accounts.Fixtures.fixture(:user)
  page = page_fixture(user)
  assert :ok = Bodyguard.permit(Hello.CMS, :update, user, page)
end

test "allows user to create pages" do
  user = Hello.Accounts.Fixtures.fixture(:user)
  assert :ok = Bodyguard.permit(Hello.CMS, :create, user)
end

test "does not allow user to update/delete pages of someone else" do
  user1 = Hello.Accounts.Fixtures.fixture(:user)
  user2 = Hello.Accounts.Fixtures.fixture(:user)

  page = page_fixture(user2)

  assert {:error, :unauthorized} = Bodyguard.permit(Hello.CMS, :update, user1, page)
end

test "allows super_admin to do anything" do
  super_admin = Hello.Accounts.Fixtures.fixture(:user_super_admin)
  user = Hello.Accounts.Fixtures.fixture(:user)
  page = page_fixture(user)
  assert :ok = Bodyguard.permit(Hello.CMS, :index, super_admin)
  assert :ok = Bodyguard.permit(Hello.CMS, :create, super_admin)
  assert :ok = Bodyguard.permit(Hello.CMS, :update, super_admin, page)
end

Implementing Policy Scopes

We’ve now allowed users to access resources based on some attributes. The next part of the puzzle is to handle the listing of resources. As you might have noticed above, we are allowing users to list everything.

But you might have specific business requirements on what a user can and can’t list. For example, in the Github example, users can only see public repositories and the repositories that they have access to (e.g., through their organization or team). Similarly, you could set a restriction that users see all published posts but only their own drafts in a CMS.

Roll Your Own Policy Scoping

This just boils down to using the correct queries based on the user and their access rights. For example, a simple implementation inside a context could look like this:

1
2
3
4
5
6
7
8
defmodule Hello.CMS do
  def list_pages(%{id: id} = user) do
    Page
    |> where(author_id: ^id)
    |> or_where(state: :published)
    |> Repo.all()
  end
end

It is definitely possible to do this with simple Ecto queries on the accessor methods. However, it usually makes much more sense to centralize these kinds of domain requirements so that future developers don’t forget to include one of the requirements while adding a new feature.

Use Bodyguard

With Bodyguard, we can provide default scoping to query items that a user can access. Implement a scope/3 function inside an Ecto.Schema module from the @behaviour Bodyguard.Schema. The function should filter the query down to only include the resources the user is allowed to access. You can also pass custom params when invoking the scoping to provide further filtering.

Here’s how it looks in practice:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
defmodule Hello.CMS.Page do
  use Ecto.Schema

  alias Hello.Accounts.User

  def scope(query, user, params) do
    scope_published(query, user, params)
  end

  # Signed in users can access published posts or their own posts (in any state)
  defp scope_published(query, %User{role: :user, id: id}, _params) do
    query
    |> where(author_id: ^id)
    |> or_where(state: :published)
  end

  # Super admins can access anything
  defp scope_published(query, %User{role: :super_admin}, _params), do: query

  # Anonymous users can access only published posts
  defp scope_published(query, _user, _params), do: query |> where(state: :published)
end

Now, this can then be used inside your context when querying. For example:

1
2
3
4
5
6
7
defmodule Hello.CMS do
  def list_pages(user) do
    Page
    |> Bodyguard.scope(user) # <-- defers to Page.scope/3
    |> Repo.all()
  end
end

If we need to add another listing of the pages later, we can simply use the same Bodyguard.scope(query, user) call and know that everything will be appropriately scoped.

Authorization in Phoenix: Further Reading

In this post, we saw how to implement authorization in your Phoenix apps. We focused on a simple CMS example to explore authorization with — and without — external libraries.

I recommend Dockyard’s Authorization Considerations For Phoenix Contexts blog post for a more detailed look at rolling out your authorization solution.

If you are looking for external libraries, check out Bodyguard. I have been using it in production for a while and can vouch for its customizability in authorization. It stays out of the way when we don’t want it and also clarifies operations that would otherwise be scattered inside the context and controller methods.

I hope you’ve found this a useful guide that’s inspired you to dive into policy scoping and authorization in Phoenix apps.

Until next time, happy coding!

P.S. If you’d like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Our guest author Sapan Diwakar is a full-stack developer. He writes about his interests on his blog and is a big fan of keeping things simple, in life and in code. When he’s not working with technology, he loves to spend time in the garden, hiking around forests, and playing outdoor sports.

5 favorite Elixir articles

10 latest Elixir articles

Go back
Elixir alchemy icon

Subscribe to

Elixir Alchemy

A true alchemist is never done exploring. And neither are we. Sign up for our Elixir Alchemy email series and receive deep insights about Elixir, Phoenix and other developments.

We'd like to set cookies, read why.