Current section
Files
Jump to
Current section
Files
lib/canary/hooks.ex
if Code.ensure_loaded?(Phoenix.LiveView) do
defmodule Canary.Hooks do
@moduledoc """
Hooks functions for loading and authorizing resources for the LiveView events.
If you want to authorize `handle_params` and `handle_event` LiveView callbacks
you can use `mount_canary` macro to attach the hooks.
You can think about the `mount_canary` as something similar to `plug` but for LiveView events.
For `handle_params` it uses `socket.assigns.live_action` as `:action`.
For `handle_event` it uses the event name as `:action`.
> Note that the `event_name` is a string - but in Canary it's converted to an atom for consistency.
`Canary.Hooks` and `Canary.Plugs` are separate modules but they share the same API.
You can define plugs for standard pages and hooks for LiveView events with the same opts.
For the authorization actions, when the `:required` is false (by default it's true) it might be nil.
Then the `Canada.Can` implementation should be the module name of the model rather than a struct.
## Example
```elixir
use Canary.Hooks
mount_canary :load_and_authorize_resource,
on: [:handle_params, :handle_event],
model: Post,
only: [:show, :edit, :update]
mount_canary :authorize_resource,
on: [:handle_event],
model: Post,
only: [:my_event],
required: false
# ...
def handle_params(params, _uri, socket) do
# resource is already loaded and authorized
post = socket.assigns.post
end
def handle_event("my_event", _unsigned_params, socket) do
# Only admin is allowed to perform my_event
end
```
`lib/abilities/user.ex`:
```elixir
defimpl Canada.Can, for: User do
def can?(%User{} = user, :my_event, Post), do: user.role == "admin"
def can?(%User{id: id}, _, %Post{user_id: user_id}), do: id == user_id
end
```
"""
# Copyright 2025 Piotr Baj
@moduledoc since: "2.0.0"
import Canary.Utils
import Canada.Can, only: [can?: 3]
import Phoenix.LiveView, only: [attach_hook: 4]
import Phoenix.Component, only: [assign: 3]
alias Phoenix.LiveView.Socket
@doc false
defmacro __using__(_opts) do
quote do
import Canary.Hooks
Module.register_attribute(__MODULE__, :canary_hooks, accumulate: true)
# Register the Canary.Hooks.__before_compile__/1 callback to be called before Phoenix.LiveView.
hooks = Module.delete_attribute(__MODULE__, :before_compile)
@before_compile Canary.Hooks
Enum.each(hooks, fn {mod, fun} ->
@before_compile {mod, fun}
end)
end
end
@doc false
defmacro __before_compile__(env) do
stages =
Module.get_attribute(env.module, :canary_hooks, [])
|> Enum.reverse()
wrapped_hooks = Enum.with_index(stages, &wrap_hooks/2)
mount_attach = attach_hooks_on_mount(env, stages)
[wrapped_hooks, mount_attach]
end
defp wrap_hooks({stage, hook, opts}, id) do
name = hook_name(stage, hook, id)
quote do
def unquote(name)(hook_arg_1, hook_arg_2, %Socket{} = socket) do
metadata = %{hook: unquote(hook), stage: unquote(stage), opts: unquote(opts)}
handle_hook(metadata, [hook_arg_1, hook_arg_2, socket])
end
end
end
defp attach_hooks_on_mount(env, stages) do
hooks =
Enum.with_index(stages)
|> Enum.map(fn {{stage, hook, _opts}, id} ->
name = hook_name(stage, hook, id)
{name, stage}
end)
quote bind_quoted: [module: env.module, hooks: hooks] do
on_mount {Canary.Hooks, {:initialize, module, hooks}}
end
end
defp hook_name(stage, hook, id) do
String.to_atom("#{stage}_#{hook}_#{id}")
end
@doc false
def handle_hook(metadata, [hook_arg_1, hook_arg_2, socket]) do
%{hook: hook, stage: stage, opts: opts} = metadata
case hook do
:load_resource ->
load_resource(stage, hook_arg_1, hook_arg_2, socket, opts)
:authorize_resource ->
authorize_resource(stage, hook_arg_1, hook_arg_2, socket, opts)
:load_and_authorize_resource ->
load_and_authorize_resource(stage, hook_arg_1, hook_arg_2, socket, opts)
_ ->
IO.warn(
"Invalid type #{inspect(hook)} for Canary hook call. Please review defined hooks with mount_canary/2.",
module: __MODULE__
)
{:cont, socket}
end
end
@doc """
Mount canary authorization hooks on the current module.
It creates a wrapper function to handle_params and handle_event,
and attaches the hooks to the Live View.
## Example
```
mount_canary :load_and_authorize_resource,
model: Post,
only: [:edit: :update]
```
"""
defmacro mount_canary(type, opts) do
stages = get_stages(opts)
if Enum.empty?(stages),
do:
IO.warn("mount_canary called with empty :on stages",
module: __CALLER__.module,
file: __CALLER__.file,
line: __CALLER__.line
)
Enum.reduce(stages, [], fn stage, acc ->
[put_canary_hook(__CALLER__.module, stage, type, opts) | acc]
end)
|> Enum.reverse()
end
defp put_canary_hook(module, stage, type, opts) do
quote do
Module.put_attribute(
unquote(module),
:canary_hooks,
{unquote(stage), unquote(type), unquote(opts)}
)
end
end
@doc false
def on_mount({:initialize, mod, stages}, _params, _session, %Socket{} = socket) do
socket =
Enum.reduce(stages, socket, fn {name, stage}, socket ->
fun = Function.capture(mod, name, 3)
attach_hook(socket, name, stage, fun)
end)
{:cont, socket}
end
def on_mount(_, :not_mounted_at_router, _session, socket), do: {:cont, socket}
@doc """
Authorize the `:current_user` for the ginve resource. If the `:current_user` is not authorized it will halt the socket.
For the authorization check, it uses the `can?/3` function from the `Canada.Can` module -
`can?(subject, action, resource)` where:
1. The subject is the `:current_user` from the socket assigns. The `:current_user` key can be changed in the `opts` or in the `Application.get_env(:canary, :current_user, :current_user)`. By default it's `:current_user`.
2. The action for `handle_params` is `socket.assigns.live_action`, for `handle_event` it uses the event name.
3. The resource is the loaded resource from the socket assigns or the model name if the resource is not loaded and not required.
Required opts:
* `:model` - Specifies the module name of the model to load resources from
* `:on` - Specifies the LiveView lifecycle stages to attach the hook. Default :handle_params
Optional opts:
* `:only` - Specifies which actions to authorize
* `:except` - Specifies which actions for which to skip authorization
> For `handle_params` it uses `socket.assigns.live_action` as `:action`.
> For `handle_event` it uses the event name as `:action`.
* `:as` - Specifies the `resource_name` to get from assigns
* `:current_user` - Specifies the key in the socket assigns to get the current user
* `:required` - Specifies if the resource is required, when it's not assigned in socket it will halt the socket
* `:unauthorized_handler` - Specify a handler function to be called if the action is unauthorized
Example:
```elixir
mount_canary :authorize_resource,
model: Post,
only: [:show, :edit, :update]
current_user: :current_user
mount_canary :authorize_resource,
model: Post,
as: :custom_resource_name,
except: [:new, :create],
unauthorized_handler: {ErrorHandler, :unauthorized_handler}
```
"""
def authorize_resource(:handle_params, _params, _uri, %Socket{} = socket, opts) do
action = socket.assigns.live_action
do_authorize_resource(action, socket, opts)
end
def authorize_resource(:handle_event, event_name, _unsigned_params, %Socket{} = socket, opts) do
action = String.to_atom(event_name)
do_authorize_resource(action, socket, opts)
end
@doc """
Loads the resource and assigns it to the socket. When resource is required it will
halt the socket if the resource is not found.
`load_resource` wrapper for attached hook functions, similar to the `load_resource/2` plug
but for LiveView events on `:handle_params` and `:handle_event` stages.
Required opts:
* `:model` - Specifies the module name of the model to load resources from
* `:on` - Specifies the LiveView lifecycle stages to attach the hook. Default :handle_params
Optional opts:
* `:only` - Specifies which actions to authorize
* `:except` - Specifies which actions for which to skip authorization
> For `handle_params` it uses `socket.assigns.live_action` as `:action`.
> For `handle_event` it uses the event name as `:action`.
* `:as` - Specifies the `resource_name` to use in assigns
* `:preload` - Specifies association(s) to preload
* `:id_name` - Specifies the name of the id in `params`, defaults to "id"
* `:id_field` - Specifies the name of the ID field in the database for searching :id_name value, defaults to "id".
* `:required` - Specifies if the resource is required, when it's not found it will halt the socket
* `:not_found_handler` - Specify a handler function to be called if the resource is not found
Example:
```elixir
mount_canary :load_resource,
model: Post,
only: [:show, :edit, :update],
preload: [:comments]
mount_canary :load_resource,
on: [:handle_params, :handle_event]
model: Post,
as: :custom_name,
except: [:new, :create],
preload: [:comments],
not_found_handler: {ErrorHandler, :not_found_handler}
```
"""
def load_resource(:handle_params, params, _uri, %Socket{} = socket, opts) do
action = socket.assigns.live_action
do_load_resource(action, socket, params, opts)
end
def load_resource(:handle_event, event_name, unsigned_params, %Socket{} = socket, opts) do
action = String.to_atom(event_name)
do_load_resource(action, socket, unsigned_params, opts)
end
@doc """
Loads and autorize resource and assigns it to the socket. When resource is required it will
halt the socket if the resource is not found. If the user is not authorized it will halt the socket.
It combines `load_resource` and `authorize_resource` functions.
Required opts:
* `:model` - Specifies the module name of the model to load resources from
* `:on` - Specifies the LiveView lifecycle stages to attach the hook. Default :handle_params
Optional opts:
* `:only` - Specifies which actions to authorize
* `:except` - Specifies which actions for which to skip authorization
> For `handle_params` it uses `socket.assigns.live_action` as `:action`.
> For `handle_event` it uses the event name as `:action`.
* `:as` - Specifies the `resource_name` to use in assigns
* `:current_user` - Specifies the key in the socket assigns to get the current user
* `:preload` - Specifies association(s) to preload
* `:id_name` - Specifies the name of the id in `params`, defaults to "id"
* `:id_field` - Specifies the name of the ID field in the database for searching :id_name value, defaults to "id".
* `:required` - Specifies if the resource is required, when it's not found it will halt the socket, default true
* `:not_found_handler` - Specify a handler function to be called if the resource is not found
* `:unauthorized_handler` - Specify a handler function to be called if the action is unauthorized
Example:
```elixir
mount_canary :load_and_authorize_resource,
model: Comments,
id_name: :post_id,
id_field: :post_id,
only: [:comments]
mount_canary :load_and_authorize_resource,
model: Post,
as: :custom_name,
except: [:new, :create],
preload: [:comments],
error_handler: CustomErrorHandler
```
"""
def load_and_authorize_resource(:handle_params, params, _uri, %Socket{} = socket, opts) do
action = socket.assigns.live_action
do_load_and_authorize_resource(action, params, socket, opts)
end
def load_and_authorize_resource(
:handle_event,
event_name,
unsigned_params,
%Socket{} = socket,
opts
) do
action = String.to_atom(event_name)
do_load_and_authorize_resource(action, unsigned_params, socket, opts)
end
defp do_load_resource(action, socket, params, opts) do
if action_valid?(action, opts) do
assign(socket, :canary_action, action)
|> load_resource(params, opts)
|> verify_resource(opts)
else
{:cont, socket}
end
end
defp do_load_and_authorize_resource(action, params, socket, opts) do
if action_valid?(action, opts) do
assign(socket, :canary_action, action)
|> load_resource(params, opts)
|> check_authorization(action, opts)
|> verify_authorized_resource(opts)
else
{:cont, socket}
end
end
defp do_authorize_resource(action, socket, opts) do
if action_valid?(action, opts) do
assign(socket, :canary_action, action)
|> check_authorization(action, opts)
|> verify_authorized_resource(opts)
else
{:cont, socket}
end
end
# Check if the resource is already loaded in the socket assigns
# If not we need to load and assign it
defp load_resource(%Socket{} = socket, params, opts) do
resource =
case fetch_resource(socket, opts) do
{:ok, resource} ->
resource
_ ->
repo_get_resource(params, opts)
end
action = socket.assigns.canary_action
assign(socket, get_resource_name(action, opts), resource)
end
# Fetch the resource from the socket assigns or nil
defp fetch_resource(%Socket{} = socket, opts) do
action = socket.assigns.canary_action
case Map.get(socket.assigns, get_resource_name(action, opts), nil) do
resource when is_struct(resource) ->
if resource.__struct__ == opts[:model] do
{:ok, resource}
else
nil
end
_ ->
nil
end
end
# Load the resource from the repo
defp repo_get_resource(params, opts) do
repo = Application.get_env(:canary, :repo)
field_name = Keyword.get(opts, :id_field, "id")
get_map_args = %{String.to_atom(field_name) => get_resource_id(params, opts)}
repo.get_by(opts[:model], get_map_args)
|> preload_if_needed(repo, opts)
end
# Perform the authorization check
defp check_authorization(%Socket{} = socket, action, opts) do
current_user_name =
opts[:current_user] || Application.get_env(:canary, :current_user, :current_user)
current_user = Map.fetch(socket.assigns, current_user_name)
resource = fetch_resoruce_or_model(socket, opts)
case {current_user, resource} do
{{:ok, _current_user}, nil} ->
assign(socket, :authorized, false)
{{:ok, current_user}, _} ->
assign(socket, :authorized, can?(current_user, action, resource))
_ ->
assign(socket, :authorized, false)
end
end
# Fetch resource form assigns or model name if empty and not required
defp fetch_resoruce_or_model(%Socket{} = socket, opts) do
case fetch_resource(socket, opts) do
{:ok, resource} ->
resource
_ ->
if required?(opts) do
nil
else
opts[:model]
end
end
end
# Verify if subject is authorized to perform action on resource
defp verify_authorized_resource(%Socket{} = socket, opts) do
authorized = Map.get(socket.assigns, :authorized, false)
if authorized do
verify_resource(socket, opts)
else
apply_error_handler(socket, :unauthorized_handler, opts)
end
end
# Verify if the resource is loaded and if it is required
defp verify_resource(%Socket{} = socket, opts) do
is_required = required?(opts)
resource = fetch_resource(socket, opts)
if is_nil(resource) && is_required do
apply_error_handler(socket, :not_found_handler, opts)
else
{:cont, socket}
end
end
defp get_stages(opts) do
Keyword.get(opts, :on, :handle_params)
|> validate_stages()
end
defp validate_stages(stage) when is_atom(stage), do: validate_stages([stage])
defp validate_stages(stages) when is_list(stages) do
allowed_satges = [:handle_params, :handle_event]
Enum.filter(stages, &Enum.member?(allowed_satges, &1))
end
end
end