Phoenix user role based access control

I have the following routes

    get "/locations", LocationsController, :show
    get "/locations/add", LocationsController, :add
    post "/locations/add", LocationsController, :create

    get "/units", LocationsController, :show_units
    get "/unit/add", LocationsController, :add_units
    post "/unit/add", LocationsController, :create_units

    get "/products", ProductsController, :show
    get "/product/add", ProductsController, :add
    post "/products/add", ProductsController, :create_product

    get "/barcodes", ProductsController, :show_barcodes
    get "/barcode/add", ProductsController, :add_barcode
    post "/barcode/add", ProductsController, :create_barcode

I have three permission groups(admin can add more permission group)


can_admin_location
can_admin_products
can_admin_barcode

For example, You can see the below image to understand more what i am trying to achieve

I want to restrict route access based on permission given

For example if the below permission

can_admin_barcode (can use only below routes)

    get "/barcodes", ProductsController, :show_barcodes
    get "/barcode/add", ProductsController, :add_barcode
    post "/barcode/add", ProductsController, :create_barcode

Then admin can change the user access level for this permission group

can_admin_barcode (can only view barcodes and only the below route will be accessible by this user)

    get "/barcodes", ProductsController, :show_barcodes

Routes blocking must happen dynamically

I am storing current logged in user permission in session. I believe i can achieve this Route restriction using plugs.
Can someone share some insight on how to achieve this or any example. Your help is greatly appreciated

4 Likes

I created a similar Plug which protects routes against authorization rules. It’s on GitHub and the source is trivial:

Let me know if it doesn’t answer your case.

Thanks. I will check now.:smiley:

I checked your code but I could able to figure out how I can use it to my situation.

For example, A user with this permission “can_admin_barcode” Can be allowed to view only below routes.
This value “can_admin_barcode” will be stored in session.

get "/barcodes", ProductsController, :show_barcodes
get "/barcode/add", ProductsController, :add_barcode
post "/barcode/add", ProductsController, :create_barcode

Can you give me insight on how to achieve this using your library.

The plug does not know about the routes… but a simple plug like this might do

def MyPlug do
  import Plug.Conn
  def init(opts \\ []), do: opts
  def call(conn, _opts) do
    if can_admin_barcode && String.start_with?(conn.request_path, "/barcodes") do
      conn
    else
      conn 
      |> halt()
    end
    ...
  end
end
1 Like

Assuming that the user session is stored in conn.assigns under the :current_user key (if not, just adapt) :

defmodule MyAppWeb.AuthorizationPlug do
  def init(opts), do: opts

  def call(conn, opts) do
	current_user = Map.get(conn.assigns, :current_user)
	resource = Keyword.fetch!(opts, :resource)

	authorize(conn, current_user, resource)
  end

  # below you will list all your authorization rules: 
  
  def authorize(conn, %{permissions: permissions}, :barcode_routes) do
    # check that the :can_admin_barcode permission is included in the permissions
    # if not, set status to 403 and halt the conn
    conn
  end
  
  def authorize(conn, %{permissions: permissions}, :location_routes) do
    # ...
  end
end
pipeline :ensure_barcode_routes_authorized do
  plug MyAppWeb.AuthorizationPlug, resource: :barcode_routes
end

pipeline :ensure_location_routes_authorized do
  plug MyAppWeb.AuthorizationPlug, resource: :location_routes
end

# etc

scope "/barcode", MyAppWeb do
  pipe_through [:browser, :ensure_barcode_routes_authorized]
  
  get "/", ProductsController, :show_barcodes
  get "/add", ProductsController, :add_barcode
  post "/add", ProductsController, :create_barcode
end

It’s just one way to achieve authorization over routes.

2 Likes

Thank you very much for your time and very neat explanation.

One small dout

My current router is like this

scope “/admin”, LogisticsWeb.Admin, as: :admin do

pipe_through [:browser, :admin, LogisticsWeb.Plugs.AuthenticateAdmin]

get "/", DashboardController, :index

resources "/admins", AdminController

get "/locations", LocationsController, :show
get "/locations/add", LocationsController, :add
post "/locations/add", LocationsController, :create

get "/products", ProductsController, :show
get "/product/add", ProductsController, :add
post "/products/add", ProductsController, :create_product

get "/barcodes", ProductsController, :show_barcodes
get "/barcode/add", ProductsController, :add_barcode
post "/barcode/add", ProductsController, :create_barcode

end

I am changing like this. Is this a proper way to achieve this?

	pipeline :ensure_barcode_routes_authorized do
	  plug MyAppWeb.AuthorizationPlug, resource: :barcode_routes
	end

	pipeline :ensure_location_routes_authorized do
	  plug MyAppWeb.AuthorizationPlug, resource: :location_routes
	end

  scope "/admin", LogisticsWeb.Admin, as: :admin do

    pipe_through [:browser, :admin, LogisticsWeb.Plugs.AuthenticateAdmin]

    get "/", DashboardController, :index

    resources "/admins", AdminController

  end

  scope "/admin/location", LogisticsWeb.Admin, as: :admin do

    pipe_through [:browser, :admin, LogisticsWeb.Plugs.AuthenticateAdmin, :ensure_location_routes_authorized]

    get "/locations", LocationsController, :show
    get "/locations/add", LocationsController, :add
    post "/locations/add", LocationsController, :create

  end


  scope "/admin/barcodes", LogisticsWeb.Admin, as: :admin do

    pipe_through [:browser, :admin, LogisticsWeb.Plugs.AuthenticateAdmin, :ensure_barcode_routes_authorized]

    get "/barcodes", ProductsController, :show_barcodes
    get "/barcode/add", ProductsController, :add_barcode
    post "/barcode/add", ProductsController, :create_barcode

  end

Yeah, that’s how I do it. You can see that I in fact attach an atom to each set of routes. Like all these barcode routes and the :barcode_routes atom.

Then in the Plug I retrieve that atom (representing thus that set of routes), and I check if the user has the right permissions for those routes.

Maybe there’s an easier way for you, but I just show you how I implemented it. This way, if you want to check something more than the permissions property in the future, there are no restrictions and you can check other user attributes.

1 Like

Continuing the discussion from Phoenix user role based access control:

You can also check out changelog.com’s approach. They define an authorize plug that allows them to define a policy for each route on a controller.

# authorize.ex

defmodule ChangelogWeb.Plug.Authorize do
  import Plug.Conn
  import Phoenix.Controller

  def init(opts), do: opts

  def call(conn, policy_module) when not is_list(policy_module),
    do: call(conn, [policy_module, nil])

  def call(conn, [policy_module, resource_name]) do
    user = conn.assigns.current_user
    resource = conn.assigns[resource_name]

    if apply_policy(policy_module, action_name(conn), user, resource) do
      conn
    else
      conn
      |> put_flash(:result, "failure")
      |> redirect(to: ChangelogWeb.Plug.Conn.referer_or_root_path(conn))
      |> halt()
    end
  end

  defp apply_policy(module, action, user, nil), do: apply(module, action, [user])
  defp apply_policy(module, action, user, resource), do: apply(module, action, [user, resource])
end

For example in the admin postcontroller these two plugs are defined.

plug :assign_post when action in [:edit, :update, :delete, :publish, :unpublish]
plug Authorize, [Policies.Admin.Post, :post]

For actions where an existing post is being manipulated, that post is first preloaded.
Then, permissions are checked on a per action basis using the Policies.Admin.Post policy which looks like:

defmodule Changelog.Policies.Admin.Post do
  use Changelog.Policies.Default

  def create(actor), do: is_admin_or_editor(actor)
  def index(actor), do: is_admin_or_editor(actor)
  def show(actor, post), do: is_admin_or_post_contributor(actor, post)
  def update(actor, post), do: is_admin_or_post_contributor(actor, post)
  def delete(actor, post = %{published: false}), do: is_admin_or_post_contributor(actor, post)
  def delete(_actor, _post), do: false

  def publish(actor, post), do: is_admin_or_post_contributor(actor, post)
  def unpublish(actor, post), do: publish(actor, post)

  # ...
end

Taken all together, when a user tries to update a post,

  1. The podcast for that id is loaded
  2. The podcast and the user are loaded in the Authorize plug. Because the resource exists ie the podcast assign is there, the update method of the post policy is called with the user and the podcast. For create, the podcast assign would be nil and the create function would be called with just the user.
  3. The policy return a bool that gives access if the user is an admin or a contributor to the post
  4. If 3 returns true, call the update method. Else reject the request
1 Like