Add @spec type annotations to SystemActor functions
Add type specifications for all private functions to improve static analysis with Dialyzer and documentation quality.
This commit is contained in:
parent
a3cf8571ff
commit
c5bd58e7d3
1 changed files with 226 additions and 111 deletions
|
|
@ -29,6 +29,12 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
The cache is invalidated on application restart. For long-running applications,
|
The cache is invalidated on application restart. For long-running applications,
|
||||||
consider implementing cache invalidation on role changes.
|
consider implementing cache invalidation on role changes.
|
||||||
|
|
||||||
|
## Race Conditions
|
||||||
|
|
||||||
|
The system actor creation uses `upsert?: true` with `upsert_identity: :unique_email`
|
||||||
|
to prevent race conditions when multiple processes try to create the system user
|
||||||
|
simultaneously. This ensures idempotent creation and prevents database constraint errors.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -73,25 +79,70 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
"""
|
"""
|
||||||
@spec get_system_actor() :: Mv.Accounts.User.t()
|
@spec get_system_actor() :: Mv.Accounts.User.t()
|
||||||
def get_system_actor do
|
def get_system_actor do
|
||||||
|
case get_system_actor_result() do
|
||||||
|
{:ok, actor} -> actor
|
||||||
|
{:error, reason} -> raise "Failed to load system actor: #{inspect(reason)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the system actor as a result tuple.
|
||||||
|
|
||||||
|
This variant returns `{:ok, actor}` or `{:error, reason}` instead of raising,
|
||||||
|
which is useful for error handling in pipes or when you want to handle errors explicitly.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- `{:ok, %Mv.Accounts.User{}}` - Successfully loaded system actor
|
||||||
|
- `{:error, term()}` - Error loading system actor
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
case SystemActor.get_system_actor_result() do
|
||||||
|
{:ok, actor} -> use_actor(actor)
|
||||||
|
{:error, reason} -> handle_error(reason)
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec get_system_actor_result() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
|
||||||
|
def get_system_actor_result do
|
||||||
# In test environment, always load directly to avoid Agent/Sandbox issues
|
# In test environment, always load directly to avoid Agent/Sandbox issues
|
||||||
if Mix.env() == :test do
|
if Mix.env() == :test do
|
||||||
load_system_actor()
|
try do
|
||||||
|
{:ok, load_system_actor()}
|
||||||
|
rescue
|
||||||
|
e -> {:error, e}
|
||||||
|
end
|
||||||
else
|
else
|
||||||
try do
|
try do
|
||||||
Agent.get_and_update(__MODULE__, fn
|
result =
|
||||||
nil ->
|
Agent.get_and_update(__MODULE__, fn
|
||||||
# Cache miss - load system actor
|
nil ->
|
||||||
actor = load_system_actor()
|
# Cache miss - load system actor
|
||||||
{actor, actor}
|
try do
|
||||||
|
actor = load_system_actor()
|
||||||
|
{actor, actor}
|
||||||
|
rescue
|
||||||
|
e -> {{:error, e}, nil}
|
||||||
|
end
|
||||||
|
|
||||||
cached_actor ->
|
cached_actor ->
|
||||||
# Cache hit - return cached actor
|
# Cache hit - return cached actor
|
||||||
{cached_actor, cached_actor}
|
{cached_actor, cached_actor}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
actor -> {:ok, actor}
|
||||||
|
end
|
||||||
catch
|
catch
|
||||||
:exit, {:noproc, _} ->
|
:exit, {:noproc, _} ->
|
||||||
# Agent not started - load directly without caching
|
# Agent not started - load directly without caching
|
||||||
load_system_actor()
|
try do
|
||||||
|
{:ok, load_system_actor()}
|
||||||
|
rescue
|
||||||
|
e -> {:error, e}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -113,128 +164,186 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
Agent.update(__MODULE__, fn _state -> nil end)
|
Agent.update(__MODULE__, fn _state -> nil end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the email address of the system user.
|
||||||
|
|
||||||
|
This is useful for other modules that need to reference the system user
|
||||||
|
without loading the full user record.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- `String.t()` - The system user email address ("system@mila.local")
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Mv.Helpers.SystemActor.system_user_email()
|
||||||
|
"system@mila.local"
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec system_user_email() :: String.t()
|
||||||
|
def system_user_email, do: @system_user_email
|
||||||
|
|
||||||
# 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()
|
||||||
defp load_system_actor do
|
defp load_system_actor do
|
||||||
# Try to find system user first
|
|
||||||
case find_user_by_email(@system_user_email) do
|
case find_user_by_email(@system_user_email) 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)
|
||||||
|
|
||||||
{:ok, nil} ->
|
{:ok, nil} ->
|
||||||
# System user doesn't exist - fall back to admin user
|
handle_system_user_not_found("no system user or admin user found")
|
||||||
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 ->
|
{:error, _reason} = error ->
|
||||||
# Database error - try fallback
|
handle_system_user_error(error)
|
||||||
case load_admin_user_fallback() do
|
end
|
||||||
{:ok, admin_user} ->
|
end
|
||||||
admin_user
|
|
||||||
|
|
||||||
{:error, _} ->
|
# Handles case when system user doesn't exist
|
||||||
# In test environment, create a temporary admin user if none exists
|
@spec handle_system_user_not_found(String.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
if Mix.env() == :test do
|
defp handle_system_user_not_found(message) do
|
||||||
create_test_system_actor()
|
case load_admin_user_fallback() do
|
||||||
else
|
{:ok, admin_user} ->
|
||||||
raise "Failed to load system actor: #{inspect(error)}"
|
admin_user
|
||||||
end
|
|
||||||
end
|
{:error, _} ->
|
||||||
|
handle_fallback_error(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles database error when loading system user
|
||||||
|
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp handle_system_user_error(error) do
|
||||||
|
case load_admin_user_fallback() do
|
||||||
|
{:ok, admin_user} ->
|
||||||
|
admin_user
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
handle_fallback_error("Failed to load system actor: #{inspect(error)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles fallback error - creates test actor or raises
|
||||||
|
@spec handle_fallback_error(String.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp handle_fallback_error(message) do
|
||||||
|
if Mix.env() == :test do
|
||||||
|
create_test_system_actor()
|
||||||
|
else
|
||||||
|
raise "Failed to load system actor: #{message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Creates a temporary admin user for tests when no system/admin user exists
|
# Creates a temporary admin user for tests when no system/admin user exists
|
||||||
|
@spec create_test_system_actor() :: Mv.Accounts.User.t() | no_return()
|
||||||
defp create_test_system_actor do
|
defp create_test_system_actor do
|
||||||
|
alias Mv.Accounts
|
||||||
alias Mv.Authorization
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
admin_role = ensure_admin_role_exists()
|
||||||
|
create_system_user_with_role(admin_role)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensures admin role exists - finds or creates it
|
||||||
|
@spec ensure_admin_role_exists() :: Mv.Authorization.Role.t() | no_return()
|
||||||
|
defp ensure_admin_role_exists do
|
||||||
|
case find_admin_role() do
|
||||||
|
{:ok, role} ->
|
||||||
|
role
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
create_admin_role_with_retry()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds admin role in existing roles
|
||||||
|
@spec find_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :not_found}
|
||||||
|
defp find_admin_role do
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
case Authorization.list_roles() do
|
||||||
|
{:ok, roles} ->
|
||||||
|
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
role -> {:ok, role}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Creates admin role, handling race conditions
|
||||||
|
@spec create_admin_role_with_retry() :: Mv.Authorization.Role.t() | no_return()
|
||||||
|
defp create_admin_role_with_retry do
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
case create_admin_role() do
|
||||||
|
{:ok, role} ->
|
||||||
|
role
|
||||||
|
|
||||||
|
{:error, :already_exists} ->
|
||||||
|
find_existing_admin_role()
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
raise "Failed to create admin role: #{inspect(error)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Attempts to create admin role
|
||||||
|
@spec create_admin_role() ::
|
||||||
|
{:ok, Mv.Authorization.Role.t()} | {:error, :already_exists | term()}
|
||||||
|
defp create_admin_role do
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
case Authorization.create_role(%{
|
||||||
|
name: "Admin",
|
||||||
|
description: "Administrator with full access",
|
||||||
|
permission_set_name: "admin"
|
||||||
|
}) do
|
||||||
|
{:ok, role} ->
|
||||||
|
{:ok, role}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
|
||||||
|
{:error, :already_exists}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:error, error}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds existing admin role after creation attempt failed due to race condition
|
||||||
|
@spec find_existing_admin_role() :: Mv.Authorization.Role.t() | no_return()
|
||||||
|
defp find_existing_admin_role do
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
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 after creation attempt"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Creates system user with admin role assigned
|
||||||
|
@spec create_system_user_with_role(Mv.Authorization.Role.t()) ::
|
||||||
|
Mv.Accounts.User.t() | no_return()
|
||||||
|
defp create_system_user_with_role(admin_role) do
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
|
|
||||||
# Ensure admin role exists - find or create
|
Accounts.create_user!(%{email: @system_user_email},
|
||||||
admin_role =
|
upsert?: true,
|
||||||
case Authorization.list_roles() do
|
upsert_identity: :unique_email
|
||||||
{:ok, roles} ->
|
)
|
||||||
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
nil ->
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
# Try to create, but handle case where it already exists (race condition)
|
|> Ash.update!()
|
||||||
case Authorization.create_role(%{
|
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||||
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
|
end
|
||||||
|
|
||||||
# Finds a user by email address
|
# Finds a user by email address
|
||||||
|
@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)
|
||||||
|
|
@ -242,6 +351,7 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Loads a user with their role preloaded (required for authorization)
|
# Loads a user with their role preloaded (required for authorization)
|
||||||
|
@spec load_user_with_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
defp load_user_with_role(user) do
|
defp load_user_with_role(user) do
|
||||||
case Ash.load(user, :role, domain: Mv.Accounts) do
|
case Ash.load(user, :role, domain: Mv.Accounts) do
|
||||||
{:ok, user_with_role} ->
|
{:ok, user_with_role} ->
|
||||||
|
|
@ -253,10 +363,12 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validates that the user has an admin role
|
# Validates that the user has an admin role
|
||||||
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
defp validate_admin_role(%{role: %{permission_set_name: "admin"}} = user) do
|
defp validate_admin_role(%{role: %{permission_set_name: "admin"}} = user) do
|
||||||
user
|
user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
|
||||||
defp validate_admin_role(%{role: %{permission_set_name: permission_set}}) do
|
defp validate_admin_role(%{role: %{permission_set_name: permission_set}}) do
|
||||||
raise """
|
raise """
|
||||||
System actor must have admin role, but has permission_set_name: #{permission_set}
|
System actor must have admin role, but has permission_set_name: #{permission_set}
|
||||||
|
|
@ -264,6 +376,7 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
|
||||||
defp validate_admin_role(%{role: nil}) do
|
defp validate_admin_role(%{role: nil}) do
|
||||||
raise """
|
raise """
|
||||||
System actor must have a role assigned, but role is nil.
|
System actor must have a role assigned, but role is nil.
|
||||||
|
|
@ -271,6 +384,7 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec validate_admin_role(term()) :: no_return()
|
||||||
defp validate_admin_role(_user) do
|
defp validate_admin_role(_user) do
|
||||||
raise """
|
raise """
|
||||||
System actor must have a role with admin permissions.
|
System actor must have a role with admin permissions.
|
||||||
|
|
@ -279,6 +393,7 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Fallback: Loads admin user from seeds (ADMIN_EMAIL env var or default)
|
# Fallback: Loads admin user from seeds (ADMIN_EMAIL env var or default)
|
||||||
|
@spec load_admin_user_fallback() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
|
||||||
defp load_admin_user_fallback do
|
defp load_admin_user_fallback do
|
||||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue