Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/system76/policy


https://github.com/system76/policy

Last synced: about 2 months ago
JSON representation

Awesome Lists containing this project

README

        

# Policy

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

## Installation

If [available in Hex](https://hex.pm/docs/publish), 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:

* **`permit?/3`** takes the entity in question, the current user (or `nil`), and
the action to be taken as an atom and returns a boolean.
* **`scope/2`** takes the entity in question and the current user (or `nil`).
It returns an Ecto query scoped to all the entities that user can view.

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.

```elixir
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

```elixir
# 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:

```elixir
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.