Hey! I recorded two video courses!
If you like this article, you will also like the courses! Check them out here!
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!
🔗 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 Twitter. Follow me on Twitter 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: