defmodule Mv.Authorization.Actor do @moduledoc """ Helper functions for ensuring actors have required data loaded. ## Actor Invariant Authorization policies (especially HasPermission) require that the actor has their `:role` relationship loaded. This module provides helpers to ensure this invariant is maintained across all entry points: - LiveView on_mount hooks - Plug pipelines - Background jobs - Tests ## Usage # In LiveView on_mount def ensure_user_role_loaded(_name, socket) do user = Actor.ensure_loaded(socket.assigns[:current_user]) assign(socket, :current_user, user) end # In tests user = Actor.ensure_loaded(user) ## Security Note `ensure_loaded/1` loads the role with `authorize?: false` to avoid circular dependency (actor needs role loaded to be authorized, but loading role requires authorization). This is safe because: - The actor is loading their OWN role (actor.role relationship) - This load is needed FOR authorization checks to work - The role itself contains no sensitive data (just permission_set reference) - The actor is already authenticated (passed auth boundary) Alternative would be to denormalize permission_set_name on User, but that adds complexity and potential for inconsistency. """ require Logger @doc """ Ensures the actor has their `:role` relationship loaded. - If actor is nil, returns nil - If role is already loaded, returns actor as-is - If role is %Ash.NotLoaded{}, loads it and returns updated actor ## Examples iex> Actor.ensure_loaded(nil) nil iex> Actor.ensure_loaded(%User{role: %Role{}}) %User{role: %Role{}} iex> Actor.ensure_loaded(%User{role: %Ash.NotLoaded{}}) %User{role: %Role{}} # role loaded """ def ensure_loaded(nil), do: nil def ensure_loaded(%{role: %Ash.NotLoaded{}} = actor) do # Only attempt to load if actor is a valid Ash resource if ash_resource?(actor) do load_role(actor) else # Not an Ash resource (e.g., plain map in tests) - return as-is actor end end def ensure_loaded(actor), do: actor # Check if actor is a valid Ash resource defp ash_resource?(actor) do is_struct(actor) and Ash.Resource.Info.resource?(actor.__struct__) end defp load_role(actor) do # SECURITY: We skip authorization here because this is a bootstrap scenario: # - The actor is loading their OWN role (actor.role relationship) # - This load is needed FOR authorization checks to work (circular dependency) # - The role itself contains no sensitive data (just permission_set reference) # - The actor is already authenticated (passed auth boundary) # Alternative would be to denormalize permission_set_name on User. case Ash.load(actor, :role, domain: Mv.Accounts, authorize?: false) do {:ok, loaded_actor} -> loaded_actor {:error, error} -> # Log error but don't crash - fail-closed for authorization Logger.warning( "Failed to load actor role: #{inspect(error)}. " <> "Authorization may fail if role is required." ) actor end end end