- Role lookup and creation (find_admin_role, create_admin_role) - System user creation and role assignment - Role loading during initialization
453 lines
14 KiB
Elixir
453 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
|
|
# SECURITY: Uses authorize?: false for bootstrap role lookup.
|
|
@spec find_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :not_found}
|
|
defp find_admin_role do
|
|
alias Mv.Authorization
|
|
|
|
case Authorization.list_roles(authorize?: false) 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
|
|
# SECURITY: Uses authorize?: false for bootstrap role creation.
|
|
@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"
|
|
},
|
|
authorize?: false
|
|
) 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
|
|
# SECURITY: Uses authorize?: false for bootstrap role lookup.
|
|
@spec find_existing_admin_role() :: Mv.Authorization.Role.t() | no_return()
|
|
defp find_existing_admin_role do
|
|
alias Mv.Authorization
|
|
|
|
case Authorization.list_roles(authorize?: false) 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
|
|
|
|
# SECURITY: Uses authorize?: false for bootstrap user creation.
|
|
# This is necessary because we're creating the system actor itself,
|
|
# which would otherwise be needed for authorization (chicken-and-egg).
|
|
# This is safe because:
|
|
# 1. Only creates system user with known email
|
|
# 2. Only called during system actor initialization (bootstrap)
|
|
# 3. Once created, all subsequent operations use proper authorization
|
|
Accounts.create_user!(%{email: system_user_email_config()},
|
|
upsert?: true,
|
|
upsert_identity: :unique_email,
|
|
authorize?: false
|
|
)
|
|
|> Ash.Changeset.for_update(:update, %{})
|
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
|> Ash.update!(authorize?: false)
|
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
|
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)
|
|
# SECURITY: Uses authorize?: false for bootstrap role loading.
|
|
# This is necessary because loading the role is part of system actor initialization,
|
|
# which would otherwise require an actor (chicken-and-egg).
|
|
@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, authorize?: false) 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
|