From 6ad777860da2a0359b244b83879e0c42e8d25b72 Mon Sep 17 00:00:00 2001 From: Moritz Date: Sun, 25 Jan 2026 13:39:10 +0100 Subject: [PATCH] feat: implement attribute-level default for role_id assignment Replace action-level changes with attribute default function to ensure all users get the 'Mitglied' role regardless of creation path. --- lib/accounts/user.ex | 60 +++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index efac3b8..592ac00 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -68,12 +68,9 @@ defmodule Mv.Accounts.User do hash_provider AshAuthentication.BcryptProvider confirmation_required? false - # NOTE: The auto-generated :register_with_password action does NOT assign a default role. - # This is intentional because: - # - In production, users are created via OIDC (:register_with_rauthy), which DOES assign roles - # - Manual user creation via :create_user DOES assign roles - # - Tests that need a role can use :create_user or manually assign via fixtures - # - The migration ensures existing users without roles get the "Mitglied" role + resettable do + sender Mv.Accounts.User.Senders.SendPasswordResetEmail + end end end end @@ -122,8 +119,7 @@ defmodule Mv.Accounts.User do argument :member, :map, allow_nil?: true upsert? true - # Assign default "Mitglied" role to new users - change Mv.Accounts.User.Changes.AssignDefaultRole + # Note: Default role is automatically assigned via attribute default (see attributes block) # Manage the member relationship during user creation change manage_relationship(:member, :member, @@ -273,9 +269,8 @@ defmodule Mv.Accounts.User do # - The LinkOidcAccountLive will auto-link passwordless users without password prompt validate Mv.Accounts.User.Validations.OidcEmailCollision - # Assign default "Mitglied" role to new OIDC users - # Note: upsert_fields [:email] ensures this doesn't overwrite existing users' roles - change Mv.Accounts.User.Changes.AssignDefaultRole + # Note: Default role is automatically assigned via attribute default (see attributes block) + # upsert_fields [:email] ensures existing users' roles are preserved during upserts # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember @@ -395,6 +390,15 @@ defmodule Mv.Accounts.User do attribute :hashed_password, :string, sensitive?: true, allow_nil?: true attribute :oidc_id, :string, allow_nil?: true + + # Role assignment: Explicitly defined to enforce default value + # This ensures every user has a role, regardless of creation path + # (register_with_password, create_user, seeds, etc.) + attribute :role_id, :uuid do + allow_nil? false + default &__MODULE__.default_role_id/0 + public? false + end end relationships do @@ -404,10 +408,13 @@ defmodule Mv.Accounts.User do belongs_to :member, Mv.Membership.Member # 1:1 relationship - User belongs to a Role - # This automatically creates a `role_id` attribute in the User table - # The relationship is optional (allow_nil? true by default) + # We define role_id ourselves (above in attributes) to control default value # Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users) - belongs_to :role, Mv.Authorization.Role + belongs_to :role, Mv.Authorization.Role do + define_attribute? false + source_attribute :role_id + allow_nil? false + end end identities do @@ -427,4 +434,29 @@ defmodule Mv.Accounts.User do # forbid_if(always()) # end # end + + @doc """ + Returns the default role ID for new users. + + This function is called automatically when creating a user without an explicit role_id. + It fetches the "Mitglied" role from the database without authorization checks + (safe during user creation bootstrap phase). + + ## Returns + + - UUID of the "Mitglied" role if it exists + - `nil` if the role doesn't exist (will cause validation error due to `allow_nil? false`) + + ## Examples + + iex> Mv.Accounts.User.default_role_id() + "019bf2e2-873a-7712-a7ce-a5a1f90c5f4f" + """ + @spec default_role_id() :: Ecto.UUID.t() | nil + def default_role_id do + case Mv.Authorization.Role.get_mitglied_role() do + {:ok, %Mv.Authorization.Role{id: role_id}} -> role_id + _ -> nil + end + end end