mitgliederverwaltung/lib/mv/helpers/system_actor.ex
Moritz a3cf8571ff Document System Actor pattern in code guidelines
Add section explaining when and how to use system actor for systemic operations.
Include examples and distinction between user mode and system mode.
2026-01-20 22:10:11 +01:00

296 lines
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