From 4ec90770a487880626f9f2cf723da5670ce7f543 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sat, 24 Jan 2026 19:13:08 +0100 Subject: [PATCH] Add AssignDefaultRole change for automatic role assignment - Assigns 'Mitglied' role to new users if no role is set --- .../user/changes/assign_default_role.ex | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 lib/accounts/user/changes/assign_default_role.ex diff --git a/lib/accounts/user/changes/assign_default_role.ex b/lib/accounts/user/changes/assign_default_role.ex new file mode 100644 index 0000000..65cddca --- /dev/null +++ b/lib/accounts/user/changes/assign_default_role.ex @@ -0,0 +1,103 @@ +defmodule Mv.Accounts.User.Changes.AssignDefaultRole do + @moduledoc """ + Assigns the default "Mitglied" role to new users if no role is explicitly provided. + + This change runs during user creation actions (`:create_user`, `:register_with_password`, + `:register_with_rauthy`) and ensures that all users have a role assigned. + + ## Behavior + + - Skips assignment if `role_id` is already set in the changeset, data, or arguments + - Loads the "Mitglied" role without authorization (safe for system operations) + - Returns unchanged changeset if "Mitglied" role doesn't exist (test environments) + - Adds error to changeset on unexpected failures + + ## Important Notes + + - Works with upserts: When combined with `upsert_fields`, only new users get the role + - Uses `authorize?: false` to avoid circular dependencies during user creation + - The "Mitglied" role must exist (created by seeds or migration) + + ## Examples + + # Automatically assigns "Mitglied" role during user creation: + {:ok, user} = + User + |> Ash.Changeset.for_create(:create_user, %{email: "new@example.com"}) + |> Ash.create() + + # User now has "Mitglied" role assigned + {:ok, user_with_role} = Ash.load(user, :role) + assert user_with_role.role.name == "Mitglied" + + # Skips assignment if role is already set: + {:ok, user} = + User + |> Ash.Changeset.for_create(:create_user, %{email: "admin@example.com", role_id: admin_role.id}) + |> Ash.create() + + # User has the explicitly set role, not "Mitglied" + {:ok, user_with_role} = Ash.load(user, :role) + assert user_with_role.role.name == "Admin" + """ + use Ash.Resource.Change + + @impl true + @spec change(Ash.Changeset.t(), keyword(), map()) :: Ash.Changeset.t() + def change(changeset, _opts, _context) do + # Check role_id in changeset attributes (for new assignments) + role_id_in_changeset = Ash.Changeset.get_attribute(changeset, :role_id) + + # Check role_id in existing data (for upserts) + role_id_in_data = Map.get(changeset.data, :role_id) + + # Check if role is being set via argument + role_arg = Ash.Changeset.get_argument(changeset, :role) + + # Check if role relationship is already being managed + # Relationships are stored as a list of tuples: [{record_or_changes, opts}] + role_relationship = Map.get(changeset.relationships || %{}, :role) + + # Skip if role is already set anywhere (changeset, data, argument, or relationship) + has_role = + not is_nil(role_id_in_changeset) or + not is_nil(role_id_in_data) or + not is_nil(role_arg) or + (not is_nil(role_relationship) and role_relationship != []) + + if has_role do + changeset + else + assign_default_role(changeset) + end + end + + @spec assign_default_role(Ash.Changeset.t()) :: Ash.Changeset.t() + defp assign_default_role(changeset) do + # Load the "Mitglied" role without authorization + # This is safe because: + # 1. We're only reading a public system role (no sensitive data) + # 2. This runs during user creation (bootstrap phase) + # 3. Using SystemActor here would create circular dependency (SystemActor needs a user) + case Mv.Authorization.Role.get_mitglied_role() do + {:ok, %Mv.Authorization.Role{} = mitglied_role} -> + # Assign the role using manage_relationship + # Note: :append_and_remove is the correct type for Ash 2.0+ (replaces :replace) + Ash.Changeset.manage_relationship(changeset, :role, mitglied_role, + type: :append_and_remove + ) + + {:ok, nil} -> + # Role doesn't exist - skip assignment (common in test environments) + # In production, the migration will have created the role + changeset + + {:error, error} -> + # Unexpected error during role lookup + Ash.Changeset.add_error(changeset, + field: :role_id, + message: "Failed to load default role: #{inspect(error)}" + ) + end + end +end