From ddb1252831ec4429bdd2cba81c22d12c49407cf8 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 22:09:16 +0100 Subject: [PATCH 01/16] 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. --- lib/mv/application.ex | 1 + lib/mv/helpers/system_actor.ex | 282 +++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 lib/mv/helpers/system_actor.ex diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 09eefce..ea0c78e 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -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 diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex new file mode 100644 index 0000000..64f95f1 --- /dev/null +++ b/lib/mv/helpers/system_actor.ex @@ -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 -- 2.47.2 From d993bd39138667c9d9a0cf14949a0de99edc644e Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 22:09:17 +0100 Subject: [PATCH 02/16] Create system user in seeds Add system@mila.local user with admin role for systemic operations. This user is used by SystemActor helper for mandatory side effects. --- priv/repo/seeds.exs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 2e7543a..03a8564 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -202,6 +202,37 @@ admin_user_with_role = raise "Failed to load admin user: #{inspect(error)}" end +# Create system user for systemic operations (email sync, validations, cycle generation) +# This user is used by Mv.Helpers.SystemActor for operations that must always run +system_user_email = "system@mila.local" + +case Accounts.User + |> Ash.Query.filter(email == ^system_user_email) + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, existing_system_user} when not is_nil(existing_system_user) -> + # System user already exists - ensure it has admin role + existing_system_user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!() + + {:ok, nil} -> + # System user doesn't exist - create it with admin role + # Note: No password is set - this user should never be used for login + 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!() + + {:error, error} -> + # Log error but don't fail seeds - SystemActor will fall back to admin user + IO.puts("Warning: Failed to create system user: #{inspect(error)}") + IO.puts("SystemActor will fall back to admin user (#{admin_email})") +end + # Load all membership fee types for assignment # Sort by name to ensure deterministic order all_fee_types = -- 2.47.2 From 8acd92e8d47cf090c0641bfa5368f8d255714188 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 22:09:18 +0100 Subject: [PATCH 03/16] Use system actor for email synchronization Update email sync loader and changes to use system actor instead of user actor. This ensures email sync always works regardless of user permissions. --- .../changes/sync_member_email_to_user.ex | 4 +-- .../changes/sync_user_email_to_member.ex | 27 ++++---------- lib/mv/email_sync/loader.ex | 35 ++++++++++--------- 3 files changed, 27 insertions(+), 39 deletions(-) diff --git a/lib/mv/email_sync/changes/sync_member_email_to_user.ex b/lib/mv/email_sync/changes/sync_member_email_to_user.ex index 0c0d8f7..48c7955 100644 --- a/lib/mv/email_sync/changes/sync_member_email_to_user.ex +++ b/lib/mv/email_sync/changes/sync_member_email_to_user.ex @@ -41,10 +41,8 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do Ash.Changeset.around_transaction(changeset, fn cs, callback -> result = callback.(cs) - actor = Map.get(changeset.context, :actor) - with {:ok, member} <- Helpers.extract_record(result), - linked_user <- Loader.get_linked_user(member, actor) do + linked_user <- Loader.get_linked_user(member) do Helpers.sync_email_to_linked_record(result, linked_user, new_email) else _ -> result diff --git a/lib/mv/email_sync/changes/sync_user_email_to_member.ex b/lib/mv/email_sync/changes/sync_user_email_to_member.ex index 54829a4..eb6770c 100644 --- a/lib/mv/email_sync/changes/sync_user_email_to_member.ex +++ b/lib/mv/email_sync/changes/sync_user_email_to_member.ex @@ -33,17 +33,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do if Map.get(context, :syncing_email, false) do changeset else - # Ensure actor is in changeset context - get it from context if available - actor = Map.get(changeset.context, :actor) || Map.get(context, :actor) - - changeset_with_actor = - if actor && !Map.has_key?(changeset.context, :actor) do - Ash.Changeset.put_context(changeset, :actor, actor) - else - changeset - end - - sync_email(changeset_with_actor) + sync_email(changeset) end end @@ -52,7 +42,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do result = callback.(cs) with {:ok, record} <- Helpers.extract_record(result), - {:ok, user, member} <- get_user_and_member(record, cs) do + {:ok, user, member} <- get_user_and_member(record) do # When called from Member-side, we need to update the member in the result # When called from User-side, we update the linked member in DB only case record do @@ -71,19 +61,16 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do end # Retrieves user and member - works for both resource types - defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do - actor = Map.get(changeset.context, :actor) - - case Loader.get_linked_member(user, actor) do + # Uses system actor via Loader functions + defp get_user_and_member(%Mv.Accounts.User{} = user) do + case Loader.get_linked_member(user) do nil -> {:error, :no_member} member -> {:ok, user, member} end end - defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do - actor = Map.get(changeset.context, :actor) - - case Loader.load_linked_user!(member, actor) do + defp get_user_and_member(%Mv.Membership.Member{} = member) do + case Loader.load_linked_user!(member) do {:ok, user} -> {:ok, user, member} error -> error end diff --git a/lib/mv/email_sync/loader.ex b/lib/mv/email_sync/loader.ex index 91927fb..98f85df 100644 --- a/lib/mv/email_sync/loader.ex +++ b/lib/mv/email_sync/loader.ex @@ -5,25 +5,26 @@ defmodule Mv.EmailSync.Loader do ## Authorization - This module runs systemically and accepts optional actor parameters. - When called from hooks/changes, actor is extracted from changeset context. - When called directly, actor should be provided for proper authorization. + This module runs systemically and uses the system actor for all operations. + This ensures that email synchronization always works, regardless of user permissions. - All functions accept an optional `actor` parameter that is passed to Ash operations - to ensure proper authorization checks are performed. + All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass + user permission checks, as email sync is a mandatory side effect. """ alias Mv.Helpers + alias Mv.Helpers.SystemActor @doc """ Loads the member linked to a user, returns nil if not linked or on error. - Accepts optional actor for authorization. + Uses system actor for authorization to ensure email sync always works. """ - def get_linked_member(user, actor \\ nil) - def get_linked_member(%{member_id: nil}, _actor), do: nil + def get_linked_member(user) + def get_linked_member(%{member_id: nil}), do: nil - def get_linked_member(%{member_id: id}, actor) do - opts = Helpers.ash_actor_opts(actor) + def get_linked_member(%{member_id: id}) 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 @@ -34,10 +35,11 @@ defmodule Mv.EmailSync.Loader do @doc """ Loads the user linked to a member, returns nil if not linked or on error. - Accepts optional actor for authorization. + Uses system actor for authorization to ensure email sync always works. """ - def get_linked_user(member, actor \\ nil) do - opts = Helpers.ash_actor_opts(actor) + def get_linked_user(member) do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) case Ash.load(member, :user, opts) do {:ok, %{user: user}} -> user @@ -49,10 +51,11 @@ defmodule Mv.EmailSync.Loader do Loads the user linked to a member, returning an error tuple if not linked. Useful when a link is required for the operation. - Accepts optional actor for authorization. + Uses system actor for authorization to ensure email sync always works. """ - def load_linked_user!(member, actor \\ nil) do - opts = Helpers.ash_actor_opts(actor) + def load_linked_user!(member) do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) case Ash.load(member, :user, opts) do {:ok, %{user: user}} when not is_nil(user) -> {:ok, user} -- 2.47.2 From f0169c95b7dec8bd1fc1df54ca9640454de0bd1c Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 22:09:19 +0100 Subject: [PATCH 04/16] Use system actor for email uniqueness validation Update email validation modules to use system actor for queries. This ensures data integrity checks always run regardless of user permissions. --- .../email_not_used_by_other_member.ex | 8 +++++++- .../email_not_used_by_other_user.ex | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index af68f96..8c28af6 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -73,12 +73,18 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do end defp check_email_uniqueness(email, exclude_member_id) do + alias Mv.Helpers + alias Mv.Helpers.SystemActor + query = Mv.Membership.Member |> Ash.Query.filter(email == ^to_string(email)) |> maybe_exclude_id(exclude_member_id) - case Ash.read(query) do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) + + case Ash.read(query, opts) do {:ok, []} -> :ok diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex index e1bbc4e..3e6ae58 100644 --- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -30,8 +30,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do def validate(changeset, _opts, _context) do email_changing? = Ash.Changeset.changing_attribute?(changeset, :email) - actor = Map.get(changeset.context || %{}, :actor) - linked_user_id = get_linked_user_id(changeset.data, actor) + linked_user_id = get_linked_user_id(changeset.data) is_linked? = not is_nil(linked_user_id) # Only validate if member is already linked AND email is changing @@ -40,19 +39,22 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do if should_validate? do new_email = Ash.Changeset.get_attribute(changeset, :email) - check_email_uniqueness(new_email, linked_user_id, actor) + check_email_uniqueness(new_email, linked_user_id) else :ok end end - defp check_email_uniqueness(email, exclude_user_id, actor) do + defp check_email_uniqueness(email, exclude_user_id) do + alias Mv.Helpers.SystemActor + query = Mv.Accounts.User |> Ash.Query.filter(email == ^email) |> maybe_exclude_id(exclude_user_id) - opts = Helpers.ash_actor_opts(actor) + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) case Ash.read(query, opts) do {:ok, []} -> @@ -69,8 +71,11 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do defp maybe_exclude_id(query, nil), do: query defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) - defp get_linked_user_id(member_data, actor) do - opts = Helpers.ash_actor_opts(actor) + defp get_linked_user_id(member_data) do + alias Mv.Helpers.SystemActor + + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) case Ash.load(member_data, :user, opts) do {:ok, %{user: %{id: id}}} -> id -- 2.47.2 From c64b74588f077d13e7c7be220e00fb8feaf19dc0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 22:09:20 +0100 Subject: [PATCH 05/16] Use system actor for cycle generation Update cycle generator, member hooks, and job to use system actor. Remove actor parameters as cycle generation is a mandatory side effect. --- lib/membership/member.ex | 82 ++++++++----------- lib/mv/membership_fees/cycle_generator.ex | 67 +++++++-------- .../show/membership_fees_component.ex | 3 +- 3 files changed, 65 insertions(+), 87 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 5a4d01c..ef3aca1 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -125,7 +125,7 @@ defmodule Mv.Membership.Member do {:ok, member} -> if member.membership_fee_type_id && member.join_date do actor = Map.get(changeset.context, :actor) - handle_cycle_generation(member, actor: actor) + handle_cycle_generation(member, []) end {:error, _} -> @@ -196,9 +196,7 @@ defmodule Mv.Membership.Member do Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) if fee_type_changed && member.membership_fee_type_id && member.join_date do - actor = Map.get(changeset.context, :actor) - - case regenerate_cycles_on_type_change(member, actor: actor) do + case regenerate_cycles_on_type_change(member) do {:ok, notifications} -> # Return notifications to Ash - they will be sent automatically after commit {:ok, member, notifications} @@ -231,7 +229,7 @@ defmodule Mv.Membership.Member do if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do actor = Map.get(changeset.context, :actor) - handle_cycle_generation(member, actor: actor) + handle_cycle_generation(member, []) end {:error, _} -> @@ -790,37 +788,37 @@ defmodule Mv.Membership.Member do # Returns {:ok, notifications} or {:error, reason} where notifications are collected # to be sent after transaction commits @doc false - def regenerate_cycles_on_type_change(member, opts \\ []) do + # Uses system actor for cycle regeneration (mandatory side effect) + def regenerate_cycles_on_type_change(member, _opts \\ []) do + alias Mv.Helpers + alias Mv.Helpers.SystemActor + today = Date.utc_today() lock_key = :erlang.phash2(member.id) - actor = Keyword.get(opts, :actor) # Use advisory lock to prevent concurrent deletion and regeneration # This ensures atomicity when multiple updates happen simultaneously if Mv.Repo.in_transaction?() do - regenerate_cycles_in_transaction(member, today, lock_key, actor: actor) + regenerate_cycles_in_transaction(member, today, lock_key) else - regenerate_cycles_new_transaction(member, today, lock_key, actor: actor) + regenerate_cycles_new_transaction(member, today, lock_key) end end # Already in transaction: use advisory lock directly # Returns {:ok, notifications} - notifications should be returned to after_action hook - defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do - actor = Keyword.get(opts, :actor) + defp regenerate_cycles_in_transaction(member, today, lock_key) do Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) - do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) + do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) end # Not in transaction: start new transaction with advisory lock # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action) - defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do - actor = Keyword.get(opts, :actor) - + defp regenerate_cycles_new_transaction(member, today, lock_key) do Mv.Repo.transaction(fn -> Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) - case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) do + case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do {:ok, notifications} -> # Return notifications - they will be sent by the caller notifications @@ -838,11 +836,16 @@ defmodule Mv.Membership.Member do # Performs the actual cycle deletion and regeneration # Returns {:ok, notifications} or {:error, reason} # notifications are collected to be sent after transaction commits + # Uses system actor for all operations defp do_regenerate_cycles_on_type_change(member, today, opts) do + alias Mv.Helpers + alias Mv.Helpers.SystemActor + require Ash.Query skip_lock? = Keyword.get(opts, :skip_lock?, false) - actor = Keyword.get(opts, :actor) + system_actor = SystemActor.get_system_actor() + actor_opts = Helpers.ash_actor_opts(system_actor) # Find all unpaid cycles for this member # We need to check cycle_end for each cycle using its own interval @@ -852,21 +855,11 @@ defmodule Mv.Membership.Member do |> Ash.Query.filter(status == :unpaid) |> Ash.Query.load([:membership_fee_type]) - result = - if actor do - Ash.read(all_unpaid_cycles_query, actor: actor) - else - Ash.read(all_unpaid_cycles_query) - end - - case result do + case Ash.read(all_unpaid_cycles_query, actor_opts) do {:ok, all_unpaid_cycles} -> cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today) - delete_and_regenerate_cycles(cycles_to_delete, member.id, today, - skip_lock?: skip_lock?, - actor: actor - ) + delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?) {:error, reason} -> {:error, reason} @@ -893,16 +886,16 @@ defmodule Mv.Membership.Member do # Deletes future cycles and regenerates them with the new type/amount # Passes today to ensure consistent date across deletion and regeneration # Returns {:ok, notifications} or {:error, reason} + # Uses system actor for cycle generation defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do skip_lock? = Keyword.get(opts, :skip_lock?, false) - actor = Keyword.get(opts, :actor) if Enum.empty?(cycles_to_delete) do # No cycles to delete, just regenerate - regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor) + regenerate_cycles(member_id, today, skip_lock?: skip_lock?) else case delete_cycles(cycles_to_delete) do - :ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor) + :ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?) {:error, reason} -> {:error, reason} end end @@ -928,13 +921,11 @@ defmodule Mv.Membership.Member do # Returns {:ok, notifications} - notifications should be returned to after_action hook defp regenerate_cycles(member_id, today, opts) do skip_lock? = Keyword.get(opts, :skip_lock?, false) - actor = Keyword.get(opts, :actor) case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( member_id, today: today, - skip_lock?: skip_lock?, - actor: actor + skip_lock?: skip_lock? ) do {:ok, _cycles, notifications} when is_list(notifications) -> {:ok, notifications} @@ -948,25 +939,22 @@ defmodule Mv.Membership.Member do # based on environment (test vs production) # This function encapsulates the common logic for cycle generation # to avoid code duplication across different hooks - defp handle_cycle_generation(member, opts) do - actor = Keyword.get(opts, :actor) - + # Uses system actor for cycle generation (mandatory side effect) + defp handle_cycle_generation(member, _opts) do if Mv.Config.sql_sandbox?() do - handle_cycle_generation_sync(member, actor: actor) + handle_cycle_generation_sync(member) else - handle_cycle_generation_async(member, actor: actor) + handle_cycle_generation_async(member) end end # Runs cycle generation synchronously (for test environment) - defp handle_cycle_generation_sync(member, opts) do + defp handle_cycle_generation_sync(member) do require Logger - actor = Keyword.get(opts, :actor) case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( member.id, - today: Date.utc_today(), - actor: actor + today: Date.utc_today() ) do {:ok, cycles, notifications} -> send_notifications_if_any(notifications) @@ -978,11 +966,9 @@ defmodule Mv.Membership.Member do end # Runs cycle generation asynchronously (for production environment) - defp handle_cycle_generation_async(member, opts) do - actor = Keyword.get(opts, :actor) - + defp handle_cycle_generation_async(member) do Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn -> - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do + case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do {:ok, cycles, notifications} -> send_notifications_if_any(notifications) log_cycle_generation_success(member, cycles, notifications, sync: false) diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index 0d17dd3..ec33914 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -30,12 +30,11 @@ defmodule Mv.MembershipFees.CycleGenerator do ## Authorization - This module runs systemically and accepts optional actor parameters. - When called from hooks/changes, actor is extracted from changeset context. - When called directly, actor should be provided for proper authorization. + This module runs systemically and uses the system actor for all operations. + This ensures that cycle generation always works, regardless of user permissions. - All functions accept an optional `actor` parameter in the `opts` keyword list - that is passed to Ash operations to ensure proper authorization checks are performed. + All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass + user permission checks, as cycle generation is a mandatory side effect. ## Examples @@ -47,6 +46,8 @@ defmodule Mv.MembershipFees.CycleGenerator do """ + alias Mv.Helpers + alias Mv.Helpers.SystemActor alias Mv.Membership.Member alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate @@ -86,9 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do def generate_cycles_for_member(member_or_id, opts \\ []) def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do - actor = Keyword.get(opts, :actor) - - case load_member(member_id, actor: actor) do + case load_member(member_id) do {:ok, member} -> generate_cycles_for_member(member, opts) {:error, reason} -> {:error, reason} end @@ -98,27 +97,25 @@ defmodule Mv.MembershipFees.CycleGenerator do today = Keyword.get(opts, :today, Date.utc_today()) skip_lock? = Keyword.get(opts, :skip_lock?, false) - do_generate_cycles_with_lock(member, today, skip_lock?, opts) + do_generate_cycles_with_lock(member, today, skip_lock?) end # Generate cycles with lock handling # Returns {:ok, cycles, notifications} - notifications are never sent here, # they should be returned to the caller (e.g., via after_action hook) - defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do + defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do # Lock already set by caller (e.g., regenerate_cycles_on_type_change) # Just generate cycles without additional locking - actor = Keyword.get(opts, :actor) - do_generate_cycles(member, today, actor: actor) + do_generate_cycles(member, today) end - defp do_generate_cycles_with_lock(member, today, false, opts) do + defp do_generate_cycles_with_lock(member, today, false) do lock_key = :erlang.phash2(member.id) - actor = Keyword.get(opts, :actor) Repo.transaction(fn -> Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) - case do_generate_cycles(member, today, actor: actor) do + case do_generate_cycles(member, today) do {:ok, cycles, notifications} -> # Return cycles and notifications - do NOT send notifications here # They will be sent by the caller (e.g., via after_action hook) @@ -168,12 +165,15 @@ defmodule Mv.MembershipFees.CycleGenerator do # Query ALL members with fee type assigned (including inactive/left members) # The exit_date boundary is applied during cycle generation, not here. # This allows catch-up generation for members who left but are missing cycles. + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) + query = Member |> Ash.Query.filter(not is_nil(membership_fee_type_id)) |> Ash.Query.filter(not is_nil(join_date)) - case Ash.read(query) do + case Ash.read(query, opts) do {:ok, members} -> results = process_members_in_batches(members, batch_size, today) {:ok, build_results_summary(results)} @@ -235,33 +235,25 @@ defmodule Mv.MembershipFees.CycleGenerator do # Private functions - defp load_member(member_id, opts) do - actor = Keyword.get(opts, :actor) + defp load_member(member_id) do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) query = Member |> Ash.Query.filter(id == ^member_id) |> Ash.Query.load([:membership_fee_type, :membership_fee_cycles]) - result = - if actor do - Ash.read_one(query, actor: actor) - else - Ash.read_one(query) - end - - case result do + case Ash.read_one(query, opts) do {:ok, nil} -> {:error, :member_not_found} {:ok, member} -> {:ok, member} {:error, reason} -> {:error, reason} end end - defp do_generate_cycles(member, today, opts) do - actor = Keyword.get(opts, :actor) - + defp do_generate_cycles(member, today) do # Reload member with relationships to ensure fresh data - case load_member(member.id, actor: actor) do + case load_member(member.id) do {:ok, member} -> cond do is_nil(member.membership_fee_type_id) -> @@ -271,7 +263,7 @@ defmodule Mv.MembershipFees.CycleGenerator do {:error, :no_join_date} true -> - generate_missing_cycles(member, today, actor: actor) + generate_missing_cycles(member, today) end {:error, reason} -> @@ -279,8 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do end end - defp generate_missing_cycles(member, today, opts) do - actor = Keyword.get(opts, :actor) + defp generate_missing_cycles(member, today) do fee_type = member.membership_fee_type interval = fee_type.interval amount = fee_type.amount @@ -296,7 +287,7 @@ defmodule Mv.MembershipFees.CycleGenerator do # Only generate if start_date <= end_date if start_date && Date.compare(start_date, end_date) != :gt do cycle_starts = generate_cycle_starts(start_date, end_date, interval) - create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor) + create_cycles(cycle_starts, member.id, fee_type.id, amount) else {:ok, [], []} end @@ -391,8 +382,10 @@ defmodule Mv.MembershipFees.CycleGenerator do end end - defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do - actor = Keyword.get(opts, :actor) + defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) + # Always use return_notifications?: true to collect notifications # Notifications will be returned to the caller, who is responsible for # sending them (e.g., via after_action hook returning {:ok, result, notifications}) @@ -407,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do } handle_cycle_creation_result( - Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor), + Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts), cycle_start ) end) diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index e85baab..186e8b7 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -556,9 +556,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do def handle_event("regenerate_cycles", _params, socket) do socket = assign(socket, :regenerating, true) member = socket.assigns.member - actor = current_actor(socket) - case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do + case CycleGenerator.generate_cycles_for_member(member.id) do {:ok, _new_cycles, _notifications} -> # Reload member with cycles actor = current_actor(socket) -- 2.47.2 From f1bb6a0f9af9fca5163a7cdb0c602b6ad3b2f799 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 22:09:21 +0100 Subject: [PATCH 06/16] Add tests for System Actor helper Test system actor retrieval, caching, fallback behavior, and auto-creation in test environment. --- test/mv/helpers/system_actor_test.exs | 193 ++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 test/mv/helpers/system_actor_test.exs diff --git a/test/mv/helpers/system_actor_test.exs b/test/mv/helpers/system_actor_test.exs new file mode 100644 index 0000000..2cf6b7f --- /dev/null +++ b/test/mv/helpers/system_actor_test.exs @@ -0,0 +1,193 @@ +defmodule Mv.Helpers.SystemActorTest do + @moduledoc """ + Tests for the SystemActor helper module. + """ + use Mv.DataCase, async: false + + alias Mv.Helpers.SystemActor + alias Mv.Authorization + alias Mv.Accounts + + require Ash.Query + + setup do + # Ensure admin role exists + admin_role = + case Authorization.list_roles() do + {:ok, roles} -> + case Enum.find(roles, &(&1.permission_set_name == "admin")) do + nil -> + {:ok, role} = + Authorization.create_role(%{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }) + + role + + role -> + role + end + + _ -> + {:ok, role} = + Authorization.create_role(%{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }) + + role + end + + # Ensure system user exists + system_user = + case Accounts.User + |> Ash.Query.filter(email == ^"system@mila.local") + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!() + |> Ash.load!(:role, domain: Mv.Accounts) + + _ -> + Accounts.create_user!(%{email: "system@mila.local"}, + 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) + end + + # Ensure admin user exists (for fallback) + admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" + + admin_user = + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!() + |> Ash.load!(:role, domain: Mv.Accounts) + + _ -> + Accounts.create_user!(%{email: admin_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) + end + + # Invalidate cache to ensure fresh load + SystemActor.invalidate_cache() + + %{admin_role: admin_role, system_user: system_user, admin_user: admin_user} + end + + describe "get_system_actor/0" do + test "returns system user with admin role", %{system_user: _system_user} do + system_actor = SystemActor.get_system_actor() + + assert %Mv.Accounts.User{} = system_actor + assert to_string(system_actor.email) == "system@mila.local" + assert system_actor.role != nil + assert system_actor.role.permission_set_name == "admin" + end + + test "falls back to admin user if system user doesn't exist", %{admin_user: _admin_user} do + # Delete system user if it exists + case Accounts.User + |> Ash.Query.filter(email == ^"system@mila.local") + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + Ash.destroy!(user, domain: Mv.Accounts) + + _ -> + :ok + end + + # Invalidate cache to force reload + SystemActor.invalidate_cache() + + # Should fall back to admin user + system_actor = SystemActor.get_system_actor() + + assert %Mv.Accounts.User{} = system_actor + assert system_actor.role != nil + assert system_actor.role.permission_set_name == "admin" + # Should be admin user, not system user + assert to_string(system_actor.email) != "system@mila.local" + end + + test "caches system actor for performance" do + # First call + actor1 = SystemActor.get_system_actor() + + # Second call should return cached actor (same struct) + actor2 = SystemActor.get_system_actor() + + # Should be the same struct (cached) + assert actor1.id == actor2.id + end + + test "creates system user in test environment if none exists", %{admin_role: _admin_role} do + # In test environment, system actor should auto-create if missing + # Delete all users to test auto-creation + case Accounts.User + |> Ash.Query.filter(email == ^"system@mila.local") + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + Ash.destroy!(user, domain: Mv.Accounts) + + _ -> + :ok + end + + admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + Ash.destroy!(user, domain: Mv.Accounts) + + _ -> + :ok + end + + # Invalidate cache + SystemActor.invalidate_cache() + + # Should auto-create system user in test environment + system_actor = SystemActor.get_system_actor() + + assert %Mv.Accounts.User{} = system_actor + assert to_string(system_actor.email) == "system@mila.local" + assert system_actor.role != nil + assert system_actor.role.permission_set_name == "admin" + end + end + + describe "invalidate_cache/0" do + test "forces reload of system actor on next call" do + # Get initial actor + actor1 = SystemActor.get_system_actor() + + # Invalidate cache + :ok = SystemActor.invalidate_cache() + + # Next call should reload (but should be same user) + actor2 = SystemActor.get_system_actor() + + # Should be same user (same ID) + assert actor1.id == actor2.id + end + end +end -- 2.47.2 From a3cf8571ff9e10fe7eca0dde448749745d8989e7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 22:09:22 +0100 Subject: [PATCH 07/16] 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. --- CODE_GUIDELINES.md | 49 ++++++++++++++++++++++++++- lib/mv/helpers/system_actor.ex | 26 ++++++++++---- test/mv/helpers/system_actor_test.exs | 6 +++- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index fe2d816..604e2af 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -641,7 +641,54 @@ def card(assigns) do 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:** diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex index 64f95f1..dd11690 100644 --- a/lib/mv/helpers/system_actor.ex +++ b/lib/mv/helpers/system_actor.ex @@ -124,7 +124,9 @@ defmodule Mv.Helpers.SystemActor do {:ok, nil} -> # System user doesn't exist - fall back to admin user case load_admin_user_fallback() do - {:ok, admin_user} -> admin_user + {:ok, admin_user} -> + admin_user + {:error, _} -> # In test environment, create a temporary admin user if none exists if Mix.env() == :test do @@ -137,7 +139,9 @@ defmodule Mv.Helpers.SystemActor do {:error, _reason} = error -> # Database error - try fallback case load_admin_user_fallback() do - {:ok, admin_user} -> admin_user + {:ok, admin_user} -> + admin_user + {:error, _} -> # In test environment, create a temporary admin user if none exists if Mix.env() == :test do @@ -166,16 +170,21 @@ defmodule Mv.Helpers.SystemActor do 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"}]}} -> + {: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 @@ -191,16 +200,21 @@ defmodule Mv.Helpers.SystemActor do 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"}]}} -> + {: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 diff --git a/test/mv/helpers/system_actor_test.exs b/test/mv/helpers/system_actor_test.exs index 2cf6b7f..c1f6d0e 100644 --- a/test/mv/helpers/system_actor_test.exs +++ b/test/mv/helpers/system_actor_test.exs @@ -79,7 +79,10 @@ defmodule Mv.Helpers.SystemActorTest do |> 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.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!() @@ -152,6 +155,7 @@ defmodule Mv.Helpers.SystemActorTest do end admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" + case Accounts.User |> Ash.Query.filter(email == ^admin_email) |> Ash.read_one(domain: Mv.Accounts) do -- 2.47.2 From c5bd58e7d3cc2c75f5bea20f9609b9d273c694eb Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 23:16:39 +0100 Subject: [PATCH 08/16] Add @spec type annotations to SystemActor functions Add type specifications for all private functions to improve static analysis with Dialyzer and documentation quality. --- lib/mv/helpers/system_actor.ex | 337 ++++++++++++++++++++++----------- 1 file changed, 226 insertions(+), 111 deletions(-) diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex index dd11690..1349c80 100644 --- a/lib/mv/helpers/system_actor.ex +++ b/lib/mv/helpers/system_actor.ex @@ -29,6 +29,12 @@ defmodule Mv.Helpers.SystemActor do The cache is invalidated on application restart. For long-running applications, 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 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() 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 if Mix.env() == :test do - load_system_actor() + try do + {:ok, load_system_actor()} + rescue + e -> {:error, e} + end else try do - Agent.get_and_update(__MODULE__, fn - nil -> - # Cache miss - load system actor - actor = load_system_actor() - {actor, actor} + result = + Agent.get_and_update(__MODULE__, fn + nil -> + # Cache miss - load system actor + try do + actor = load_system_actor() + {actor, actor} + rescue + e -> {{:error, e}, nil} + end - cached_actor -> - # Cache hit - return cached actor - {cached_actor, cached_actor} - end) + cached_actor -> + # Cache hit - return cached actor + {cached_actor, cached_actor} + end) + + case result do + {:error, reason} -> {:error, reason} + actor -> {:ok, actor} + end catch :exit, {:noproc, _} -> # Agent not started - load directly without caching - load_system_actor() + try do + {:ok, load_system_actor()} + rescue + e -> {:error, e} + end end end end @@ -113,128 +164,186 @@ defmodule Mv.Helpers.SystemActor do Agent.update(__MODULE__, fn _state -> nil 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 # 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 - # 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 + handle_system_user_not_found("no system user or admin user found") {:error, _reason} = error -> - # Database error - try fallback - case load_admin_user_fallback() do - {:ok, admin_user} -> - admin_user + handle_system_user_error(error) + end + end - {: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 + # Handles case when system user doesn't exist + @spec handle_system_user_not_found(String.t()) :: Mv.Accounts.User.t() | no_return() + defp handle_system_user_not_found(message) do + case load_admin_user_fallback() do + {:ok, admin_user} -> + admin_user + + {: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 # 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 + alias Mv.Accounts 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 - # 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 + 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) end # 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 Mv.Accounts.User |> Ash.Query.filter(email == ^email) @@ -242,6 +351,7 @@ defmodule Mv.Helpers.SystemActor do end # 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 case Ash.load(user, :role, domain: Mv.Accounts) do {:ok, user_with_role} -> @@ -253,10 +363,12 @@ defmodule Mv.Helpers.SystemActor do end # 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 user end + @spec validate_admin_role(Mv.Accounts.User.t()) :: no_return() 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} @@ -264,6 +376,7 @@ defmodule Mv.Helpers.SystemActor do """ end + @spec validate_admin_role(Mv.Accounts.User.t()) :: no_return() defp validate_admin_role(%{role: nil}) do raise """ System actor must have a role assigned, but role is nil. @@ -271,6 +384,7 @@ defmodule Mv.Helpers.SystemActor do """ end + @spec validate_admin_role(term()) :: no_return() defp validate_admin_role(_user) do raise """ System actor must have a role with admin permissions. @@ -279,6 +393,7 @@ defmodule Mv.Helpers.SystemActor do end # 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 admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" -- 2.47.2 From 5eadd5f0907618a30c66e700eef9593fc13923a2 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 20 Jan 2026 23:16:40 +0100 Subject: [PATCH 09/16] Refactor test setup into helper functions Extract setup code into reusable helper functions to reduce duplication and improve maintainability. --- test/mv/helpers/system_actor_test.exs | 301 ++++++++++++++++++++------ 1 file changed, 233 insertions(+), 68 deletions(-) diff --git a/test/mv/helpers/system_actor_test.exs b/test/mv/helpers/system_actor_test.exs index c1f6d0e..751f5c5 100644 --- a/test/mv/helpers/system_actor_test.exs +++ b/test/mv/helpers/system_actor_test.exs @@ -10,84 +10,91 @@ defmodule Mv.Helpers.SystemActorTest do require Ash.Query - setup do - # Ensure admin role exists - admin_role = - case Authorization.list_roles() do - {:ok, roles} -> - case Enum.find(roles, &(&1.permission_set_name == "admin")) do - nil -> - {:ok, role} = - Authorization.create_role(%{ - name: "Admin", - description: "Administrator with full access", - permission_set_name: "admin" - }) + # Helper function to ensure admin role exists + defp ensure_admin_role do + case Authorization.list_roles() do + {:ok, roles} -> + case Enum.find(roles, &(&1.permission_set_name == "admin")) do + nil -> + {:ok, role} = + Authorization.create_role(%{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }) - role + role - role -> - role - end + role -> + role + end - _ -> - {:ok, role} = - Authorization.create_role(%{ - name: "Admin", - description: "Administrator with full access", - permission_set_name: "admin" - }) + _ -> + {:ok, role} = + Authorization.create_role(%{ + name: "Admin", + description: "Administrator with full access", + permission_set_name: "admin" + }) - role - end + role + end + end - # Ensure system user exists - system_user = - case Accounts.User - |> Ash.Query.filter(email == ^"system@mila.local") - |> Ash.read_one(domain: Mv.Accounts) do - {:ok, user} when not is_nil(user) -> - user - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!() - |> Ash.load!(:role, domain: Mv.Accounts) + # Helper function to ensure system user exists with admin role + defp ensure_system_user(admin_role) do + case Accounts.User + |> Ash.Query.filter(email == ^"system@mila.local") + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!() + |> Ash.load!(:role, domain: Mv.Accounts) - _ -> - Accounts.create_user!(%{email: "system@mila.local"}, - 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) - end + _ -> + Accounts.create_user!(%{email: "system@mila.local"}, + 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) + end + end - # Ensure admin user exists (for fallback) + # Helper function to ensure admin user exists with admin role + defp ensure_admin_user(admin_role) do admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" - admin_user = - case Accounts.User - |> Ash.Query.filter(email == ^admin_email) - |> Ash.read_one(domain: Mv.Accounts) do - {:ok, user} when not is_nil(user) -> - user - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!() - |> Ash.load!(:role, domain: Mv.Accounts) + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!() + |> Ash.load!(:role, domain: Mv.Accounts) - _ -> - Accounts.create_user!(%{email: admin_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) - end + _ -> + Accounts.create_user!(%{email: admin_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) + end + end + + setup do + admin_role = ensure_admin_role() + system_user = ensure_system_user(admin_role) + admin_user = ensure_admin_user(admin_role) # Invalidate cache to ensure fresh load SystemActor.invalidate_cache() @@ -194,4 +201,162 @@ defmodule Mv.Helpers.SystemActorTest do assert actor1.id == actor2.id end end + + describe "get_system_actor_result/0" do + test "returns ok tuple with system actor" do + assert {:ok, actor} = SystemActor.get_system_actor_result() + assert %Mv.Accounts.User{} = actor + assert actor.role.permission_set_name == "admin" + end + + test "returns error tuple when system actor cannot be loaded" do + # Delete all users to force error + case Accounts.User + |> Ash.Query.filter(email == ^"system@mila.local") + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + Ash.destroy!(user, domain: Mv.Accounts) + + _ -> + :ok + end + + admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" + + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + Ash.destroy!(user, domain: Mv.Accounts) + + _ -> + :ok + end + + SystemActor.invalidate_cache() + + # In test environment, it should auto-create, so this should succeed + # But if it fails, we should get an error tuple + result = SystemActor.get_system_actor_result() + + # Should either succeed (auto-created) or return error + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + end + + describe "system_user_email/0" do + test "returns the system user email address" do + assert SystemActor.system_user_email() == "system@mila.local" + end + end + + describe "edge cases" do + test "raises error if admin user has no role", %{admin_user: admin_user} do + # Remove role from admin user + admin_user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove) + |> Ash.update!() + + # Delete system user to force fallback + case Accounts.User + |> Ash.Query.filter(email == ^"system@mila.local") + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + Ash.destroy!(user, domain: Mv.Accounts) + + _ -> + :ok + end + + SystemActor.invalidate_cache() + + # Should raise error because admin user has no role + assert_raise RuntimeError, ~r/System actor must have a role assigned/, fn -> + SystemActor.get_system_actor() + end + end + + test "handles concurrent calls without race conditions" do + # Delete system user and admin user to force creation + case Accounts.User + |> Ash.Query.filter(email == ^"system@mila.local") + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + Ash.destroy!(user, domain: Mv.Accounts) + + _ -> + :ok + end + + admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" + + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts) do + {:ok, user} when not is_nil(user) -> + Ash.destroy!(user, domain: Mv.Accounts) + + _ -> + :ok + end + + SystemActor.invalidate_cache() + + # Call get_system_actor concurrently from multiple processes + tasks = + for _ <- 1..10 do + Task.async(fn -> SystemActor.get_system_actor() end) + end + + results = Task.await_many(tasks, :infinity) + + # All should succeed and return the same actor + assert length(results) == 10 + assert Enum.all?(results, &(&1.role.permission_set_name == "admin")) + assert Enum.all?(results, fn actor -> to_string(actor.email) == "system@mila.local" end) + + # All should have the same ID (same user) + ids = Enum.map(results, & &1.id) + assert Enum.uniq(ids) |> length() == 1 + end + + test "raises error if system user has wrong role", %{system_user: system_user} do + # Create a non-admin role (using read_only as it's a valid permission set) + {:ok, read_only_role} = + Authorization.create_role(%{ + name: "Read Only Role", + description: "Read-only access", + permission_set_name: "read_only" + }) + + # Assign wrong role to system user + system_user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove) + |> Ash.update!() + + SystemActor.invalidate_cache() + + # Should raise error because system user doesn't have admin role + assert_raise RuntimeError, ~r/System actor must have admin role/, fn -> + SystemActor.get_system_actor() + end + end + + test "raises error if system user has no role", %{system_user: system_user} do + # Remove role from system user + system_user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove) + |> Ash.update!() + + SystemActor.invalidate_cache() + + # Should raise error because system user has no role + assert_raise RuntimeError, ~r/System actor must have a role assigned/, fn -> + SystemActor.get_system_actor() + end + end + end end -- 2.47.2 From 006b1aaf068fa8d1ccf070d9475b0a9f9d508eac Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 21 Jan 2026 08:02:31 +0100 Subject: [PATCH 10/16] Replace Mix.env() with Config.sql_sandbox?() in SystemActor Use Application config instead of Mix.env() to prevent runtime crashes in production releases where Mix is not available --- lib/mv/helpers/system_actor.ex | 43 +++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex index 1349c80..7a8ab8b 100644 --- a/lib/mv/helpers/system_actor.ex +++ b/lib/mv/helpers/system_actor.ex @@ -39,13 +39,18 @@ defmodule Mv.Helpers.SystemActor do The system actor should NEVER be used for user-initiated actions. It is only for systemic operations that must bypass user permissions. + + The system user is created without a password (`hashed_password = nil`) and + without an OIDC ID (`oidc_id = nil`) to prevent login. This ensures the + system user cannot be used for authentication, even if credentials are + somehow obtained. """ use Agent require Ash.Query - @system_user_email "system@mila.local" + alias Mv.Config @doc """ Starts the SystemActor Agent. @@ -106,8 +111,8 @@ defmodule Mv.Helpers.SystemActor do """ @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 - if Mix.env() == :test do + # In test environment (SQL sandbox), always load directly to avoid Agent/Sandbox issues + if Config.sql_sandbox?() do try do {:ok, load_system_actor()} rescue @@ -161,7 +166,10 @@ defmodule Mv.Helpers.SystemActor do """ @spec invalidate_cache() :: :ok def invalidate_cache do - Agent.update(__MODULE__, fn _state -> nil end) + case Process.whereis(__MODULE__) do + nil -> :ok + _pid -> Agent.update(__MODULE__, fn _state -> nil end) + end end @doc """ @@ -181,13 +189,20 @@ defmodule Mv.Helpers.SystemActor do """ @spec system_user_email() :: String.t() - def system_user_email, do: @system_user_email + def system_user_email, do: system_user_email_config() + + # Returns the system user email from environment variable or default + # This allows configuration via SYSTEM_ACTOR_EMAIL env var + @spec system_user_email_config() :: String.t() + defp system_user_email_config do + System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local" + end # Loads the system actor from the database # 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 - case find_user_by_email(@system_user_email) do + case find_user_by_email(system_user_email_config()) do {:ok, user} when not is_nil(user) -> load_user_with_role(user) @@ -226,7 +241,7 @@ defmodule Mv.Helpers.SystemActor do # 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 + if Config.sql_sandbox?() do create_test_system_actor() else raise "Failed to load system actor: #{message}" @@ -327,12 +342,15 @@ defmodule Mv.Helpers.SystemActor do end # Creates system user with admin role assigned + # SECURITY: System user is created without password (hashed_password = nil) and + # without OIDC ID (oidc_id = nil) to prevent login. This user is ONLY for + # internal system operations via SystemActor and should never be used for authentication. @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 - Accounts.create_user!(%{email: @system_user_email}, + Accounts.create_user!(%{email: system_user_email_config()}, upsert?: true, upsert_identity: :unique_email ) @@ -343,11 +361,18 @@ defmodule Mv.Helpers.SystemActor do end # Finds a user by email address + # SECURITY: Uses authorize?: false for bootstrap lookup only. + # This is necessary because we need to find the system/admin user before + # we can load the system actor. If User policies require an actor, this + # would create a chicken-and-egg problem. This is safe because: + # 1. We only query by email (no sensitive data exposed) + # 2. This is only used during system actor initialization (bootstrap phase) + # 3. Once system actor is loaded, all subsequent operations use proper authorization @spec find_user_by_email(String.t()) :: {:ok, Mv.Accounts.User.t() | nil} | {:error, term()} defp find_user_by_email(email) do Mv.Accounts.User |> Ash.Query.filter(email == ^email) - |> Ash.read_one(domain: Mv.Accounts) + |> Ash.read_one(domain: Mv.Accounts, authorize?: false) end # Loads a user with their role preloaded (required for authorization) -- 2.47.2 From 5c3657fed19be8a6dd3ed03af2a9256970699b91 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 21 Jan 2026 08:02:32 +0100 Subject: [PATCH 11/16] Use SystemActor opts for cycle deletion operations Pass actor_opts to delete_cycles/1 to ensure proper authorization when MembershipFeeCycle policies are enforced --- lib/membership/member.ex | 84 ++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index ef3aca1..828e82e 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -124,8 +124,9 @@ defmodule Mv.Membership.Member do case result do {:ok, member} -> if member.membership_fee_type_id && member.join_date do - actor = Map.get(changeset.context, :actor) - handle_cycle_generation(member, []) + # Capture initiator for audit trail (if available) + initiator = Map.get(changeset.context, :actor) + handle_cycle_generation(member, initiator: initiator) end {:error, _} -> @@ -228,8 +229,9 @@ defmodule Mv.Membership.Member do exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date) if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do - actor = Map.get(changeset.context, :actor) - handle_cycle_generation(member, []) + # Capture initiator for audit trail (if available) + initiator = Map.get(changeset.context, :actor) + handle_cycle_generation(member, initiator: initiator) end {:error, _} -> @@ -859,7 +861,13 @@ defmodule Mv.Membership.Member do {:ok, all_unpaid_cycles} -> cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today) - delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?) + delete_and_regenerate_cycles( + cycles_to_delete, + member.id, + today, + actor_opts, + skip_lock?: skip_lock? + ) {:error, reason} -> {:error, reason} @@ -886,15 +894,15 @@ defmodule Mv.Membership.Member do # Deletes future cycles and regenerates them with the new type/amount # Passes today to ensure consistent date across deletion and regeneration # Returns {:ok, notifications} or {:error, reason} - # Uses system actor for cycle generation - defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do + # Uses system actor for cycle generation and deletion + defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, actor_opts, opts) do skip_lock? = Keyword.get(opts, :skip_lock?, false) if Enum.empty?(cycles_to_delete) do # No cycles to delete, just regenerate regenerate_cycles(member_id, today, skip_lock?: skip_lock?) else - case delete_cycles(cycles_to_delete) do + case delete_cycles(cycles_to_delete, actor_opts) do :ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?) {:error, reason} -> {:error, reason} end @@ -902,10 +910,11 @@ defmodule Mv.Membership.Member do end # Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise - defp delete_cycles(cycles_to_delete) do + # Uses system actor for authorization to ensure deletion always works + defp delete_cycles(cycles_to_delete, actor_opts) do delete_results = Enum.map(cycles_to_delete, fn cycle -> - Ash.destroy(cycle) + Ash.destroy(cycle, actor_opts) end) if Enum.any?(delete_results, &match?({:error, _}, &1)) do @@ -940,43 +949,58 @@ defmodule Mv.Membership.Member do # This function encapsulates the common logic for cycle generation # to avoid code duplication across different hooks # Uses system actor for cycle generation (mandatory side effect) - defp handle_cycle_generation(member, _opts) do + # Captures initiator for audit trail (if available in opts) + defp handle_cycle_generation(member, opts) do + initiator = Keyword.get(opts, :initiator) + if Mv.Config.sql_sandbox?() do - handle_cycle_generation_sync(member) + handle_cycle_generation_sync(member, initiator) else - handle_cycle_generation_async(member) + handle_cycle_generation_async(member, initiator) end end # Runs cycle generation synchronously (for test environment) - defp handle_cycle_generation_sync(member) do + defp handle_cycle_generation_sync(member, initiator) do require Logger case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( member.id, - today: Date.utc_today() + today: Date.utc_today(), + initiator: initiator ) do {:ok, cycles, notifications} -> send_notifications_if_any(notifications) - log_cycle_generation_success(member, cycles, notifications, sync: true) + + log_cycle_generation_success(member, cycles, notifications, + sync: true, + initiator: initiator + ) {:error, reason} -> - log_cycle_generation_error(member, reason, sync: true) + log_cycle_generation_error(member, reason, sync: true, initiator: initiator) end end # Runs cycle generation asynchronously (for production environment) - defp handle_cycle_generation_async(member) do + defp handle_cycle_generation_async(member, initiator) do Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn -> - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do + case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, + initiator: initiator + ) do {:ok, cycles, notifications} -> send_notifications_if_any(notifications) - log_cycle_generation_success(member, cycles, notifications, sync: false) + + log_cycle_generation_success(member, cycles, notifications, + sync: false, + initiator: initiator + ) {:error, reason} -> - log_cycle_generation_error(member, reason, sync: false) + log_cycle_generation_error(member, reason, sync: false, initiator: initiator) end end) + |> Task.await(:infinity) end # Sends notifications if any are present @@ -987,13 +1011,17 @@ defmodule Mv.Membership.Member do end # Logs successful cycle generation - defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do + defp log_cycle_generation_success(member, cycles, notifications, + sync: sync?, + initiator: initiator + ) do require Logger sync_label = if sync?, do: "", else: " (async)" + initiator_info = get_initiator_info(initiator) Logger.debug( - "Successfully generated cycles for member#{sync_label}", + "Successfully generated cycles for member#{sync_label} (initiator: #{initiator_info})", member_id: member.id, cycles_count: length(cycles), notifications_count: length(notifications) @@ -1001,13 +1029,14 @@ defmodule Mv.Membership.Member do end # Logs cycle generation errors - defp log_cycle_generation_error(member, reason, sync: sync?) do + defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do require Logger sync_label = if sync?, do: "", else: " (async)" + initiator_info = get_initiator_info(initiator) Logger.error( - "Failed to generate cycles for member#{sync_label}", + "Failed to generate cycles for member#{sync_label} (initiator: #{initiator_info})", member_id: member.id, member_email: member.email, error: inspect(reason), @@ -1015,6 +1044,11 @@ defmodule Mv.Membership.Member do ) end + # Extracts initiator information for audit trail + defp get_initiator_info(nil), do: "system" + defp get_initiator_info(%{email: email}), do: email + defp get_initiator_info(_), do: "unknown" + # Helper to extract error type for structured logging defp error_type(%{__struct__: struct_name}), do: struct_name defp error_type(error) when is_atom(error), do: error -- 2.47.2 From 7e9de8e95b7b67ae7b078c2ec2d1ff16e522cd53 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 21 Jan 2026 08:02:33 +0100 Subject: [PATCH 12/16] Add logging for fail-open email uniqueness validations Log warnings when query errors occur in email uniqueness checks to improve visibility of data integrity issues --- .../user/validations/email_not_used_by_other_member.ex | 8 +++++++- .../member/validations/email_not_used_by_other_user.ex | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index 8c28af6..9f900eb 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -91,7 +91,13 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do {:ok, _} -> {:error, field: :email, message: "is already used by another member", value: email} - {:error, _} -> + {:error, reason} -> + require Logger + + Logger.warning( + "Email uniqueness validation query failed for user email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)." + ) + :ok end end diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex index 3e6ae58..78af70c 100644 --- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -63,7 +63,13 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do {:ok, _} -> {:error, field: :email, message: "is already used by another user", value: email} - {:error, _} -> + {:error, reason} -> + require Logger + + Logger.warning( + "Email uniqueness validation query failed for member email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)." + ) + :ok end end -- 2.47.2 From ea399612beadd4c74d84619f0135947018233341 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 21 Jan 2026 08:02:35 +0100 Subject: [PATCH 13/16] Make system actor email configurable via SYSTEM_ACTOR_EMAIL Allow system user email to be configured via environment variable with fallback to default 'system@mila.local' --- priv/repo/seeds.exs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 03a8564..ccc90f5 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -204,7 +204,8 @@ admin_user_with_role = # Create system user for systemic operations (email sync, validations, cycle generation) # This user is used by Mv.Helpers.SystemActor for operations that must always run -system_user_email = "system@mila.local" +# Email is configurable via SYSTEM_ACTOR_EMAIL environment variable +system_user_email = Mv.Helpers.SystemActor.system_user_email() case Accounts.User |> Ash.Query.filter(email == ^system_user_email) @@ -218,7 +219,11 @@ case Accounts.User {:ok, nil} -> # System user doesn't exist - create it with admin role - # Note: No password is set - this user should never be used for login + # SECURITY: System user must NOT be able to log in: + # - No password (hashed_password = nil) - prevents password login + # - No OIDC ID (oidc_id = nil) - prevents OIDC login + # - This user is ONLY for internal system operations via SystemActor + # If either hashed_password or oidc_id is set, the user could potentially log in Accounts.create_user!(%{email: system_user_email}, upsert?: true, upsert_identity: :unique_email -- 2.47.2 From b0ddf99117fba15f27596048e72c2b5a039243a6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 21 Jan 2026 08:02:38 +0100 Subject: [PATCH 14/16] Add admin authorization check for regenerate cycles button Restrict UI access to cycle regeneration to administrators only to prevent policy bypass via user interface --- .../show/membership_fees_component.ex | 76 +++++++++++-------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 186e8b7..5350e9f 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -554,45 +554,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do end def handle_event("regenerate_cycles", _params, socket) do - socket = assign(socket, :regenerating, true) - member = socket.assigns.member + actor = current_actor(socket) - case CycleGenerator.generate_cycles_for_member(member.id) do - {:ok, _new_cycles, _notifications} -> - # Reload member with cycles - actor = current_actor(socket) + # SECURITY: Only admins can manually regenerate cycles via UI + # Cycle generation itself uses system actor, but UI access should be restricted + if actor.role && actor.role.permission_set_name == "admin" do + socket = assign(socket, :regenerating, true) + member = socket.assigns.member - updated_member = - member - |> Ash.load!( - [ - :membership_fee_type, - membership_fee_cycles: [:membership_fee_type] - ], - actor: actor - ) + case CycleGenerator.generate_cycles_for_member(member.id) do + {:ok, _new_cycles, _notifications} -> + # Reload member with cycles + actor = current_actor(socket) - cycles = - Enum.sort_by( - updated_member.membership_fee_cycles || [], - & &1.cycle_start, - {:desc, Date} - ) + updated_member = + member + |> Ash.load!( + [ + :membership_fee_type, + membership_fee_cycles: [:membership_fee_type] + ], + actor: actor + ) - send(self(), {:member_updated, updated_member}) + cycles = + Enum.sort_by( + updated_member.membership_fee_cycles || [], + & &1.cycle_start, + {:desc, Date} + ) - {:noreply, - socket - |> assign(:member, updated_member) - |> assign(:cycles, cycles) - |> assign(:regenerating, false) - |> put_flash(:info, gettext("Cycles regenerated successfully"))} + send(self(), {:member_updated, updated_member}) - {:error, error} -> - {:noreply, - socket - |> assign(:regenerating, false) - |> put_flash(:error, format_error(error))} + {:noreply, + socket + |> assign(:member, updated_member) + |> assign(:cycles, cycles) + |> assign(:regenerating, false) + |> put_flash(:info, gettext("Cycles regenerated successfully"))} + + {:error, error} -> + {:noreply, + socket + |> assign(:regenerating, false) + |> put_flash(:error, format_error(error))} + end + else + {:noreply, + socket + |> put_flash(:error, gettext("Only administrators can regenerate cycles"))} end end -- 2.47.2 From 1c5bd046613664f32c9afae02443d36fc8d1d1d9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 21 Jan 2026 08:02:40 +0100 Subject: [PATCH 15/16] Update gettext translations for new UI strings --- priv/gettext/de/LC_MESSAGES/default.po | 290 +----------------------- priv/gettext/default.pot | 5 + priv/gettext/en/LC_MESSAGES/default.po | 295 +------------------------ 3 files changed, 13 insertions(+), 577 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index fa5cf54..70d3c53 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -1926,289 +1926,7 @@ msgstr "Validierung fehlgeschlagen: %{field} %{message}" msgid "Validation failed: %{message}" msgstr "Validierung fehlgeschlagen: %{message}" -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Use this form to manage Custom Field Value records in your database." -#~ msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Member" -#~ msgstr "Mitglied" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Choose a custom field" -#~ msgstr "Wähle ein Benutzerdefiniertes Feld" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Joining year - reduced to 0" -#~ msgstr "Beitrittsjahr – auf 0 reduziert" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Regular" -#~ msgstr "Regulär" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Current" -#~ msgstr "Aktuell" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Paid via bank transfer" -#~ msgstr "Bezahlt durch Überweisung" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Mark as Unpaid" -#~ msgstr "Als unbezahlt markieren" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Half-yearly contribution for supporting members" -#~ msgstr "Halbjährlicher Beitrag für Fördermitglieder" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Reduced fee for unemployed, pensioners, or low income" -#~ msgstr "Ermäßigter Beitrag für Arbeitslose, Rentner*innen oder Geringverdienende" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Custom field value not found" -#~ msgstr "Benutzerdefinierter Feldwert nicht gefunden" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Supporting Member" -#~ msgstr "Fördermitglied" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Monthly fee for students and trainees" -#~ msgstr "Monatlicher Beitrag für Studierende und Auszubildende" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Custom field value %{action} successfully" -#~ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Total Contributions" -#~ msgstr "Gesamtbeiträge" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Manage contribution types for membership fees." -#~ msgstr "Beitragsarten für Mitgliedsbeiträge verwalten." - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Change Contribution Type" -#~ msgstr "Beitragsart ändern" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "New Contribution Type" -#~ msgstr "Neue Beitragsart" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Time Period" -#~ msgstr "Zeitraum" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Custom field value deleted successfully" -#~ msgstr "Benutzerdefinierter Feldwert erfolgreich gelöscht" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "You do not have permission to access this custom field value" -#~ msgstr "Sie haben keine Berechtigung, auf diesen benutzerdefinierten Feldwert zuzugreifen" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Cannot delete - members assigned" -#~ msgstr "Löschen nicht möglich – es sind Mitglieder zugewiesen" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Preview Mockup" -#~ msgstr "Vorschau" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Contribution Types" -#~ msgstr "Beitragsarten" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "This page is not functional and only displays the planned features." -#~ msgstr "Diese Seite ist nicht funktionsfähig und zeigt nur geplante Funktionen." - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Member since" -#~ msgstr "Mitglied seit" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Unsupported value type: %{type}" -#~ msgstr "Nicht unterstützter Wertetyp: %{type}" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Custom field" -#~ msgstr "Benutzerdefinierte Felder" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Mark as Paid" -#~ msgstr "Als bezahlt markieren" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Contribution type" -#~ msgstr "Beitragsart" - -#~ #: lib/mv_web/components/layouts/sidebar.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Contributions" -#~ msgstr "Beiträge" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Reduced" -#~ msgstr "Reduziert" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "No fee for honorary members" -#~ msgstr "Kein Beitrag für ehrenamtliche Mitglieder" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "You do not have permission to delete this custom field value" -#~ msgstr "Sie haben keine Berechtigung, diesen benutzerdefinierten Feldwert zu löschen" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "%{count} period selected" -#~ msgid_plural "%{count} periods selected" -#~ msgstr[0] "%{count} Zyklus ausgewählt" -#~ msgstr[1] "%{count} Zyklen ausgewählt" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Mark as Suspended" -#~ msgstr "Als pausiert markieren" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." -#~ msgstr "Beitragsarten definieren verschiedene Beitragsmodelle. Jede Art hat einen festen Zyklus (monatlich, vierteljährlich, halbjährlich, jährlich), der nach Erstellung nicht mehr geändert werden kann." - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Choose a member" -#~ msgstr "Mitglied auswählen" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Suspend" -#~ msgstr "Pausieren" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Reopen" -#~ msgstr "Wieder öffnen" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Value" -#~ msgstr "Wert" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Why are not all contribution types shown?" -#~ msgstr "Warum werden nicht alle Beitragsarten angezeigt?" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Contribution Start" -#~ msgstr "Beitragsbeginn" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Standard membership fee for regular members" -#~ msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Save Custom Field Value" -#~ msgstr "Benutzerdefinierten Feldwert speichern" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Honorary" -#~ msgstr "Ehrenamtlich" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Contributions for %{name}" -#~ msgstr "Beiträge für %{name}" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Family" -#~ msgstr "Familie" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "You do not have permission to view custom field values" -#~ msgstr "Sie haben keine Berechtigung, benutzerdefinierte Feldwerte anzuzeigen" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Student" -#~ msgstr "Student" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Quarterly fee for family memberships" -#~ msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps." -#~ msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z. B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden." - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Please select a custom field first" -#~ msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Open Contributions" -#~ msgstr "Offene Beiträge" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Member Contributions" -#~ msgstr "Mitgliedsbeiträge" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "About Contribution Types" -#~ msgstr "Über Beitragsarten" +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Only administrators can regenerate cycles" +msgstr "Nur Administrator*innen können Zyklen regenerieren" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d43c1a6..5803c6e 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -1926,3 +1926,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Validation failed: %{message}" msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Only administrators can regenerate cycles" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a6d8fc2..44c91a3 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1927,294 +1927,7 @@ msgstr "" msgid "Validation failed: %{message}" msgstr "" -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Use this form to manage Custom Field Value records in your database." -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Member" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Choose a custom field" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Joining year - reduced to 0" -#~ msgstr "" - -#~ #: lib/mv_web/components/layouts/sidebar.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Admin" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Regular" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Current" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Paid via bank transfer" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Mark as Unpaid" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Half-yearly contribution for supporting members" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Reduced fee for unemployed, pensioners, or low income" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Custom field value not found" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Supporting Member" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Monthly fee for students and trainees" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Custom field value %{action} successfully" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Total Contributions" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Manage contribution types for membership fees." -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Change Contribution Type" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "New Contribution Type" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Time Period" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Custom field value deleted successfully" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "You do not have permission to access this custom field value" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Cannot delete - members assigned" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Preview Mockup" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Contribution Types" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "This page is not functional and only displays the planned features." -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Member since" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Unsupported value type: %{type}" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Custom field" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Mark as Paid" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Contribution type" -#~ msgstr "" - -#~ #: lib/mv_web/components/layouts/sidebar.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Contributions" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Reduced" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "No fee for honorary members" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "You do not have permission to delete this custom field value" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "%{count} period selected" -#~ msgid_plural "%{count} periods selected" -#~ msgstr[0] "" -#~ msgstr[1] "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Mark as Suspended" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Choose a member" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Suspend" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Reopen" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Value" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Why are not all contribution types shown?" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Contribution Start" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Standard membership fee for regular members" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Save Custom Field Value" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Honorary" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Contributions for %{name}" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Family" -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "You do not have permission to view custom field values" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Student" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Quarterly fee for family memberships" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps." -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_value_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Please select a custom field first" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Open Contributions" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_period_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Member Contributions" -#~ msgstr "" - -#~ #: lib/mv_web/live/contribution_type_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "About Contribution Types" -#~ msgstr "" +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Only administrators can regenerate cycles" +msgstr "" -- 2.47.2 From d07f1984cd84794ea948f9f0225a593766609fcd Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 21 Jan 2026 08:35:34 +0100 Subject: [PATCH 16/16] Move require Logger to module level Move require Logger statements from function/case level to module level for better code organization and consistency with Elixir best practices --- lib/membership/member.ex | 8 -------- .../user/validations/email_not_used_by_other_member.ex | 4 ++-- .../member/validations/email_not_used_by_other_user.ex | 4 ++-- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 828e82e..cebcc5c 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -203,8 +203,6 @@ defmodule Mv.Membership.Member do {:ok, member, notifications} {:error, reason} -> - require Logger - Logger.warning( "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}" ) @@ -962,8 +960,6 @@ defmodule Mv.Membership.Member do # Runs cycle generation synchronously (for test environment) defp handle_cycle_generation_sync(member, initiator) do - require Logger - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( member.id, today: Date.utc_today(), @@ -1015,8 +1011,6 @@ defmodule Mv.Membership.Member do sync: sync?, initiator: initiator ) do - require Logger - sync_label = if sync?, do: "", else: " (async)" initiator_info = get_initiator_info(initiator) @@ -1030,8 +1024,6 @@ defmodule Mv.Membership.Member do # Logs cycle generation errors defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do - require Logger - sync_label = if sync?, do: "", else: " (async)" initiator_info = get_initiator_info(initiator) diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index 9f900eb..0e693e1 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -9,6 +9,8 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do """ use Ash.Resource.Validation + require Logger + @doc """ Validates email uniqueness across linked User-Member pairs. @@ -92,8 +94,6 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do {:error, field: :email, message: "is already used by another member", value: email} {:error, reason} -> - require Logger - Logger.warning( "Email uniqueness validation query failed for user email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)." ) diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex index 78af70c..f9fba1b 100644 --- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -10,6 +10,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do use Ash.Resource.Validation alias Mv.Helpers + require Logger + @doc """ Validates email uniqueness across linked Member-User pairs. @@ -64,8 +66,6 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do {:error, field: :email, message: "is already used by another user", value: email} {:error, reason} -> - require Logger - Logger.warning( "Email uniqueness validation query failed for member email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)." ) -- 2.47.2