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