lib/terminator.ex

defmodule Terminator.UUID do
  @moduledoc """
  Main Terminator module for including macros

  Terminator has 3 main components:

    * `Terminator.UUID.Ability` - Representation of a single permission e.g. :view, :delete, :update
    * `Terminator.UUID.Performer` - Main actor which is holding given abilities
    * `Terminator.UUID.Role` - Grouped set of multiple abilities, e.g. :admin, :manager, :editor

  ## Relations between models

  `Terminator.UUID.Performer` -> `Terminator.UUID.Ability` [1-n] - Any given performer can hold multiple abilities
  this allows you to have very granular set of abilities per each performer

  `Terminator.UUID.Performer` -> `Terminator.UUID.Role` [1-n] - Any given performer can act as multiple roles
  this allows you to manage multple sets of abilities for multiple performers at once

  `Terminator.UUID.Role` -> `Terminator.UUID.Ability` [m-n] - Any role can have multiple abilities therefore
  you can have multiple roles to have different/same abilities

  ## Calculating abilities

  Calculation of abilities is done by *OR* and *DISTINCT* abilities. That means if you have

  `Role[:admin, abilities: [:delete]]`, `Role[:editor, abilities: [:update]]`, `Role[:user, abilities: [:view]]`
  and all roles are granted to single performer, resulting abilities will be `[:delete, :update, :view]`


  ## Available permissions

    * `Terminator.UUID.has_ability/1` - Requires single ability to be present on performer
    * `Terminator.UUID.has_role/1` - Requires single role to be present on performer

  """

  defmacro __using__(_) do
    quote do
      import unquote(__MODULE__)
      @before_compile unquote(__MODULE__)
    end
  end

  defmacro __before_compile__(_env) do
    create_terminator()
  end

  @doc """
  Macro for defining required permissions

  ## Example

      defmodule HelloTest do
        use Terminator.UUID

        def test_authorization do
          permissions do
            has_role(:admin)
            has_ability(:view)
          end
        end
      end
  """

  defmacro permissions(do: block) do
    quote do
      reset_session()
      unquote(block)
    end
  end

  @doc """
  Resets ETS table
  """
  def reset_session() do
    Terminator.UUID.Registry.insert(:required_abilities, [])
    Terminator.UUID.Registry.insert(:required_roles, [])
    Terminator.UUID.Registry.insert(:calculated_permissions, [])
    Terminator.UUID.Registry.insert(:extra_rules, [])
  end

  @doc """
  Macro for wrapping protected code

  ## Example

      defmodule HelloTest do
        use Terminator.UUID

        def test_authorization do
          as_authorized do
            IO.inspect("This code is executed only for authorized performer")
          end
        end
      end
  """

  defmacro as_authorized(do: block) do
    quote do
      with :ok <- perform_authorization!() do
        unquote(block)
      end
    end
  end

  @doc """
  Defines calculated permission to be evaluated in runtime

  ## Examples

      defmodule HelloTest do
        use Terminator.UUID

        def test_authorization do
          permissions do
            calculated(fn performer ->
              performer.email_confirmed?
            end)
          end

          as_authorized do
            IO.inspect("This code is executed only for authorized performer")
          end
        end
      end

  You can also use DSL form which takes function name as argument

        defmodule HelloTest do
        use Terminator.UUID

        def test_authorization do
          permissions do
            calculated(:email_confirmed)
          end

          as_authorized do
            IO.inspect("This code is executed only for authorized performer")
          end
        end

        def email_confirmed(performer) do
          performer.email_confirmed?
        end
      end

    For more complex calculation you need to pass bindings to the function

        defmodule HelloTest do
        use Terminator.UUID

        def test_authorization do
          post = %Post{owner_id: 1}

          permissions do
            calculated(:is_owner, [post])
            calculated(fn performer, [post] ->
              post.owner_id == performer.id
            end)
          end

          as_authorized do
            IO.inspect("This code is executed only for authorized performer")
          end
        end

        def is_owner(performer, [post]) do
          post.owner_id == performer.id
        end
      end

  """
  defmacro calculated(func_name) when is_atom(func_name) do
    quote do
      {:ok, current_performer} = Terminator.UUID.Registry.lookup(:current_performer)

      Terminator.UUID.Registry.add(
        :calculated_permissions,
        unquote(func_name)(current_performer)
      )
    end
  end

  defmacro calculated(callback) do
    quote do
      {:ok, current_performer} = Terminator.UUID.Registry.lookup(:current_performer)

      result = apply(unquote(callback), [current_performer])

      Terminator.UUID.Registry.add(
        :calculated_permissions,
        result
      )
    end
  end

  defmacro calculated(func_name, bindings) when is_atom(func_name) do
    quote do
      {:ok, current_performer} = Terminator.UUID.Registry.lookup(:current_performer)

      result = unquote(func_name)(current_performer, unquote(bindings))

      Terminator.UUID.Registry.add(
        :calculated_permissions,
        result
      )
    end
  end

  defmacro calculated(callback, bindings) do
    quote do
      {:ok, current_performer} = Terminator.UUID.Registry.lookup(:current_performer)

      result = apply(unquote(callback), [current_performer, unquote(bindings)])

      Terminator.UUID.Registry.add(
        :calculated_permissions,
        result
      )
    end
  end

  @doc ~S"""
  Returns authorization result on collected performer and required roles/abilities

  ## Example

      defmodule HelloTest do
        use Terminator.UUID

        def test_authorization do
          case is_authorized? do
            :ok -> "Performer is authorized"
            {:error, message: _message} -> "Performer is not authorized"
        end
      end
  """
  @spec is_authorized?() :: :ok | {:error, String.t()}
  def is_authorized? do
    perform_authorization!()
  end

  @doc """
  Perform authorization on passed performer and abilities
  """
  @spec has_ability?(Terminator.UUID.Performer.t(), atom()) :: boolean()
  def has_ability?(%Terminator.UUID.Performer{} = performer, ability_name) do
    perform_authorization!(performer, [Atom.to_string(ability_name)], []) == :ok
  end

  def has_ability?(
        %Terminator.UUID.Performer{} = performer,
        ability_name,
        %{__struct__: _entity_name, id: _entity_id} = entity
      ) do
    active_abilities =
      case Terminator.UUID.Performer.load_performer_entities(performer, entity) do
        nil -> []
        entity -> entity.abilities
      end

    Enum.member?(active_abilities, Atom.to_string(ability_name))
  end

  @doc """
  Perform role check on passed performer and role
  """
  def has_role?(%Terminator.UUID.Performer{} = performer, role_name) do
    perform_authorization!(performer, nil, [Atom.to_string(role_name)], nil) == :ok
  end

  @doc false
  def perform_authorization!(
        current_performer \\ nil,
        required_abilities \\ [],
        required_roles \\ [],
        extra_rules \\ []
      ) do
    current_performer =
      case current_performer do
        nil ->
          {:ok, current_performer} = Terminator.UUID.Registry.lookup(:current_performer)
          current_performer

        _ ->
          current_performer
      end

    required_abilities = ensure_array_from_ets(required_abilities, :required_abilities)
    required_roles = ensure_array_from_ets(required_roles, :required_roles)
    extra_rules = ensure_array_from_ets(extra_rules, :extra_rules)
    calculated_permissions = ensure_array_from_ets([], :calculated_permissions)

    # If no performer is given we can assume that permissions are not granted
    if is_nil(current_performer) do
      {:error, "Performer is not granted to perform this action"}
    else
      # If no permissions were required then we can assume performe is granted
      if length(required_abilities) + length(required_roles) + length(calculated_permissions) +
           length(extra_rules) == 0 do
        :ok
      else
        # 1st layer of authorization (optimize db load)
        first_layer =
          authorize!(
            [
              authorize_abilities(current_performer.abilities, required_abilities)
            ] ++ calculated_permissions ++ extra_rules
          )

        if first_layer == :ok do
          first_layer
        else
          # 2nd layer with DB preloading of roles
          %{roles: current_roles} = load_performer_roles(current_performer)

          second_layer =
            authorize!([
              authorize_roles(current_roles, required_roles),
              authorize_inherited_abilities(current_roles, required_abilities)
            ])

          if second_layer == :ok do
            second_layer
          else
            {:error, "Performer is not granted to perform this action"}
          end
        end
      end
    end
  end

  defp ensure_array_from_ets(value, name) do
    value =
      case value do
        [] ->
          {:ok, value} = Terminator.UUID.Registry.lookup(name)
          value

        value ->
          value
      end

    case value do
      nil -> []
      _ -> value
    end
  end

  @doc false
  def create_terminator() do
    quote do
      import Terminator.UUID, only: [store_performer!: 1, load_and_store_performer!: 1]

      def load_and_authorize_performer(%Terminator.UUID.Performer{id: _id} = performer),
        do: store_performer!(performer)

      def load_and_authorize_performer(%{performer: %Terminator.UUID.Performer{id: _id} = performer}),
        do: store_performer!(performer)

      def load_and_authorize_performer(%{performer_id: performer_id})
          when not is_nil(performer_id),
          do: load_and_store_performer!(performer_id)

      def load_and_authorize_performer(performer),
        do: raise(ArgumentError, message: "Invalid performer given #{inspect(performer)}")
    end
  end

  @doc false
  @spec load_and_store_performer!(integer()) :: {:ok, Terminator.UUID.Performer.t()}
  def load_and_store_performer!(performer_id) do
    performer = Terminator.UUID.Repo.get!(Terminator.UUID.Performer, performer_id)
    store_performer!(performer)
  end

  @doc false
  @spec load_performer_roles(Terminator.UUID.Performer.t()) :: Terminator.UUID.Performer.t()
  def load_performer_roles(performer) do
    performer |> Terminator.UUID.Repo.preload([:roles])
  end

  @doc false
  @spec store_performer!(Terminator.UUID.Performer.t()) :: {:ok, Terminator.UUID.Performer.t()}
  def store_performer!(%Terminator.UUID.Performer{id: _id} = performer) do
    Terminator.UUID.Registry.insert(:current_performer, performer)
    {:ok, performer}
  end

  @doc false
  def authorize_abilities(active_abilities \\ [], required_abilities \\ []) do
    authorized =
      Enum.filter(required_abilities, fn ability ->
        Enum.member?(active_abilities, ability)
      end)

    length(authorized) > 0
  end

  @doc false
  def authorize_inherited_abilities(active_roles \\ [], required_abilities \\ []) do
    active_abilities =
      active_roles
      |> Enum.map(& &1.abilities)
      |> List.flatten()
      |> Enum.uniq()

    authorized =
      Enum.filter(required_abilities, fn ability ->
        Enum.member?(active_abilities, ability)
      end)

    length(authorized) > 0
  end

  @doc false
  def authorize_roles(active_roles \\ [], required_roles \\ []) do
    active_roles =
      active_roles
      |> Enum.map(& &1.identifier)
      |> Enum.uniq()

    authorized =
      Enum.filter(required_roles, fn role ->
        Enum.member?(active_roles, role)
      end)

    length(authorized) > 0
  end

  @doc false
  def authorize!(conditions) do
    # Authorize empty conditions as true

    conditions =
      case length(conditions) do
        0 -> conditions ++ [true]
        _ -> conditions
      end

    authorized =
      Enum.reduce(conditions, false, fn condition, acc ->
        condition || acc
      end)

    case authorized do
      true -> :ok
      _ -> {:error, "Performer is not granted to perform this action"}
    end
  end

  @doc """
  Requires an ability within permissions block

  ## Example

      defmodule HelloTest do
        use Terminator.UUID

        def test_authorization do
          permissions do
            has_ability(:can_run_test_authorization)
          end
        end
      end
  """
  @spec has_ability(atom()) :: {:ok, atom()}
  def has_ability(ability) do
    Terminator.UUID.Registry.add(:required_abilities, Atom.to_string(ability))
    {:ok, ability}
  end

  def has_ability(ability, %{__struct__: _entity_name, id: _entity_id} = entity) do
    {:ok, current_performer} = Terminator.UUID.Registry.lookup(:current_performer)

    Terminator.UUID.Registry.add(:extra_rules, has_ability?(current_performer, ability, entity))
    {:ok, ability}
  end

  @doc """
  Requires a role within permissions block

  ## Example

      defmodule HelloTest do
        use Terminator.UUID

        def test_authorization do
          permissions do
            has_role(:admin)
          end
        end
      end
  """
  @spec has_role(atom()) :: {:ok, atom()}
  def has_role(role) do
    Terminator.UUID.Registry.add(:required_roles, Atom.to_string(role))
    {:ok, role}
  end
end