Merge branch 'main' into feature/filter-boolean-custom-fields
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
672b4a8250
45 changed files with 3166 additions and 359 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
lib/mv/authorization/actor.ex
Normal file
99
lib/mv/authorization/actor.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
436
lib/mv/helpers/system_actor.ex
Normal file
436
lib/mv/helpers/system_actor.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue