diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 09eefce..ea0c78e 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -14,6 +14,7 @@ defmodule Mv.Application do {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Mv.PubSub}, {AshAuthentication.Supervisor, otp_app: :my}, + Mv.Helpers.SystemActor, # Start a worker by calling: Mv.Worker.start_link(arg) # {Mv.Worker, arg}, # Start to serve requests, typically the last entry diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex new file mode 100644 index 0000000..64f95f1 --- /dev/null +++ b/lib/mv/helpers/system_actor.ex @@ -0,0 +1,282 @@ +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