Merge branch 'main' into feature/335_csv_import_ui
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
465fe5a5b1
80 changed files with 4742 additions and 6541 deletions
|
|
@ -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
|
||||
|
|
@ -7,7 +7,7 @@ defmodule Mv.Authorization do
|
|||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1`
|
||||
- Role CRUD: `create_role/1`, `list_roles/0`, `get_role/1`, `update_role/2`, `destroy_role/1`
|
||||
|
||||
## Admin Interface
|
||||
The domain is configured with AshAdmin for management UI.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -310,20 +310,10 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
case process_row(row_map, line_number, custom_field_lookup, actor) do
|
||||
{:ok, _member} ->
|
||||
{acc_inserted + 1, acc_failed, acc_errors, acc_error_count, acc_truncated?}
|
||||
update_inserted(acc)
|
||||
|
||||
{:error, error} ->
|
||||
new_acc_failed = acc_failed + 1
|
||||
|
||||
# Only collect errors if under limit
|
||||
{new_acc_errors, new_error_count, new_truncated?} =
|
||||
if current_error_count < max_errors do
|
||||
{[error | acc_errors], acc_error_count + 1, acc_truncated?}
|
||||
else
|
||||
{acc_errors, acc_error_count, true}
|
||||
end
|
||||
|
||||
{acc_inserted, new_acc_failed, new_acc_errors, new_error_count, new_truncated?}
|
||||
handle_row_error(acc, error, current_error_count, max_errors)
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -397,11 +387,9 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
# Extracts the first error from a changeset and converts it to a MemberCSV.Error struct
|
||||
defp extract_changeset_error(changeset, csv_line_number) do
|
||||
case Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end) do
|
||||
errors = Ecto.Changeset.traverse_errors(changeset, &format_error_message/1)
|
||||
|
||||
case errors do
|
||||
%{email: [message | _]} ->
|
||||
# Email-specific error
|
||||
%Error{
|
||||
|
|
@ -430,6 +418,56 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
end
|
||||
end
|
||||
|
||||
# Helper function to update accumulator when row is successfully inserted
|
||||
defp update_inserted({acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}) do
|
||||
{acc_inserted + 1, acc_failed, acc_errors, acc_error_count, acc_truncated?}
|
||||
end
|
||||
|
||||
# Helper function to handle row error with error count limit checking
|
||||
defp handle_row_error(
|
||||
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
|
||||
error,
|
||||
current_error_count,
|
||||
max_errors
|
||||
) do
|
||||
new_acc_failed = acc_failed + 1
|
||||
|
||||
{new_acc_errors, new_error_count, new_truncated?} =
|
||||
collect_error_if_under_limit(
|
||||
error,
|
||||
acc_errors,
|
||||
acc_error_count,
|
||||
acc_truncated?,
|
||||
current_error_count,
|
||||
max_errors
|
||||
)
|
||||
|
||||
{acc_inserted, new_acc_failed, new_acc_errors, new_error_count, new_truncated?}
|
||||
end
|
||||
|
||||
# Helper function to collect error only if under limit
|
||||
defp collect_error_if_under_limit(
|
||||
error,
|
||||
acc_errors,
|
||||
acc_error_count,
|
||||
acc_truncated?,
|
||||
current_error_count,
|
||||
max_errors
|
||||
) do
|
||||
if current_error_count < max_errors do
|
||||
{[error | acc_errors], acc_error_count + 1, acc_truncated?}
|
||||
else
|
||||
{acc_errors, acc_error_count, true}
|
||||
end
|
||||
end
|
||||
|
||||
# Formats error message by replacing placeholders
|
||||
defp format_error_message({msg, opts}) do
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end
|
||||
|
||||
# Maps changeset error messages to appropriate Gettext messages
|
||||
defp gettext_error_message(message) when is_binary(message) do
|
||||
cond do
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue