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