defmodule Mv.Authorization.Actor do @moduledoc """ Helper functions for ensuring User actors have required data loaded and for querying actor capabilities (e.g. admin, permission set). ## 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 # Check if actor is admin (policy checks, validations) if Actor.admin?(actor), do: ... # Get permission set name (string or nil) ps_name = Actor.permission_set_name(actor) ## 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 alias Mv.Helpers.SystemActor @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 @doc """ Returns the actor's permission set name (string or atom) from their role, or nil. Ensures role is loaded (including when role is nil). Supports both atom and string keys for session/socket assigns. Use for capability checks consistent with `ActorIsAdmin` and `HasPermission`. """ @spec permission_set_name(Mv.Accounts.User.t() | map() | nil) :: String.t() | atom() | nil def permission_set_name(nil), do: nil def permission_set_name(actor) do actor = actor |> ensure_loaded() |> maybe_load_role() get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) || get_in(actor, [Access.key("role"), Access.key("permission_set_name")]) end @doc """ Returns true if the actor is the system user or has the admin permission set. Use for validations and policy checks that require admin capability (e.g. changing a linked member's email). Consistent with `ActorIsAdmin` policy check. """ @spec admin?(Mv.Accounts.User.t() | map() | nil) :: boolean() def admin?(nil), do: false def admin?(actor) do SystemActor.system_user?(actor) or permission_set_name(actor) in ["admin", :admin] end defp maybe_load_role(%Mv.Accounts.User{role: nil} = user) do case Ash.load(user, :role, domain: Mv.Accounts, authorize?: false) do {:ok, loaded} -> loaded _ -> user end end defp maybe_load_role(actor), do: actor end