Merge branch 'main' into feature/filter-boolean-custom-fields
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-01-23 14:41:48 +01:00
commit 672b4a8250
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
45 changed files with 3166 additions and 359 deletions

View file

@ -5,9 +5,8 @@ defmodule Mv.Accounts.User do
use Ash.Resource,
domain: Mv.Accounts,
data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication]
# authorizers: [Ash.Policy.Authorizer]
extensions: [AshAuthentication],
authorizers: [Ash.Policy.Authorizer]
postgres do
table "users"
@ -267,6 +266,36 @@ defmodule Mv.Accounts.User do
end
end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# AshAuthentication bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "Allow AshAuthentication internal operations (registration, login)"
authorize_if always()
end
# NoActor bypass (test fixtures only, see no_actor.ex)
bypass action_type([:create, :read, :update, :destroy]) do
description "Allow system operations without actor (test environment only)"
authorize_if Mv.Authorization.Checks.NoActor
end
# READ bypass for list queries (scope :own via expr)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
# Default: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# Global validations - applied to all relevant actions
validations do
# Password strength policy: minimum 8 characters for all password-related actions

View file

@ -42,25 +42,29 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
if email && oidc_id && user_info do
# Check if a user with this oidc_id already exists
# If yes, this will be an upsert (email update), not a new registration
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
existing_oidc_user =
case Mv.Accounts.User
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|> Ash.read_one() do
|> Ash.read_one(actor: system_actor) do
{:ok, user} -> user
_ -> nil
end
check_email_collision(email, oidc_id, user_info, existing_oidc_user)
check_email_collision(email, oidc_id, user_info, existing_oidc_user, system_actor)
else
:ok
end
end
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) do
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
# Find existing user with this email
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
case Mv.Accounts.User
|> Ash.Query.filter(email == ^to_string(email))
|> Ash.read_one() do
|> Ash.read_one(actor: system_actor) do
{:ok, nil} ->
# No user exists with this email - OK to create new user
:ok

View file

@ -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, actor: actor)
# Capture initiator for audit trail (if available)
initiator = Map.get(changeset.context, :actor)
handle_cycle_generation(member, initiator: initiator)
end
{:error, _} ->
@ -196,16 +197,12 @@ 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}
{:error, reason} ->
require Logger
Logger.warning(
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
)
@ -230,8 +227,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, actor: actor)
# Capture initiator for audit trail (if available)
initiator = Map.get(changeset.context, :actor)
handle_cycle_generation(member, initiator: initiator)
end
{:error, _} ->
@ -409,8 +407,16 @@ defmodule Mv.Membership.Member do
actor = Map.get(changeset.context || %{}, :actor)
# Check the current state of the user in the database
# Pass actor to ensure proper authorization (User might have policies in future)
case Ash.get(Mv.Accounts.User, user_id, actor: actor) do
# Check if authorization is disabled in the parent operation's context
# Access private context where authorize? flag is stored
authorize? =
case get_in(changeset.context, [:private, :authorize?]) do
false -> false
_ -> true
end
# Pass actor and authorize? to ensure proper authorization (User might have policies in future)
case Ash.get(Mv.Accounts.User, user_id, actor: actor, authorize?: authorize?) do
# User is free to be linked
{:ok, %{member_id: nil}} ->
:ok
@ -790,37 +796,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 +844,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,20 +863,16 @@ 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,
actor_opts,
skip_lock?: skip_lock?
)
{:error, reason} ->
@ -893,26 +900,27 @@ 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}
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)
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)
case delete_cycles(cycles_to_delete, actor_opts) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
{:error, reason} -> {:error, reason}
end
end
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
@ -928,13 +936,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,49 +954,57 @@ 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
# Uses system actor for cycle generation (mandatory side effect)
# Captures initiator for audit trail (if available in opts)
defp handle_cycle_generation(member, opts) do
actor = Keyword.get(opts, :actor)
initiator = Keyword.get(opts, :initiator)
if Mv.Config.sql_sandbox?() do
handle_cycle_generation_sync(member, actor: actor)
handle_cycle_generation_sync(member, initiator)
else
handle_cycle_generation_async(member, actor: actor)
handle_cycle_generation_async(member, initiator)
end
end
# Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member, opts) do
require Logger
actor = Keyword.get(opts, :actor)
defp handle_cycle_generation_sync(member, initiator) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today(),
actor: actor
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, opts) do
actor = Keyword.get(opts, :actor)
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, actor: actor) 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
@ -1001,13 +1015,15 @@ defmodule Mv.Membership.Member do
end
# Logs successful cycle generation
defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do
require Logger
defp log_cycle_generation_success(member, cycles, notifications,
sync: sync?,
initiator: initiator
) do
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)
@ -1015,13 +1031,12 @@ defmodule Mv.Membership.Member do
end
# Logs cycle generation errors
defp log_cycle_generation_error(member, reason, sync: sync?) do
require Logger
defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do
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),
@ -1029,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

View file

@ -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.
@ -73,19 +75,29 @@ 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
{:ok, _} ->
{:error, field: :email, message: "is already used by another member", value: email}
{:error, _} ->
{:error, reason} ->
Logger.warning(
"Email uniqueness validation query failed for user email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end

View file

@ -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

View file

@ -0,0 +1,99 @@
defmodule Mv.Authorization.Actor do
@moduledoc """
Helper functions for ensuring User actors have required data loaded.
## Actor Invariant
Authorization policies (especially HasPermission) require that the User actor
has their `:role` relationship loaded. This module provides helpers to
ensure this invariant is maintained across all entry points:
- LiveView on_mount hooks
- Plug pipelines
- Background jobs
- Tests
## Scope
This module ONLY handles `Mv.Accounts.User` resources. Other resources with
a `:role` field are ignored (returned as-is). This prevents accidental
authorization bypasses and keeps the logic focused.
## Usage
# In LiveView on_mount
def ensure_user_role_loaded(_name, socket) do
user = Actor.ensure_loaded(socket.assigns[:current_user])
assign(socket, :current_user, user)
end
# In tests
user = Actor.ensure_loaded(user)
## Security Note
`ensure_loaded/1` loads the role with `authorize?: false` to avoid circular
dependency (actor needs role loaded to be authorized, but loading role requires
authorization). This is safe because:
- The actor (User) is loading their OWN role (user.role relationship)
- This load is needed FOR authorization checks to work
- The role itself contains no sensitive data (just permission_set reference)
- The actor is already authenticated (passed auth boundary)
Alternative would be to denormalize permission_set_name on User, but that
adds complexity and potential for inconsistency.
"""
require Logger
@doc """
Ensures the actor (User) has their `:role` relationship loaded.
- If actor is nil, returns nil
- If role is already loaded, returns actor as-is
- If role is %Ash.NotLoaded{}, loads it and returns updated actor
- If actor is not a User, returns as-is (no-op)
## Examples
iex> Actor.ensure_loaded(nil)
nil
iex> Actor.ensure_loaded(%User{role: %Role{}})
%User{role: %Role{}}
iex> Actor.ensure_loaded(%User{role: %Ash.NotLoaded{}})
%User{role: %Role{}} # role loaded
"""
def ensure_loaded(nil), do: nil
# Only handle Mv.Accounts.User - clear intention, no accidental other resources
def ensure_loaded(%Mv.Accounts.User{role: %Ash.NotLoaded{}} = user) do
load_role(user)
end
def ensure_loaded(actor), do: actor
defp load_role(actor) do
# SECURITY: We skip authorization here because this is a bootstrap scenario:
# - The actor is loading their OWN role (actor.role relationship)
# - This load is needed FOR authorization checks to work (circular dependency)
# - The role itself contains no sensitive data (just permission_set reference)
# - The actor is already authenticated (passed auth boundary)
# Alternative would be to denormalize permission_set_name on User.
case Ash.load(actor, :role, domain: Mv.Accounts, authorize?: false) do
{:ok, loaded_actor} ->
loaded_actor
{:error, error} ->
# Log error but don't crash - fail-closed for authorization
Logger.warning(
"Failed to load actor role: #{inspect(error)}. " <>
"Authorization may fail if role is required."
)
actor
end
end
end

View file

@ -8,10 +8,37 @@ defmodule Mv.Authorization.Checks.HasPermission do
3. Finds matching permission for current resource + action
4. Applies scope filter (:own, :linked, :all)
## Important: strict_check Behavior
For filter-based scopes (`:own`, `:linked`):
- **WITH record**: Evaluates filter against record (returns `true`/`false`)
- **WITHOUT record** (queries/lists): Returns `false`
**Why `false` instead of `:unknown`?**
Ash's policy evaluation doesn't reliably call `auto_filter` when `strict_check`
returns `:unknown`. To ensure list queries work correctly, resources **MUST** use
bypass policies with `expr()` for READ operations (see `docs/policy-bypass-vs-haspermission.md`).
This means `HasPermission` is **NOT** generically reusable for query authorization
with filter scopes - it requires companion bypass policies.
## Usage Pattern
See `docs/policy-bypass-vs-haspermission.md` for the two-tier pattern:
- **READ**: `bypass` with `expr()` (handles auto_filter)
- **UPDATE/CREATE/DESTROY**: `HasPermission` (handles scope evaluation)
## Usage in Ash Resource
policies do
policy action_type(:read) do
# READ: Bypass for list queries
bypass action_type(:read) do
authorize_if expr(id == ^actor(:id))
end
# UPDATE: HasPermission for scope evaluation
policy action_type([:update, :create, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
@ -34,6 +61,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
All errors result in Forbidden (policy fails).
## Role Loading Fallback
If the actor's `:role` relationship is `%Ash.NotLoaded{}`, this check will
attempt to load it automatically. This provides a fallback if `on_mount` hooks
didn't run (e.g., in non-LiveView contexts).
## Examples
# In a resource policy
@ -83,6 +116,9 @@ defmodule Mv.Authorization.Checks.HasPermission do
# Helper function to reduce nesting depth
defp strict_check_with_permissions(actor, resource, action, record) do
# Ensure role is loaded (fallback if on_mount didn't run)
actor = ensure_role_loaded(actor)
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
@ -95,11 +131,25 @@ defmodule Mv.Authorization.Checks.HasPermission do
resource_name
) do
:authorized ->
# For :all scope, authorize directly
{:ok, true}
{:filter, filter_expr} ->
# For strict_check on single records, evaluate the filter against the record
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
# For :own/:linked scope:
# - With a record, evaluate filter against record for strict authorization
# - Without a record (queries/lists), return false
#
# NOTE: Returning false here forces the use of expr-based bypass policies.
# This is necessary because Ash's policy evaluation doesn't reliably call auto_filter
# when strict_check returns :unknown. Instead, resources should use bypass policies
# with expr() directly for filter-based authorization (see User resource).
if record do
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
else
# No record yet (e.g., read/list queries) - deny at strict_check level
# Resources must use expr-based bypass policies for list filtering
{:ok, false}
end
false ->
{:ok, false}
@ -224,9 +274,18 @@ defmodule Mv.Authorization.Checks.HasPermission do
end
# Evaluate filter expression for strict_check on single records
# For :own scope with User resource: id == actor.id
# For :linked scope with Member resource: id == actor.member_id
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
case {resource_name, record} do
{"User", %{id: user_id}} when not is_nil(user_id) ->
# Check if this user's ID matches the actor's ID (scope :own)
if user_id == actor.id do
{:ok, true}
else
{:ok, false}
end
{"Member", %{id: member_id}} when not is_nil(member_id) ->
# Check if this member's ID matches the actor's member_id
if member_id == actor.member_id do
@ -330,4 +389,10 @@ defmodule Mv.Authorization.Checks.HasPermission do
defp get_resource_name_for_logging(_resource) do
"unknown"
end
# Fallback: Load role if not loaded (in case on_mount didn't run)
# Delegates to centralized Actor helper
defp ensure_role_loaded(actor) do
Mv.Authorization.Actor.ensure_loaded(actor)
end
end

View file

@ -42,9 +42,9 @@ defmodule Mv.Authorization.Checks.NoActor do
use Ash.Policy.SimpleCheck
# Compile-time check: Only allow no-actor bypass in test environment
@allow_no_actor_bypass Mix.env() == :test
# Alternative (if you want to control via config):
# @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
# SECURITY: This must ONLY be true in test.exs, never in prod/dev
# Using compile_env instead of Mix.env() for release-safety
@allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
@impl true
def describe(_opts) do
@ -58,13 +58,9 @@ defmodule Mv.Authorization.Checks.NoActor do
@impl true
def match?(nil, _context, _opts) do
# Actor is nil
if @allow_no_actor_bypass do
# Test environment: Allow all operations
true
else
# Production/dev: Deny all operations (fail-closed for security)
false
end
# SECURITY: Only allow if compile_env flag is set (test.exs only)
# No runtime Mix.env() check - fail-closed by default (false)
@allow_no_actor_bypass
end
def match?(_actor, _context, _opts) do

View file

@ -95,7 +95,9 @@ defmodule Mv.Authorization.PermissionSets do
def get_permissions(:own_data) do
%{
resources: [
# User: Can always read/update own credentials
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
@ -125,6 +127,8 @@ defmodule Mv.Authorization.PermissionSets do
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},
@ -157,6 +161,8 @@ defmodule Mv.Authorization.PermissionSets do
%{
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{resource: "User", action: :read, scope: :own, granted: true},
%{resource: "User", action: :update, scope: :own, granted: true},

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -0,0 +1,436 @@
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.
## 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
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
alias Mv.Config
@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
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 (SQL sandbox), always load directly to avoid Agent/Sandbox issues
if Config.sql_sandbox?() do
try do
{:ok, load_system_actor()}
rescue
e -> {:error, e}
end
else
try do
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)
case result do
{:error, reason} -> {:error, reason}
actor -> {:ok, actor}
end
catch
:exit, {:noproc, _} ->
# Agent not started - load directly without caching
try do
{:ok, load_system_actor()}
rescue
e -> {:error, e}
end
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
case Process.whereis(__MODULE__) do
nil -> :ok
_pid -> Agent.update(__MODULE__, fn _state -> nil end)
end
end
@doc """
Returns the email address of the system user.
This is useful for other modules that need to reference the system user
without loading the full user record.
## Returns
- `String.t()` - The system user email address ("system@mila.local")
## Examples
iex> Mv.Helpers.SystemActor.system_user_email()
"system@mila.local"
"""
@spec system_user_email() :: String.t()
def system_user_email, do: system_user_email_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_config()) do
{:ok, user} when not is_nil(user) ->
load_user_with_role(user)
{:ok, nil} ->
handle_system_user_not_found("no system user or admin user found")
{:error, _reason} = error ->
handle_system_user_error(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 Config.sql_sandbox?() 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
# 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_config()},
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
# 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, authorize?: false)
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} ->
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
@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}
Please assign the "Admin" role to the system user.
"""
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.
Please assign the "Admin" role to the system user.
"""
end
@spec validate_admin_role(term()) :: no_return()
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)
@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"
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

View file

@ -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.
@ -30,8 +32,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 +41,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, []} ->
@ -61,7 +65,11 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
{:ok, _} ->
{:error, field: :email, message: "is already used by another user", value: email}
{:error, _} ->
{:error, reason} ->
Logger.warning(
"Email uniqueness validation query failed for member email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
)
:ok
end
end
@ -69,8 +77,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

View file

@ -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)

View file

@ -52,7 +52,8 @@ defmodule MvWeb do
quote do
use Phoenix.LiveView
on_mount MvWeb.LiveHelpers
on_mount {MvWeb.LiveHelpers, :default}
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
unquote(html_helpers())
end

View file

@ -20,10 +20,13 @@ defmodule MvWeb.LinkOidcAccountLive do
@impl true
def mount(_params, session, socket) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
oidc_user_info when not is_nil(oidc_user_info) <-
Map.get(session, "oidc_linking_user_info"),
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id, actor: system_actor) do
# Check if user is passwordless
if passwordless?(user) do
# Auto-link passwordless user immediately
@ -46,9 +49,12 @@ defmodule MvWeb.LinkOidcAccountLive do
end
defp reload_user!(user_id) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Mv.Accounts.User
|> Ash.Query.filter(id == ^user_id)
|> Ash.read_one!()
|> Ash.read_one!(actor: system_actor)
end
defp reset_password_form(socket) do
@ -58,13 +64,16 @@ defmodule MvWeb.LinkOidcAccountLive do
defp auto_link_passwordless_user(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (passwordless user auto-linking)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
case user.id
|> reload_user!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
|> Ash.update(actor: system_actor) do
{:ok, updated_user} ->
Logger.info(
"Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
@ -187,6 +196,9 @@ defmodule MvWeb.LinkOidcAccountLive do
defp link_oidc_account(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (user just verified password but is not yet logged in)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Update the user with the OIDC ID
case user.id
|> reload_user!()
@ -194,7 +206,7 @@ defmodule MvWeb.LinkOidcAccountLive do
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
|> Ash.update(actor: system_actor) do
{:ok, updated_user} ->
# After successful linking, redirect to OIDC login
# Since the user now has an oidc_id, the next OIDC login will succeed

View file

@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.MembershipFees

View file

@ -27,8 +27,6 @@ defmodule MvWeb.MemberLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
require Ash.Query
require Logger
import Ash.Expr

View file

@ -22,8 +22,6 @@ defmodule MvWeb.MemberLive.Show do
import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true

View file

@ -554,46 +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, actor: actor) 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

View file

@ -13,7 +13,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
require Ash.Query

View file

@ -14,7 +14,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query

View file

@ -17,8 +17,6 @@ defmodule MvWeb.RoleLive.Form do
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def render(assigns) do
~H"""

View file

@ -24,8 +24,6 @@ defmodule MvWeb.RoleLive.Index do
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(_params, _session, socket) do
actor = socket.assigns[:current_user]

View file

@ -19,8 +19,6 @@ defmodule MvWeb.RoleLive.Show do
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(%{"id" => id}, _session, socket) do
try do

View file

@ -33,7 +33,6 @@ defmodule MvWeb.UserLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true

View file

@ -23,7 +23,6 @@ defmodule MvWeb.UserLive.Index do
use MvWeb, :live_view
import MvWeb.TableComponents
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true

View file

@ -26,7 +26,6 @@ defmodule MvWeb.UserLive.Show do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true

View file

@ -27,39 +27,17 @@ defmodule MvWeb.LiveHelpers do
end
defp ensure_user_role_loaded(socket) do
if socket.assigns[:current_user] do
user = socket.assigns.current_user
user_with_role = load_user_role(user)
user = socket.assigns[:current_user]
if user do
# Use centralized Actor helper to ensure role is loaded
user_with_role = Mv.Authorization.Actor.ensure_loaded(user)
assign(socket, :current_user, user_with_role)
else
socket
end
end
defp load_user_role(user) do
case Map.get(user, :role) do
%Ash.NotLoaded{} -> load_role_safely(user)
nil -> load_role_safely(user)
_role -> user
end
end
defp load_role_safely(user) do
# Use self as actor for loading own role relationship
opts = [domain: Mv.Accounts, actor: user]
case Ash.load(user, :role, opts) do
{:ok, loaded_user} ->
loaded_user
{:error, error} ->
# Log warning if role loading fails - this can cause authorization issues
require Logger
Logger.warning("Failed to load role for user #{user.id}: #{inspect(error)}")
user
end
end
@doc """
Helper function to get the current actor (user) from socket assigns.