mitgliederverwaltung/lib/mv/authorization/actor.ex
Moritz ef4df57a6f
Restrict Actor.ensure_loaded to Mv.Accounts.User only
Pattern match on %Mv.Accounts.User{} instead of generic actor.
Clearer intention, prevents accidental authorization bypasses.
Non-User actors are returned as-is (no-op).
2026-01-27 10:23:13 +01:00

99 lines
3.2 KiB
Elixir

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