defmodule Permit.Phoenix.Controller do
@moduledoc """
Injects authorization plug (Permit.Phoenix.Plug), allowing to
provide its options either directly in options of `use`, or
as overridable functions.
Example:
# my_app_web.ex
def controller do
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization,
fallback_path: "/unauthorized"
end
# your controller module
defmodule MyAppWeb.PageController do
use MyAppWeb, :live_view
@impl true
def resource_module, do: MyApp.Item
# you might or might not want to override something here
@impl true
def fallback_path: "/foo"
end
"""
alias Permit.Phoenix.Types, as: PhoenixTypes
alias Permit.Types
alias PermitPhoenix.RecordNotFoundError
import Plug.Conn
import Phoenix.Controller
@doc ~S"""
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 authorization_module() :: Types.authorization_module()
@doc ~S"""
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 resource_module() :: Types.resource_module()
if :ok == Application.ensure_loaded(:permit_ecto) do
@doc ~S"""
Creates the basis for an Ecto query constructed by `Permit.Ecto` based on controller action, resource module, subject (typically `:current_user`) and controller params.
Typically useful when using [nested resource routes](https://hexdocs.pm/phoenix/routing.html#nested-resources). In an action routed like `/users/:user_id/posts/:id`, you can use the `c: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 `c:id_`).
## 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 base_query(Types.resolution_context()) :: Ecto.Query.t()
@doc ~S"""
Post-processes an Ecto query constructed by `Permit.Ecto` based on controller action, resource module, subject (typically `:current_user`) and controller params.
Typically useful when using [nested resource routes](https://hexdocs.pm/phoenix/routing.html#nested-resources). In an action routed like `/users/:user_id/posts/:id`, you can use the `c: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 `c:id_`).
## 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 finalize_query(Ecto.Query.t(), Types.resolution_context()) :: Ecto.Query.t()
end
@doc ~S"""
Called when authorization on an action or a loaded record is not granted. Must halt `conn` after rendering or redirecting.
## Example
defmodule MyApp.CommentController do
use Permit.Phoenix.Controller,
authorization_module: MyApp.Authorization
resource_module: MyApp.Blog.Comment
@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
end
"""
@callback handle_unauthorized(Types.action_group(), PhoenixTypes.conn()) :: PhoenixTypes.conn()
@doc ~S"""
Retrieves the current user from `conn` as the authorization subject. Defaults to `conn.assigns[:current_user]`.
## Example
@impl true
def fetch_subject(%{assigns: assigns}) do
assigns[:user]
end
"""
@callback fetch_subject(PhoenixTypes.conn()) :: Types.subject()
@doc ~S"""
Declares which actions in the controller are to use Permit's automatic preloading and authorization in addition to defaults: `[:show, :edit, :update, :delete, :index]`.
Defaults to `[]`, which means that `[:show, :edit, :update, :delete, :index]` and no other actions will use preloading.
"""
@callback preload_actions() :: list(Types.action_group())
@doc ~S"""
If `c:handle_unauthorized/2` is not defined, sets the fallback path to which the user is redirected on authorization failure.
Defaults to `/`.
"""
@callback fallback_path(Types.action_group(), PhoenixTypes.conn()) :: binary()
@doc ~S"""
Allows opting out of using Permit for given controller actions.
Defaults to `[]`, thus by default all actions are guarded with Permit.
"""
@callback except() :: list(Types.action_group())
@doc ~S"""
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).
## 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 loader(Types.resolution_context()) :: Types.object() | nil
@doc ~S"""
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.
"""
@callback id_param_name(Types.action_group(), PhoenixTypes.conn()) :: binary()
@doc ~S"""
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.
"""
@callback id_struct_field_name(Types.action_group(), PhoenixTypes.conn()) :: atom()
@callback handle_not_found(PhoenixTypes.conn()) :: PhoenixTypes.conn()
@callback unauthorized_message(Types.action_group(), PhoenixTypes.conn()) :: binary()
@optional_callbacks [
if(:ok == Application.ensure_loaded(:permit_ecto),
do: {:base_query, 1}
),
if(:ok == Application.ensure_loaded(:permit_ecto),
do: {:finalize_query, 2}
),
handle_unauthorized: 2,
preload_actions: 0,
fallback_path: 2,
resource_module: 0,
except: 0,
fetch_subject: 1,
loader: 1,
handle_not_found: 1,
unauthorized_message: 2
]
|> Enum.filter(& &1)
defmacro __using__(opts) do
quote generated: true do
require Logger
@behaviour unquote(__MODULE__)
@before_compile unquote(__MODULE__)
@opts unquote(opts)
@impl true
def handle_unauthorized(action, conn) do
unquote(__MODULE__).handle_unauthorized(action, conn, unquote(opts))
end
@impl true
def handle_not_found(conn) do
unquote(__MODULE__).handle_not_found(conn, unquote(opts))
end
@impl true
def unauthorized_message(action, conn) do
unquote(__MODULE__).unauthorized_message(action, conn, unquote(opts))
end
@impl true
def authorization_module,
do:
unquote(opts[:authorization_module]) ||
raise(":authorization_module option must be given when using ControllerAuthorization")
@impl true
def resource_module, do: unquote(opts[:resource_module])
@impl true
def preload_actions do
unquote(__MODULE__).preload_actions(unquote(opts))
end
@impl true
def fallback_path(action, conn) do
unquote(__MODULE__).fallback_path(action, conn, unquote(opts))
end
@impl true
def except do
unquote(__MODULE__).except(unquote(opts))
end
if :ok == Application.ensure_loaded(:permit_ecto) do
@impl true
def base_query(%{
action: action,
resource_module: resource_module,
conn: conn,
params: params
}) do
param = id_param_name(action, conn)
field = id_struct_field_name(action, conn)
case params do
%{^param => id} ->
resource_module
|> Permit.Ecto.filter_by_field(field, id)
_ ->
Permit.Ecto.from(resource_module)
end
end
@impl true
def finalize_query(query, resolution_context),
do: unquote(__MODULE__).finalize_query(query, resolution_context, unquote(opts))
end
@impl true
def id_param_name(action, conn) do
unquote(__MODULE__).id_param_name(action, conn, unquote(opts))
end
@impl true
def id_struct_field_name(action, conn) do
unquote(__MODULE__).id_struct_field_name(action, conn, unquote(opts))
end
@impl true
def fetch_subject(conn) do
unquote(__MODULE__).fetch_subject(conn, unquote(opts))
end
defoverridable(
[
if(:ok == Application.ensure_loaded(:permit_ecto),
do: {:base_query, 1}
),
if(:ok == Application.ensure_loaded(:permit_ecto),
do: {:finalize_query, 2}
),
handle_unauthorized: 2,
preload_actions: 0,
fallback_path: 2,
resource_module: 0,
except: 0,
fetch_subject: 1,
id_param_name: 2,
id_struct_field_name: 2,
handle_not_found: 1,
unauthorized_message: 2
]
|> Enum.filter(& &1)
)
plug(:permit_phoenix_plug)
end
end
defmacro __before_compile__(_env) do
quote do
if Module.defines?(__MODULE__, {:loader, 1}) do
@loader_defined? true
else
@loader_defined? false
@impl true
def loader(resolution_context) do
unquote(__MODULE__).loader(resolution_context, @opts)
end
end
def permit_phoenix_plug(conn, _opts) do
Permit.Phoenix.Plug.call(
conn,
[
if(:ok == Application.ensure_loaded(:permit_ecto),
do: {:base_query, &__MODULE__.base_query/1}
),
if(:ok == Application.ensure_loaded(:permit_ecto),
do: {:finalize_query, &__MODULE__.finalize_query/2}
),
if(:ok == Application.ensure_loaded(:permit_ecto),
do: {:use_loader?, @loader_defined?}
),
authorization_module: &__MODULE__.authorization_module/0,
resource_module: &__MODULE__.resource_module/0,
preload_actions: &__MODULE__.preload_actions/0,
fallback_path: &__MODULE__.fallback_path/2,
except: &__MODULE__.except/0,
fetch_subject: &__MODULE__.fetch_subject/1,
handle_unauthorized: &__MODULE__.handle_unauthorized/2,
loader: &__MODULE__.loader/1,
id_param_name: &__MODULE__.id_param_name/2,
id_struct_field_name: &__MODULE__.id_struct_field_name/2,
handle_not_found: &__MODULE__.handle_not_found/1,
unauthorized_message: &__MODULE__.unauthorized_message/2
]
|> Enum.filter(& &1)
)
end
end
end
@doc false
def handle_unauthorized(action, conn, _opts) do
conn
|> put_flash(:error, controller_module(conn).unauthorized_message(action, conn))
|> redirect(to: controller_module(conn).fallback_path(action, conn))
|> halt()
end
def handle_not_found(_conn, _opts) do
raise RecordNotFoundError, "Expected at least one result but got none"
end
@doc false
def fallback_path(action, conn, opts) do
case opts[:fallback_path] do
nil -> "/"
fun when is_function(fun) -> fun.(action, conn)
path -> path
end
end
def unauthorized_message(action, conn, opts) do
case opts[:unauthorized_message] do
nil -> "You do not have permission to perform this action."
fun when is_function(fun) -> fun.(action, conn)
msg -> msg
end
end
@doc false
def preload_actions(opts) do
case opts[:preload_actions] do
nil -> [:show, :edit, :update, :delete, :index]
list when is_list(list) -> list ++ [:show, :edit, :update, :delete, :index]
end
end
@doc false
def except(opts) do
case opts[:except] do
nil -> []
except -> except
end
end
if :ok == Application.ensure_loaded(:permit_ecto) do
@doc false
def base_query(
%{
action: action,
resource_module: resource_module,
conn: conn,
params: params
},
opts
) do
param = __MODULE__.id_param_name(action, conn, opts)
field = __MODULE__.id_struct_field_name(action, conn, opts)
case params do
%{^param => id} ->
apply(Permit.Ecto, :filter_by_field, [resource_module, field, id])
_ ->
apply(Permit.Ecto, :from, [resource_module])
end
end
@doc false
def finalize_query(query, %{}, _), do: query
end
@doc false
def loader(resolution_context, opts) do
case opts[:loader] do
nil -> nil
function -> function.(resolution_context)
end
end
@doc false
def id_param_name(action, conn, opts) do
case opts[:id_param_name] do
nil -> "id"
param_name when is_binary(param_name) -> param_name
param_name_fn when is_function(param_name_fn) -> param_name_fn.(action, conn)
end
end
@doc false
def id_struct_field_name(action, conn, opts) do
case opts[:id_struct_field_name] do
nil ->
:id
struct_field_name when is_binary(struct_field_name) ->
struct_field_name
struct_field_name_fn when is_function(struct_field_name_fn) ->
struct_field_name_fn.(action, conn)
end
end
@doc false
def fetch_subject(conn, opts) do
fetch_subject_fn = opts[:fetch_subject_fn]
if is_function(fetch_subject_fn, 1) do
fetch_subject_fn.(conn)
else
conn.assigns[:current_user]
end
end
end