Authorization and Policy Scopes for Phoenix Apps
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:
It refers to 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 defining access policies and scoping.
For example, consider a platform like Github. In very simple terms, it must handle which repositories:
- a particular user is allowed to read (this is policy scoping), and
- the user is allowed to write (authorization).
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:
- Access policy - Is this user allowed to perform this operation on this resource?
- 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 any 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:
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:
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 return 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:
:ok
ortrue
to permit an action.:error
,{:error, reason}
, orfalse
to deny an action.
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
:
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:
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 the first argument of that context module:
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:
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:
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 include only 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:
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:
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 the 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!
-—
This article was originally posted on AppSignal Blog as a guest writer.