Skip to content

Phoenix, Plug and LiveView integrations for the Permit authorization library.

License

Notifications You must be signed in to change notification settings

curiosum-dev/permit_phoenix

Permit.Phoenix

Phoenix Framework and LiveView integration for Permit - Authorization made simple for controllers and live views.

Contact Us Visit Curiosum License: MIT


Purpose and usage

Permit.Phoenix provides seamless authorization integration for Phoenix Framework applications, enabling consistent permission checking across controllers and LiveViews without code duplication.

Key features:

  • Automatic authorization - Plug-based controllers and LiveViews authorize actions automatically
  • Resource preloading - Automatically load and scope single database records and lists based on user permissions
  • LiveView 1.0+ support - Optional integration with streams and modern LiveView features
  • Flexible error handling - Customizable unauthorized and not-found behaviors
  • Router integration - Automatic action mapping from Phoenix routes
  • Event authorization - Authorize LiveView events with custom mapping

Hex version badge Actions Status Code coverage badge License badge

Installation

The package can be installed by adding permit_phoenix to your list of dependencies in mix.exs:

def deps do
  [
    {:permit, "~> 0.3.2"},          # Core authorization library
    {:permit_phoenix, "~> 0.4.0"},  # Phoenix & LiveView integration
    {:permit_ecto, "~> 0.2.4"}      # Optional: for database integration
  ]
end

For GraphQL support, also add :permit_absinthe.

Quick start

Assumes Phoenix 1.8+ and authentication generated with mix phx.gen.auth, with scopes used by default (i.e. current user is available as @current_scope.user).

  1. Create your Actions module (lib/my_app/actions.ex):

    defmodule MyApp.Actions do
      # Permission-defining functions will be generated based on action names from the router.
      use Permit.Phoenix.Actions, router: MyAppWeb.Router
    end
  2. Create your Permissions module (lib/my_app/permissions.ex):

    defmodule MyApp.Permissions do
      use Permit.Ecto.Permissions, actions_module: MyApp.Actions
    
      def can(%MyApp.Accounts.Scope{user: %{id: user_id}}) do
        permit()
        |> all(MyApp.Article, author_id: user_id)
        |> read(MyApp.Article)
      end
    
      def can(_), do: permit()
    end
  3. Create your Authorization module (lib/my_app/authorization.ex):

    defmodule MyApp.Authorization do
      use Permit.Ecto,
        permissions_module: MyApp.Permissions,
        repo: MyApp.Repo
    end
  4. Configure your web module (lib/my_app_web/web.ex):

    # In controller/0:
    use Permit.Phoenix.Controller,
      authorization_module: MyApp.Authorization
    
    # In live_view/0:
    use Permit.Phoenix.LiveView,
      authorization_module: MyApp.Authorization
  5. Update your router for LiveView integration (lib/my_app_web/router.ex):

    live_session :require_authenticated_user,
      on_mount: [
        {MyAppWeb.UserAuth, :ensure_authenticated},
        Permit.Phoenix.LiveView.AuthorizeHook  # Add this line
      ] do
      # your routes
    end

How it works

  • Permit provides the permission definition syntax
  • Permit.Ecto is optional, but - if present - it constructs queries to look up accessible records from a database, based on defined permissions
  • Permit.Phoenix plugs into controllers and live views in order to automatically preload records and check authorization permissions to perform actions.

Requires :permit and :permit_phoenix packages, with optional :permit_ecto for database integration.

Configuration

While in basic Permit all actions must be defined in a module implementing the Permit.Actions behaviour, in the grouping_schema/0 callback implementation, in Phoenix it is potentially inconvenient - adding a new controller action name would require adding it to the grouping_schema/0 implementation every single time.

For this reason, Permit.Phoenix provides the Permit.Phoenix.Actions module, building upon the standard way of defining action names with Permit.Actions and additionally enabling you to automatically define actions based on controller and LiveView actions defined in the router.

defmodule MyApp.Authorization do
  use Permit.Ecto,
    permissions_module: MyApp.Permissions,
    repo: MyApp.Repo
end

defmodule MyApp.Actions do
  # Merge the actions from the router into the default grouping schema.
  use Permit.Phoenix.Actions, router: MyAppWeb.Router
end

defmodule MyAppWeb.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  # :view and :watch will get imported into `MyApp.Actions.grouping_schema/0`.
  # This way you won't have to add them manually.
  get("/items/:id/view", MyAppWeb.ItemController, :view)
  live("/items/:id/watch", MyAppWeb.ItemLive, :watch)
end

defmodule MyApp.Permissions do
  @moduledoc false
  use Permit.Ecto.Permissions, actions_module: MyApp.Actions

  def can(%{id: user_id} = _user) do
    permit()
    |> create(MyApp.Item)
    |> view(MyApp.Item, owner_id: user_id)
    |> watch(MyApp.Item, owner_id: user_id)
  end

  def can(_user), do: permit()
end

The view/3 and watch/3 functions are shorthands to permission_to/4 in which the first argument would've been :view or :watch, respectively - they're generated based on the module implementing grouping_schema/0 callback from Permit.Actions.

Controllers

All options of Permit.Phoenix.Controller can be provided as option keywords with use Permit.Phoenix.Controller or as callback implementations. For example, defining a handle_unauthorized: fn action, conn -> ... end option is equivalent to:

@impl true
def handle_unauthorized(action, conn), do: ...

In practice, it depends on use case:

  • when providing options for different actions, etc., consider using callback implementations
  • if you want to provide values as literals instead of functions, consider using option keywords
  • for global settings throughout controllers using use MyAppWeb, :controller, set globals as keywords, and override in specific controllers using callback implementations.

Whenever resolution_context is referred to, it is typified by Permit.Types.resolution_context.

One-off usage

defmodule MyAppWeb.ArticleController do
  use Permit.Phoenix.Controller,
    # Mandatory options:
    authorization_module: MyApp.Authorization,
    resource_module: MyApp.Article,

    # Additional available options:
    fallback_path: fn action, conn -> ... end,
    handle_unauthorized: fn action, conn -> ... end,
    fetch_subject: fn conn -> ... end,
    preload_actions: [:action1, :action2, ...],
    except: [:action3, :action4, ...],
    id_param_name: fn action, conn -> ... end,
    id_struct_field_name: fn action, conn -> ... end,

    # Non-Ecto only:
    loader: fn resolution_context -> ... end,

    # Ecto only:
    base_query: fn resolution_context -> ... end,
    finalize_query: fn query, resolution_context -> ... end

  def show(conn, params) do
    # If there is a MyApp.Article with ID == params[:id] that
    # matches the current user's permissions, it will be
    # available as the @loaded_resource assign.
    #
    # Otherwise, handle_unauthorized/2 is called, defaulting to
    # redirecting to `/`.
  end

  def index(conn, params) do
    # If the :index action is authorized for the user, the
    # @loaded_resources assign will contain all records accessible
    # by the current user per the app's permissions configuration.
    #
    # Pagination and other concerns can be configured with
    # the base_query/1 callback.
    #
    # Otherwise, handle_unauthorized/2 is called, defaulting to
    # redirecting to `/`.
  end
end

Global usage with settings in specific controllers

defmodule MyAppWeb do
  def controller do
    quote do
      # ...
      use Permit.Phoenix.Controller,
        authorization_module: MyApp.Authorization,
        # global options go here
    end
  end
end

defmodule MyAppWeb.ArticleController do
  use MyAppWeb, :controller

  @impl true
  def resource_module, do: MyApp.Article

  # etc., etc.
end

Using without Ecto

If you're not using Ecto, you can provide a custom loader function:

defmodule MyAppWeb.ArticleController do
  # Capture a function to be used as loader
  # (see Permit.Phoenix.Controller.loader/1 callback).
  use Permit.Phoenix.Controller,
    authorization_module: MyApp.Authorization,
    resource_module: MyApp.Article,
    loader: &MyApp.ArticleContext.load/1

  # Alternatively, loader function (adhering to the same callback signature)
  # can be defined directly in a controller.
  @impl true
  def loader(%{action: :index, params: params}) do
    MyApp.ArticleContext.list_articles(params)
  end

  def loader(%{action: action, params: %{"id" => id}})
    when action in [:show, :edit, :update, :delete] do
    MyApp.ArticleContext.get_article(id)
  end
end

Advanced error handling

defmodule MyAppWeb.ArticleController do
  use Permit.Phoenix.Controller,
    authorization_module: MyApp.Authorization,
    resource_module: MyApp.Article

  @impl true
  def handle_unauthorized(action, conn) do
    case get_format(conn) do
      "json" ->
        conn
        |> put_status(:forbidden)
        |> json(%{error: "Access denied"})
        |> halt()

      "html" ->
        conn
        |> put_flash(:error, "You don't have permission for this action")
        |> redirect(to: "/")
        |> halt()
    end
  end

  @impl true
  def handle_not_found(conn) do
    conn
    |> put_status(:not_found)
    |> put_flash(:error, "Resource not found")
    |> redirect(to: "/")
    |> halt()
  end

  @impl true
  def unauthorized_message(action, conn) do
    "You cannot #{action} this article"
  end
end

LiveView

To use Permit.Phoenix with LiveView, the provided hook module must be added to the :on_mount option of the live_session in the router, then configure authorization in your app's LiveView modules.

Router configuration

defmodule MyAppWeb.Router do
  # ...

  scope "/", MyAppWeb do
    # ...

    # Configure using an :on_mount hook
    live_session :my_app_session, on_mount: [
      {MyAppWeb.UserAuth, :ensure_authenticated},
      Permit.Phoenix.LiveView.AuthorizeHook # Add after authentication
    ] do
      # The :live_action names provided here will be
      live "/live/articles", ArticleLive.Index, :index
      live "/live/articles/new", ArticleLive.Index, :new
      live "/live/articles/:id/edit", ArticleLive.Index, :edit

      live "/live/articles/:id", ArticleLive.Show, :show
      live "/live/articles/:id/show/edit", ArticleLive.Show, :edit
    end
  end
end

LiveView configuration

Permit.Phoenix.LiveView performs authorization at three key points:

  1. During mount - via the on_mount: Permit.Phoenix.LiveView.AuthorizeHook
  2. During live navigation - when handle_params/3 is called and :live_action changes
  3. During events - when handle_event/3 is called for events defined in event_mapping/0

In a similar way to configuring controllers, LiveViews can be configured with option keywords or callback implementations, thus let's omit lengthy examples of both.

Most options are similar to controller options, with socket in place of conn.

defmodule MyAppWeb.ArticleLive.Index do
  use MyAppWeb, :live_view

  use Permit.Phoenix.LiveView,
    authorization_module: MyApp.Authorization,
    resource_module: MyApp.Article

  @impl true
  def mount(_params, _session, socket) do
    # If the :index action is authorized, @loaded_resources assign
    # will contain the list of accessible resources (maybe empty).
    #
    # Pagination, etc. can be configured using base_query/1 callback.
  end

  @impl true
  def handle_params(params, _url, socket) do
    # If assigns[:live_action] has changed, authorization and preloading occurs.
    #
    # If authorized successfully, it is assigned into @loaded_resource or
    # @loaded_resources for singular and plural actions, respectively.
    #
    # If authorization fails, the default implementation of handle_unauthorized/2
    # does:
    #   {:halt, push_redirect(socket, to: "/")}
    # Alternatively you can implement a callback to do something different,
    # for instance you can do {:cont, ...} and assign something to the socket
    # to display a message.
  end
end

Authorizing LiveView Events

You can also authorize Phoenix LiveView events:

defmodule MyAppWeb.ArticleLive.Show do
  use Permit.Phoenix.LiveView,
    authorization_module: MyApp.Authorization,
    resource_module: MyApp.Article

  @impl true
  def handle_event("delete", params, socket) do
    # Event authorization happens automatically based on event_mapping
    {:noreply, socket}
  end

  # Customize event to action mapping: "delete" event will be authorized against
  # Permit rules for :delete action on MyApp.Article.
  @impl true
  def event_mapping do
    %{
      "delete" => :delete,
      "archive" => :update,
      "publish" => :create
    }
  end
end

Using streams in LiveView

For better performance with large datasets, you can use streams instead of assigns:

defmodule MyAppWeb.ArticleLive.Index do
  # Configure Permit.Phoenix.LiveView to use streams in plural actions
  # such as :index.
  use Permit.Phoenix.LiveView,
    authorization_module: MyApp.Authorization,
    resource_module: MyApp.Article,
    use_stream?: true

  # Alternatively, use a callback for conditional stream usage.
  #
  # You needn't set use_stream? to false with singular actions, e.g. :show, etc.
  # - in their case, even if set to true, normal assigns will be used.
  @impl true
  def use_stream?(%{assigns: %{live_action: :index}} = _socket), do: true
  def use_stream?(_socket), do: false

  @impl true
  def handle_params(_params, _url, socket) do
    # Resources are now available as the :loaded_resources stream if navigating
    # to a plural action.
    {:noreply, socket}
  end
end

Handling authorization errors in LiveView

LiveView error handling in Permit.Phoenix covers both navigation-based authorization (via :live_action) and event-based authorization. Understanding when to use {:cont, socket} vs {:halt, socket} and the role of navigation is crucial for proper error handling.

By default, authorization errors result in displaying a flash message (customizable using the :unauthorized_message option or callback). If needed (e.g. entering a route via a direct link from outside a LiveView session), the :fallback_path option is configurable so it can be navigated to (defaulting to /).

Permit.Phoenix provides a useful mounting?/1 function to help you determine the appropriate error handling response

  • which may be different depending on whether the page is being rendered server-side, or it is dealing with in-place navigation via handle_params.
defmodule MyAppWeb.ArticleLive.Show do
  use Permit.Phoenix.LiveView,
    authorization_module: MyApp.Authorization,
    resource_module: MyApp.Article

  @impl true
  def handle_unauthorized(action, socket) do
    # Use mounting?/1 to determine the appropriate response
    if mounting?(socket) do
      # During mount - redirect is required for halt to work properly
      socket =
        socket
        |> put_flash(:error, "Access denied")
        |> push_navigate(to: ~p"/articles")

      {:halt, socket}  # Must redirect during mount
    else
      # During handle_params navigation - can stay on page
      socket =
        socket
        |> assign(:access_denied, true)
        |> put_flash(:error, "Access denied for this view")

      {:cont, socket}  # Can show inline error during navigation
    end
  end
end

Subjects, current user, and Phoenix Scopes

Permit's subject is typically the current user, in other words, the actor that is performing the action; or any data structure that represents the actor and contains all the information needed to verify its permissions against a resource.

The subject is passed to the permission-defining functions in your Permissions module, so its fields can be pattern matched on.

In some cases, you may need to authorize against a different structure.

  • For purely role-based authorization, the subject would just be the current user's :role field.
  • When Phoenix Scopes are used, and other scope-encapsulated data (e.g. the user's tenant organization) is needed, the subject would be the entire scope struct.

This can be customized using options described below.

Configuration with Phoenix Scopes

Permit.Phoenix LiveView and Controller integrations supports Phoenix Scopes (available in Phoenix 1.8+), which are data structures that hold information about the current request or session (current user, organization, permissions, etc.). Scopes are particularly useful for multi-tenant applications or when you need to maintain more than just user information.

This is used by default in the current version of Phoenix (>= 1.8) and LiveView, and is recommended.

First, ensure your scope is defined (usually generated by mix phx.gen.auth):

# lib/my_app/accounts/scope.ex
defmodule MyApp.Accounts.Scope do
  alias MyApp.Accounts.User

  defstruct user: nil

  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil
end

Examples below are for LiveView, but configuration for controllers is identical - using use option keywords or allback implementations.

Then, configure your LiveView to use scopes - in the current version of Phoenix (>= 1.8) and LiveView, this is really all you need to do now:

defmodule MyAppWeb.ArticleLive.Index do
  # Put it in the controller, or the `MyAppWeb` module's `live_view` function
  use Permit.Phoenix.LiveView,
    authorization_module: MyApp.Authorization,
    resource_module: MyApp.Article

  # If you're using Phoenix >=1.8's `mix phx.gen.auth` and only need to authorize against,
  # the current user (`@current_scope.user`), that's all!
end

For compatibility with projects created with Phoenix <1.8, or when using a custom configuration, you can disable scope-based authorization and use the traditional approach:

defmodule MyAppWeb do
  def live_view do
    quote do
      use Permit.Phoenix.LiveView,
        authorization_module: MyApp.Authorization,
        resource_module: MyApp.Article,
        scope_subject: :admin # Use the admin key as the subject by default
        use_scope?: false, # Switch to authorizing against @current_user
        fetch_subject: fn _socket, session -> ... end # Fetch the subject from the session
    end
  end
end

Then, you can override the options in a specific LiveView using callbacks - see traditional configuration example below.

Custom Scope-Subject Mapping

You can configure that the subject should be the entire scope struct, instead of just the user key, by setting scope_subject to scope itself, or perhaps a different key in the scope, e.g. :admin.

defmodule MyAppWeb.ArticleLive.Index do
  use MyAppWeb, :live_view

  # Use a different key (e.g. `@current_scope.admin`), or the entire scope as the
  # subject
  @impl true
  def scope_subject(scope), do: scope

  @impl true
  def mount(_params, _session, socket) do
    # socket.assigns.current_scope contains whatever is needed in the app's context
    {:ok, socket}
  end
end

If you've configured scope_subject as scope itself, inside the can/1 predicates you'll have access to the entire scope struct.

Update your permissions to work with scopes:

defmodule MyApp.Permissions do
  use Permit.Ecto.Permissions, actions_module: MyApp.Actions

  # The subject passed will be the scope struct
  def can(%MyApp.Accounts.Scope{user: %{id: user_id}}) do
    permit()
    |> read(MyApp.Article, user_id: user_id)
    |> create(MyApp.Article)
  end

  def can(_scope), do: permit()
end

Configuration without Phoenix Scopes (Traditional)

For applications not using Phoenix Scopes, continue using the traditional approach and use the fetch_subject/2 callback to fetch the subject from the session:

defmodule MyAppWeb.ArticleLive.Index do
  use MyAppWeb, :live_view

  # For Phoenix projects bootstrapped below 1.8, disable scope-based authorization
  # (will take current user from the :current_user assign)
  @impl true
  def use_scope?, do: false

  # Optional - if you need to fetch the subject differently than by default (from
  # the :current_scope assign or the current_user assign)
  @impl true
  def fetch_subject(_socket, session) do
    # Fetch and return the current user directly
    user_token = session["user_token"]
    user_token && MyApp.Accounts.get_user_by_session_token(user_token)
  end

  @impl true
  def mount(_params, _session, socket) do
    # The user is available as socket.assigns.current_user
    {:ok, socket}
  end
end

Actions: naming and grouping

Actions defined in the app's Actions module generate convenience functions in your permissions module to grant authorization to them:

defmodule MyApp.Actions do
  use Permit.Actions

  def grouping_schema do
    %{
      view: []
    }
  end
end

defmodule MyApp.Permissions do
  use Permit.Permissions, actions_module: MyApp.Actions

  def can(_user) do
    permit()
    |> view(MyApp.Item) # view/1 generated by grouping_schema/0
  end
end

Corresponding action_name?/2 functions are generated for each action in the grouping schema in the authorization module, so you can perform an authorization check.

iex> MyApp.Authorization.can(%{id: 1}) |> MyApp.Authorization.view?(%MyApp.Item{id: 1})
true

Action grouping

Thanks to default mapping defined in Permit.Phoenix.Actions, the default :create, :read, and :update permissions are automatically extended to :new (for :create), :index and :show (for :read), and :edit (for :update) - this is for convenience when using default Phoenix action names.

This is inspired by CanCanCan's default behaviour - Ruby on Rails practitioners may be familiar with it.

By default, Permit.Phoenix.Actions provides the following action mapping to implement this behaviour:

%{
  new: [:create],
  index: [:read],
  show: [:read],
  edit: [:update],
  delete: []
}

Then, :read permission will also permit :index and :show - both in direct checks via your authorization module, and in automatic load-and-authorize flow in LiveViews and controllers.

def can(_user) do
  permit()
  |> read(MyApp.Item) # allows :index and :show
end

iex> MyApp.Authorization.can(%{id: 1}) |> MyApp.Authorization.read?(%MyApp.Item{id: 1})
true

iex> MyApp.Authorization.can(%{id: 1}) |> MyApp.Authorization.show?(%MyApp.Item{id: 1})
true

iex> MyApp.Authorization.can(%{id: 1}) |> MyApp.Authorization.index?(%MyApp.Item{id: 1})
true

Action plurality

Actions are either singular (e.g. :show, :edit, :new, :delete, :update, :create) or plural (e.g. :index)

  • in singular actions, the resource is loaded and authorized as a single record, while in plural actions, the resources are loaded and authorized as a collection of records.

By default, an action is considered singular if it's one of: :show, :edit, :new, :delete, :update, :create. Using the singular_actions/0 callback, you can override this behaviour and declare additional singular actions.

Overriding is possible either in the actions module, or in the controller or LiveView module itself, which takes precedence.

Recommended: use the :router option described in the next section, so that all action names are automatically included in the actions module, and their plurality is determined based on the route definition.

Actions from routes

For convenience, the :router option of use Permit.Phoenix.Actions allows taking action names from the router

  • it will include all controller action names and defined :live_action names for live routes.

The actions will be automatically inferred to be singular or plural based on the route definition. An action is singular by default if:

  • it's one of: :show, :edit, :new, :delete, :update, :create, or
  • it is a POST request, or
  • it's a route with an :id, :uuid or :slug parameter, e.g. /items/:id/view or /items/:uuid/view, or
  • the route's last segment is a parameter, e.g. /items/:name, /items/:identifier.
defmodule MyApp.Router do
  # ...

  get("/items/:id", MyApp.ItemController, :view)
end

defmodule MyApp.Actions do
  # Will include :view action in the grouping schema
  use Permit.Phoenix.Actions, router: MyApp.Router
end

Ecosystem

Permit.Phoenix is part of the modular Permit ecosystem:

Package Version Description
permit Hex.pm Core authorization library
permit_ecto Hex.pm Ecto integration for database queries
permit_phoenix Hex.pm Phoenix Controllers & LiveView integration
permit_absinthe Hex.pm GraphQL API authorization via Absinthe

Documentation

Contributing

We welcome contributions! Please see our Contributing Guide for details.

Development setup

Just clone the repository, install dependencies normally, develop and run tests. When running Credo and Dialyzer, please use MIX_ENV=test to ensure tests and support files are validated, too.

Community

Contact

License

This project is licensed under the MIT License - see the LICENSE file for details.

About

Phoenix, Plug and LiveView integrations for the Permit authorization library.

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •  

Languages