defmodule Mv.Accounts.User do @moduledoc """ The ressource for keeping user-specific data related to the login process. It is used by AshAuthentication to handle the Authentication strategies like SSO. """ use Ash.Resource, domain: Mv.Accounts, data_layer: AshPostgres.DataLayer, extensions: [AshAuthentication] # authorizers: [Ash.Policy.Authorizer] postgres do table "users" repo Mv.Repo references do # When a member is deleted, set the user's member_id to NULL # This allows users to continue existing even if their linked member is removed reference :member, on_delete: :nilify end end @doc """ AshAuthentication specific: Defines the strategies we want to use for authentication. Currently password and SSO with Rauthy as OIDC provider """ authentication do session_identifier Application.compile_env!(:mv, :session_identifier) tokens do enabled? true token_resource Mv.Accounts.Token require_token_presence_for_authentication? Application.compile_env!( :mv, :require_token_presence_for_authentication ) store_all_tokens? true # signing_algorithm "EdDSA" -> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87 signing_secret fn _, _ -> {:ok, Application.get_env(:mv, :token_signing_secret)} end end strategies do oidc :rauthy do client_id Mv.Secrets base_url Mv.Secrets redirect_uri Mv.Secrets client_secret Mv.Secrets auth_method :client_secret_jwt code_verifier true # id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87 end password :password do identity_field :email hash_provider AshAuthentication.BcryptProvider confirmation_required? false end end end actions do # Default actions for framework/tooling integration: # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. # # NOTE: :create is INTENTIONALLY excluded from defaults! # Using a default :create would bypass email-synchronization logic. # Always use one of these explicit create actions instead: # - :create_user (for manual user creation with optional member link) # - :register_with_password (for password-based registration) # - :register_with_rauthy (for OIDC-based registration) defaults [:read, :destroy] # Primary generic update action: # - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix # helpers that assume a default update action. # - Intended for simple attribute updates (e.g., :email) and scenarios # that do NOT need to manage the :member relationship. # - For linking/unlinking a member (and the related validations), prefer # the specialized :update_user action below. update :update do primary? true # Required because custom validation functions (email validation, member relationship validation) # cannot be executed atomically. These validations need to query the database and perform # complex checks that are not supported in atomic operations. require_atomic? false # Sync email changes to linked member (User → Member) # Only runs when email is being changed change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:email)] end end create :create_user do description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument." # Only accept email directly - member_id is NOT in accept list # This prevents direct foreign key manipulation, forcing use of manage_relationship accept [:email] # Allow member to be passed as argument for relationship management argument :member, :map, allow_nil?: true upsert? true # Manage the member relationship during user creation change manage_relationship(:member, :member, # Look up existing member and relate to it on_lookup: :relate, # Error if member doesn't exist in database on_no_match: :error, # If member already linked to this user, ignore (shouldn't happen in create) on_match: :ignore, # If no member provided, that's fine (optional relationship) on_missing: :ignore ) # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember end update :update_user do description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one." # Only accept email directly - member_id is NOT in accept list # This prevents direct foreign key manipulation, forcing use of manage_relationship accept [:email] # Allow member to be passed as argument for relationship management argument :member, :map, allow_nil?: true # Required because custom validation functions (email validation, member relationship validation) # cannot be executed atomically. These validations need to query the database and perform # complex checks that are not supported in atomic operations. require_atomic? false # Manage the member relationship during user update change manage_relationship(:member, :member, # Look up existing member and relate to it on_lookup: :relate, # Error if member doesn't exist in database on_no_match: :error, # If same member provided, that's fine (allows updates with same member) on_match: :ignore, # If no member provided, remove existing relationship (allows member removal) on_missing: :unrelate ) # Sync email changes and handle linking (User → Member) # Runs when email OR member relationship changes change Mv.EmailSync.Changes.SyncUserEmailToMember do where any([changing(:email), changing(:member)]) end end # Admin action for direct password changes in admin panel # Uses the official Ash Authentication HashPasswordChange with correct context update :admin_set_password do accept [:email] argument :password, :string, allow_nil?: false, sensitive?: true require_atomic? false # Set the strategy context that HashPasswordChange expects change set_context(%{strategy_name: :password}) # Use the official Ash Authentication password change change AshAuthentication.Strategy.Password.HashPasswordChange end read :get_by_subject do description "Get a user by the subject claim in a JWT" argument :subject, :string, allow_nil?: false get? true prepare AshAuthentication.Preparations.FilterBySubject end read :sign_in_with_rauthy do argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false prepare AshAuthentication.Strategy.OAuth2.SignInPreparation filter expr(email == get_path(^arg(:user_info), [:preferred_username])) end create :register_with_rauthy do argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false upsert? true upsert_identity :unique_oidc_id validate &__MODULE__.validate_oidc_id_present/2 change AshAuthentication.GenerateTokenChange change fn changeset, _ctx -> user_info = Ash.Changeset.get_argument(changeset, :user_info) changeset |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember end end # Global validations - applied to all relevant actions validations do # Password strength policy: minimum 8 characters for all password-related actions validate string_length(:password, min: 8), where: [action_is([:register_with_password, :admin_set_password])], message: "must have length of at least 8" # Email uniqueness check for all actions that change the email attribute # Validates that user email is not already used by another (unlinked) member validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember # Email validation with EctoCommons.EmailValidator (same as Member) # This ensures consistency between User and Member email validation validate fn changeset, _ -> # Get email from attribute (Ash.CiString) and convert to string email = Ash.Changeset.get_attribute(changeset, :email) email_string = if email, do: to_string(email), else: nil # Only validate if email is present if email_string do changeset2 = {%{}, %{email: :string}} |> Ecto.Changeset.cast(%{email: email_string}, [:email]) |> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) if changeset2.valid? do :ok else {:error, field: :email, message: "is not a valid email"} end else :ok end end # Prevent overwriting existing member relationship # This validation ensures race condition safety by requiring explicit two-step process: # 1. Remove existing member (set member to nil) # 2. Add new member # This prevents accidental overwrites when multiple admins work simultaneously validate fn changeset, _context -> member_arg = Ash.Changeset.get_argument(changeset, :member) current_member_id = changeset.data.member_id # Only trigger if: # - member argument is provided AND has an ID # - user currently has a member # - the new member ID is different from current member ID if member_arg && member_arg[:id] && current_member_id && member_arg[:id] != current_member_id do {:error, field: :member, message: "User already has a member. Remove existing member first."} else :ok end end end def validate_oidc_id_present(changeset, _context) do user_info = Ash.Changeset.get_argument(changeset, :user_info) || %{} if is_binary(user_info["sub"]) or is_binary(user_info["id"]) do :ok else {:error, [user_info: "OIDC user_info must contain a non-empty 'sub' or 'id' field"]} end end attributes do uuid_primary_key :id # IMPORTANT: Email Synchronization # When user and member are linked, emails are automatically synced bidirectionally. # User.email is the source of truth - when a link is established, member.email # is overridden to match user.email. Subsequent changes to either email will # sync to the other resource. # See: Mv.EmailSync.Changes.SyncUserEmailToMember # Mv.EmailSync.Changes.SyncMemberEmailToUser attribute :email, :ci_string do allow_nil? false public? true # Same constraints as Member email for consistency constraints min_length: 5, max_length: 254 end attribute :hashed_password, :string, sensitive?: true, allow_nil?: true attribute :oidc_id, :string, allow_nil?: true end relationships do # 1:1 relationship - User can optionally belong to one Member # This automatically creates a `member_id` attribute in the User table # The relationship is optional (allow_nil? true by default) belongs_to :member, Mv.Membership.Member end identities do identity :unique_email, [:email] identity :unique_oidc_id, [:oidc_id] identity :unique_member, [:member_id] end # You can customize this if you wish, but this is a safe default that # only allows user data to be interacted with via AshAuthentication. # policies do # bypass AshAuthentication.Checks.AshAuthenticationInteraction do # authorize_if(always()) # end # policy always() do # forbid_if(always()) # end # end end