Build a Roles and Permissions System for Phoenix - Part 1

- 12 min read

Hey folks, before we begin:

Salespeople can be persistent, can't they?The other day, one tried to sell me a coffin.

I told him that's the last thing I need!

Anyways. Today, we’ll look into how to set up a basic roles and permissions (RAP) system with Phoenix and Ecto. I got this idea from my dear ex-colleague Lucas and my current colleagues Vitor and Antonio, who built the backend part of our RAP system here at Remote. Their work was pretty brilliant and I wanted to share the basic building blocks of the system they implemented. If you like my posts, maybe consider working with me since my team is hiring :) Let’s get started.

What’s a RAP?

A role and permission system controls which user can see what data based on the user’s role and the role’s permissions. For example, a user with the Invoice Admin role can see invoices and basic info about an invoiced company. But they can’t see the addresses of the company’s employees. On the other hand, a user with the Customer Support role can see the addresses of the company’s employees, but can’t see the company’s invoices. You get the point.

Building a RAP is inevitable once your company grew to a certain size. The more people you employ, the higher the chance that having access to everyone’s data might be abused. It is also a security risk because an attacker can target any of your users. Once a user is compromised, the attacker gains access to all your data, often without the knowledge of your user. To reduce the attack surface, you usually limit everyone’s access to the utmost necessary, which is called the Principle of Least Privilege. Now, if a user is compromised, the attacker gains access only to a small part of your data. Having a RAP in place also becomes mandatory once you have to comply with privacy laws like the GDPR.

What does a RAP do?

Our RAP will have two functions: Access Control and Query Restriction. Access Control is a basic Is the user allowed to perform that action on this resource? validation. If yes, we let the request pass. If not, then we block the request. For example, an Invoice Admin is allowed to read invoices whereas a Customer Support Employee isn’t. But whereas a Customer Support Employee is allowed to read addresses, they can’t delete them. A Customer Support Admin, however, is allowed to both read and delete addresses. So, access control verifies the user’s general “access” to a resource, but also the action that a user wants to perform on the resource.

Query Restriction is a bit more complicated. A user might have permission to access a resource but shouldn’t see all the data related to that resource. Imagine that you build a payroll service and store the salaries of internal and external employees in the same table. Internal employees need to see the salaries of external employees to do customer support. But you don’t want them to see the salaries of their colleagues maybe. So, although an internal employee has access to the salary resource, they should only see the external salaries in the table. Our RAP should filter out the data belonging to internal employees automatically.

This blog post covers the first function, Access Control. I’ll cover the second function, Query Restriction, in a later blog post. But now, let’s start building!

Like what you read? Sign up for more!

Set up the RAP

Our RAP will have three parts: The user, the role, and permissions. We will store the permissions as JSON-map on the role and user schemas instead of creating a separate database schema for them. This saves us from preloading a long list of permissions whenever a user makes a request, but feel free to implement it differently. Let’s have a look at the User schema:

defmodule RAP.User do  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    has_one(:address, RAP.Address)

    belongs_to(:role, RAP.Role)
    field(:custom_permissions, :map, default: %{})

    timestamps()
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:role_id, :custom_permissions])
    |> validate_required([:role_id])
    |> RAP.Permissions.validate_permissions(:custom_permissions)
  end
end

Our User schema has an address and two RAP-related fields, the role and custom_permissions. A role is a group of permissions that we can define once and then assign to multiple users. Roles are good for grouping and assigning multiple permissions at once, but sometimes they are too static.

What if a user needs just one more permission to do their job? If you create a special role for that one user you might end up with special roles like Tony from Accounting Special Role DONT TOUCH Final Final 2. That doesn’t seem right. Instead, you could assign individual permissions directly to the user as custom permissions.

This way, Tony can have the role of an Accounting Admin, but also have that special permission he needs to do his job better. Be aware that more flexibility also comes with more complexity and potential problems. What if nobody remembers anymore why Tony from Accounting has all these specific permissions? What if an attacker gets access to his account? What if people start assigning permissions directly instead of using roles? You see, many things can go wrong. My advice would be to start with roles only and maybe extend to custom permissions once enough people requested it. But let’s move back to our User schema now.

Note the call to RAP.Permissions.validate_permissions/2 in our changeset. When we set custom permissions on a user, we want to make sure that the permissions are valid and in the correct format. Our Permissions module will check that for us. Let’s have a look at it next:

defmodule RAP.Permissions do  import Ecto.Changeset

  def all() do
    %{
      "invoices" => ["create", "read", "update", "delete"],
      "addresses" => ["read", "update", "delete"]
    }
  end

  def validate_permissions(changeset, field) do
    validate_change(changeset, field, fn _field, permissions ->
      permissions
      |> Enum.reject(&has_permission?(all(), &1))
      |> case do
        [] -> []
        invalid_permissions -> [{field, {"invalid permissions", invalid_permissions}}]
      end
    end)
  end

  def has_permission?(permissions, {name, actions}) do
    exists?(name, permissions) && actions_valid?(name, actions, permissions)
  end

  defp exists?(name, permissions), do: Map.has_key?(permissions, name)

  defp actions_valid?(permission_name, given_action, permissions) when is_binary(given_action) do
    actions_valid?(permission_name, [given_action], permissions)
  end

  defp actions_valid?(permission_name, given_actions, permissions) when is_list(given_actions) do
    defined_actions = Map.get(permissions, permission_name)
    Enum.all?(given_actions, &(&1 in defined_actions))
  end
end

Let’s unpack this. Our Permissions module is the heart of our RAP system. It defines all possible permissions and their valid actions in all/0. For example, we defined two permissions so far. One for the resource invoices and one for addresses. Our Permissions module defines all allowed actions for each permission. So, although a user could create, read, update, and delete invoices, no user could ever create or update addresses, maybe because we’re autogenerating them from e.g. contracts or an external service.

The function validate_permissions/2 is a helper to use in our User and Role schema changesets. It checks whether a given set of permissions are valid. That means that their resource and actions were defined in all/0. We’ll discuss use cases for this later.

Let’s have a look at the last part of our RAP next, the Role schema:

defmodule RAP.Role do  use Ecto.Schema
  import Ecto.Changeset

  schema "roles" do
    field(:name, :string)
    field(:permissions, :map)

    has_many(:users, RAP.User)

    timestamps()
  end

  @doc false
  def changeset(role, attrs) do
    role
    |> cast(attrs, [:name, :permissions])
    |> validate_required([:name, :permissions])
    |> unique_constraint(:name)
    |> validate_at_least_one_permission()
    |> RAP.Permissions.validate_permissions(:permissions)
  end

  defp validate_at_least_one_permission(changeset) do
    validate_change(changeset, :permissions, fn field, permissions ->
      if map_size(permissions) == 0 do
        [{field, "must have at least one permission"}]
      else
        []
      end
    end)
  end
end

Our Role schema has a unique name and a map of permissions. It must have at least one permission, otherwise, the role shouldn’t exist. It also uses RAP.Permissions.validate_permissions/2 to validate that all permissions are valid before persisting them.

But what’s a “Role” and how can we use it? Let’s define some default roles for our system to understand them better:

defmodule RAP.DefaultRoles do  def all() do
    [
      %{
        name: "Invoice Admin",
        permissions: %{
          "invoices" => ["create", "read", "update", "delete"]
        }
      },
      %{
        name: "Customer Support Admin",
        permissions: %{
          "invoices" => ["read"],
          "addresses" => ["read", "delete"]
        }
      },
      %{
        name: "Customer Support Employee",
        permissions: %{
          "addresses" => ["read"]
        }
      }
    ]
  end
end

Our DefaultRoles module defines three roles with which we’ll seed our database. The Invoice Admin role has access to all invoices and any user who has that role can create, read, update, and delete invoices. An Invoice Admin doesn’t have access to the addresses in the system, but a Customer Support Admin does. The Customer Support Admin can only read invoices, but not create, update, or delete them. Our Customer Support Employee has no access to invoices and can only read the user’s address.

You see that we can use the RAP to define specific roles that mirror the “real-world” roles of our users. We can further fine-tune each role by defining its permissions and the actions it can take on the resources connected to the permissions. Usually, our permissions are 1-to-1 related to the resources of our system. You can create composite permissions which allow access to multiple resources, but it’s not advised. You would lose granularity and add more complexity to your RAP system.

Okay, this is all nice and well, but how do you use it?

Use the RAP

The first step of adding the RAP to your system is to seed its default roles to our database. In our seeds.exs file, we’ll add:

for role <- RAP.DefaultRoles.all() do  unless RAP.get_role_by_name(role.name) do
    {:ok, _role} = RAP.create_role(role)
  end
end

This checks that our default roles exist in the database whenever we start our application. Now, we can strat assigning the roles to individual users, like this:

defmodule RAP.Users do  def add_role_to_user(user, role_name) do
    with {:ok, role} <- get_role_by_name(role_name) do
      update_user(user, %{role_id: role.id})
    end
  end

If a user needs additional permissions, we can add them to the user’s custom_permissions like this:

defmodule RAP.Users do
  # add_role_to_user/2

  def add_custom_permission_to_user(user, name, actions) do
    custom_permissions = Map.put(user.custom_permissions, name, actions)
    update_user(user, %{custom_permissions, custom_permissions})
  end
end

From now on, the user will have a certain role and custom permissions! Easy peasy.

But how can we use these to achieve our actual goal, Access Control? Let’s create a Phoenix controller for our addresses and find out!

defmodule RAPWeb.AddressController do  use RAPWeb, :controller

  plug(RAPWeb.Plugs.CheckPermissions,
    actions: [
      index: {"addresses", "read"},
      show: {"addresses", "read"},
      delete: {"addresses", "delete"}
    ]
  )

  def index(conn, _params_) do
    # Fetch and return all addresses
  end

  def show(conn, %{"id" => address_id}) do
    # Fetch and return address
  end

  def delete(conn, %{"id" => address_id}) do
    # Delete address
  end
end

The AddressController is where we set up our Access Control. Aside from creating three endpoints, the controller defines the permission necessary to use each endpoint. If a user doesn’t have that permission, we return an error. We moved that check to our CheckPermissions plug so that we can reuse it in other controllers as well. Let’s have a look at that plug next:

defmodule RAPWeb.Plugs.CheckPermissions do  import Plug.Conn
  import Phoenix.Controller, only: [action_name: 1]

  alias RAP.Permissions
  alias RAP.Repo

  def init(opts), do: opts

  def call(conn, opts) do
    user = get_user(conn)
    required_permission = get_required_permission(conn, opts)

    if Permissions.user_has_permission?(user, required_permission) do
      conn
    else
      conn
      |> put_status(:forbidden)
      |> halt()
    end
  end

  defp get_user(conn) do
    user = conn.assigns.current_user
    Repo.preload(user, :role)
  end

  defp get_required_permission(conn, opts) do
    action = action_name(conn)

    opts
    |> Keyword.fetch!(:actions)
    |> Keyword.fetch!(action)
  end
end

Whenever a user makes a request to our AddressController, Phoenix invokes CheckPermissions.call/2 first. Here, we first fetch the current_user and preload its role. We then retrieve the required permission which we defined in our controller. If we forgot to define a permission, the Keyword.fetch!/2 call raises an exception and stops the request.

We don’t want to spread our permissions logic all over our application, so we moved the permission checking to our Permissions module again. It simply checks whether a user’s role or custom_permissions contain the required permission. If yes, the request hits our controller endpoint and executes the action. If not, we block the request and add a 403 (forbidden) error to it. Let’s have a look at the last part, the Permissions.user_has_permission?/2 check:

defmodule RAP.Permissions do
  # Other code

  def user_has_permission?(user, permission) do
    has_permission?(user.role.permissions, permission) ||
      has_permission?(user.custom_permissions, permission)
  end
end

Our user_has_permission?/2 function checks whether a given user has the required permission in either its role permissions or its custom permissions.

And with that last piece in place, the Access Control part of our RAP is done! Let’s recap.

Rerap

Our RAP is built around permissions. We can create permission groups, called roles, and assign them to individual users. We can also give specific permissions to individual users if needed by adding them to user.custom_permissions. We define the required permission for calling our endpoints in the controllers using the CheckPermissions plug. If a user doesn’t have the required permission, we block the request. The Permissions module is at the heart of our RAP and defines all valid permissions and their actions. It also takes care of validating and checking permissions. And that’s it!

This article explained how to set up Access Controls for your Phoenix app. However, access controls are only the outermost layer of protection you can implement. In the next blog post, we’ll discuss how to add another, deeper layer of protection by implementing Query Restrictions. So stay tuned!

Conclusion

I hope you enjoyed this article! This is the first part of the two-part series. You can find the second part here.

Thanks again to my dear colleagues Lucas, Vitor, and Antonio for their brilliant work and support. If you have questions or comments, let’s discuss them on BlueSky. Follow me on BlueSky or subscribe to my newsletter below if you want to get notified when I publish the next blog post. If you found this topic interesting, here are some references for further reading:

Liked this article? Sign up for more