/bodyguard

Simple authorization conventions for Phoenix apps

Primary LanguageElixirMIT LicenseMIT

Bodyguard

Bodyguard protects the context boundaries of your application. 💪

Version 2.0 was built from the ground-up to integrate nicely with Phoenix contexts. Authorization callbacks can be implemented directly in contexts, so permissions can be checked from controllers, views, sockets, tests, and even other contexts.

To promote reuse and DRY up repetitive configuration, authorization can be constructed and executed in a composable way with Bodyguard.Action.

Additionally, the Bodyguard.Schema behaviour provides a convention for limiting user-accessible data from within the context.

This is an all-new API, so refer to the 1.x branch (still maintained!) if you are using versions prior to 2.0.

Quick Example

Define authorization rules directly in the context module, in this case MyApp.Blog:

defmodule MyApp.Blog do
  @behaviour Bodyguard.Policy

  # Implement this callback:
  def authorize(:update_post, user, %{post: post}) do
    # Return :ok to permit
    # Return {:error, reason} to deny
  end
end

# Authorize a controller action (note the keyword list is converted to a map for the callback):
with :ok <- Bodyguard.permit(MyApp.Blog, :update_post, user, post: post) do
  # ...
end

Policies

To implement a policy, add @behaviour Bodyguard.Policy to a context, then define authorize(action, user, params) callbacks, which must return:

  • :ok to permit the action, or
  • {:error, reason} to deny the action (most commonly {:error, :unauthorized})

The action argument, an atom, might map one-to-one with the actual context function name, or it can be more broad (e.g. :manage_post or :read_post) to indicate a rule encompassing a wider range of actions.

defmodule MyApp.Blog do
  @behaviour Bodyguard.Policy

  # Admin users can do anything
  def authorize(_, %Blog.User{role: :admin}, _), do: :ok

  # Regular users can create posts
  def authorize(:create_post, _, _), do: :ok

  # Regular users can modify their own posts
  def authorize(action, %{id: user_id}, %{post: %{user_id: post_user_id}}) 
    when action in [:update_post, :delete_post] 
    and user_id == post_user_id, do: :ok

  # Catch-all: deny everything else
  def authorize(_, _, _), do: {:error, :unauthorized}
end

If you prefer a more structured approach, define a dedicated policy module outside of the context, and configure the context to use it with the :policy option:

defmodule MyApp.Blog do
  use Bodyguard.Policy, policy: MyApp.Blog.Policy
end

defmodule MyApp.Blog.Policy do
  @behaviour Bodyguard.Policy
  def authorize(action, user, params), do: # ...
end

For more info, see Bodyguard.Policy in the docs.

Controllers

Phoenix 1.3 introduces the action_fallback controller macro. This is the recommended way to deal with authorization failures.

The fallback controller should handle any {:error, reason} results returned by authorize/3 callbacks.

Normally, authorization failure results in {:error, :unauthorized}. If you wish to deny access without leaking the existence of a particular resource, consider returning {:error, :not_found} instead, and handle it separately in the fallback controller.

defmodule MyApp.Web.PostController do
  use MyApp.Web, :controller

  action_fallback MyApp.Web.FallbackController

  def index(conn, _) do
    user = conn.assigns.current_user
    with :ok <- Bodyguard.permit(MyApp.Blog, :list_posts, user) do
      posts = MyApp.Blog.list_posts(user)
      render(conn, posts: posts)
    end
  end
end

defmodule MyApp.Web.FallbackController do
  use MyApp.Web, :controller

  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(:forbidden)
    |> render(MyApp.Web.ErrorView, :"403")
  end
end

See the section "Overriding action/2 for custom arguments" in the Phoenix.Controller docs for a clean way to pass in the user to each action.

Composable Actions

The concept of an authorized action is encapsulated by the Bodyguard.Action struct. It can be initialized with defaults, modified during the request cycle, and finally executed in a controller or socket action.

This example is exactly equivalent to the above:

defmodule MyApp.Web.PostController do
  use MyApp.Web, :controller
  import Bodyguard.Action       # Import act/1, permit/3, run/2, etc.

  action_fallback MyApp.Web.FallbackController
  
  def index(conn, _) do
    user = # get current user

    act(MyApp.Blog)             # Initialize a %Bodyguard.Action{}
    |> put_user(user)           # Assign the user
    |> permit(:list_posts)      # Check with MyApp.Blog.authorize/3 callback
    |> run(fn action ->         # Job only executed if authorization passes
      posts = MyApp.Blog.list_posts(action.user)
      render(conn, posts: posts)
    end)                        # Return the job's result: a rendered conn
  end
end

The function passed to run/2 is called the job, and it only executes if authorization succeeds. If not, then the job is skipped, and the result of the authorization failure is returned instead, to be handled by the fallback controller.

This particular example is verbose for demonstration, but the Bodyguard.Plug.BuildAction plug can be used to construct an Action with common parameters ahead of time.

There are many more options – see Bodyguard.Action in the docs for details.

Testing

Testing is pretty straightforward – use the Bodyguard top-level API.

assert :ok == Bodyguard.permit(MyApp.Blog, :successful_action, user)
assert {:error, :unauthorized} == Bodyguard.permit(MyApp.Blog, :failing_action, user)

assert Bodyguard.permit(MyApp.Blog, :successful_action, user)
refute Bodyguard.permit(MyApp.Blog, :failing_action, user)

error = assert_raise Bodyguard.NotAuthorizedError, fun ->
  Bodyguard.permit(MyApp.Blog, :failing_action, user)
end
assert %{status: 403, message: "not authorized"} = error

Plugs

  • Bodyguard.Plug.Authorize – perform authorization in the middle of a pipeline
  • Bodyguard.Plug.BuildAction – create an Action with some defaults on the connection

Schema Scopes

Bodyguard also provides the Bodyguard.Schema behaviour to query which items a user can access. Implement it directly on schema modules.

defmodule MyApp.Blog.Post do
  import Ecto.Query, only: [from: 2]
  @behaviour Bodyguard.Schema

  def scope(query, user, _) do
    from ms in query, where: ms.user_id == ^user.id
  end
end

To leverage scopes, the Bodyguard.scope/3 helper function (not the callback!) can infer the type of a query and automatically defer to the appropriate callback.

defmodule MyApp.Blog do
  import Bodyguard

  def list_user_posts(user) do
    Blog.Post
    |> scope(user)          # <-- defers to MyApp.Blog.Post.scope/3
    |> where(draft: false)
    |> Repo.all
  end
end

Installation

  1. Add bodyguard to your list of dependencies in mix.exs.
def deps do
  [{:bodyguard, "~> 2.0.0"}]
end
  1. Create an error view for handling 403 Forbidden.
defmodule MyApp.ErrorView do
  use MyApp.Web, :view

  def render("403.html", _assigns) do
    "Forbidden"
  end
end
  1. Wire up a fallback controller to render this view on authorization failures.

  2. Add @behaviour Bodyguard.Policy to contexts that require authorization, and implement the authorize/3 callback.

  3. (Optional) Add @behaviour Bodyguard.Schema on schemas available for user-scoping, and implement the scope/3 callback.

  4. (Optional) Edit web.ex and add import Bodyguard to controllers, views, channels, and anywhere else to take advantage of the top-level API.

Alternatives

Not what you're looking for?

License

MIT License, Copyright (c) 2017 Rockwell Schrock

Acknowledgements

Thanks to Ben Cates for helping maintain and mature this library.