Add System Actor helper for systemic operations
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.
This commit is contained in:
parent
264323504f
commit
ddb1252831
2 changed files with 283 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
282
lib/mv/helpers/system_actor.ex
Normal file
282
lib/mv/helpers/system_actor.ex
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue