Add centralized Actor.ensure_loaded helper

Consolidate role loading logic from HasPermission and LiveHelpers.
Use Ash.Resource.Info.resource? for reliable Ash detection.
This commit is contained in:
Moritz 2026-01-22 22:37:07 +01:00
parent 05c71132e4
commit f2def20fce
4 changed files with 181 additions and 51 deletions

View file

@ -0,0 +1,89 @@
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?: true` (default).
The Role resource must have policies that allow an actor to read their own role.
See `Mv.Authorization.Checks.HasPermission` for the fallback implementation.
"""
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
# Need to specify domain for Ash.load to work
case Ash.load(actor, :role, domain: Mv.Accounts) 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

View file

@ -391,29 +391,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
end
# Fallback: Load role if not loaded (in case on_mount didn't run)
defp ensure_role_loaded(%{role: %Ash.NotLoaded{}} = actor) do
if ash_resource?(actor) do
load_role_for_actor(actor)
else
# Not an Ash resource (plain map), return as-is
actor
end
end
defp ensure_role_loaded(actor), do: actor
# Check if actor is a valid Ash resource
defp ash_resource?(actor) do
is_map(actor) and Map.has_key?(actor, :__struct__) and
function_exported?(actor.__struct__, :__ash_resource__, 0)
end
# Attempt to load role for Ash resource
defp load_role_for_actor(actor) do
case Ash.load(actor, :role, domain: Mv.Accounts, actor: actor) do
{:ok, loaded} -> loaded
# Return original if loading fails
{:error, _} -> actor
end
# Delegates to centralized Actor helper
defp ensure_role_loaded(actor) do
Mv.Authorization.Actor.ensure_loaded(actor)
end
end

View file

@ -27,39 +27,17 @@ defmodule MvWeb.LiveHelpers do
end
defp ensure_user_role_loaded(socket) do
if socket.assigns[:current_user] do
user = socket.assigns.current_user
user_with_role = load_user_role(user)
user = socket.assigns[:current_user]
if user do
# Use centralized Actor helper to ensure role is loaded
user_with_role = Mv.Authorization.Actor.ensure_loaded(user)
assign(socket, :current_user, user_with_role)
else
socket
end
end
defp load_user_role(user) do
case Map.get(user, :role) do
%Ash.NotLoaded{} -> load_role_safely(user)
nil -> load_role_safely(user)
_role -> user
end
end
defp load_role_safely(user) do
# Use self as actor for loading own role relationship
opts = [domain: Mv.Accounts, actor: user]
case Ash.load(user, :role, opts) do
{:ok, loaded_user} ->
loaded_user
{:error, error} ->
# Log warning if role loading fails - this can cause authorization issues
require Logger
Logger.warning("Failed to load role for user #{user.id}: #{inspect(error)}")
user
end
end
@doc """
Helper function to get the current actor (user) from socket assigns.