System Actor Mode for Systemic Flows closes #348 #361
3 changed files with 73 additions and 8 deletions
|
|
@ -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:**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue