Bodyguard
May 24, 2024 ยท View on GitHub
Bodyguard protects the context boundaries of your application. ๐ช
Authorization callbacks are implemented directly on context modules, so permissions can be checked from controllers, views, sockets, tests, and even other contexts.
The Bodyguard.Policy behaviour has a single required callback, c:Bodyguard.Policy.authorize/3. Additionally, the Bodyguard.Schema behaviour provides a convention for limiting query results per-user.
Quick Example
Define authorization rules directly in the context module:
# lib/my_app/blog/blog.ex
defmodule MyApp.Blog do
@behaviour Bodyguard.Policy
# Admins can update anything
def authorize(:update_post, %{role: :admin} = _user, _post), do: :ok
# Users can update their owned posts
def authorize(:update_post, %{id: user_id} = _user, %{user_id: user_id} = _post), do: :ok
# Otherwise, denied
def authorize(:update_post, _user, _post), do: :error
end
# lib/my_app_web/controllers/post_controller.ex
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
def update(conn, %{"id" => id, "post" => post_params}) do
user = conn.assigns.current_user
post = MyApp.Blog.get_post!(id)
with :ok <- Bodyguard.permit(MyApp.Blog, :update_post, user, post),
{:ok, post} <- MyApp.Blog.update_post(post, post_params)
do
redirect(conn, to: post_path(conn, :show, post))
end
end
end
Policies
To implement a policy, add @behaviour Bodyguard.Policy to a context, then define an authorize(action, user, params) callback, which must return:
:okortrueto permit an action:error,{:error, reason}, orfalseto deny an action
Don't use these callbacks directly - instead, go through Bodyguard.permit/4. This will convert keyword-list params into a map, and will coerce the callback result into a strict :ok or {:error, reason} result. The default failure result is {:error, :unauthorized}.
Helpers Bodyguard.permit?/4 and Bodyguard.permit!/5 are also provided.
# lib/my_app/blog/blog.ex
defmodule MyApp.Blog do
@behaviour Bodyguard.Policy
alias __MODULE__
# Admin users can do anything
def authorize(_, %Blog.User{role: :admin}, _), do: true
# Regular users can create posts
def authorize(:create_post, _, _), do: true
# Regular users can modify their own posts
def authorize(action, %Blog.User{id: user_id}, %Blog.Post{user_id: user_id})
when action in [:update_post, :delete_post], do: true
# Catch-all: deny everything else
def authorize(_, _, _), do: false
end
If you want to keep the policy separate from the context, define a dedicated policy module and use defdelegate:
# lib/my_app/blog/blog.ex
defmodule MyApp.Blog do
defdelegate authorize(action, user, params), to: MyApp.Blog.Policy
end
# lib/my_app/blog/policy.ex
defmodule MyApp.Blog.Policy do
@behaviour Bodyguard.Policy
def authorize(action, user, params), do: # ...
end
Controllers
The action_fallback controller macro is the recommended way to deal with authorization failures. The fallback controller will handle the {:error, reason} results from the main controllers.
# lib/my_app_web/controllers/fallback_controller.ex
defmodule MyAppWeb.FallbackController do
use MyAppWeb, :controller
def call(conn, {:error, :unauthorized}) do
conn
|> put_status(:forbidden)
|> put_view(html: MyAppWeb.ErrorHTML)
|> render(:"403")
end
end
# lib/my_app_controllers/page_controller.ex
defmodule MyAppWeb.PageController do
use MyAppWeb, :controller
# This can be defined here, or in the MyAppWeb.controller/0 macro
action_fallback MyAppWeb.FallbackController
# ...actions here...
end
When Using the Plug
If the Bodyguard.Plug.Authorize plug is being used, its :fallback option must be specified, since the plug pipeline will be halted before the controller action can be called.
Returning "404 Not Found"
Typically, failures will result 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 as a 404.
Related Reading
Bodyguard doesn't make any assumptions about where authorization checks are performed. You can do it before calling into the context, or within the context itself. There is a good discussion of the tradeoffs in this blog post.
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.
Plugs
Bodyguard.Plug.Authorizeโ perform authorization in the middle of a pipeline
This plug's config utilizes callback functions called getters, which are 1-arity functions that
accept the conn and return the appropriate value.
# lib/my_app_web/controllers/post_controller.ex
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
# Fetch the post and put into conn assigns
plug :get_post when action in [:show]
# Do the check
plug Bodyguard.Plug.Authorize,
policy: MyApp.Blog.Policy,
action: {Phoenix.Controller, :action_name},
user: {MyApp.Authentication, :current_user},
params: {__MODULE__, :extract_post},
fallback: MyAppWeb.FallbackController
def show(conn, _) do
# Already assigned and authorized
render(conn, "show.html")
end
defp get_post(conn, _) do
assign(conn, :post, MyApp.Posts.get_post!(conn.params["id"]))
end
# Helper for the Authorize plug
def extract_post(conn), do: conn.assigns.posts
end
See the docs for more information about configuring application-wide defaults for the plug.
LiveViews
Authorization checks can be performed in the mount/3 and handle_event/3 callbacks of a LiveView. See the LiveView documentation for hints and examples.
Schema Scopes
Bodyguard also provides the Bodyguard.Schema behaviour to query which items a user can access. Implement it directly on schema modules.
# lib/my_app/blog/post.ex
defmodule MyApp.Blog.Post do
import Ecto.Query, only: [from: 2]
@behaviour Bodyguard.Schema
def scope(query, %MyApp.Blog.User{id: user_id}, _) do
from ms in query, where: ms.user_id == ^user_id
end
end
To leverage scopes, the Bodyguard.scope/4 helper function (not the callback!) can infer the type of a query and automatically defer to the appropriate callback.
# lib/my_app/blog/blog.ex
defmodule MyApp.Blog do
def list_user_posts(user) do
MyApp.Blog.Post
|> Bodyguard.scope(user) # <-- defers to MyApp.Blog.Post.scope/3
|> where(draft: false)
|> Repo.all
end
end
Configuration
Here is the default library config.
config :bodyguard,
# The second element of the {:error, reason} tuple returned on auth failure
default_error: :unauthorized
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
Installation
-
Add
:bodyguardto your list of dependencies:# mix.exs def deps do [ {:bodyguard, "~> 2.4"} ] end -
Add
@behaviour Bodyguard.Policyto contexts that require authorization, and implementc:Bodyguard.Policy.authorize/3callbacks. -
Create up a fallback controller to render an error on
{:error, :unauthorized}.
Optional Installation Steps
-
Add
@behaviour Bodyguard.Schemaon schemas available for user-scoping, and implementc:Bodyguard.Schema.scope/3callbacks. -
Edit
my_app_web.exand addimport Bodyguardto controllers, views, channels, etc.
Alternatives
Not what you're looking for?
Community
Join our communities!
License
MIT License, Copyright (c) 2024 Rockwell Schrock