Role Based Authentication with overlapping resources and roles

Hello, I need some help architecting this the right way.

Let’s say that I have different roles (teacher, student, secretary) and different routes

scope "/", MyAppWeb do
   get "/votes", Votes
   get "/lessons", Lessons
   get "/admin", Admin
   get "/enroll", Enroll
end

And I also have a plug EnsureRole where I can pass the list of roles allowed to be logged in.

I want the following:

  • the resource /votes should be accessible by teachers, secretaries and students
  • the resource /lessons should only be accessible by teachers and students
  • the resource /admin should only be accessible by teachers and secretaries
  • the resource /enroll should only be accessible by students and secretaries

I have tried to create different pipelines like this:

pipeline :teacher do
   plug MyAppWeb.EnsureRolePlug, ["teacher"]
end

pipeline :teacher_and_student do
   plug MyAppWeb.EnsureRolePlug, ["teacher", "student"]
end

# and so on...

and then have the following

scope "/", MyAppWeb do
   scope "/", :teacher_and_student do
      pipe_through :teacher_and_student
      get "/lessons", Lessons
   end
   
   # and so on...
end

but I was wondering if there is a cleaner and less repetitive way to do this (especially for the resource where multiple roles can have access)?

Take a look at the README of https://github.com/schrockwell/bodyguard. Even if you don’t want to use that package, it’s pretty informative on the subject.

1 Like

Have you tried something like this?

scope "/", MyAppWeb do
   pipe_through :teacher_and_student
   get "/lessons", Lessons
end

scope "/", MyAppWeb do
   pipe_through :teacher_and_secretaries
   get "/admin", Lessons
end

This is the way I was going with.

The problem is that in the future, as the business requirements change (this was a dummy example) I could have all combinations of the roles, and I was looking for a cleaner and more composible way

Another solution is to let auth requirements at controller level:

defmodule MyWeb.PostController do
  plug :auth when action in ~w|show create update|a

  def show(conn, params) do
    # ...
  end
end

Instead of using a new plug for each combination, how about creating a single auth plug that maps routes to lists of role names that are allowed to access it? I did something similar to migrate a rails pundit based auth system to phoenix and its working well so far.

1 Like

What you need is a simplified ABAC system (attribute based authorization system), you can do it with a simple plug like this:

defmodule AppWeb.AbacPlug do
  import Plug.Conn

  # set of path permissions for each role
  @admin_perms ~w(lessons admin)
  @teacher_perms ~w(lessons grading)
  @student_perms ~w(lessons)

  def init(options), do: options

  def call(conn, _opts) do
    # getting the first part of the path after the domain
    path_head = hd(conn.path_info)
    user_perms = get_user_perms(conn)

    case Enum.member?(user_perms, path_head) do
      true ->
        conn
        |> put_resp_content_type("text/plain")
        |> send_resp(200, "YAY!\n")

      false ->
        conn
        |> put_resp_content_type("text/plain")
        |> send_resp(403, "NAY")
    end
  end

  # Get the user role and perms from the conn here
  # in this example the perms are hardcoded.
  defp get_user_perms(_conn) do
    user_role = "student"

    case user_role do
      "admin" -> @admin_perms
      "teacher" -> @teacher_perms
      "student" -> @student_perms
    end
  end
end

You can get pretty far with an ABAC, setting read, update, create and delete permissions separately, hour of the day when each action is permitted and so on, there’s a few libraries that can help you with that, but for the most basic case this plug will work.

This approach also allows you to model your user with dynamic permissions, so you can have an admin that has access to a part of the admin that other admins don’t without having to create a new role only for that.

3 Likes