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
3 changed files with 73 additions and 8 deletions
Showing only changes of commit a3cf8571ff - Show all commits

View file

@ -641,7 +641,54 @@ def card(assigns) do
end end
``` ```
### 3.3 Ash Framework ### 3.3 System Actor Pattern
**When to Use System Actor:**
Some operations must always run regardless of user permissions. These are **systemic operations** that are mandatory side effects:
- **Email synchronization** (Member ↔ User)
- **Email uniqueness validation** (data integrity requirement)
- **Cycle generation** (if defined as mandatory side effect)
- **Background jobs**
- **Seeds**
**Implementation:**
Use `Mv.Helpers.SystemActor.get_system_actor/0` for all systemic operations:
```elixir
# Good - Email sync uses system actor
def get_linked_member(user) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.get(Mv.Membership.Member, id, opts) do
{:ok, member} -> member
{:error, _} -> nil
end
end
# Bad - Using user actor for systemic operation
def get_linked_member(user, actor) do
opts = Helpers.ash_actor_opts(actor) # May fail if user lacks permissions!
# ...
end
```
**System Actor Details:**
- System actor is a user with admin role (email: "system@mila.local")
- Cached in Agent for performance
- Falls back to admin user from seeds if system user doesn't exist
- Should NEVER be used for user-initiated actions (only systemic operations)
**User Mode vs System Mode:**
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced
- **System Mode**: Systemic operations use system actor, bypass user permissions
### 3.4 Ash Framework
**Resource Definition Best Practices:** **Resource Definition Best Practices:**

View file

@ -124,7 +124,9 @@ defmodule Mv.Helpers.SystemActor do
{:ok, nil} -> {:ok, nil} ->
# System user doesn't exist - fall back to admin user # System user doesn't exist - fall back to admin user
case load_admin_user_fallback() do case load_admin_user_fallback() do
{:ok, admin_user} -> admin_user {:ok, admin_user} ->
admin_user
{:error, _} -> {:error, _} ->
# In test environment, create a temporary admin user if none exists # In test environment, create a temporary admin user if none exists
if Mix.env() == :test do if Mix.env() == :test do
@ -137,7 +139,9 @@ defmodule Mv.Helpers.SystemActor do
{:error, _reason} = error -> {:error, _reason} = error ->
# Database error - try fallback # Database error - try fallback
case load_admin_user_fallback() do case load_admin_user_fallback() do
{:ok, admin_user} -> admin_user {:ok, admin_user} ->
admin_user
{:error, _} -> {:error, _} ->
# In test environment, create a temporary admin user if none exists # In test environment, create a temporary admin user if none exists
if Mix.env() == :test do if Mix.env() == :test do
@ -166,16 +170,21 @@ defmodule Mv.Helpers.SystemActor do
description: "Administrator with full access", description: "Administrator with full access",
permission_set_name: "admin" permission_set_name: "admin"
}) do }) do
{:ok, role} -> role {:ok, role} ->
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} -> role
{:error,
%Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
# Role was created by another process - find it # Role was created by another process - find it
case Authorization.list_roles() do case Authorization.list_roles() do
{:ok, updated_roles} -> {:ok, updated_roles} ->
Enum.find(updated_roles, &(&1.permission_set_name == "admin")) || Enum.find(updated_roles, &(&1.permission_set_name == "admin")) ||
raise "Admin role should exist but was not found" raise "Admin role should exist but was not found"
_ -> _ ->
raise "Failed to find admin role after creation attempt" raise "Failed to find admin role after creation attempt"
end end
{:error, error} -> {:error, error} ->
raise "Failed to create admin role: #{inspect(error)}" raise "Failed to create admin role: #{inspect(error)}"
end end
@ -191,16 +200,21 @@ defmodule Mv.Helpers.SystemActor do
description: "Administrator with full access", description: "Administrator with full access",
permission_set_name: "admin" permission_set_name: "admin"
}) do }) do
{:ok, role} -> role {:ok, role} ->
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} -> role
{:error,
%Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
# Role exists - try to find it # Role exists - try to find it
case Authorization.list_roles() do case Authorization.list_roles() do
{:ok, roles} -> {:ok, roles} ->
Enum.find(roles, &(&1.permission_set_name == "admin")) || Enum.find(roles, &(&1.permission_set_name == "admin")) ||
raise "Admin role should exist but was not found" raise "Admin role should exist but was not found"
_ -> _ ->
raise "Failed to find admin role" raise "Failed to find admin role"
end end
{:error, error} -> {:error, error} ->
raise "Failed to create admin role: #{inspect(error)}" raise "Failed to create admin role: #{inspect(error)}"
end end

View file

@ -79,7 +79,10 @@ defmodule Mv.Helpers.SystemActorTest do
|> Ash.load!(:role, domain: Mv.Accounts) |> Ash.load!(:role, domain: Mv.Accounts)
_ -> _ ->
Accounts.create_user!(%{email: admin_email}, upsert?: true, upsert_identity: :unique_email) Accounts.create_user!(%{email: admin_email},
upsert?: true,
upsert_identity: :unique_email
)
|> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!() |> Ash.update!()
@ -152,6 +155,7 @@ defmodule Mv.Helpers.SystemActorTest do
end end
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
case Accounts.User case Accounts.User
|> Ash.Query.filter(email == ^admin_email) |> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts) do |> Ash.read_one(domain: Mv.Accounts) do