Introduce Mv.Helpers.SystemActor module with lazy loading for operations that must always run regardless of user permissions. System actor has admin role and auto-creates in test environment.
282 lines
8.9 KiB
Elixir
282 lines
8.9 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.
|
|
|
|
## Security
|
|
|
|
The system actor should NEVER be used for user-initiated actions. It is
|
|
only for systemic operations that must bypass user permissions.
|
|
"""
|
|
|
|
use Agent
|
|
|
|
require Ash.Query
|
|
|
|
@system_user_email "system@mila.local"
|
|
|
|
@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
|
|
# In test environment, always load directly to avoid Agent/Sandbox issues
|
|
if Mix.env() == :test do
|
|
load_system_actor()
|
|
else
|
|
try do
|
|
Agent.get_and_update(__MODULE__, fn
|
|
nil ->
|
|
# Cache miss - load system actor
|
|
actor = load_system_actor()
|
|
{actor, actor}
|
|
|
|
cached_actor ->
|
|
# Cache hit - return cached actor
|
|
{cached_actor, cached_actor}
|
|
end)
|
|
catch
|
|
:exit, {:noproc, _} ->
|
|
# Agent not started - load directly without caching
|
|
load_system_actor()
|
|
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
|
|
Agent.update(__MODULE__, fn _state -> nil end)
|
|
end
|
|
|
|
# Loads the system actor from the database
|
|
# First tries to find system@mila.local, then falls back to admin user
|
|
defp load_system_actor do
|
|
# Try to find system user first
|
|
case find_user_by_email(@system_user_email) do
|
|
{:ok, user} when not is_nil(user) ->
|
|
load_user_with_role(user)
|
|
|
|
{:ok, nil} ->
|
|
# System user doesn't exist - fall back to admin user
|
|
case load_admin_user_fallback() do
|
|
{:ok, admin_user} -> admin_user
|
|
{:error, _} ->
|
|
# In test environment, create a temporary admin user if none exists
|
|
if Mix.env() == :test do
|
|
create_test_system_actor()
|
|
else
|
|
raise "Failed to load system actor: no system user or admin user found"
|
|
end
|
|
end
|
|
|
|
{:error, _reason} = error ->
|
|
# Database error - try fallback
|
|
case load_admin_user_fallback() do
|
|
{:ok, admin_user} -> admin_user
|
|
{:error, _} ->
|
|
# In test environment, create a temporary admin user if none exists
|
|
if Mix.env() == :test do
|
|
create_test_system_actor()
|
|
else
|
|
raise "Failed to load system actor: #{inspect(error)}"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Creates a temporary admin user for tests when no system/admin user exists
|
|
defp create_test_system_actor do
|
|
alias Mv.Authorization
|
|
alias Mv.Accounts
|
|
|
|
# Ensure admin role exists - find or create
|
|
admin_role =
|
|
case Authorization.list_roles() do
|
|
{:ok, roles} ->
|
|
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
|
nil ->
|
|
# Try to create, but handle case where it already exists (race condition)
|
|
case Authorization.create_role(%{
|
|
name: "Admin",
|
|
description: "Administrator with full access",
|
|
permission_set_name: "admin"
|
|
}) do
|
|
{:ok, role} -> role
|
|
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
|
|
# Role was created by another process - find it
|
|
case Authorization.list_roles() do
|
|
{:ok, updated_roles} ->
|
|
Enum.find(updated_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
|
|
{:error, error} ->
|
|
raise "Failed to create admin role: #{inspect(error)}"
|
|
end
|
|
|
|
role ->
|
|
role
|
|
end
|
|
|
|
_ ->
|
|
# If list_roles fails, try to create anyway
|
|
case Authorization.create_role(%{
|
|
name: "Admin",
|
|
description: "Administrator with full access",
|
|
permission_set_name: "admin"
|
|
}) do
|
|
{:ok, role} -> role
|
|
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
|
|
# Role exists - try to find it
|
|
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"
|
|
end
|
|
{:error, error} ->
|
|
raise "Failed to create admin role: #{inspect(error)}"
|
|
end
|
|
end
|
|
|
|
# Create system user for tests
|
|
system_user =
|
|
Accounts.create_user!(%{email: @system_user_email},
|
|
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)
|
|
|
|
system_user
|
|
end
|
|
|
|
# Finds a user by email address
|
|
defp find_user_by_email(email) do
|
|
Mv.Accounts.User
|
|
|> Ash.Query.filter(email == ^email)
|
|
|> Ash.read_one(domain: Mv.Accounts)
|
|
end
|
|
|
|
# Loads a user with their role preloaded (required for authorization)
|
|
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
|
|
defp validate_admin_role(%{role: %{permission_set_name: "admin"}} = user) do
|
|
user
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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)
|
|
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
|