lib/permit/permissions.ex

defmodule Permit.Permissions do
  @moduledoc """
  Defines the application's permission set. When used with `Permit.Ecto`, one should use `Permit.Ecto.Permissions` instead of `Permit.Permissions`.

  The behaviour defines the `c:can/1` callback, which must be implemented for defining permissions for a given subject.

  The module's `__using__/1` macro creates functions for each action defined in the module specified as the macro's option, defaulting to `Permit.Actions.CrudActions`.

  ## Usage

  A very simple usage example:
  ```
  defmodule MyApp.Permissions do
    use Permit.Permissions, actions_module: Permit.Actions.CrudActions

    @impl true
    def can(%MyApp.User{role: :admin}) do
      permit()
      |> all(Article)
    end

    def can(%MyApp.User{id: user_id}) do
      permit()
      |> read(Article)
      |> all(Article, author_id: user_id)
    end

    def can(_), do: permit()
  end
  ```

  ## Named action functions

  Each action defined in the `:actions_module` results in a 2-, 3-, and 4-arity function being generated.

  For instance, if a `:read` action is defined, there are the following calls available to grant the `:read` permission on a given resource type:
  * `read/2` function - grants permission without additional conditions
  * `read/3` function - with conditions defined using keywords and operators (see below),
  * `read/4` macro - with conditions defined using keywords, operators and bindings (see below).

  ### Example

      def can(%User{id: user_id}) do
        permit()
        |> read(Article, author_id: user_id)
        |> vote(Article, vote_count: {:<=, 100})
        |> review(Article, [user, article], user.level >= article.level)
      end

  ## `permission_to` functions

  Instead of action names, if more convenient, `permission_to` can be used, and the action name passed as an argument.

  ### Example

      def can(%User{id: user_id}) do
        permit()
        |> permission_to(:read, Article, author_id: user_id)
        |> permission_to(:vote, Article, vote_count: {:<=, 100})
        |> permission_to(:review, Article, [user, article], user.level >= article.level)
      end

  ## `all` functions

  In order to grant the user permission to all defined actions, use the `all` functions.

  ### Example

      def can(%User{id: user_id}) do
        permit()
        |> all(Article, author_id: user_id)
        |> all(Article, vote_count: {:<=, 100})
        |> all(Article, [user, article], user.level >= article.level)
      end
  """

  alias Permit.Permissions.ConditionParser
  alias Permit.Permissions.ParsedCondition
  alias Permit.Permissions.DisjunctiveNormalForm, as: DNF
  alias Permit.Types

  import Permit.Helpers, only: [resource_module_from_resource: 1]

  defstruct conditions_map: %{}

  @type conditions_by_action_and_resource :: %{
          {Types.action_group(), Types.resource_module()} => DNF.t()
        }
  @type t :: %__MODULE__{conditions_map: conditions_by_action_and_resource()}

  @callback can(Permit.Types.subject()) :: Permit.Types.permissions()

  defmacro __using__(opts) do
    alias Permit.Permissions.ActionFunctions
    alias Permit.Permissions.PermissionTo

    condition_parser = opts[:condition_parser] || (&ConditionParser.build/2)
    condition_types_module = opts[:condition_types_module] || Permit.Types.ConditionTypes

    actions_module = opts |> Keyword.get(:actions_module, Permit.Actions.CrudActions)

    # Unnamed action macro
    permission_to = PermissionTo.mixin(condition_parser, condition_types_module)

    # Named action functions
    action_functions =
      ActionFunctions.named_actions_mixin(
        actions_module,
        __CALLER__,
        condition_parser,
        condition_types_module
      )

    all_actions_mixin =
      ActionFunctions.all_actions_mixin(
        actions_module,
        condition_parser,
        condition_types_module
      )

    quote do
      @behaviour Permit.Permissions
      import Permit.Permissions

      alias Permit.Permissions.ParsedCondition
      alias Permit.Types

      unquote(permission_to)

      unquote(action_functions)

      unquote(all_actions_mixin)

      def actions_module, do: unquote(actions_module)

      @doc """
      Initializes a structure holding permissions for a given user role.

      Returns a Permit struct.
      """
      @spec permit() :: Types.permissions()
      def permit, do: %Permit.Permissions{}
    end
  end

  @doc false
  def add_permission(permissions, action, resource, bindings, conditions, condition_parser) do
    parsed_conditions =
      __MODULE__.parse_conditions(
        bindings,
        conditions,
        condition_parser
      )

    permissions
    |> __MODULE__.add(action, resource, parsed_conditions)
  end

  @doc false
  def escape_bindings_and_conditions(bindings, conditions) do
    escaped_bindings =
      bindings
      |> Enum.map(&elem(&1, 0))
      |> Macro.escape()

    escaped_conditions =
      conditions
      |> Macro.escape()

    {escaped_bindings, escaped_conditions}
  end

  @doc false
  def parse_condition(condition, bindings, condition_parser) when length(bindings) <= 2 do
    condition_parser.(condition, bindings: bindings)
  end

  @doc false
  def parse_condition(_condition, bindings, _condition_parser) do
    raise "Binding list should have at most 2 elements (subject and object), Given #{inspect(bindings)}"
  end

  def parse_conditions(bindings, condition, condition_parser) when not is_list(condition) do
    parse_conditions(bindings, [condition], condition_parser)
  end

  def parse_conditions(bindings, raw_conditions, condition_parser) do
    # raw_conditions
    # |> Enum.map(
    #   &(&1
    #     |> __MODULE__.parse_condition(bindings)
    #     |> condition_parser.())
    # )

    raw_conditions
    |> Enum.map(&__MODULE__.parse_condition(&1, bindings, condition_parser))
  end

  @doc false
  @spec add(__MODULE__.t(), Types.action_group(), Types.resource_module(), [ParsedCondition.t()]) ::
          __MODULE__.t()
  def add(permissions, action, resource, conditions) do
    permissions.conditions_map
    |> Map.update({action, resource}, DNF.add_clauses(DNF.new(), conditions), fn dnf ->
      DNF.add_clauses(dnf, conditions)
    end)
    |> new()
  end

  @doc false
  @spec granted?(
          __MODULE__.t(),
          Types.action_group(),
          Types.object_or_resource_module(),
          Types.subject()
        ) ::
          boolean()
  def granted?(permissions, action, record, subject) do
    permissions
    |> dnf_for_action_and_record(action, record)
    |> DNF.any_satisfied?(record, subject)
  end

  @doc false
  @spec join(__MODULE__.t(), __MODULE__.t()) :: __MODULE__.t()
  def join(p1, p2) do
    Map.merge(p1.conditions_map, p2.conditions_map, fn
      _k, dnf1, dnf2 -> DNF.join(dnf1, dnf2)
    end)
    |> then(&%__MODULE__{conditions_map: &1})
  end

  @doc false
  @spec new() :: __MODULE__.t()
  def new, do: %__MODULE__{}

  @spec new(conditions_by_action_and_resource()) :: __MODULE__.t()
  defp new(rca), do: %__MODULE__{conditions_map: rca}

  @doc false
  @spec dnf_for_action_and_record(
          __MODULE__.t(),
          Types.action_group(),
          Types.object_or_resource_module()
        ) ::
          DNF.t()
  defp dnf_for_action_and_record(permissions, action, resource) do
    resource_module = resource_module_from_resource(resource)

    permissions.conditions_map
    |> Map.get({action, resource_module}, DNF.new())
  end
end