How to handle routes controlled by user permissions in LiveView

I have an app where users have different permissions. Those permissions are used to determine whether a user can see a page. Each route has one or more permissions associated with it, for dead views it’s easy enough to define a plug that checks for the authorisation and use the private conn fields to pass along the permission info, eg:

# router.ex
scope "/", MyWeb do
  pipe_through([:browser, :authorize])
  ...
  get("/some-page", SomePage :index, private: %{required_permissions: [:admin]})
  ...
end

# user_auth.ex
def authorize(conn, _opts) do
  required_permissions = Map.get(conn.private, :required_permissions)
  user = conn.assigns[:current_user]

  if user && user.confirmed_at && required_permissions do
    if Users.has_all_permissions?(user, required_permissions) do
      conn
    else
      conn
      |> Phoenix.Controller.put_flash(:error, "Unauthorized.")
      |> Phoenix.Controller.redirect(to: ~p"/")
      |> Plug.Conn.halt()
    end
 else
  ...
 end
end

For liveview though we want to check the permissions on mount. Mount does not get conn it gets params, session and socket, so even though you can do this:

# router.ex
live("the-page", Pages.ThePage, :new, private: %{required_permissions: [:admin]})

The required_permissions are not available to you. You can instead do this:

live_session :admin_user,
  session: %{"required_permissions" => [:admin]},
  on_mount: [{UserAuth, :ensure_authenticated}] do
    live("/the-page", ThePage, :new)
end

and you will see required_permissions in session. But this means you have to make a new live_session for each unqiue set of permissions. Eg if I have one route that requires :view_users and one route that requires :admin they now can’t be in the same live_session, which means you miss out on the benefit of live_redirects.

What are some strategies you’ve tried to avoid this?

The only other thing I’ve tried is inverting the control by specifying the route in a live_view and using that in an on_mount

  def live_view(route) do
    quote do
      ...
      unquote(html_helpers())
      on_mount({AuthorizeRoute, unquote(route)})
    end
  end

  defmacro __using__(:live_view) do
    raise_message()
  end

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end

  defp raise_message() do
    raise """
    When defining a live view you should provide the type and route options like so:

        use MyAppWeb, type: :live_view, route: :the_route

    The route should have a matching function head in AuthorizeRoute.on_mount/4
    so that the live_view can be authorized.
    """
  end

# in a live view:
defmodule MyAppWeb.Pages.ThePage do
  use MyAppWeb, type: :live_view, route: :the_page
...
end

# authorixe_route.ex
defmodule AuthorizeRoute do
  @permissions %{
    the_page [:admin],
    some_other_route: [:view_users]
  }
  def on_mount(route, params, session, socket) do
    if permissions = Map.get(@permissions, route, nil) do
      user = socket.assigns.current_user
      if Users.has_all_permissions?(user, permission) do
      ...
      else
       ....
      end
    else
      # Could flash
      {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
    end
  end
end

But that feels kinda nasty.

You should be able to achieve the same by attaching a hook: Phoenix.LiveView — Phoenix LiveView v0.20.17

Could you expand a little? I can’t see a way to write one generic hook that you use everywhere outside of using the hook the way I showed in the question

You OP doesn’t seem to cover using attach_hook.

For example, in your on_mount in the router, add in another module. For example I’ve added (and created a new module) MyWeb.RouteAssigns to the below

live_session :admin_user,
  session: %{"required_permissions" => [:admin]},
  on_mount: [{UserAuth, :ensure_authenticated}, MyWeb.RouteAssigns] do
    live("/the-page", ThePage, :new)
end

Then in your new module, you can do the following

defmodule MyWeb.RouteAssigns do
  import Phoenix.LiveView

  def on_mount(:default, _params, _session, socket) do
    {:cont, attach_hook(socket, :check_permissions, :handle_params, &check_permissions/3)
  end

  defp check_permissions(params, uri, socket) do
    # if permitted
      {:cont, socket}
    # else not permitted
      {:halt, push_navigate(socket, to: ~p"/")}
  end
end

Every LiveView within your live_session will now call the check_permissions function before handle_params is called your LiveView.

Here you can intercept it, halt the lifecycle and navigate the user away if they fail the permission check.

1 Like

Can you please expand on this? this API changed a lot of times, however back in the day you would receive those parameters in session parameter of the mount callback, this also should integrate very well with the server-side hook I provided the link to.

If it doesn’t work, does it not work for all liveviews or after the first live_redirect?

I don’t think the above helps OP further towards a “uniform” way of configuring authorisation rules per LiveView. Correct me if I’m wrong, but I think the question is more about how to keep authorisation between the Plug pipeline and LiveView in sync, given the differences in required permissions per route.

Putting the authorisation checks in a hook that’s triggered before handle_params could work, because you have the information about which uri is being handled. But it still backwards, because you have to derive the required permissions from the uri.

I don’t think there is a way to configure each LiveView individually, with its required set of permissions from the outside (the router). Most of the time you see examples where all LiveViews in a session share the same authorisation rules.

I’ve ran into a related challenge previously, where I wanted to reuse a LiveView for different routes. I wanted a way to configure the same LiveView sightly different for different routes. But I haven’t found a way to pass (compile-time) configuration into each LiveView, through the router. There is the “action” to differentiate, but that can only be an atom, not an arbitrary term to express a more complex configuration.

This is just to say: I feel the pain, but can’t suggest a step toward a better solution.

TIL about the :private option of the Phoenix.LiveView.Router.live/4 macro:

:private - an optional map of private data to put in the plug connection, for example: %{route_name: :foo, access: :user}. The data will be available inside conn.private in plug functions.

But I can’t think of a good usecase for this option…
If there was a similar option that would set something on the socket before mounting the LiveView, we would be golden.

1 Like

It’s possible to store ‘metadata’ in the router for each route, ie

live("/page", PageLive, :page, metadata: %{md_permissions: "View Page"})

Going back to the RouteAssigns method, you can retrieve the LiveView and metadata that is for that uri with route_info, and run your permission check from there.

It “Returns the compile-time route info and runtime path params for a request”

defp check_permissions(_params, uri, socket) do
  uri = URI.parse(uri)

  Phoenix.Router.route_info(AppWeb.Router, "GET", uri.path, uri.host)
  |> check_permission_for_lv()

  # ... 
  # {:cont, socket} / {:halt, socket}
end

defp check_permission_for_lv(%{phoenix_live_view: {ThePage, _new}, md_permissions: md_permissions) do
  # if permissed
end

The metadata in the metadata key in the router is copied into the result of route_info

This might cover the scenario you are referring too?