Bodyguard Authorization Library

repository·main·Indexed 21 days ago

https://github.com/schrockwell/bodyguard

An authorization library for Elixir designed to protect application context boundaries. Bodyguard allows developers to define permissions via the Bodyguard.Policy and Bodyguard.Schema behaviours, enabling authorization checks across controllers, LiveViews, and other contexts. It provides helpers like permit/4, permit?/4, and scope/4 for filtering Ecto queries, as well as a Bodyguard.Plug.Authorize plug for Phoenix pipelines and a composable Bodyguard.Action system for managing authorization lifecycles.

Tokens
6.7K
Snippets
24
Records
27
Agent score
72%

What's inside Bodyguard

  1. Handle authorization failures with action_fallback

    main

    The recommended way to handle authorization failures in Phoenix controllers is using the action_fallback/1 macro. This allows a dedicated controller to handle {:error, reason} results returned by your actions.

    To prevent leaking the existence of resources, you can return {:error, :not_found} from your policy instead of the default :unauthorized, and handle that specific error in your fallback controller to render a 404.

    # lib/my_app_web/controllers/fallback_controller.ex
    module 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_web/page_controller.ex
    module MyAppWeb.PageController do
      use MyAppWeb, :controller
    
      action_fallback MyAppWeb.FallbackController
    
      # ...actions here...
    end
  2. Install Bodyguard

    main

    To use Bodyguard in your Elixir project, add it to your mix.exs dependencies:

    # mix.exs
    def deps do
      [
        {:bodyguard, "~> 2.4"}
      ]
    end

    After installation, follow these steps to set up authorization:

    1. Add @behaviour Bodyguard.Policy to your context modules and implement the authorize/3 callback.
    2. Create a fallback controller to handle authorization failures (see Controllers).
    3. (Optional) Add @behaviour Bodyguard.Schema to your schema modules for user-scoping.
    4. (Optional) Add import Bodyguard to your my_app_web.ex file to make helpers available in controllers, views, and channels.
  3. How Bodyguard.Action works

    main

    An Action is a composable data structure used to build up authorization parameters throughout a request lifecycle before executing a final task (the job).

    It follows this general lifecycle:

    1. Initialize: Create an action with a context using act(context).
    2. Configure: Add the user (put_user/2), custom policies (put_policy/2), or extra parameters (assign/3).
    3. Authorize: Perform the check using permit/3 or permit!/3. This step uses the provided policy to validate the user against a specific action name.
    4. Execute: Run the task using run/1 or run!/1.

    If authorization succeeds, the job is executed. If it fails, Bodyguard will either execute a provided fallback function or return/raise the authorization error.

    import Bodyguard.Action
          alias MyApp.Blog
    
    act(Blog)
          |> put_user(get_current_user())
          |> put_policy(Blog.SomeSpecialPolicy)
          |> assign(:drafts, true)
          |> permit(:list_posts, drafts: true)
          |> put_job(fn action ->
            Blog.list_posts(action.user, drafts_only: action.assigns.drafts)
          end)
          |> put_fallback(fn _action -> {:error, :not_found} end)
          |> run()
  4. How to delegate scoping to another module

    main

    Instead of defining the scope/3 logic directly inside your schema module, you can delegate the call to a specialized scoping module. This is the preferred pattern to keep schema modules focused on data definition rather than authorization logic.

    If you are migrating from the deprecated use Bodyguard.Schema macro, replace it with a defdelegate call.

    defmodule MyApp.MyModel.MySchema do
      @behaviour Bodyguard.Schema
      # Delegate the scope call to a dedicated module
      defdelegate scope(query, user, params), to: Some.Other.Scope
    end
  5. Use Bodyguard.Plug.Authorize in a Plug pipeline

    main

    The Bodyguard.Plug.Authorize plug performs authorization checks within a Plug pipeline (such as a Phoenix router or controller). It evaluates a policy against a specific action, a user, and optional parameters.

    If authorization fails:

    1. Default behavior: It raises a Bodyguard.NotAuthorizedError directly to the router.
    2. With :fallback: If a :fallback option is provided (a module acting as a plug or controller), the plug calls the fallback with the error and then halts the connection pipeline.
    # Raise on failure
    plug Bodyguard.Plug.Authorize,
      policy: MyApp.Blog,
      action: &action_name/1,
      user: {MyApp.Authentication, :current_user}
    
    # Fallback on failure
    plug Bodyguard.Plug.Authorize,
      policy: MyApp.Blog,
      action: &action_name/1,
      user: {MyApp.Authentication, :current_user},
      fallback: MyAppWeb.FallbackController
    
    # Params as a function
    plug Bodyguard.Plug.Authorize,
      policy: MyApp.Blog,
      action: &action_name/1,
      user: {MyApp.Authentication, :current_user},
      params: &get_params/1
  6. Delegate authorization to another module

    main

    Instead of implementing authorize/3 directly in your context module, it is recommended to define your policy in a dedicated module and use defdelegate to expose it. This keeps your context modules clean and follows the current best practices for Bodyguard.

    Note: While use Bodyguard.Policy is available, it is deprecated. Use defdelegate instead.

    defmodule MyApp.MyContext do
      defdelegate authorize(action, user, params), to: Some.Other.Policy
    end
  7. Test Bodyguard authorization

    main

    Testing authorization is done using the top-level Bodyguard API. You can assert against successful permits, failures, and the specific Bodyguard.NotAuthorizedError raised by permit/4.

    Key functions for testing:

    • Bodyguard.permit(policy, action, user, params): Returns :ok or {:error, reason}.
    • Bodyguard.permit?(policy, action, user, params): Returns true or false.
    • Bodyguard.permit!(policy, action, user, params): Raises Bodyguard.NotAuthorizedError on failure.
    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
  8. Use the Bodyguard.Plug.Authorize plug

    main

    You can perform authorization in the middle of a Phoenix pipeline using Bodyguard.Plug.Authorize.

    Because the plug halts the pipeline on failure, you must specify the :fallback option to point to a controller (like your FallbackController) that can handle the error.

    Configuration options for the plug include:

    • policy: The module implementing @behaviour Bodyguard.Policy.
    • action: A tuple defining the action to check (e.g., {Phoenix.Controller, :action_name}).
    • user: A 1-arity getter function that accepts conn and returns the user.
    • params: A 1-arity getter function that accepts conn and returns the parameters to pass to authorize/3.
    • fallback: The controller to call if authorization fails.
    # lib/my_app_web/controllers/post_controller.ex
    module MyAppWeb.PostController do
      use MyAppWeb, :controller
    
      plug :get_post when action in [:show]
    
      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
        render(conn, "show.html")
      end
    
      defp get_post(conn, _) do
        assign(conn, :post, MyApp.Posts.get_post!(conn.params["id"]))
      end
    
      def extract_post(conn), do: conn.assigns.posts
    end
  9. Implement Schema Scopes with Bodyguard.Schema

    main

    To limit query results per-user (e.g., ensuring a user only sees their own posts), implement the @behaviour Bodyguard.Schema on your schema modules.

    Implementation

    Implement the scope(query, user, params) callback. This function should return an Ecto query that restricts the results based on the provided user.

    Usage

    Use the Bodyguard.scope/4 helper function (not the callback) to automatically defer to the schema's scope/3 implementation.

    # lib/my_app/blog/post.ex
    module 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
    
    # lib/my_app/blog/blog.ex
    module MyApp.Blog do
      def list_user_posts(user) do
        MyApp.Blog.Post
        |> Bodyguard.scope(user)
        |> where(draft: false)
        |> Repo.all
      end
    end
  10. Implement the Bodyguard.Policy behaviour

    main

    To define authorization rules, add @behaviour Bodyguard.Policy to a module (typically a context). You must implement the authorize(action, user, params) callback.

    Callback Signature

    authorize(action, user, params)

    Return Values

    Your callback should return one of the following to indicate the result:

    • :ok or true: Permit the action.
    • :error, {:error, reason}, or false: Deny the action.

    Using Bodyguard.permit/4

    Do not call your authorize/3 callback directly. Instead, use Bodyguard.permit/4. This helper:

    1. Converts params (which may be a keyword list) into a map.
    2. Coerces your callback result into a strict :ok or {:error, reason} tuple.
    3. Defaults to {:error, :unauthorized} if the result is a failure.

    If you want to keep policies separate from your context, you can use defdelegate to forward the authorize/3 call to a dedicated policy 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
  11. Create a custom wrapper for Bodyguard.Plug.Authorize

    main

    You can provide default options for the authorize plug by wrapping it in your own module. This is useful for setting up standard getters for users or actions across your application (e.g., integrating with Pow).

    defmodule MyAppWeb.Authorize do
      def init(opts) do
        opts
        |> Keyword.put_new(:action, {Phoenix.Controller, :action_name})
        |> Keyword.put_new(:user, {Pow.Plug, :current_user})
        |> Bodyguard.Plug.Authorize.init()
      end
    
      def call(conn, opts) do
        Bodyguard.Plug.Authorize.call(conn, opts)
      end
    end
  12. Check authorization with permit?/4

    main

    Use permit?/4 when you need a simple boolean check to see if an action is allowed. It returns true if the policy returns :ok or true, and false otherwise.

    if permit?(MyPolicy, :delete, current_user, %{post_id: 123}) do
      # proceed with deletion
    else
      # handle unauthorized
    end