fix: CustomField policies, no system-actor fallback, guidelines

- Tests and UI pass actor for CustomField create/read/destroy; seeds use actor
- Member required-custom-fields validation uses context.actor only (no fallback)
- CODE_GUIDELINES: add rule forbidding system-actor fallbacks
This commit is contained in:
Moritz 2026-01-29 13:53:55 +01:00
parent 250369d142
commit 21dbdbe366
10 changed files with 116 additions and 43 deletions

View file

@ -471,11 +471,12 @@ defmodule Mv.Membership.Member do
end
end
# Validate required custom fields
validate fn changeset, _ ->
# Validate required custom fields (actor from validation context only; no fallback)
validate fn changeset, context ->
provided_values = provided_custom_field_values(changeset)
actor = context.actor
case Mv.Membership.list_required_custom_fields() do
case Mv.Membership.list_required_custom_fields(actor: actor) do
{:ok, required_custom_fields} ->
missing_fields = missing_required_fields(required_custom_fields, provided_values)

View file

@ -155,23 +155,31 @@ defmodule Mv.Membership do
Lists only required custom fields.
This is an optimized version that filters at the database level instead of
loading all custom fields and filtering in memory.
loading all custom fields and filtering in memory. Requires an actor for
authorization (CustomField read policy).
## Options
- `:actor` - Required. The actor for authorization (e.g. current user).
All roles can read CustomField; actor must have a valid role.
## Returns
- `{:ok, required_custom_fields}` - List of required custom fields
- `{:error, error}` - Error reading custom fields
- `{:error, error}` - Error reading custom fields (e.g. Forbidden when no actor)
## Examples
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields()
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor)
iex> Enum.all?(required_fields, & &1.required)
true
"""
def list_required_custom_fields do
def list_required_custom_fields(opts \\ []) do
actor = Keyword.get(opts, :actor)
Mv.Membership.CustomField
|> Ash.Query.filter(expr(required == true))
|> Ash.read(domain: __MODULE__)
|> Ash.read(domain: __MODULE__, actor: actor)
end
@doc """

View file

@ -176,6 +176,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
"""
end
defp stream_custom_fields(actor) do
case Ash.read(Mv.Membership.CustomField, actor: actor) do
{:ok, custom_fields} -> custom_fields
{:error, _} -> []
end
end
@impl true
def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
@ -207,7 +214,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_new(:custom_field_to_delete, fn -> nil end)
|> assign_new(:slug_confirmation, fn -> "" end)
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)}
|> stream(:custom_fields, stream_custom_fields(assigns[:actor]), reset: true)}
end
@impl true
@ -226,7 +233,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("edit_custom_field", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id)
actor = socket.assigns[:actor]
custom_field = Ash.get!(Mv.Membership.CustomField, id, actor: actor)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
@ -242,7 +250,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
actor = socket.assigns[:actor]
custom_field =
Ash.get!(Mv.Membership.CustomField, id,
load: [:assigned_members_count],
actor: actor
)
{:noreply,
socket
@ -259,9 +273,10 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("confirm_delete", _params, socket) do
custom_field = socket.assigns.custom_field_to_delete
actor = socket.assigns[:actor]
if socket.assigns.slug_confirmation == custom_field.slug do
case Ash.destroy(custom_field) do
case Ash.destroy(custom_field, actor: actor) do
:ok ->
send(self(), {:custom_field_deleted, custom_field})

View file

@ -130,6 +130,7 @@ defmodule MvWeb.GlobalSettingsLive do
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
</.form_section>

View file

@ -226,7 +226,7 @@ defmodule MvWeb.MemberLive.Form do
def mount(params, _session, socket) do
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
actor = current_actor(socket)
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
{:ok, custom_fields} = Mv.Membership.list_custom_fields(actor: actor)
initial_custom_field_values =
Enum.map(custom_fields, fn cf ->