Current section
Files
Jump to
Current section
Files
lib/permit_phoenix/live_view/authorize_hook.ex
defmodule Permit.Phoenix.LiveView.AuthorizeHook do
@moduledoc """
Hooks into the :mount and :handle_params lifecycles to authorize the current action.
The current action is denoted by the :live_action assign (retrieved from the router),
for example with the following route definition:
live "/organizations", OrganizationLive.Index, :index
the :live_action assign value will be :index.
## Configuration
Add this module to the :on_mount option of the live_session in your router.
```
live_session :require_authenticated_user, on_mount: [
{BlogWeb.UserAuth, :ensure_authenticated}, # authentication generated by mix phx.gen.auth
Permit.Phoenix.LiveView.AuthorizeHook # Permit.Phoenix's hook at mount
] do
# Your live routes with named :live_action names
end
```
## Usage
See `Permit.Phoenix.LiveView` for detailed information on authorizing LiveView navigation and events.
"""
alias Permit.Phoenix.Types, as: PhoenixTypes
alias Permit.Types
# These two modules will be checked for the existence of the assign/3 function.
# If neither exists (no LiveView dependency in a project), no failure happens.
Code.ensure_loaded(Phoenix.LiveView)
Code.ensure_loaded(Phoenix.Component)
@doc false
defmacro live_view_assign(socket, key, value) do
if Mix.Project.config()[:deps]
|> Enum.any?(fn
{:phoenix_live_view, _} -> true
{:phoenix_live_view, _, _} -> true
_ -> false
end) do
quote do
if is_list(unquote(value)) and unquote(socket).view.use_stream?(unquote(socket)) do
unquote(socket)
|> Phoenix.LiveView.stream(unquote(key), unquote(value))
else
unquote(socket)
|> Phoenix.Component.assign(unquote(key), unquote(value))
end
end
else
quote do
raise RuntimeError,
"""
Phoenix LiveView is not available.
Please add a dependency {:phoenix_live_view, \"~> 0.16\"} to use LiveView integration.
"""
end
end
end
@doc false
@spec on_mount(term(), map(), map(), PhoenixTypes.socket()) :: PhoenixTypes.hook_outcome()
def on_mount(_opt, params, session, socket) do
action = socket.assigns.live_action
socket
|> attach_hooks(params, session)
|> authenticate_and_authorize!(action, session, params, :handle_params)
end
defp authenticate_and_authorize!(socket, action, session, params, action_origin) do
socket
|> authorize(session, action, params, action_origin)
|> respond()
end
@spec authorize(
PhoenixTypes.socket(),
PhoenixTypes.session(),
Types.action_group(),
map(),
atom()
) ::
PhoenixTypes.live_authorization_result()
defp authorize(socket, session, action, params, action_origin) do
cond do
action in socket.view.singular_actions() and action_origin == :handle_event and
not socket.view.reload_on_event?(action, socket) ->
authorize_preloaded_resource(socket, session, action)
action in socket.view.skip_preload() ->
just_authorize(socket, session, action)
true ->
preload_and_authorize(socket, session, action, params, action_origin)
end
end
defp authorize_preloaded_resource(socket, session, action) do
record = socket.assigns[:loaded_resource]
if !record do
raise ~S"""
#{socket.view} has the :reload_on_event? option set to false, but in processing
an event mapped to the #{action} action, the record was not found preloaded in
@loaded_resource.
It either must be ensured that the record is preloaded in @loaded_resource (e.g.
when the LiveView mounts), or the :reload_on_event? option must be set to true.
"""
end
subject = get_subject(socket, session)
authorization_module = socket.view.authorization_module()
# Check authorization on the action in general, then on the specific record
with {:authorized, socket} <- just_authorize(socket, session, action),
true <-
authorization_module.can(subject) |> authorization_module.do?(action, record) do
{:authorized, socket}
else
_ -> {:unauthorized, socket}
end
end
@spec just_authorize(PhoenixTypes.socket(), PhoenixTypes.session(), Types.action_group()) ::
PhoenixTypes.live_authorization_result()
defp just_authorize(socket, session, action) do
authorization_module = socket.view.authorization_module()
resource_module = socket.view.resource_module()
resolver_module = authorization_module.resolver_module()
subject = get_subject(socket, session)
case resolver_module.authorized?(
subject,
authorization_module,
resource_module,
action
) do
true -> {:authorized, socket}
false -> {:unauthorized, socket}
end
end
@spec preload_and_authorize(
PhoenixTypes.socket(),
PhoenixTypes.session(),
Types.action_group(),
map(),
atom()
) ::
PhoenixTypes.live_authorization_result()
defp preload_and_authorize(socket, session, action, params, action_origin) do
view = socket.view
use_loader? = view.use_loader?()
authorization_module = view.authorization_module()
resolver_module = authorization_module.resolver_module()
resource_module = view.resource_module()
base_query = &view.base_query/1
loader = &view.loader/1
finalize_query = &view.finalize_query/2
subject = get_subject(socket, session)
singular? = action in view.singular_actions()
{load_key, other_key, auth_function} =
if singular? do
{:loaded_resource, :loaded_resources, &resolver_module.authorize_and_preload_one!/5}
else
{:loaded_resources, :loaded_resource, &resolver_module.authorize_and_preload_all!/5}
end
id_param_name = view.id_param_name(action, socket)
id_struct_field_name = view.id_struct_field_name(action, socket)
params =
if action_origin == :handle_event and singular? do
Map.put_new_lazy(params, id_param_name, fn ->
Map.get(socket.assigns[load_key], id_struct_field_name)
end)
else
params
end
case auth_function.(
subject,
authorization_module,
resource_module,
action,
%{
params: params,
loader: loader,
socket: socket,
base_query: base_query,
finalize_query: finalize_query,
use_loader?: use_loader?
}
) do
# In events related to single-record actions like "delete", event params typically
# contain the record ID [socket.view.id_param_name(action, socket)].
#
# In events triggered by a form, params contain only the form's payload and not the
# record ID. This usually means that we're in a LiveView like "Edit", in which we load
# and assign the record on mount, before the user triggers the event.
# In this case, we need to assume that the record is in `assigns[:loaded_resource]`.
#
# The default behaviour with Permit.Ecto is to reload the record to ensure that another
# agent did not change the record concurrently in a way that might affect authorization.
#
# If `use_loader?` is true (or there is no Permit.Ecto), by default, we will reload the
# record using the loader function and it's on the developer to ensure event params and
# socket assigns contain everything necessary to load the record.
#
# The developer can opt out of reloading the record by setting `reload_on_event?` to false.
{:authorized, records} ->
{:authorized,
socket
|> live_view_assign(load_key, records)
|> live_view_assign(other_key, nil)}
:unauthorized ->
{:unauthorized,
socket
|> live_view_assign(load_key, nil)
|> live_view_assign(other_key, nil)}
:not_found ->
{:not_found,
socket
|> live_view_assign(load_key, nil)
|> live_view_assign(other_key, nil)}
end
end
defp get_subject(socket, session) do
# In Phoenix <1.8, phx.gen.auth would assign the current user to the :current_user assign.
# In Phoenix >=1.8, phx.gen.auth now assign it to the :user key in the scope.
cond do
function_exported?(socket.view, :fetch_subject, 2) ->
socket.view.fetch_subject(socket, session)
socket.view.use_scope?() ->
socket.view.scope_subject(socket.assigns[:current_scope])
true ->
socket.assigns[:current_user]
end
end
@spec respond(PhoenixTypes.live_authorization_result()) :: PhoenixTypes.hook_outcome()
defp respond(live_authorization_result)
defp respond({:unauthorized, socket}) do
socket.view.handle_unauthorized(socket.assigns[:live_action], socket)
end
defp respond({:not_found, socket}) do
socket.view.handle_not_found(socket)
end
defp respond({:authorized, socket}) do
{:cont, socket}
end
defp attach_hooks(socket, _mount_params, session) do
socket
|> Phoenix.LiveView.attach_hook(:params_authorization, :handle_params, fn params,
_uri,
socket ->
# Handle params-based authorization only if not mounting; otherwise, the authorization
# has already been done in the on_mount/4 callback implementation.
if Permit.Phoenix.LiveView.mounting?(socket),
do: {:cont, socket},
else:
authenticate_and_authorize!(
socket,
socket.assigns.live_action,
session,
params,
:handle_params
)
end)
|> Phoenix.LiveView.attach_hook(:event_authorization, :handle_event, fn event,
params,
socket ->
if action = socket.view.event_mapping()[event] do
authenticate_and_authorize!(socket, action, session, params, :handle_event)
else
{:cont, socket}
end
end)
end
end