lib/membership.ex

defmodule Membership do
  @moduledoc """
  Main Membership module for including macros

  Membership has 4 main components:

    * `Membership.Role` -  Representation of a single role e.g. :admin, :luser, :user
    * `Membership.Plan` -  Representation of a single plan e.g. :gold, :silver, :copper
    * `Membership.Member` - Main actor which is holding given plans
    * `Membership.Feature` - Feature of a plan eg. :edit_feature

  ## Relations between models

  `Membership.Member`  -> `Membership.Feature`[1-n] - Any given member have multiple features with this we can have more granular features for each member is adding a specific feature to a member not in his plan

  `Membership.Member` -> `Membership.Plan` [1-n] - Any given member can have multiple plans

  `Membership.Member` -> `Membership.Role` [1-n] - Any given member can have multiple roles

  `Membership.Plan` -> `Membership.Feature` [m-n] - Any given plan can have multiple features

  `Membership.Role` -> `Membership.Feature` [m-n] - Any given role can have multiple features


  ## Calculating permissions

  Calculation of permissions is done by 2 ets tables one holding the logged in members permissions the other holds modules function/permissions, then
    true = Enum.member?(module_permissions, member_permissions)


  ## Available as_authorized
    * `Membership.has_plan?/3` - Requires single plan to be present on member
    * `Membership.has_role?/3` - Requires single role to be present on member
    * `Membership.has_feature?/3` - Requires single feature to be present on member

  """

  @default_features []

  defmacro __using__(opts) do
    quote do
      opts = unquote(opts)

      @registry Keyword.fetch!(opts, :registry)

      use Membership.Behaviour, registry: @registry
      @before_compile unquote(__MODULE__)
    end
  end

  defmacro __before_compile__(_env) do
    create_membership()
  end

  @doc """
  Macro for defining required permissions

  ## Example

      defmodule HelloTest do
        use Membership

        def test_authorization do
          permissions do
            has_feature(:admin_feature, :test_authorization)
            has_plan(:gold, :test_authorization)
          end
        end
      end
  """
  defmacro permissions(do: block) do
    quote do
      load_ets_data(unquote(__MODULE__))
      data = unquote(block)

      case is_nil(data) do
        true -> @default_features
        false -> data
      end
    end
  end

  defmacro permissions(member, do: block) do
    quote do
      load_and_authorize_member(unquote(member))
      load_ets_data(unquote(__MODULE__))
      data = unquote(block)

      case is_nil(data) do
        true -> @default_features
        false -> data
      end
    end
  end

  def add_function_param_to_block(block) do
    {:ok, block}
  end

  @doc """
  The Function list to ignore when building the permissions registry's
  """
  def ignored_functions() do
    Membership.module_info()
    |> Keyword.fetch!(:exports)
    |> Enum.map(fn {key, _data} -> key end)
  end

  @doc """
  Load the plans into ets for the module/functions
  """
  def load_ets_data(current_module \\ __MODULE__) do
    require Logger

    status = Membership.Permissions.Supervisor.start(current_module)

    case status do
      {:error, error} ->
        Logger.error(error)
        :ok

      {:ok, _} ->
        Map.__info__(:functions)
        |> Enum.filter(fn {x, _} -> Enum.member?(ignored_functions(), x) end)
        |> Enum.each(fn {x, _} ->
          Membership.Permission.Server.insert(current_module, x, [])
        end)
    end
  end

  @doc """
  Macro for wrapping protected code

  ## Example

      defmodule HelloTest do
        use Membership
        member = HelloTest.Repo.get(Membership.Member, 1)
        {:ok, member }  = load_and_authorize_member(member)

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

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

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

  ## Examples

      defmodule HelloTest do
        use Membership
        member = HelloTest.Repo.get(Membership.Member, 1)
        {:ok, member }  = load_and_authorize_member(member)

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

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

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

        defmodule HelloTest do
        use Membership

        def test_authorization do
        use Membership
        member = HelloTest.Repo.get(Membership.Member, 1)
       {:ok, member } = load_and_authorize_member(member)

          permissions do
            calculated(member,:email_confirmed, :calculated)
          end

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

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

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

        defmodule HelloTest do
        use Membership
        member = HelloTest.Repo.get(Membership.Member, 1)
        {:ok, member} = load_and_authorize_member(member)

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

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

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

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

  """
  defmacro calculated(current_member, func_name)
           when is_atom(func_name) do
    quote do
      {:ok, current_member} = Membership.Member.Server.show(unquote(current_member))

      rules = unquote(func_name)(current_member)

      data = {unquote(func_name), rules}

      Membership.Member.Server.add_to_calculated_registry(current_member, data)
    end
  end

  defmacro calculated(current_member, callback, func_name) when is_atom(func_name) do
    quote do
      {:ok, current_member} = Membership.Member.Server.show(unquote(current_member))

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

      data = {unquote(func_name), [result]}

      Membership.Member.Server.add_to_calculated_registry(current_member, data)
    end
  end

  defmacro calculated(current_member, func_name, bindings) when is_atom(func_name) do
    quote do
      {:ok, current_member} = Membership.Member.Server.show(unquote(current_member))
      result = unquote(func_name)(current_member, unquote(bindings))

      data = {unquote(func_name), [result]}

      Membership.Member.Server.add_to_calculated_registry(current_member, data)
    end
  end

  defmacro calculated(current_member, callback, bindings, func_name)
           when is_atom(func_name) do
    quote do
      {:ok, current_member} = Membership.Member.Server.show(unquote(current_member))
      result = apply(unquote(callback), [current_member, unquote(bindings)])

      data = {unquote(func_name), [result]}

      Membership.Member.Server.add_to_calculated_registry(current_member, data)
    end
  end

  @doc ~S"""
  Returns authorization result on collected member and required features/plans

  ## Example

      defmodule HelloTest do
        use Membership

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

  @doc """
  Perform authorization on passed member and plans
  """
  @spec has_plan?(Membership.Member.t(), atom(), String.t()) :: boolean()
  def has_plan?(%Membership.Member{} = member, func_name, plan_name) do
    perform_authorization!(member, func_name, [], [Atom.to_string(plan_name)]) == :ok
  end

  @doc """
  Perform authorization on passed member and roles
  """
  @spec has_role?(Membership.Member.t(), atom(), String.t()) :: boolean()
  def has_role?(%Membership.Member{} = member, role_name) do
    roles = Enum.map(member.roles, fn x -> x.identifier end)
    Enum.member?(roles, role_name) == true
  end

  @doc """
  Perform authorization on passed member and roles
  """
  @spec has_role?(Membership.Member.t(), atom(), String.t()) :: boolean()
  def has_role?(%Membership.Member{} = member, func_name, role_name) do
    perform_authorization!(member, func_name, [], [], [Atom.to_string(role_name)]) == :ok
  end

  @doc """
  Perform feature check on passed member and feature
  """
  def has_feature?(%Membership.Member{} = member, feature_name) do
    Enum.member?(member.features, feature_name)
  end

  def has_feature?(%Membership.Member{} = member, func_name, feature_name) do
    perform_authorization!(member, func_name, [Atom.to_string(feature_name)]) == :ok
  end

  @doc false
  def perform_authorization!(
        current_member \\ nil,
        func_name \\ nil,
        required_features \\ [],
        required_plans \\ [],
        required_roles \\ []
      ) do
    # If no member is given we can assume that as_authorized are not granted
    if is_nil(current_member) do
      {:error, "Member is not granted to perform this action"}
    else
      rules =
        try do
          fetch_rules_from_ets(func_name)
        rescue
          _ ->
            []
        end

      calculated_rules =
        Membership.Member.Server.fetch_from_calculated_registry(current_member, func_name)

      calculated_rules =
        case calculated_rules do
          {_, value} ->
            value

          _ ->
            calculated_rules
        end

      rules =
        case is_nil(rules) do
          true -> @default_features
          false -> rules
        end

      plan_features =
        List.flatten(
          Enum.map(required_plans, fn p ->
            p.features
          end)
        )

      role_features =
        List.flatten(
          Enum.map(required_roles, fn r ->
            r.features
          end)
        )

      required_features =
        required_features ++
          plan_features ++
          role_features ++ rules

      # If no as_authorized were required then we can assume member is granted
      if length(required_features ++ calculated_rules) == 0 do
        :ok
      else
        reply =
          authorize!(
            [
              authorize_features(current_member.features, required_features)
            ] ++
              calculated_rules
          )

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

  defp fetch_rules_from_ets(nil) do
    {:error, "Unknown ETS Record for Registry nil"}
  end

  defp fetch_rules_from_ets(func_name) do
    {:ok, value} = Membership.Registry.lookup(__MODULE__, func_name)
    value
  end

  @doc false
  def create_membership() do
    quote do
      import Membership, only: [load_and_store_member!: 2, unload_member!: 1]

      def load_and_authorize_member(%Membership.Member{id: _id} = member),
        do: load_and_store_member!(member, %{})

      def load_and_authorize_member(%Membership.Member{id: _id} = member, opts),
        do: load_and_store_member!(member, opts)

      def load_and_authorize_member(%{member: %Membership.Member{id: _id} = member}),
        do: load_and_store_member!(member, %{})

      def load_and_authorize_member(%{member: %Membership.Member{id: _id} = member}, opts),
        do: load_and_store_member!(member, opts)

      def load_and_authorize_member(%{member_id: member_id}, opts)
          when is_nil(member_id),
          do: nil

      def load_and_authorize_member(%{member_id: member_id}, opts),
        do: load_and_store_member!(%Membership.Member{id: member_id}, opts)

      def load_and_authorize_member(member) do
        load_and_store_member!(%Membership.Member{id: member.member_id}, %{})
      end
    end
  end

  @doc false
  @spec load_and_store_member!(integer(), map()) :: {:ok, Membership.Member.t()}
  def load_and_store_member!(member_id, opts) when is_integer(member_id) do
    opts =
      case is_nil(opts) do
        true -> %{}
        false -> opts
      end

    member = Membership.Repo.get!(Membership.Member, member_id) |> Map.merge(opts)
    status = Membership.Memberships.Supervisor.start(member)

    case status do
      {:ok, _} -> member
      {:error, {:already_started, _}} -> Membership.Memberships.Supervisor.update(member)
      {:error, _} -> nil
    end
  end

  @doc false
  @spec load_and_store_member!(Membership.Member.t(), map()) :: {:ok, Membership.Member.t()}
  def load_and_store_member!(%Membership.Member{} = member, opts) do
    member = Membership.Repo.get!(Membership.Member, member.id)

    case is_nil(opts) do
      true ->
        status = Membership.Memberships.Supervisor.start(member)

        case status do
          {:ok, _} -> member
          {:error, {:already_started, _}} -> member
          {:error, _} -> nil
        end

      false ->
        member = member |> Map.merge(opts)

        status = Membership.Memberships.Supervisor.start(member)

        case status do
          {:ok, _} ->
            member

          {:error, {:already_started, _pid}} ->
            status = Membership.Memberships.Supervisor.reload(member)

            case status do
              {:ok, _} ->
                member

              {:error, _} ->
                member
            end

          {:error, _} ->
            nil
        end
    end
  end

  @doc false
  @spec unload_member!(Membership.Member.t()) :: {:ok, Membership.Member.t()}
  def unload_member!(%Membership.Member{} = member) do
    Membership.Memberships.Supervisor.stop(member.identifier)
  end

  @doc false
  @spec load_member_features(Membership.Member.t()) :: Membership.Member.t()
  def load_member_features(member) do
    member
  end

  @doc false
  def authorize_features(active_features \\ [], required_features \\ []) do
    active_features = Enum.map(active_features, fn x -> String.to_atom(x) end)

    authorized =
      Enum.filter(required_features, fn feature ->
        Enum.member?(active_features, feature)
      end)

    length(authorized) > 0
  end

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

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

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

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

  @doc """
  Requires an plan within permissions block

  ## Example

      defmodule HelloTest do
        use Membership

        def test_authorization do
          permissions do
            has_plan(:gold, :test_authorization)
          end
        end
      end
  """
  @spec has_plan(atom(), atom()) :: {:ok, atom()}
  def has_plan(plan, func_name) do
    case :ets.lookup(:membership_plans, plan) do
      [] ->
        {:error, "plan: #{plan} Not Found"}

      {plan, features} ->
        Membership.Registry.add(__MODULE__, func_name, features)
        {:ok, plan}
    end
  end

  @doc """
  Requires an role within permissions block

  ## Example

      defmodule HelloTest do
        use Membership

        def test_authorization do
          permissions do
            has_role(:gold, :test_authorization)
          end
        end
      end
  """
  @spec has_role(atom(), atom()) :: {:ok, atom()}
  def has_role(role, func_name) do
    case :ets.lookup(:membership_roles, role) do
      [] ->
        {:error, "plan: #{role} Not Found"}

      {plan, features} ->
        Membership.Registry.add(__MODULE__, func_name, features)
        {:ok, plan}
    end
  end

  @doc """
  Requires a feature within permissions block

  ## Example

      defmodule HelloTest do
        use Membership

        def test_authorization do
          permissions do
            has_feature(:admin)
          end
        end
      end
  """
  @spec has_feature(atom(), atom()) :: {:ok, atom()}
  def has_feature(feature, func_name) do
    Membership.Registry.add(__MODULE__, func_name, feature)
    {:ok, feature}
  end

  @doc """
  List membership and related modules.

  ## Examples

      iex> Membership.get_loaded_modules()
  """

  def get_loaded_modules() do
    {:ok, modules} = :application.get_key(:ex_membership, :modules)

    modules
    |> Enum.map(&Module.split/1)
    |> Enum.reject(fn module ->
      Enum.any?(
        module,
        &Enum.member?(
          ["Mix", "Tasks", "Post", "Config", "Application", "Behaviour", "InvalidConfigError"],
          &1
        )
      )
    end)
    |> Enum.map(&Module.concat/1)
  end

  @doc """
  List version.

  ## Examples

      iex> Membership.version()
  """
  @version Mix.Project.config()[:version]
  def version, do: @version
end