Phx.gen.auth and role based authentication

This is the best resource to understand and implement Authorization flow:


I took it to next level with FunWithFlags feature toggles!!

Here are the reason:

  • Role based: falls short, as it suffers from Role Explosion.

  • Claim/Group Based: falls short, as anyone can claim to be something they are not! (e.g. Underage user claiming to be able to drink!!)

  • Policy Based: falls short, because once you define a policy that someone must be above 18 yrs old to claim to be able to drink, you won’t be able to change that at run time!!

  • Permission Based, it’s a combination of all of the above, so I have all the flexibility I could think of, and I can grant or deny access at runtime!! (It falls short because the decision is boolean, i.e. True or False, but world isn’t black or white)

  • Scope Based: Later I will add scopes, so I can limit access even further on top of Permissions. (For instance, an Admin shouldn’t be able to view Private user posts, or One Tenant can’t change data of another Tenant, etc)

For example:

# ==============================================================================
# Scope: Used to fine tune the output from the Repo
# ==============================================================================

# Signed in users can delete their own photos within same org
defp scope_photos(query, %User{role: :user, id: id}, _params) do
   query
   |> where(author_id: ^id)
   |> or_where(state: :published) # Just an example
end

Or maybe I will create a web page, so I can control the scope dynamically.

See how to dynamically write business logic on web; opus, exop, espresso:

For more info on RBAC vs CBAC, see:


Here’s how it looks right now:

lib/derpy_coder/photos.ex

defmodule DerpyCoder.Photos do
  @moduledoc """
  The Photos context.
  """

  defdelegate can?(user, action, entity), to: DerpyCoder.Photos.Policy

  # ==============================================================================
  # Verify that the user is authorized.
  # ==============================================================================
  def verify_authorization({:cont, socket}, action, entity) do
    user = socket.assigns.current_user

    if can?(user, action, entity) do
      {:cont, socket}
    else
      {:halt, socket |> LiveHelpers.kick_unauthorized_user_out()}
    end
  end

  def verify_authorization({:halt, _} = arg, _, _), do: arg

  def verify_authorization({:cont, socket}, entity) do
    user = socket.assigns.current_user
    action = socket.assigns.live_action

    if can?(user, action, entity) do
      {:cont, socket}
    else
      {:halt, socket |> LiveHelpers.kick_unauthorized_user_out()}
    end
  end

  def verify_authorization({:halt, _} = arg, _), do: arg

  def verify_authorization({:cont, socket}) do
    user = socket.assigns.current_user
    action = socket.assigns.live_action

    if can?(user, action, Photo) do
      {:cont, socket}
    else
      {:halt, socket |> LiveHelpers.kick_unauthorized_user_out()}
    end
  end

  def verify_authorization({:halt, _} = arg), do: arg
  ...
end

lib/derpy_coder/photos/policy.ex

defmodule DerpyCoder.Photos.Policy do
  @moduledoc """
  Policy: Used to authorize user access
  """
  alias DerpyCoder.Accounts.User
  alias DerpyCoder.Photos.Photo

  @type entity :: struct()
  @type action :: :new | :index | :edit | :show | :delete

  @spec can?(User, action(), entity()) :: boolean()
  def can?(user, action, entity)

  # ==============================================================================
  # Super Admin
  # ==============================================================================
  def can?(%User{id: id, role: :super_admin}, _, _), do: DerpyCoder.Accounts.is_super_admin?(id)

  # ==============================================================================
  # Admin
  # ==============================================================================
  def can?(%User{role: :admin} = user, :new, _),
    do: FunWithFlags.enabled?(:new_photos, for: user)

  def can?(%User{role: :admin} = user, :edit, _),
    do: FunWithFlags.enabled?(:edit_photos, for: user)

  def can?(%User{role: :admin} = user, _, Photo), do: FunWithFlags.Group.in?(user, :photography)
  def can?(%User{role: :admin}, _, _), do: true

  # ==============================================================================
  # User
  # ==============================================================================
  def can?(%User{} = user, :new, Photo), do: FunWithFlags.enabled?(:new_photos, for: user)

  def can?(%User{id: id} = user, :edit, %Photo{user_id: id}),
    do: FunWithFlags.enabled?(:edit_photos, for: user)

  def can?(%User{id: id} = user, :delete, %Photo{user_id: id}),
    do: FunWithFlags.enabled?(:delete_photos, for: user)

  def can?(_, _, _), do: false
end

Usage in HEEX:

<%= if Photos.can?(@current_user, :new, Photo) do %>
    <%= live_patch("New Photo", to: Routes.photo_index_path(@socket, :new)) %>
<% end %>

<%= if Photos.can?(@current_user, :edit, photo) do %>
     <%= live_patch("Edit", to: Routes.photo_index_path(@socket, :edit, photo)) %>
<% end %><%= if Photos.can?(@current_user, :delete, photo) do %>

<%= if Photos.can?(@current_user, :delete, photo) do %>
    <%= link("Delete",
        to: "#",
        phx_click: "delete",
        phx_value_id: photo.id,
        data: [confirm: "Are you sure?"]
    ) %>
<% end %>

In EX Side:

defp apply_action(socket, :new, _params) do
    {:cont, socket}
    |> verify_user()
    |> verify_email()
    |> Photos.verify_authorization()
    |> case do
      {:cont, socket} ->
        socket
        |> assign(:photo, %Photo{})

      {:halt, socket} ->
        socket
    end
end

defp apply_action(socket, :edit, params) do
    photo = photo_from_params(params)

    {:cont, socket}
    |> verify_user()
    |> Photos.verify_authorization(photo)
    |> case do
      {:cont, socket} ->
        socket
        |> assign(:photo, photo)

      {:halt, socket} ->
        socket
    end
end

def handle_event("delete", params, socket) do
    photo = photo_from_params(params)

    {:cont, socket}
    |> verify_user()
    |> Photos.verify_authorization(photo, :delete)
    |> case do
      {:cont, socket} ->
        {:ok, _} = Photos.delete_photo(photo)

        {:noreply,
         socket
         |> assign(:photos, list_photos())}

      {:halt, socket} ->
        {:noreply, socket}
    end
end

Similary for other resources:

<%= if Accounts.can?(@current_user, :view, DerpyCoderWeb.UserDashboardLive) do %>
    <%= live_redirect("User Dashboard", to: Routes.user_dashboard_path(@socket, :index)) %>
<% end %>

Here’s how to grant or deny Permissions:

defmodule DerpyCoderWeb.Permissions do
  @moduledoc """
  Used to initialize the default permissions for each group or resource.
  Powered by FunWithFlags.
  """

  @doc """
  Used to grant permissions to groups or resources.
  """
  def grant() do
    # Since Indexing & Viewing is public by default, adding these permissions don't make sense!
    # FunWithFlags.enable(:index_photos, for_group: :photography)
    # FunWithFlags.enable(:show_photos, for_group: :photography)
    FunWithFlags.enable(:new_photos, for_group: :photography)
    FunWithFlags.enable(:edit_photos, for_group: :photography)
    FunWithFlags.enable(:delete_photos, for_group: :photography)
  end

  @doc """
  Used to redact permissions for groups & resources.
  """
  def deny() do
    # If Admin, is to be treaded like normal user, then below flags can be removed.
    # And admin's new & edit power can be restricted in scope.
    FunWithFlags.disable(:new_photos, for_group: :admin)
    FunWithFlags.disable(:edit_photos, for_group: :admin)
  end
end

Seeds.exs

# ==============================================================================
# Creating Default Users
# ==============================================================================
{:ok, admin} =
  Accounts.seed_admin(%{
    email: "admin@derpycoder.com",
    password: "wubalubadubdub",
    password_confirmation: "wubalubadubdub",
    groups: ~w(admin photography)a
  })

{:ok, abhijit} =
  Accounts.seed_user(%{
    email: "abhijit@derpycoder.com",
    password: "wubalubadubdub",
    password_confirmation: "wubalubadubdub",
    groups: ~w(photography)a
  })

Here’s how I enable or disable access in LiveBook.


derp = Accounts.get_user_by_email("derp@derpycoder.com")

# Disable Access
FunWithFlags.disable(:new_photos, for_actor: derp)
FunWithFlags.disable(:delete_photos, for_actor: derp)

# Re enable Access
FunWithFlags.enable(:new_photos, for_actor: derp)
FunWithFlags.enable(:delete_photos, for_actor: derp)

Later I will have a web view for toggling feature, permission and any other types of flags. (I wanted unified UI instead of using UI that comes with Fun With Flags!!)


P.S. It made the testing difficult, will try to figure out testing after Phoenix 1.7 is out.

8 Likes