Use Application config instead of Mix.env() to prevent runtime crashes in production releases where Mix is not available
436 lines
14 KiB
Elixir
436 lines
14 KiB
Elixir
defmodule Mv.Helpers.SystemActor do
|
|
@moduledoc """
|
|
Provides access to the system actor for systemic operations.
|
|
|
|
The system actor is a user with admin permissions that is used
|
|
for operations that must always run regardless of user permissions:
|
|
- Email synchronization
|
|
- Email uniqueness validation
|
|
- Cycle generation (if mandatory)
|
|
- Background jobs
|
|
- Seeds
|
|
|
|
## Usage
|
|
|
|
# Get system actor for systemic operations
|
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
Ash.read(query, actor: system_actor)
|
|
|
|
## Implementation
|
|
|
|
The system actor is cached in an Agent for performance. On first access,
|
|
it attempts to load a user with email "system@mila.local" and admin role.
|
|
If that user doesn't exist, it falls back to the admin user from seeds
|
|
(identified by ADMIN_EMAIL environment variable or "admin@localhost").
|
|
|
|
## Caching
|
|
|
|
The system actor is cached in an Agent to avoid repeated database queries.
|
|
The cache is invalidated on application restart. For long-running applications,
|
|
consider implementing cache invalidation on role changes.
|
|
|
|
## Race Conditions
|
|
|
|
The system actor creation uses `upsert?: true` with `upsert_identity: :unique_email`
|
|
to prevent race conditions when multiple processes try to create the system user
|
|
simultaneously. This ensures idempotent creation and prevents database constraint errors.
|
|
|
|
## Security
|
|
|
|
The system actor should NEVER be used for user-initiated actions. It is
|
|
only for systemic operations that must bypass user permissions.
|
|
|
|
The system user is created without a password (`hashed_password = nil`) and
|
|
without an OIDC ID (`oidc_id = nil`) to prevent login. This ensures the
|
|
system user cannot be used for authentication, even if credentials are
|
|
somehow obtained.
|
|
"""
|
|
|
|
use Agent
|
|
|
|
require Ash.Query
|
|
|
|
alias Mv.Config
|
|
|
|
@doc """
|
|
Starts the SystemActor Agent.
|
|
|
|
This is called automatically by the application supervisor.
|
|
The agent starts with nil state and loads the system actor lazily on first access.
|
|
"""
|
|
def start_link(_opts) do
|
|
# Start with nil - lazy initialization on first get_system_actor call
|
|
# This prevents database access during application startup (important for tests)
|
|
Agent.start_link(fn -> nil end, name: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Returns the system actor (user with admin role).
|
|
|
|
The system actor is cached in an Agent for performance. On first access,
|
|
it loads the system user from the database or falls back to the admin user.
|
|
|
|
## Returns
|
|
|
|
- `%Mv.Accounts.User{}` - User with admin role loaded
|
|
- Raises if system actor cannot be found or loaded
|
|
|
|
## Examples
|
|
|
|
iex> system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
iex> system_actor.role.permission_set_name
|
|
"admin"
|
|
|
|
"""
|
|
@spec get_system_actor() :: Mv.Accounts.User.t()
|
|
def get_system_actor do
|
|
case get_system_actor_result() do
|
|
{:ok, actor} -> actor
|
|
{:error, reason} -> raise "Failed to load system actor: #{inspect(reason)}"
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the system actor as a result tuple.
|
|
|
|
This variant returns `{:ok, actor}` or `{:error, reason}` instead of raising,
|
|
which is useful for error handling in pipes or when you want to handle errors explicitly.
|
|
|
|
## Returns
|
|
|
|
- `{:ok, %Mv.Accounts.User{}}` - Successfully loaded system actor
|
|
- `{:error, term()}` - Error loading system actor
|
|
|
|
## Examples
|
|
|
|
case SystemActor.get_system_actor_result() do
|
|
{:ok, actor} -> use_actor(actor)
|
|
{:error, reason} -> handle_error(reason)
|
|
end
|
|
|
|
"""
|
|
@spec get_system_actor_result() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
|
|
def get_system_actor_result do
|
|
# In test environment (SQL sandbox), always load directly to avoid Agent/Sandbox issues
|
|
if Config.sql_sandbox?() do
|
|
try do
|
|
{:ok, load_system_actor()}
|
|
rescue
|
|
e -> {:error, e}
|
|
end
|
|
else
|
|
try do
|
|
result =
|
|
Agent.get_and_update(__MODULE__, fn
|
|
nil ->
|
|
# Cache miss - load system actor
|
|
try do
|
|
actor = load_system_actor()
|
|
{actor, actor}
|
|
rescue
|
|
e -> {{:error, e}, nil}
|
|
end
|
|
|
|
cached_actor ->
|
|
# Cache hit - return cached actor
|
|
{cached_actor, cached_actor}
|
|
end)
|
|
|
|
case result do
|
|
{:error, reason} -> {:error, reason}
|
|
actor -> {:ok, actor}
|
|
end
|
|
catch
|
|
:exit, {:noproc, _} ->
|
|
# Agent not started - load directly without caching
|
|
try do
|
|
{:ok, load_system_actor()}
|
|
rescue
|
|
e -> {:error, e}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Invalidates the system actor cache.
|
|
|
|
This forces a reload of the system actor on the next call to `get_system_actor/0`.
|
|
Useful when the system user's role might have changed.
|
|
|
|
## Examples
|
|
|
|
iex> Mv.Helpers.SystemActor.invalidate_cache()
|
|
:ok
|
|
|
|
"""
|
|
@spec invalidate_cache() :: :ok
|
|
def invalidate_cache do
|
|
case Process.whereis(__MODULE__) do
|
|
nil -> :ok
|
|
_pid -> Agent.update(__MODULE__, fn _state -> nil end)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the email address of the system user.
|
|
|
|
This is useful for other modules that need to reference the system user
|
|
without loading the full user record.
|
|
|
|
## Returns
|
|
|
|
- `String.t()` - The system user email address ("system@mila.local")
|
|
|
|
## Examples
|
|
|
|
iex> Mv.Helpers.SystemActor.system_user_email()
|
|
"system@mila.local"
|
|
|
|
"""
|
|
@spec system_user_email() :: String.t()
|
|
def system_user_email, do: system_user_email_config()
|
|
|
|
# Returns the system user email from environment variable or default
|
|
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
|
@spec system_user_email_config() :: String.t()
|
|
defp system_user_email_config do
|
|
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local"
|
|
end
|
|
|
|
# Loads the system actor from the database
|
|
# First tries to find system@mila.local, then falls back to admin user
|
|
@spec load_system_actor() :: Mv.Accounts.User.t() | no_return()
|
|
defp load_system_actor do
|
|
case find_user_by_email(system_user_email_config()) do
|
|
{:ok, user} when not is_nil(user) ->
|
|
load_user_with_role(user)
|
|
|
|
{:ok, nil} ->
|
|
handle_system_user_not_found("no system user or admin user found")
|
|
|
|
{:error, _reason} = error ->
|
|
handle_system_user_error(error)
|
|
end
|
|
end
|
|
|
|
# Handles case when system user doesn't exist
|
|
@spec handle_system_user_not_found(String.t()) :: Mv.Accounts.User.t() | no_return()
|
|
defp handle_system_user_not_found(message) do
|
|
case load_admin_user_fallback() do
|
|
{:ok, admin_user} ->
|
|
admin_user
|
|
|
|
{:error, _} ->
|
|
handle_fallback_error(message)
|
|
end
|
|
end
|
|
|
|
# Handles database error when loading system user
|
|
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return()
|
|
defp handle_system_user_error(error) do
|
|
case load_admin_user_fallback() do
|
|
{:ok, admin_user} ->
|
|
admin_user
|
|
|
|
{:error, _} ->
|
|
handle_fallback_error("Failed to load system actor: #{inspect(error)}")
|
|
end
|
|
end
|
|
|
|
# Handles fallback error - creates test actor or raises
|
|
@spec handle_fallback_error(String.t()) :: Mv.Accounts.User.t() | no_return()
|
|
defp handle_fallback_error(message) do
|
|
if Config.sql_sandbox?() do
|
|
create_test_system_actor()
|
|
else
|
|
raise "Failed to load system actor: #{message}"
|
|
end
|
|
end
|
|
|
|
# Creates a temporary admin user for tests when no system/admin user exists
|
|
@spec create_test_system_actor() :: Mv.Accounts.User.t() | no_return()
|
|
defp create_test_system_actor do
|
|
alias Mv.Accounts
|
|
alias Mv.Authorization
|
|
|
|
admin_role = ensure_admin_role_exists()
|
|
create_system_user_with_role(admin_role)
|
|
end
|
|
|
|
# Ensures admin role exists - finds or creates it
|
|
@spec ensure_admin_role_exists() :: Mv.Authorization.Role.t() | no_return()
|
|
defp ensure_admin_role_exists do
|
|
case find_admin_role() do
|
|
{:ok, role} ->
|
|
role
|
|
|
|
{:error, :not_found} ->
|
|
create_admin_role_with_retry()
|
|
end
|
|
end
|
|
|
|
# Finds admin role in existing roles
|
|
@spec find_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :not_found}
|
|
defp find_admin_role do
|
|
alias Mv.Authorization
|
|
|
|
case Authorization.list_roles() do
|
|
{:ok, roles} ->
|
|
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
|
nil -> {:error, :not_found}
|
|
role -> {:ok, role}
|
|
end
|
|
|
|
_ ->
|
|
{:error, :not_found}
|
|
end
|
|
end
|
|
|
|
# Creates admin role, handling race conditions
|
|
@spec create_admin_role_with_retry() :: Mv.Authorization.Role.t() | no_return()
|
|
defp create_admin_role_with_retry do
|
|
alias Mv.Authorization
|
|
|
|
case create_admin_role() do
|
|
{:ok, role} ->
|
|
role
|
|
|
|
{:error, :already_exists} ->
|
|
find_existing_admin_role()
|
|
|
|
{:error, error} ->
|
|
raise "Failed to create admin role: #{inspect(error)}"
|
|
end
|
|
end
|
|
|
|
# Attempts to create admin role
|
|
@spec create_admin_role() ::
|
|
{:ok, Mv.Authorization.Role.t()} | {:error, :already_exists | term()}
|
|
defp create_admin_role do
|
|
alias Mv.Authorization
|
|
|
|
case Authorization.create_role(%{
|
|
name: "Admin",
|
|
description: "Administrator with full access",
|
|
permission_set_name: "admin"
|
|
}) do
|
|
{:ok, role} ->
|
|
{:ok, role}
|
|
|
|
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
|
|
{:error, :already_exists}
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
# Finds existing admin role after creation attempt failed due to race condition
|
|
@spec find_existing_admin_role() :: Mv.Authorization.Role.t() | no_return()
|
|
defp find_existing_admin_role do
|
|
alias Mv.Authorization
|
|
|
|
case Authorization.list_roles() do
|
|
{:ok, roles} ->
|
|
Enum.find(roles, &(&1.permission_set_name == "admin")) ||
|
|
raise "Admin role should exist but was not found"
|
|
|
|
_ ->
|
|
raise "Failed to find admin role after creation attempt"
|
|
end
|
|
end
|
|
|
|
# Creates system user with admin role assigned
|
|
# SECURITY: System user is created without password (hashed_password = nil) and
|
|
# without OIDC ID (oidc_id = nil) to prevent login. This user is ONLY for
|
|
# internal system operations via SystemActor and should never be used for authentication.
|
|
@spec create_system_user_with_role(Mv.Authorization.Role.t()) ::
|
|
Mv.Accounts.User.t() | no_return()
|
|
defp create_system_user_with_role(admin_role) do
|
|
alias Mv.Accounts
|
|
|
|
Accounts.create_user!(%{email: system_user_email_config()},
|
|
upsert?: true,
|
|
upsert_identity: :unique_email
|
|
)
|
|
|> Ash.Changeset.for_update(:update, %{})
|
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
|> Ash.update!()
|
|
|> Ash.load!(:role, domain: Mv.Accounts)
|
|
end
|
|
|
|
# Finds a user by email address
|
|
# SECURITY: Uses authorize?: false for bootstrap lookup only.
|
|
# This is necessary because we need to find the system/admin user before
|
|
# we can load the system actor. If User policies require an actor, this
|
|
# would create a chicken-and-egg problem. This is safe because:
|
|
# 1. We only query by email (no sensitive data exposed)
|
|
# 2. This is only used during system actor initialization (bootstrap phase)
|
|
# 3. Once system actor is loaded, all subsequent operations use proper authorization
|
|
@spec find_user_by_email(String.t()) :: {:ok, Mv.Accounts.User.t() | nil} | {:error, term()}
|
|
defp find_user_by_email(email) do
|
|
Mv.Accounts.User
|
|
|> Ash.Query.filter(email == ^email)
|
|
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
|
|
end
|
|
|
|
# Loads a user with their role preloaded (required for authorization)
|
|
@spec load_user_with_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
|
|
defp load_user_with_role(user) do
|
|
case Ash.load(user, :role, domain: Mv.Accounts) do
|
|
{:ok, user_with_role} ->
|
|
validate_admin_role(user_with_role)
|
|
|
|
{:error, reason} ->
|
|
raise "Failed to load role for system actor: #{inspect(reason)}"
|
|
end
|
|
end
|
|
|
|
# Validates that the user has an admin role
|
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
|
|
defp validate_admin_role(%{role: %{permission_set_name: "admin"}} = user) do
|
|
user
|
|
end
|
|
|
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
|
|
defp validate_admin_role(%{role: %{permission_set_name: permission_set}}) do
|
|
raise """
|
|
System actor must have admin role, but has permission_set_name: #{permission_set}
|
|
Please assign the "Admin" role to the system user.
|
|
"""
|
|
end
|
|
|
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
|
|
defp validate_admin_role(%{role: nil}) do
|
|
raise """
|
|
System actor must have a role assigned, but role is nil.
|
|
Please assign the "Admin" role to the system user.
|
|
"""
|
|
end
|
|
|
|
@spec validate_admin_role(term()) :: no_return()
|
|
defp validate_admin_role(_user) do
|
|
raise """
|
|
System actor must have a role with admin permissions.
|
|
Please assign the "Admin" role to the system user.
|
|
"""
|
|
end
|
|
|
|
# Fallback: Loads admin user from seeds (ADMIN_EMAIL env var or default)
|
|
@spec load_admin_user_fallback() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
|
|
defp load_admin_user_fallback do
|
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
|
|
|
case find_user_by_email(admin_email) do
|
|
{:ok, user} when not is_nil(user) ->
|
|
{:ok, load_user_with_role(user)}
|
|
|
|
{:ok, nil} ->
|
|
{:error, :admin_user_not_found}
|
|
|
|
{:error, _reason} = error ->
|
|
error
|
|
end
|
|
end
|
|
end
|