Feb 8, 2019

Phoenix Authorization with Bodyguard

Today we’ll be looking at handling a simple authorization scenario with Bodyguard.

In the case of a full-fledged permission and roles system, authorization can get pretty complicated, it could look something like:

As you can imagine this can get pretty involved and requires a lot of supporting infrastructure in the form of database tables and forms for setting up and defining permissions and roles; and then assigning roles to users.

For the purpose of demonstrating Bodyguard, we’ll stick with a simple scenario where we have the concept of Admin users, and only certain actions can be performed by admins.

Getting started

If you’d rather grab the source code directly rather than follow along, it’s available on GitHub at https://github.com/riebeekn/phx-authorization-with-bodyguard, otherwise let’s get started!

We’ll use the same application we created in our Authentication posts as a starting point for today. So if you’ve been following along with the Authentication posts, you can continue with the code from there, otherwise you can grab the starting point code from GitHub.

Clone the Repo:

If cloning:

Terminal
git clone https://github.com/riebeekn/phx-auth-with-pow bodyguarded-warehouse
cd bodyguarded-warehouse

Let’s create a branch for today’s work:

Terminal
git checkout -b bodyguard

And make sure we have our dependencies and database set-up.

Terminal
mix deps.get
Terminal
cd assets && npm install

Note: before running ecto.setup you may need to update the username / password database settings in dev.config to match your local postgres settings, i.e.

/config/dev.config …line 69
# Configure your database
config :warehouse, Warehouse.Repo,
  username: "postgres",
  password: "postgres",
  database: "warehouse_dev",
  hostname: "localhost",
  pool_size: 10
Terminal
cd ..
mix ecto.setup

With that out of the way… let’s see where we are starting from.

Terminal
mix phx.server

If we navigate to http://localhost:4000/ we’ll see our application.

So we have the basic application you get when running mix phx.new with some scaffolding added to represent a very simple warehouse inventory system.

If we create a user and login, we’ll see we have access to our products page.

Currently any signed in user can delete a product. We want to switch things up so that only admin users can delete a product.

Adding an admin field to users

The first step is going to be adding a new field to users. We’ll add an is_admin field to indicate whether a user is an admin.

We’ll start by updating the database.

Updating the database

We need to create a migration to add the new column to the users table.

Terminal
mix ecto.gen.migration add_is_admin_to_users
/priv/repo/timestamp_add_is_admin_to_users.exs
defmodule Warehouse.Repo.Migrations.AddIsAdminToUsers do
  use Ecto.Migration

  def change do
    alter table(:users) do
      add :is_admin, :boolean, default: false
    end
  end
end

Pretty simple, all we’ve done is alter the table to include the new field, by default we’ll set the value of is_admin to false.

Now we need to run the migration.

Terminal
mix ecto.migrate

And that’s it for the database changes!

Updating the user schema

Next up is adding our new field to user.ex.

/lib/warehouse/users/user.ex
defmodule Warehouse.Users.User do
  use Ecto.Schema
  use Pow.Ecto.Schema
  use Pow.Extension.Ecto.Schema,
    extensions: [PowResetPassword, PowEmailConfirmation]

  schema "users" do
    field :is_admin, :boolean

    pow_user_fields()

    timestamps()
  end

  def changeset(user_or_changeset, attrs) do
    user_or_changeset
    |> pow_changeset(attrs)
    |> pow_extension_changeset(attrs)
    |> Ecto.Changeset.cast(attrs, [:is_admin])
  end
end

We’ve added the new field via “field :is_admin, :boolean” and also updated the changeset to include the is_admin field.

Finally we’ll update our sign in page to allow user’s to register as admins… obviously this is not something you would do in a real application, but for our example application it will provide a convenient way of creating admin users.

Updating the registration page

We’ll add a checkbox to the registration page that can be used to toggle whether a user is an admin or not.

/lib/warehouse_web/templates/pow/registration/new.html.eex …line 22
<%= label f, :is_admin, "Admin?" %>
<%= checkbox f, :is_admin %>

<div>
  <%= submit "Register" %>
</div>

Sweet, that’s it for our preliminary steps, let’s create a new user that we’ll use to test out our admin functionality.

And now we’re ready to get into some authorization with Bodyguard.

Bodyguard

The first step is to install Bodyguard.

Installation

Installation is simple, we just need to add Bodyguard to our dependencies and run mix deps.get.

/mix.exs …line 34
defp deps do
  [
    {:phoenix, "~> 1.4.0"},
    {:phoenix_pubsub, "~> 1.1"},
    {:phoenix_ecto, "~> 4.0"},
    {:ecto_sql, "~> 3.0"},
    {:postgrex, ">= 0.0.0"},
    {:phoenix_html, "~> 2.11"},
    {:phoenix_live_reload, "~> 1.2", only: :dev},
    {:gettext, "~> 0.11"},
    {:jason, "~> 1.0"},
    {:plug_cowboy, "~> 2.0"},
    {:pow, "~> 1.0.0"},
    {:bodyguard, "~> 2.2"}
  ]
end
Terminal
mix deps.get

The mix output will indicate we’ve added Bodyguard.

Setting up the authorization rules / policies

Bodyguard refers to authorization rules as policies. These policies can be defined directly in our context modules or can be placed in seperate policy modules. Although today we’re only going to be writing a single policy, I still like the idea of having policies defined in a seperate module so that’s the approach we will take.

So let’s create our policy!

Terminal
touch lib/warehouse/inventory/policy.ex
/lib/warehouse/inventory/policy.ex
defmodule Warehouse.Inventory.Policy do
  @behaviour Bodyguard.Policy

  def authorize(:delete_product, user, _product), do: user.is_admin

end

Any policy module needs to include the Bodyguard.Policy behaviour and an authorize/3 function. The authorize function is (surprise!) the function that will be called to determine whether an action is authorized or not.

In our case the authorize function is very simple. All we are doing is indicating in the case of a delete action, only a user who is an admin is allowed to perform the action. As per the Bodyguard README, a policy can return one of the following values:

  • :ok or true to permit an action
  • :error, {:error, reason}, or false to deny an action

This works perfect for us as we can just return the user.is_admin boolean. Note that true/false return values are converted to :ok and {:error, reason} respectively when Bodyguard consumes our authorize functions. In this manner a consistent set of return values are always returned from Bodyguard.

Now we need to make use of our new policy.

Using our policy

Bodyguard is agnostic in terms of where policy checks occur. We could perform our check at the controller level, but I’ve opted to perform the check in the context.

We first need to delegate the call to our policy file.

/lib/warehouse/inventory/inventory.ex …line 6
import Ecto.Query, warn: false
alias Warehouse.Repo

alias Warehouse.Inventory.Product
alias Warehouse.Users.User

defdelegate authorize(action, user, params), to: Warehouse.Inventory.Policy

We also added an alias for our User module.

Next we need to update the delete function.

/lib/warehouse/inventory/inventory.ex …line 91
def delete_product(%Product{} = product, %User{} = user) do
  with :ok <- Bodyguard.permit(Warehouse.Inventory, :delete_product, user, product) do
    Repo.delete(product)
  end
end

Nothing complicated, we are calling into Bodyguard.permit/4 and only proceeding with the deletion when we receive back an :ok atom.

The final piece of the puzzle is to update the call to Inventory.delete_product in the controller. We just need to pass thru the current user as we now require that in our context function.

/lib/warehouse_web/controllers/product_controller.ex …line 54
def delete(conn, %{"id" => id}) do
  product = Inventory.get_product!(id)
  {:ok, _product} = Inventory.delete_product(product, conn.assigns.current_user)
  ...

Let’s restart the server and see where we are at.

Terminal
mix phx.server

We’ve created an admin user betty… if she deletes a product, all is good.

If we logout, sign in as bob, re-create our Widget product and then attempt a delete, we get:

A match error in our controller… which makes sense since we aren’t matching for the unauthorized scenario. Let’s see how we can use a fallback controller to avoid having to explicitly match on {:error, :unauthorized}.

Creating a fallback controller to handle unauthorized actions

One of the functions available to us on a controller is an action_fallback. We can make use of this to handle unauthorized actions.

Let’s create a fallback controller for this purpose.

Terminal
touch lib/warehouse_web/controllers/fallback_controller.ex
/lib/warehouse_web/controllers/fallback_controller.ex
defmodule WarehouseWeb.FallbackController do
  use WarehouseWeb, :controller

  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(:forbidden)
    |> put_view(WarehouseWeb.ErrorView)
    |> render(:"403")
  end
end

Here we are matching on {:error, :unauthorized} and rendering out to the ErrorView.

We then need to specify this as the action_fallback in the controller.

/lib/warehouse_web/controllers/product_controller.ex …line 5
...
alias Warehouse.Inventory.Product

action_fallback WarehouseWeb.FallbackController
...

And we then need to update the delete function to use with.

/lib/warehouse_web/controllers/product_controller.ex …line 56
def delete(conn, %{"id" => id}) do
  product = Inventory.get_product!(id)

  with {:ok, _prod} <- Inventory.delete_product(product, conn.assigns.current_user) do
    conn
    |> put_flash(:info, "Product deleted successfully.")
    |> redirect(to: Routes.product_path(conn, :index))
  end
end

With the above in place we now get the following when an unauthorized delete is attempted.

So that is pretty much it, everything seems to be working!

It probably makes sense to hide the Delete link for users who aren’t authorized to perform a delete however. So before wrapping up for the day, let’s fix that up.

All we need to do is wrap the link in an if statement.

/lib/warehouse_web/templates/product/index.html.eex …line 18
<td>
  <%= link "Show", to: Routes.product_path(@conn, :show, product) %>
  <%= link "Edit", to: Routes.product_path(@conn, :edit, product) %>
  <%= if Bodyguard.permit?(Warehouse.Inventory, :delete_product, @current_user, product) do %>
    <%= link "Delete", to: Routes.product_path(@conn, :delete, product), method: :delete, data: [confirm: "Are you sure?"] %>
  <% end %>
</td>

We’ve made use of the Bodyguard.permit?/4 function and only show the link in cases where the value returned is true.

Bob now no longer sees the link in the UI.

And that’s it for our simple authorization scenario!

A quick word about tests

I haven’t been sure how to handle testing for the purposes of this post. Testing is obviously very important but at the same time can require a fair bit of explanation which can add significantly to the length of a post and distract from the core subject.

Initially I was going to completely ignore testing, and indeed if you run mix test you’ll see a number of tests will fail with the updates we’ve made to the code base.

This felt a little janky… so as a compromise I’ve updated the existing tests in the master branch of the GitHub repo so they pass; but there is no discussion of the tests (as you’ve no doubt noticed) and I haven’t added any new tests. Needless to say, with a real application this is not the strategy you would want to follow!

Summary

Similar to how Pow is not necessary for performing authentication in Phoenix, Bodyguard isn’t necessary for performing authorization. The README even includes a link to a roll your own implementation. However, both of these packages are easy to use, flexible, and quick to set up. In my opinion they provide great solutions to implementing authentication and authorization in Phoenix; hopefully you’ll find them useful in your own applications!

Thanks for reading and I hope you enjoyed the post!



Comment on this post!