Lets Build |> Phoenix Admin Routes

This will be the first of a series of articles that my company and I intend to share involving developing with Phoenix and Elixir in production. It has been an awesome journey and we’d love to share a little more about it.The overall intention is to share interesting paths and findings, we also hope to keep most articles minimal, so they’re easy and quick to follow. Lets get going!

About 2 months ago my boss asked me to build an Admin tool into our existing product. Together with putting a bunch of APIs in place, we knew we would need some sort of authorization mechanism to our routes… Some quick searching got us to canary and canada which were both very interesting in their own way. If you want to get authorization quickly, that might be one way to get started. Otherwise, read on…

We wanted something more customized and minimal, we take some caution and like to have control on how we load our resources from database, because of that and at the same time thinking it could be fun to give it a shot, here’s what we ended up with:

To begin, we had a few main requirements: 
1. An admin must also be a User (this means that admins were just a “role” to users, they were kept in a separate table) — we had this because of our unique sign-in flow, we did not want to maintain a different sign-in strategy.
2. A user that is not an admin should NOT be allowed to access
any admin routes (duh! seems obvious, but read 3 and you’ll understand).
3. A user that can access admin routes, should have a clearance-check to determine if certain action is allowed.

So there you go! Pretty simple requirements, we got 1 by adding an extra table, which held a reference to user_id and had a clearence_level. The question then becomes, how to build 2 and 3? There are many approaches to this, some people might say that if you implement 3 you’ll get 2 for free since a user will never have clearance unless he’s an admin. But that’s exactly where things usually go bad in engineering, so we decided to build two plugs to take care of this:

First plug: EnsureAdmin

The job of the first plug is just to ensure the user that is requesting this action is an actual admin. The benefit of setting this up like this (without any clearance logic) is that we can add this plug directly to the admin pipeline. This means that really (really, really) no one that is not an admin will not even have a chance to get a request served through our /admin endpoints. Here is how it looks like:

First we add a admin pipeline (in your router):

pipeline :admin do    
plug Guardian.Plug.EnsureAuthenticated, [handler: ProjectWeb.V1.SessionController]
plug ProjectWeb.Plug.EnsureAdmin
end

Notice that we’re using gurdian for authentication (highly recommended) and the first thing our pipeline does is to guarantee the user is authenticated. After that, our EnsureAdmin plug will run and make sure we’re talking about an admin. Then, our routes will all be protected, and can be used like this (still in your router):

scope "/admin", ProjectWeb.Admin, as: :admin, alias: Admin do
pipe_through :api
pipe_through :admin

get "/users/whatever", UserController, :whatever
...
end

Cool! Now it is just a matter of writing our plug. In this case, we’re talking about a module plug and its implementation could also not be easier:

defmodule ProjectWeb.Plug.EnsureAdmin do
@moduledoc """
This plug makes sure that the authenticated user is a super user,
otherwise it halts the connection.
"""
import Plug.Conn
import Phoenix.Controller
alias ProjectWeb.ErrorView
alias Project.Repo
alias Project.Admins.SuperUser
def init(opts), do: Enum.into(opts, %{})
def call(conn, opts \\ []) do
check_super_user(conn, opts)
end
defp check_super_user(conn, _opts) do
current_user = Guardian.Plug.current_resource(conn)
case Repo.get_by(SuperUser, %{user_id: current_user.id}) do
nil -> halt_plug(conn)
super_user -> assign(conn, :super_user, super_user)
end
end
defp halt_plug(conn) do
conn
|> put_status(:unauthorized)
|> render(ErrorView, "401.json")
|> halt()
end
end

Easy, right? All it does is a simple query to check if there is an Admin (SuperUser) associated with the current resource id. Note that if you are not using Guardian it is up to you to determine the current user properties. If the user is found, the plug assigns it and continues, otherwise it halts immediately, rendering a 401.

So this will now ensure that all our routes are covered, and it will also assign the super user to our connection for future use (since we’ll now need to check their clearance). So now the only thing left is to check permission by action. To do that, we could not think of a better way other than explicitly defining in the controller the requirements for an action. When we decided to do that, it became very clear that separating this solution into two plugs is the best idea — imagine a new developer adds in an admin route, but for some reason forgets to set the clearance for that route… Trouble, oh trouble…

Second plug: CheckClearance

The plug is also simple, and looks like this:

defmodule ProjectWeb.Plug.CheckClearence do
@moduledoc """
This plug makes sure that the authenticated user is a super user,
and that he/she has the necessary clearence to access the resource.
"""
import Plug.Conn
import Phoenix.Controller
alias ProjectWeb.ErrorView
def init(opts), do: Enum.into(opts, %{})
def call(conn, opts \\ []) do
check_clearence(conn, opts)
end
defp check_clearence(conn, opts) do
super_user = conn.assigns[:super_user]
can?(conn, super_user.level, opts.clearence_level)
end
defp can?(conn, user_level, clearence_level) when user_level >= clearence_level, do: conn
defp can?(conn, _user_level, _clearence_level), do: halt_plug(conn)
defp halt_plug(conn) do
conn
|> put_status(:unauthorized)
|> render(ErrorView, "401.json")
|> halt()
end
end

How easy is that? As you can see, we just need to implement a can?/3 function which will decide, based on the user level if he can access this specific action. Note that at this point our other plug EnsureAdmin already ran (because of how we set the pipeline), so we have the SuperUser available in conn.asigns[:super_user] !!!

Also note that in this implementation we decided to give user levels (integers from 1..10) but you could literally change the can? function to cover strings (“local_admin”, “root”…) or any other logic you want/need.

Ok cool, but how to use it? This time we cannot add it to the pipeline, since this needs to run differently for each action in your controllers. This is actually where we’re not even sure if we ended up with the best implementation, but we were inspired by how Guardian sets their authentication “handler”. This is how you can use it in a controller now:

plug ProjectWeb.Plug.CheckClearence, [clearence_level: 3] when action in [:index]  
plug ProjectWeb.Plug.CheckClearence, [clearence_level: 5] when action in [:create, :update, :delete]

The really interesting part here is how to pass params to a module plug from the controller, as you can see it is pretty straight forward. You can declare as many lines as you need to cover all your actions. I hope now it also becomes clear why adding the other plug was a good idea, since forgetting one of those little atoms inside the list could cause a major problem.

And… thats it for our first article! I hope this helps someone who’s beginning at Elixir/Phoenix and maybe it will be interesting for other people as well. I’d love to hear suggestions on how to make this better. Code strong!