defmodule Mv.Authorization.Actor do @moduledoc """ Helper functions for ensuring User actors have required data loaded. ## Actor Invariant Authorization policies (especially HasPermission) require that the User 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 ## Scope This module ONLY handles `Mv.Accounts.User` resources. Other resources with a `:role` field are ignored (returned as-is). This prevents accidental authorization bypasses and keeps the logic focused. ## 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 (User) is loading their OWN role (user.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 (User) 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 - If actor is not a User, returns as-is (no-op) ## 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 # Only handle Mv.Accounts.User - clear intention, no accidental other resources def ensure_loaded(%Mv.Accounts.User{role: %Ash.NotLoaded{}} = user) do load_role(user) end def ensure_loaded(actor), do: actor 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