SECURITY: Skip authorization for role loading to avoid circular dependency. Actor loads their OWN role, needed for authorization itself. Documented why this is safe.
102 lines
3.1 KiB
Elixir
102 lines
3.1 KiB
Elixir
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
|