Permit.Phoenix.Controller behaviour (permit_phoenix v0.4.0)
View SourceConfigures and injects the authorization plug for Phoenix controllers.
Mechanism overview
Permit.Phoenix.Controller uses an internal plug module that does the following:
- Get everything needed to authorize the current action:
- Action - from current controller action name, e.g.
:updateor:index, - Resource module - from the controller module configuration (
:resource_modulePlug option), e.g.MyApp.Article - Subject - from
@current_user,@current_scope.useror whatever else is configured.
- Action - from current controller action name, e.g.
- In "many" actions (e.g.
:index):- Check for permission to perform the action on the resource module.
- If authorized, load the list of records using Ecto (queried by authorization conditions), or a custom laoder function,
and assign the filtered list to
@loaded_resources.
- In "one" actions (e.g.
:update):- Load the resource using Ecto (queried by authorization conditions and the
"id"param, by default) or a custom loader function, - Check authorization conditions on the loaded resource. If authorized, assign the resource to
@loaded_resource.
- Load the resource using Ecto (queried by authorization conditions and the
- Handle authorization failure:
- Call
handle_unauthorized/2callback if unauthorized, which defaults to redirecting tofallback_path/2. - Call
handle_not_found/1callback if the resource is not found, which defaults to raisingPermit.Phoenix.RecordNotFoundError.
- Call
Usage
Basic setup:
defmodule MyAppWeb.ArticleController do
use MyAppWeb, :controller
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization,
resource_module: MyApp.Article
def index(conn, params) do
# @loaded_resources is assigned if authorized, containing filtered records
end
def show(conn, params) do
# @loaded_resource is assigned if authorized
end
endIt is recommended to set it up in your app's main web module and then override specific options in individual controllers.:
defmodule MyAppWeb do
def controller do
quote do
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization
end
end
end
defmodule MyAppWeb.ArticleController do
use MyAppWeb, :controller
# Set the resource module for this controller
@impl true
def resource_module, do: MyApp.Article
# Controller actions...
endHandling authorization failure can be customized at several levels:
Customize the fallback path and error message
By default, the plug will redirect to the fallback path and display a flash message with the default error message.
@impl true
def fallback_path(action, conn) do
# Default implementation
"/"
end
@impl true
def unauthorized_message(action, conn) do
# Default implementation
"You are not authorized to perform this action"
endFully customize error handling behaviour
Optionally, you can fully customize the error handling behaviour by implementing the handle_unauthorized/2
and handle_not_found/1 callbacks. These are also available as keyword options, but not recommended.
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization,
resource_module: MyApp.Article
@impl true
def handle_unauthorized(action, conn) do
# Default implementation
conn
|> put_flash(:error, "You are not authorized to perform this action")
|> redirect(to: "/")
|> halt()
end
@impl true
def handle_not_found(conn) do
# Default implementation
raise Permit.Phoenix.RecordNotFoundError, "Expected at least one result but got none"
endEcto query generation
Permit.Phoenix uses Permit.Ecto to convert defined permissions into Ecto queries. For example, if there is a permission
to delete(Article, author_id: user_id, draft: true), inside the delete controller action
it will generate a WHERE article.author_id = $1 AND draft = TRUE query. All operators defined in Permit.Operators
are supported - for reference, see Permit.Operators documentation.
In actions routed via a parent resource, you need to customize the Ecto query to filter records by the parent resource ID.
For this purpose, base_query/1 callback is available; you can also use the finalize_query/2 callback to post-process
the query.
defmodule MyAppWeb.CommentController do
use MyAppWeb, :controller
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization,
resource_module: MyApp.Article
@impl true
def base_query(%{action: :index, params: %{"article_id" => article_id}} = context) do
# Chain the originally constructed query with a custom query to filter by the parent resource ID
super(context)
|> MyApp.CommentQueries.by_article_id(article_id)
end
def index(conn, params) do
# @loaded_resources is assigned if authorized, records filtered by both the parent resource ID
# and the current user's permissions
end
endController and Permit actions
Controller actions are mapped to permission action names in the following order:
- via the
action_grouping/0callback, - as configured in your app's
Permit.Phoenix.Actionsimplementation.
Likewise, Permit determines which actions need to preload a single record (e.g. :show)
or a list of records (e.g. :index) in the following order:
- via the
singular_actions/0callback, - as configured in your app's
Permit.Phoenix.Actionsimplementation.
Default singular actions are [:show, :edit, :new, :delete, :update], any other action
is plural by default. By default, all actions preload records except those in skip_preload/0
(:create and :new by default, as there's nothing to preload for these actions).
Implementing skip_preload/0 allows opting out of preloading records for chosen actions,
in which case only the resource name is authorized against.
By default, Permit.Phoenix.Actions defines the following convenience shorthands:
:indexand:showcontroller actions are authorized with the:readpermission,:newand:createcontroller actions are authorized with the:createpermission,:editand:updatecontroller actions are authorized with the:updatepermission.:deleteaction is defined as standalone. SeePermit.Phoenix.Actionsdocumentation for more details on action grouping.
It is recommended to have the actions module read action names from the router, so that your permissions module has convenience functions for using each action.
defmodule MyApp.Actions do
# Merge the actions from the router into the default grouping schema.
use Permit.Phoenix.Actions, router: MyApp.Router
endOptions
For reference regarding the options, see callback documentation below.
In use keywords, options correspond to callback names and can be defined as:
- literal expressions,
- captured functions that match the corresponding callback signature. Anonymous functions are not supported because of compiler limitations.
Summary
Callbacks
Defines the action grouping schema for this controller. This can be overridden in individual controllers to customize the action mapping.
Configures the controller with the application's authorization configuration.
Creates the basis for an Ecto query constructed by Permit.Ecto based on controller action,
resource module, subject (taken from current_scope.user unless configured otherwise)
and controller params.
Allows opting out of using Permit for given controller actions.
If handle_unauthorized/2 is not customized, sets the fallback path to which the user is redirected
on authorization failure.
Retrieves the authorization subject from conn. Defaults to current_scope.user if use_scope?/0 is true,
otherwise conn.assigns[:current_user].
Post-processes an Ecto query constructed by Permit.Ecto. Usually, base_query/1 should
be used; the only case when finalize_query/2 should be used is when you need to modify the query
based on conditions derived from the generated query structure.
Called when a record is not found.
Called when authorization on an action or a loaded record is not granted. Must halt conn after rendering or redirecting.
Sets the name of the ID param that will be used for preloading a record for authorization.
Sets the name of the field that contains the resource's ID which should be looked for.
If Permit.Ecto is not used, it allows defining a loader function that loads a record
or a list of records, depending on action type (singular or plural).
Deprecated: Use skip_preload/0 instead.
Declares the controller's resource module. For instance, when Phoenix and Ecto is used, typically for an ArticleController the resource will be an Article Ecto schema.
Maps the current Phoenix scope to the subject, if Phoenix Scopes are used (see the use_scope?/0 callback).
Defaults to scope.user.
Defines which actions are considered singular (operating on a single resource). This can be overridden in individual controllers to customize the singular actions.
Declares which actions in the controller should skip automatic record preloading.
If handle_unauthorized/2 is not customized, sets the error message to display when authorization fails.
Determines whether to use Phoenix Scopes for fetching the subject.
Callbacks
@callback action_grouping() :: map()
Defines the action grouping schema for this controller. This can be overridden in individual controllers to customize the action mapping.
Example
@impl true
def action_grouping do
%{
new: [:create],
index: [:read],
show: [:read],
edit: [:update],
create: [:create],
update: [:update],
delete: [:delete]
}
end
@callback authorization_module() :: Permit.Types.authorization_module()
Configures the controller with the application's authorization configuration.
Example
@impl Permit.Phoenix.Controller
def authorization_module, do: MyApp.Authorization
# Requires defining an authorization configuration module
defmodule MyApp.Authorization, do:
use Permit, permissions_module: MyApp.Permissions
@callback base_query(Permit.Types.resolution_context()) :: Ecto.Query.t()
Creates the basis for an Ecto query constructed by Permit.Ecto based on controller action,
resource module, subject (taken from current_scope.user unless configured otherwise)
and controller params.
It's recommended to call super(arg) in your implementation to ensure proper
base query handling for both singular actions (like :show, which need ID filtering)
and plural actions (like :index, which may handle delete events).
Typically useful when using nested resource routes.
In an action routed like /users/:user_id/posts/:id, you can use the base_query/1 callback to
filter records by user_id, while filtering by id itself will be applied automatically
(the name of the ID parameter can be overridden with the id_param_name/2 callback).
Example
defmodule MyApp.CommentController do
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization
resource_module: MyApp.Blog.Comment
@impl true
def base_query(%{
action: :index,
params: %{"article_id" => article_id}
}) do
MyApp.CommentQueries.by_article_id(article_id)
end
end
@callback except() :: [Permit.Types.action_group()]
Allows opting out of using Permit for given controller actions.
Defaults to [], thus by default all actions are guarded with Permit.
Example
@impl true
def except do
[:index]
end
@callback fallback_path(Permit.Types.action_group(), Permit.Phoenix.Types.conn()) :: binary()
If handle_unauthorized/2 is not customized, sets the fallback path to which the user is redirected
on authorization failure.
Defaults to /.
Example
@impl true
def fallback_path(action, conn) do
case action do
:view -> "/unauthorized"
_ -> "/"
end
end
@callback fetch_subject(Permit.Phoenix.Types.conn()) :: Permit.Types.subject()
Retrieves the authorization subject from conn. Defaults to current_scope.user if use_scope?/0 is true,
otherwise conn.assigns[:current_user].
Example
@impl true
def fetch_subject(%{assigns: assigns}) do
assigns[:user]
end
@callback finalize_query(Ecto.Query.t(), Permit.Types.resolution_context()) :: Ecto.Query.t()
Post-processes an Ecto query constructed by Permit.Ecto. Usually, base_query/1 should
be used; the only case when finalize_query/2 should be used is when you need to modify the query
based on conditions derived from the generated query structure.
Example
defmodule MyApp.CommentController do
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization
resource_module: MyApp.Blog.Comment
# just for demonstration - please don't do it directly in controllers
import Ecto.Query
@impl true
def finalize_query(query, %{
action: :index,
}) do
query
|> preload([c], [:user])
end
end
@callback handle_not_found(Permit.Phoenix.Types.conn()) :: Permit.Phoenix.Types.conn()
Called when a record is not found.
Defaults to raising a Permit.Phoenix.RecordNotFoundError.
Example
@impl true
def handle_not_found(conn) do
case get_format(conn) do
"json" ->
# render a 4xx JSON response
"html" ->
# handle HTML response, e.g. redirect
end
end
@callback handle_unauthorized(Permit.Types.action_group(), Permit.Phoenix.Types.conn()) :: Permit.Phoenix.Types.conn()
Called when authorization on an action or a loaded record is not granted. Must halt conn after rendering or redirecting.
Example
@impl true
def handle_unauthorized(action, conn) do
case get_format(conn) do
"json" ->
# render a 4xx JSON response
"html" ->
# handle HTML response, e.g. redirect
end
end
@callback id_param_name(Permit.Types.action_group(), Permit.Phoenix.Types.conn()) :: binary()
Sets the name of the ID param that will be used for preloading a record for authorization.
Defaults to "id". If the route contains a different name of the record ID param, it should be changed accordingly.
Example
@impl true
def id_param_name(_action, _conn) do
"document"
end
@callback id_struct_field_name(Permit.Types.action_group(), Permit.Phoenix.Types.conn()) :: atom()
Sets the name of the field that contains the resource's ID which should be looked for.
Defaults to :id. If the record's ID (usually a primary key) is in a different field, then it should be changed accordingly.
Example
@impl true
def id_struct_field_name(_action, _conn) do
:uuid
end
@callback loader(Permit.Types.resolution_context()) :: Permit.Types.object() | nil
If Permit.Ecto is not used, it allows defining a loader function that loads a record
or a list of records, depending on action type (singular or plural).
In the argument, the resolution context is passed, which contains the action, params, conn, etc.
Example
@impl true
def loader(%{action: :index, params: %{page: page}}),
do: ItemContext.load_all(page: page)
def loader(%{action: :show}, params: %{id: id}),
do: ItemContext.load(id)
@callback preload_actions() :: [Permit.Types.action_group()]
Deprecated: Use skip_preload/0 instead.
Declares which actions in the controller are to use Permit's automatic preloading and authorization.
This callback is deprecated in favor of skip_preload/0 which inverts the logic - instead of
whitelisting actions that preload, you blacklist actions that should skip preloading.
Example
@impl true
def preload_actions do
[:view]
end
@callback resource_module() :: Permit.Types.resource_module()
Declares the controller's resource module. For instance, when Phoenix and Ecto is used, typically for an ArticleController the resource will be an Article Ecto schema.
This resource module, along with the controller action name, will be used for authorization checks before each action.
If Permit.Ecto is used, this setting selects the Ecto schema which will be used for automatic preloading a record for authorization.
Example
defmodule MyApp.ArticleController do
use Permit.Phoenix.Controller
def authorization_module, do: MyApp.Authorization
def resource_module, do: MyApp.Article
# Alternatively, you can do the following:
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization,
resource_module: MyApp.Blog.Article
end
@callback scope_subject(map()) :: Permit.Phoenix.Types.scope_subject()
Maps the current Phoenix scope to the subject, if Phoenix Scopes are used (see the use_scope?/0 callback).
Defaults to scope.user.
Example
@impl true
def scope_subject(scope) do
# Use the entire scope as the subject
scope
# Use a specific key in the scope
scope.user
end
@callback singular_actions() :: [atom()]
Defines which actions are considered singular (operating on a single resource). This can be overridden in individual controllers to customize the singular actions.
Example
@impl true
def singular_actions do
[:show, :edit, :new, :delete, :update]
end
@callback skip_preload() :: [Permit.Types.action_group()]
Declares which actions in the controller should skip automatic record preloading.
By default, all actions preload records automatically. Actions in skip_preload/0 will
only authorize against the resource module, not specific records. This is useful for
actions like :create and :new where there's no existing record to load.
Defaults to [:create, :new].
Example
@impl true
def skip_preload do
[:create, :new, :bulk_action]
end
@callback unauthorized_message(Permit.Types.action_group(), Permit.Phoenix.Types.conn()) :: binary()
If handle_unauthorized/2 is not customized, sets the error message to display when authorization fails.
Defaults to "You do not have permission to perform this action.".
Example
@impl true
def unauthorized_message(action, conn) do
"You cannot #{action} this article"
end
@callback use_scope?() :: boolean()
Determines whether to use Phoenix Scopes for fetching the subject.
If true, the subject will be fetched from current_scope.user assign. If false, the subject will be
fetched from current_user assign.
Defaults to true, must be set to false in Phoenix <1.8 or when you've migrated your code from
an earlier Phoenix version.
Example
@impl true
def use_scope? do
false
end