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:
parent
05c71132e4
commit
f2def20fce
4 changed files with 181 additions and 51 deletions
89
lib/mv/authorization/actor.ex
Normal file
89
lib/mv/authorization/actor.ex
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue