Policy

Policy is an authorization management framework for Phoenix. It aims to be minimally invasive and secure by default.

Installation

If available in Hex, the package can be installed as:

  1. Add policy to your list of dependencies in mix.exs:
```elixir
def deps do
  [{:policy, "~> 1.0"}]
end
```

Usage

Permissions are specified by implementing the Policy protocol for each controlled entity. Policies require two methods:

For example, assume we have a Post entity. Users can view all posts and edit their own posts, and admins can view and edit any post. Furthermore, admins can view all posts, but users can only view published posts or posts they own.

defimpl Policy, for: Post do
  import Ecto.Query

  # First, anyone can read a post
  def permit?(_post, _user, :read), do: true
  # Second, anonymous users can't do anything else
  def permit?(_post, nil, _action), do: false
  # Third, admins can do anything
  def permit?(_post, %User{admin: true}, _action), do: true
  # Finally, users can do anything to their own posts
  def permit?(%Post{user_id: user_id}, %User{id: user_id}, _action), do: true

  # Admins can view the whole `posts` table
  def scope(_post, %User{admin: true}), do: Post
  # Anonymous users can only view public posts
  def scope(_post, nil), do: from(p in Post, where: p.published)
  # Users can view published posts and posts they own
  def scope(_post, user), do: from p in Post, where: p.published or p.user_id == ^user.id
end

Policy has a couple of implementations out of the box which allow it to be invoked with either a bare module name or an Ecto changeset

# These are all equivalent
Policy.permit? %Post{}, current_user, :read
Policy.permit? Post, current_user, :read
Policy.permit? Ecto.Changeset.change(%Post{}, %{}), current_user, :read

Bare module name permissions are determined based on the entity's default values. Changeset permissions are determined based on an entity with all the proposed changes applied.

Policies are enforced at a controller level. Controllers can import Policy.Helpers to get the :ensure_authorization plug and the authorize!/2 function.

authorize!/2 takes the conn and a model or list of models, throws a Policy.Exception if the model (or any one of the list) is not authorized, and returns a conn that is marked as authorized.

The :ensure_authorization plug will throw a Policy.Exception if a Controller tries to send without running authorize!/2.

Policy.Exception is registered with Plug to return a 403 Unauthorized.

An example controller looks like this:

defmodule MyApp.PostController do
  use MyApp.Web, :controller
  import Policy.Helpers

  alias MyApp.Post

  plug :ensure_authorization

  def index(conn, _params) do
    posts = Post
    |> Policy.scope(conn.assigns[:current_user])
    |> Repo.all

    conn = conn
    |> authorize!(posts)
    |> render("index.html", posts: posts)
  end

  def new(conn, _params) do
    changeset = Post.changeset(%Post{})

    conn
    |> authorize!(changeset)
    |> render("new.html", changeset: changeset)
  end

  def create(conn, %{"post" => post_params}) do
    changeset = Post.changeset(%Post{}, post_params)

    conn = authorize!(conn, changeset)

    case Repo.insert(changeset) do
      {:ok, _post} ->
        conn
        |> put_flash(:info, "post created successfully.")
        |> redirect(to: post_path(conn, :index))
      {:error, changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    post = Repo.get!(Post, id)

    conn
    |> authorize!(post)
    |> render("show.html", post: post)
  end

  def edit(conn, %{"id" => id}) do
    post = Repo.get!(Post, id)
    changeset = Post.changeset(post)

    conn
    |> authorize!(changeset)
    |> render("edit.html", post: post, changeset: changeset)
  end

  def update(conn, %{"id" => id, "post" => post_params}) do
    post = Repo.get!(Post, id)
    changeset = Post.changeset(post, post_params)

    conn = authorize!(conn, changeset)

    case Repo.update(changeset) do
      {:ok, post} ->
        conn
        |> put_flash(:info, "post updated successfully.")
        |> redirect(to: post_path(conn, :show, post))
      {:error, changeset} ->
        render(conn, "edit.html", post: post, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    post = Repo.get!(Post, id)

    conn = authorize!(conn, post)

    # Here we use delete! (with a bang) because we expect
    # it to always work (and if it does not, it will raise).
    Repo.delete!(post)

    conn
    |> put_flash(:info, "post deleted successfully.")
    |> redirect(to: post_path(conn, :index))
  end
end

authorize!/2 assumes that the current user is in conn.assigns[:current_user] and that the controller action is set by Phoenix. It maps the seven RESTful actions to :create, :read, :update, and :delete, and passes all other actions through as-is.