lib/rbac.ex

defmodule RBAC do
  @moduledoc """
  Documentation for `Rbac`.
  """
  require Logger

  @doc """
  `transform_role_list_to_string/1` transforms a list of maps (roles)
  to comma-separated string of ids (minimal data use)
  which is JSON-compatible and can thus be used in the JWT in auth.

  ## Examples

      iex> RBAC.transform_role_list_to_string([%{id: 1}, %{id: 2}, %{id: 3}])
      "1,2,3"

      iex> RBAC.transform_role_list_to_string("1,2,3")
      "1,2,3"

      iex> RBAC.transform_role_list_to_string(%{name: "sub", id: 1, revoked: nil})
      "1"

      iex> RBAC.transform_role_list_to_string([%{id: 1, revoked: 1}, %{id: 3}])
      "3"
  """
  def transform_role_list_to_string(roles) when is_list(roles) do
    # remove any roles that have been revoked:
    Enum.filter(roles, fn role ->
      not Map.has_key?(role, :revoked) or is_nil(role.revoked)
    end)
    |> Enum.map_join(",", & &1.id)
  end

  # this guard is meant to return the string form if it's already defined:
  def transform_role_list_to_string(roles) when is_binary(roles) do
    roles
  end

  # if roles is a struct/map then attempt to parse it:
  def transform_role_list_to_string(roles) do
    [Map.delete(roles, :__meta__)] |> transform_role_list_to_string()
  end

  @doc """
  `get_approles/2` fetches the roles for the app from auth server.
  """
  def get_approles(auth_url, client_id) do
    HTTPoison.get("#{auth_url}/approles/#{client_id}")
    |> parse_body_response()
  end

  # `parse_body_response/1` parses the HTTP response
  # so your app can use the resulting JSON (list of roles).
  defp parse_body_response({:error, err}), do: {:error, err}

  defp parse_body_response({:ok, response}) do
    body = Map.get(response, :body)
    # make keys of map atoms for easier access in templates
    if body == nil do
      {:error, :no_body}
    else
      {:ok, str_key_map} = Jason.decode(body)

      #  Transform Map with strings as keys to atoms
      # see: https://stackoverflow.com/questions/31990134
      atom_key_map =
        Enum.map(str_key_map, fn role ->
          for {key, val} <- role, into: %{}, do: {String.to_atom(key), val}
        end)

      {:ok, atom_key_map}
    end
  end

  @doc """
  `get_personroles` fetches a list of roles assigned to a person from the
  specified `auth_url`, based off the `person_id`
  """
  def get_personroles(auth_url, person_id) do
    get_personroles(auth_url, person_id, AuthPlug.Token.client_id())
  end

  def get_personroles(auth_url, person_id, client_id) do
    case HTTPoison.get("#{auth_url}/personroles/#{person_id}/#{client_id}") do
      {:ok, resp} ->
        Map.get(resp, :body) |> Jason.decode()

      {:error, _} = err ->
        err
    end
  end

  @doc """
  `init_roles/2` fetches the list of roles for an app
  from the auth app (auth_url) based on the client_id
  and caches the list in-memory (ETS) for fast access.
  """
  def init_roles_cache(auth_url, client_id) do
    {:ok, roles} = RBAC.get_approles(auth_url, client_id)
    insert_roles_into_ets_cache(roles)
  end

  @doc """
  `insert_roles_into_ets_cache/1` inserts the list of roles into
  an ETS in-memroy cache for fast access at run-time.
  ETS is a high performance cache included *Free* in Elixir/Erlang.
  See: https://elixir-lang.org/getting-started/mix-otp/ets.html
  and: https://elixirschool.com/en/lessons/specifics/ets
  """
  def insert_roles_into_ets_cache(roles) do
    :ets.new(:roles_cache, [:set, :protected, :named_table])
    # insert full list:
    :ets.insert(:roles_cache, {"roles", roles})
    # insert individual roles for fast lookup:
    Enum.each(roles, fn role ->
      :ets.insert(:roles_cache, {role.name, role})
      :ets.insert(:roles_cache, {role.id, role})
    end)
  end

  @doc """
  `get_role_from_cache/1` retrieves a role from ets cache
  """
  def get_role_from_cache(term) do
    case :ets.lookup(:roles_cache, term) do
      # not found:
      # :error
      [] ->
        Logger.error(
          "rbac.ex:112 Role not found in ets: #{term} \n#{Exception.format_stacktrace()}"
        )

        %{id: 0}

      # role found extract role:
      [{_term, role}] ->
        role
    end
  end

  @doc """
  `parse_role_string/1` extracts the roles from String and makes a
  List of integers.

  ## Example

      iex> RBAC.parse_role_string("1,2,3")
      [1,2,3]

  """
  def parse_role_string(roles) do
    roles
    |> String.split(",", trim: true)
    |> Enum.map(&String.to_integer/1)
  end

  @doc """
  `list_approles` lists all the roles in the current role cache.
  """
  def list_approles() do
    [{"roles", roles}] = :ets.lookup(:roles_cache, "roles")

    roles
  end

  @doc """
  `has_role?/2` confirms if the person has the given role.
  Accepts list of role ids or `%Plug.Conn{}` as first argument.

  e.g:
  has_role?([1,2,42], :home_admin)
  true

  has_role?([1,2,42], "home_admin")
  true

  has_role?([1,2,14], "potus")
  false

  has_role?(%Plug.Conn{}, "home_admin")
  false
  """
  def has_role?(conn, role_name) when is_map(conn) do
    roles = parse_role_string(conn.assigns.person.roles)
    has_role?(roles, role_name)
  end

  def has_role?(roles, role) when is_list(roles) and is_atom(role) do
    has_role?(roles, Atom.to_string(role))
  end

  def has_role?(roles, role_name) when is_list(roles) do
    role = get_role_from_cache(role_name)
    Enum.member?(roles, role.id)
  end

  @doc """
  `has_role_any/2` checks if the person has any one (or more)
  of the roles listed. Allows multiple roles to access content.
  e.g:
  has_role_any?(conn, ["home_admin", "building_owner")
  true

  has_role_any?(conn, ["potus", "el_presidente")
  false
  """
  def has_role_any?(roles, roles_list) when is_list(roles) do
    list_ids =
      Enum.map(roles_list, fn role ->
        role = if is_atom(role), do: Atom.to_string(role), else: role
        r = get_role_from_cache(role)
        r.id
      end)

    #  find the first occurence of a role by id:
    found =
      Enum.find(roles, fn rid ->
        Enum.member?(list_ids, rid)
      end)

    not is_nil(found)
  end

  def has_role_any?(conn, roles_list) when is_map(conn) do
    roles = parse_role_string(conn.assigns.person.roles)
    has_role_any?(roles, roles_list)
  end
end