lib/pundit.ex

defmodule Pundit do
  @moduledoc """
  Pundit provides a set of helpers which guide you in leveraging regular Elixir methods to
  build a simple authorization system.  This library is based heavily on Jonas Nicklas'
  [Ruby project of the same name](https://github.com/varvet/pundit).

  Simple Elixir functions are defined for a given struct and allow you to encapsulate authentication logic.  You can use
  this code within a module that is an `Ecto.Schema`, but that's not necessary.  The action names are taken from the list
  of [actions defined by Phoenix controllers](https://hexdocs.pm/phoenix/controllers.html#actions).

  ## Examples
  Here's a basic example, starting with a simple struct for a `Post`.  A module named `Post.Policy` should be created to
  encapsulate all of the access methods (`Pundit` will automatically look for the `<struct module>.Policy` module
  to determine the module name to look at for access methods).

  To declare an initial set of access functions (`show?`, `edit?`, `delete?`, etc)
  which all return `false` (default safe!), just `use` `Pundit.DefaultPolicy`.  You can then override the functions as needed
  with the logic necessary to determine whether a user should be able to perform the given action.  In this example, we only
  determine whether a user can `edit?` a post, leaving all other functions (like `delete?`) to return the default of `false`.

      defmodule Post do
        defstruct [:author, :title, :body, :comments]

        defmodule Policy do
          # This will initialize all the functions listed below, that all return false
          # by default. Override them individually to return true when they should.
          use Pundit.DefaultPolicy

          def edit?(post, user) do
            user.name == post.author
          end
        end
      end

      post = %Post{author: "Snake Plissken"}
      author = %{name: "Snake Plissken"}
      # next line is same as Pundit.can?(post, author, :edit?)
      if Pundit.edit?(post, author) do
        IO.puts("Can edit!")
      end

      if Pundit.delete?(post, author) do
        IO.puts("This line should never be called")
      end

      # raise exception if user should be able to do a thing
      Pundit.authorize!(post, author, :edit?)

  ## Scope
  You can also provide query scope for a struct (say, if you're using `Ecto.Schema`) for a given user.  For instance,
  say our `Post` was an `Ecto` schema.  Our function for scoping all `Post`s to a specific `User` could be to find all
  `Post`s that were authored by a user.  For instance:

      defmodule Post do
        use Ecto.Schema
        import Ecto.Query, only: [from: 2]

        defmodule Policy do
          use Pundit.DefaultPolicy

          def scope(query, user) do
            from post in query,
              where: post.author_id == ^user.id
          end
        end
      end

      user = MyApp.Repo.get(User, 1)
      posts = Pundit.scope(Post, user) |> Repo.all()

      query = from p in Post, where: p.comment_count > 10
      popular_posts = Pundit.scope(query, user) |> Repo.all()
  """

  alias Pundit.{NotAuthorizedError, NotDefinedError}

  @doc """
  Returns true only if the user should be allowed to see an index (list) of the given things.
  """
  @spec index?(thing :: struct() | module(), user :: term()) :: boolean()
  def index?(thing, user) do
    can?(thing, user, :index?)
  end

  @doc """
  Returns true only if the user should be allowed to see the given thing.
  """
  @spec show?(thing :: struct() | module(), user :: term()) :: boolean()
  def show?(thing, user) do
    can?(thing, user, :show?)
  end

  @doc """
  Returns true only if the user should be allowed to create a new kind of thing.
  """
  @spec create?(thing :: struct() | module(), user :: term()) :: boolean()
  def create?(thing, user) do
    can?(thing, user, :create?)
  end

  @doc """
  Returns true only if the user should be allowed to see a form to create a new thing.

  See [the page on Phoenix controllers](https://hexdocs.pm/phoenix/controllers.html#actions) for more details on the
  purpose of this action.
  """
  @spec new?(thing :: struct() | module(), user :: term()) :: boolean()
  def new?(thing, user) do
    can?(thing, user, :new?)
  end

  @doc """
  Returns true only if the user should be allowed to update the attributes of a thing.
  """
  @spec update?(thing :: struct() | module(), user :: term()) :: boolean()
  def update?(thing, user) do
    can?(thing, user, :update?)
  end

  @doc """
  Returns true only if the user should be allowed to see a form for updating the thing.

  See [the page on Phoenix controllers](https://hexdocs.pm/phoenix/controllers.html#actions) for more details on the
  purpose of this action.
  """
  @spec edit?(thing :: struct() | module(), user :: term()) :: boolean()
  def edit?(thing, user) do
    can?(thing, user, :edit?)
  end

  @doc """
  Returns true only if the user should be allowed to delete a thing.
  """
  @spec delete?(thing :: struct() | module(), user :: term()) :: boolean()
  def delete?(thing, user) do
    can?(thing, user, :delete?)
  end

  @doc """
  Raise a `Pundit.NotAuthorizedError` exception unless the user can perform the action on the thing.
  """
  @spec authorize!(thing :: struct() | module(), user :: term(), action :: atom()) :: boolean()
  def authorize!(thing, user, action) do
    case authorize(thing, user, action) do
      {:ok} ->
        true

      {:error, msg} ->
        raise NotAuthorizedError, message: msg
    end
  end

  @doc """
  Return a tuple based on whether a user can perform the action on the thing.

  Returns `{:ok}` if a user can perform the action, or `{:error, message}` if not.
  """
  @spec authorize(thing :: struct() | module(), user :: term(), action :: atom()) ::
          {:ok} | {:error, String.t()}
  def authorize(thing, user, action) do
    if can?(thing, user, action) do
      {:ok}
    else
      {:error, "User #{inspect(user)} cannot #{action} #{inspect(thing)}"}
    end
  end

  @doc """
  Determine if a use can perform an action on a given thing.

  This will attempt to call a function with the same name as the action on the policy
  module of the given thing.  For instance:

      Pundit.can?(post, user, :edit?)

  is the same as:

      Post.Policy.edit?(post, user)

  """
  @spec can?(thing :: struct() | module(), user :: term(), action :: atom()) :: boolean()
  def can?(thing, user, action) do
    module = thing |> get_module() |> Module.concat(Policy)

    with {:module, _module} <- Code.ensure_compiled(module),
         true <- Kernel.function_exported?(module, action, 2) do
      apply(module, action, [thing, user])
    else
      _ -> raise NotDefinedError, message: "#{module}.#{action} is not defined."
    end
  end

  @doc """
  Scope a `Ecto.Query` or `Ecto.Schema` to a given user.

  This will call the function `scope` on the policy module of the given thing.  For instance:

      Pundit.scope(Post, user)

  is the same as:

      Post.Policy.scope(Post, user)

  Here's an example with a `Ecto.Query`:

      query = from post in Post, where: post.comments > 10
      # This call...
      Pundit.scope(query, user)
      # Is the same as...
      Post.Policy.scope(query, user)

  This is just helpful shorthand.
  """
  @spec scope(schema :: module() | Ecto.Query.t(), user :: term()) :: Ecto.Query.t()
  def scope(%{__struct__: Ecto.Query, from: %{source: {_, schema}}} = query, user) do
    schema
    |> Module.concat(Policy)
    |> do_scope(query, user)
  end

  def scope(schema, user) when is_atom(schema) do
    schema
    |> Module.concat(Policy)
    |> do_scope(schema, user)
  end

  defp do_scope(module, query, user) do
    with {:module, _module} <- Code.ensure_compiled(module),
         true <- Kernel.function_exported?(module, :scope, 2) do
      module.scope(query, user)
    else
      _ ->
        raise NotDefinedError, message: "Function scope/2 not defined on #{module}"
    end
  end

  defp get_module(thing) do
    cond do
      is_atom(thing) and Kernel.function_exported?(thing, :__info__, 1) ->
        thing

      is_map(thing) and Map.has_key?(thing, :__struct__) ->
        thing.__struct__

      true ->
        raise ArgumentError, message: "The first parameter should be a module or a struct"
    end
  end
end