146 lines
4.9 KiB
Elixir
146 lines
4.9 KiB
Elixir
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
|
|
|
|
# Load role only when it is nil (e.g. actor from session without role). ensure_loaded/1
|
|
# already handles %Ash.NotLoaded{}, so we do not double-load in the normal Ash path.
|
|
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
|