defmodule Mv.Membership.Member.Validations.EmailChangePermission do @moduledoc """ Validates that only admins or the linked user may change a linked member's email. This validation runs on member update when the email attribute is changing. It allows the change only if: - The member is not linked to a user, or - The actor has the admin permission set (via `Mv.Authorization.Actor.admin?/1`), or - The actor is the user linked to this member (actor.member_id == member.id). This prevents non-admins from changing another user's linked member email, which would sync to that user's account and break email synchronization. No system-actor fallback: missing actor is treated as not allowed. """ use Ash.Resource.Validation use Gettext, backend: MvWeb.Gettext, otp_app: :mv alias Mv.Authorization.Actor alias Mv.EmailSync.Loader @doc """ Validates that the actor may change the member's email when the member is linked. Only runs when the email attribute is changing (checked inside). Skips when member is not linked. Allows when actor is admin or owns the linked member. """ @impl true def validate(changeset, _opts, context) do if Ash.Changeset.changing_attribute?(changeset, :email) do validate_linked_member_email_change(changeset, context) else :ok end end defp validate_linked_member_email_change(changeset, context) do linked_user = Loader.get_linked_user(changeset.data) if is_nil(linked_user) do :ok else actor = resolve_actor(changeset, context) member_id = changeset.data.id if Actor.admin?(actor) or actor_owns_member?(actor, member_id) do :ok else msg = dgettext("default", "Only administrators can change email for members linked to users") {:error, field: :email, message: msg} end end end # Ash stores actor in changeset.context.private.actor; validation context also has .actor defp resolve_actor(changeset, context) do get_in(changeset.context || %{}, [:private, :actor]) || (context && Map.get(context, :actor)) end defp actor_owns_member?(nil, _member_id), do: false defp actor_owns_member?(actor, member_id) do actor_member_id = Map.get(actor, :member_id) || Map.get(actor, "member_id") actor_member_id == member_id end end