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, user, %{post: post})
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 pipelineBodyguard.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
- Add
bodyguard
to your list of dependencies inmix.exs
.
def deps do
[{:bodyguard, "~> 2.0.0"}]
end
- 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
Wire up a fallback controller to render this view on authorization failures.
Add
@behaviour Bodyguard.Policy
to contexts that require authorization, and implement theauthorize/3
callback.(Optional) Add
@behaviour Bodyguard.Schema
on schemas available for user-scoping, and implement thescope/3
callback.(Optional) Edit
web.ex
and addimport 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.