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:
- a particular user is allowed to read (this is policy scoping)
- the user is allowed to write to (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 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:
1defmodule Hello.CMS.PageController do
2 plug :authorize_page when action in [:edit, :update, :delete]
3
4 defp authorize_page(conn, _) do
5 page = CMS.get_page!(conn.params["id"])
6
7 if conn.assigns.current_author.id == page.author_id do
8 assign(conn, :page, page)
9 else
10 conn
11 |> put_flash(:error, "You can't modify that page")
12 |> redirect(to: Routes.cms_page_path(conn, :index))
13 |> halt()
14 end
15 end
16end
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:
1defmodule Hello.CMS.Policy do
2 @behaviour Bodyguard.Policy
3
4 alias Hello.Accounts.User
5
6 # Super Admins can do anything
7 def authorize(_action, %User{role: :super_admin}, _params), do: true
8
9 # Users can list/get/create anything
10 def authorize(action, %User{role: :user}, _params) when action in ~w[index show create]a, do: true
11
12 # Users can edit/update/delete own pages
13 def authorize(action, %User{id: id, role: :user} = user, %{author_id: ^id})
14 when action in ~w[update edit delete]a,
15 do: true
16
17 # Default blacklist
18 def authorize(_action, _user, _params), do: false
19end
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:
: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
:
1defmodule Hello.CMS.PageController do
2 plug :authorize_page
3
4 defp authorize_page(conn, _) do
5 page = CMS.show(conn.params["id"])
6
7 case Bodyguard.permit(__MODULE__, action, user, page) do
8 :ok ->
9 assign(conn, :page, page)
10 {:error, _reason} ->
11 conn
12 |> put_flash(:error, "You can't access that page")
13 |> redirect(to: Routes.cms_page_path(conn, :index))
14 |> halt()
15 end
16 end
17
18 defdelegate authorize(action, user, params), to: Hello.CMS.Policy
19end
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:
1defmodule Hello.CMS do
2 # ...
3 defdelegate authorize(action, user, params), to: Hello.CMS.Policy
4end
Then, when you need to call Bodyguard.permit
from your controller (or the Absinthe Resolver), pass in a first argument of that context module:
1defmodule Hello.CMS.PageController do
2 defp authorize_page(conn, _) do
3 page = CMS.show(conn.params["id"])
4 case Bodyguard.permit(CMS, action, user, page) do
5 :ok ->
6 # OK. Render page
7 {:error, reason} ->
8 # Error. Show flash and redirect
9 end
10 end
11end
It is also very easy to write tests for the above policy. Here's what the tests might look like:
1test "allows users to list pages" do
2 user = Hello.Accounts.Fixtures.fixture(:user)
3 assert :ok = Bodyguard.permit(Hello.CMS, :index, user)
4end
5
6test "allows user to update/delete own pages" do
7 user = Hello.Accounts.Fixtures.fixture(:user)
8 page = page_fixture(user)
9 assert :ok = Bodyguard.permit(Hello.CMS, :update, user, page)
10end
11
12test "allows user to create pages" do
13 user = Hello.Accounts.Fixtures.fixture(:user)
14 assert :ok = Bodyguard.permit(Hello.CMS, :create, user)
15end
16
17test "does not allow user to update/delete pages of someone else" do
18 user1 = Hello.Accounts.Fixtures.fixture(:user)
19 user2 = Hello.Accounts.Fixtures.fixture(:user)
20
21 page = page_fixture(user2)
22
23 assert {:error, :unauthorized} = Bodyguard.permit(Hello.CMS, :update, user1, page)
24end
25
26test "allows super_admin to do anything" do
27 super_admin = Hello.Accounts.Fixtures.fixture(:user_super_admin)
28 user = Hello.Accounts.Fixtures.fixture(:user)
29 page = page_fixture(user)
30 assert :ok = Bodyguard.permit(Hello.CMS, :index, super_admin)
31 assert :ok = Bodyguard.permit(Hello.CMS, :create, super_admin)
32 assert :ok = Bodyguard.permit(Hello.CMS, :update, super_admin, page)
33end
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:
1defmodule Hello.CMS do
2 def list_pages(%{id: id} = user) do
3 Page
4 |> where(author_id: ^id)
5 |> or_where(state: :published)
6 |> Repo.all()
7 end
8end
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:
1defmodule Hello.CMS.Page do
2 use Ecto.Schema
3
4 alias Hello.Accounts.User
5
6 def scope(query, user, params) do
7 scope_published(query, user, params)
8 end
9
10 # Signed in users can access published posts or their own posts (in any state)
11 defp scope_published(query, %User{role: :user, id: id}, _params) do
12 query
13 |> where(author_id: ^id)
14 |> or_where(state: :published)
15 end
16
17 # Super admins can access anything
18 defp scope_published(query, %User{role: :super_admin}, _params), do: query
19
20 # Anonymous users can access only published posts
21 defp scope_published(query, _user, _params), do: query |> where(state: :published)
22end
Now, this can then be used inside your context when querying. For example:
1defmodule Hello.CMS do
2 def list_pages(user) do
3 Page
4 |> Bodyguard.scope(user) # <-- defers to Page.scope/3
5 |> Repo.all()
6 end
7end
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!