System Actor Mode for Systemic Flows closes #348 #361

Merged
moritz merged 16 commits from feature/348_system_actor into main 2026-01-21 08:36:41 +01:00
Showing only changes of commit 006b1aaf06 - Show all commits

View file

@ -39,13 +39,18 @@ defmodule Mv.Helpers.SystemActor do
The system actor should NEVER be used for user-initiated actions. It is The system actor should NEVER be used for user-initiated actions. It is
only for systemic operations that must bypass user permissions. 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 use Agent
require Ash.Query require Ash.Query
@system_user_email "system@mila.local" alias Mv.Config
@doc """ @doc """
Starts the SystemActor Agent. Starts the SystemActor Agent.
@ -106,8 +111,8 @@ defmodule Mv.Helpers.SystemActor do
""" """
@spec get_system_actor_result() :: {:ok, Mv.Accounts.User.t()} | {:error, term()} @spec get_system_actor_result() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
def get_system_actor_result do def get_system_actor_result do
# In test environment, always load directly to avoid Agent/Sandbox issues # In test environment (SQL sandbox), always load directly to avoid Agent/Sandbox issues
if Mix.env() == :test do if Config.sql_sandbox?() do
try do try do
{:ok, load_system_actor()} {:ok, load_system_actor()}
rescue rescue
@ -161,7 +166,10 @@ defmodule Mv.Helpers.SystemActor do
""" """
@spec invalidate_cache() :: :ok @spec invalidate_cache() :: :ok
def invalidate_cache do def invalidate_cache do
Agent.update(__MODULE__, fn _state -> nil end) case Process.whereis(__MODULE__) do
nil -> :ok
_pid -> Agent.update(__MODULE__, fn _state -> nil end)
end
end end
@doc """ @doc """
@ -181,13 +189,20 @@ defmodule Mv.Helpers.SystemActor do
""" """
@spec system_user_email() :: String.t() @spec system_user_email() :: String.t()
def system_user_email, do: @system_user_email 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 # Loads the system actor from the database
# First tries to find system@mila.local, then falls back to admin user # First tries to find system@mila.local, then falls back to admin user
@spec load_system_actor() :: Mv.Accounts.User.t() | no_return() @spec load_system_actor() :: Mv.Accounts.User.t() | no_return()
defp load_system_actor do defp load_system_actor do
case find_user_by_email(@system_user_email) do case find_user_by_email(system_user_email_config()) do
{:ok, user} when not is_nil(user) -> {:ok, user} when not is_nil(user) ->
load_user_with_role(user) load_user_with_role(user)
@ -226,7 +241,7 @@ defmodule Mv.Helpers.SystemActor do
# Handles fallback error - creates test actor or raises # Handles fallback error - creates test actor or raises
@spec handle_fallback_error(String.t()) :: Mv.Accounts.User.t() | no_return() @spec handle_fallback_error(String.t()) :: Mv.Accounts.User.t() | no_return()
defp handle_fallback_error(message) do defp handle_fallback_error(message) do
if Mix.env() == :test do if Config.sql_sandbox?() do
create_test_system_actor() create_test_system_actor()
else else
raise "Failed to load system actor: #{message}" raise "Failed to load system actor: #{message}"
@ -327,12 +342,15 @@ defmodule Mv.Helpers.SystemActor do
end end
# Creates system user with admin role assigned # 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()) :: @spec create_system_user_with_role(Mv.Authorization.Role.t()) ::
Mv.Accounts.User.t() | no_return() Mv.Accounts.User.t() | no_return()
defp create_system_user_with_role(admin_role) do defp create_system_user_with_role(admin_role) do
alias Mv.Accounts alias Mv.Accounts
Accounts.create_user!(%{email: @system_user_email}, Accounts.create_user!(%{email: system_user_email_config()},
upsert?: true, upsert?: true,
upsert_identity: :unique_email upsert_identity: :unique_email
) )
@ -343,11 +361,18 @@ defmodule Mv.Helpers.SystemActor do
end end
# Finds a user by email address # 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()} @spec find_user_by_email(String.t()) :: {:ok, Mv.Accounts.User.t() | nil} | {:error, term()}
defp find_user_by_email(email) do defp find_user_by_email(email) do
Mv.Accounts.User Mv.Accounts.User
|> Ash.Query.filter(email == ^email) |> Ash.Query.filter(email == ^email)
|> Ash.read_one(domain: Mv.Accounts) |> Ash.read_one(domain: Mv.Accounts, authorize?: false)
end end
# Loads a user with their role preloaded (required for authorization) # Loads a user with their role preloaded (required for authorization)